工作区
基于 Markdown 的 Agent 身份与记忆,以及承载持久化对话内容的产物与文件存储
Minara 将其身份、角色、精选记忆及会话间状态,以纯 Markdown 文件的形式存储在工作区目录(默认为 ~/.minara/workspace/)。
工作区是 Agent 的人工精选事实来源。当推断出的用户偏好、已学习的方法论或场景剧本与 MEMORY.md / SOUL.md / AGENTS.md 的内容冲突时,工作区内容优先。Agent 循环按该顺序组装系统提示词;动态块与缓存块的组合方式详见 Agent 循环。
磁盘布局与 OpenClaw 兼容:文件名、章节及 frontmatter 均符合 OpenClaw 的 AGENTS.default 规范,因此为任一工具编写的工作区均可直接移植到另一工具(见下方 OpenClaw 兼容性)。
文件集
| 文件 | 作用 | 生命周期 |
|---|---|---|
SOUL.md | 身份、语气、边界 | 长期保留;编辑后向用户触发一次性披露 |
AGENTS.md | 会话启动规则、安全默认值 | 长期保留 |
IDENTITY.md | 显示名称、Emoji、风格(UI 界面) | 长期保留 |
USER.md | 操作者档案(时区、关注点、风险偏好、观察列表) | 长期保留 |
MEMORY.md | 精选事实 / 偏好 / 决策 | 长期保留;dreaming 任务追加 ## Dreamed YYYY-MM-DD 章节(命中 PII 过滤时改为隔离源日志,不写入 MEMORY.md) |
BOOTSTRAP.md | 两阶段首次运行引导剧本 | Agent 输出 BOOTSTRAP_DONE 后归档为 .done.<timestamp> |
TOOLS.md | 环境专属工具 / 技能说明 | 长期保留;操作者维护 |
HEARTBEAT.md | 会话间备忘录(last_seen、待处理循环、日程) | Agent 在每轮结束时改写状态章节;用户拥有 ## Schedule |
memory/YYYY-MM-DD-<session>.md | 每次会话的日记 | 每 N 轮追加一次;由 dreaming 任务合并 |
HEARTBEAT.md 是唯一由 Agent 在每轮结束时改写的文件。其余文件由操作者编辑(或仅由 dreaming 任务提议新增)。
启动时自动初始化
createApp() 在加载工作区前会调用 seedWorkspaceIfMissing(workspaceDir),将 apps/agent/src/workspace/templates/ 中缺失的模板复制到运行时目录。初始化操作是幂等的:操作者已编辑的文件(或此前已存在的文件)不会被覆盖。新安装无需手动运行 minara setup,即可获得完整文件集。
HEARTBEAT.md 有意排除在可初始化集合之外。Agent 会在第一轮结束时写入全新内容;若用过期状态初始化,会对会话连续性造成误导。模板文件仅作为 Web UI 的 restore-template 来源和规范参考,存放于 apps/agent/src/workspace/templates/HEARTBEAT.md。
编辑工作区
有三种方式可编辑工作区文件:
- 直接在磁盘上编辑:在 Agent 会话间隙,用任意编辑器打开文件。下次会话将读取保存的内容。
- REPL 斜杠命令:
/soul、/identity、/heartbeat以及/workspace [soul|agents|identity|user|memory|heartbeat|bootstrap]可打印当前文件内容。完整命令集见斜杠命令参考。 - Web UI 设置 → 工作区:从白名单中选择文件,在文本框中编辑后手动保存。保存操作通过网关进行,采用 sha256 乐观并发控制:每次
PUT携带编辑器加载时的 sha256 值;若网关返回 409,则弹出"覆盖或重新加载"对话框,确保跨浏览器标签页或跨会话的并发编辑不会静默覆盖彼此的内容。
Web UI 编辑器不直接操作文件系统,所有操作均通过 /v1/workspace/files* 路由(见 HTTP API 参考)。
记忆的三个层次
工作区与 SQLite 记忆存储协同工作(见记忆系统),分为三个时间维度:
- 短期:每日日志。每隔
WORKSPACE_DAILY_LOG_INTERVAL轮,写入器按 SQLitechat_turns水位批量写入全部未落盘 turn;日志保留真实 session、surface 和来源,不再把 correlation ID 当 session ID。每次会话独立成文件,可避免 REPL 与 HTTP 网关之间的追加竞争。 - 长期:人工精选。
MEMORY.md包含三个子章节:## Facts、## Preferences、## Decisions,由操作者维护。dreaming 任务以仅追加的方式提议新增## Dreamed YYYY-MM-DD章节。当 LLM 输出命中 PII 过滤时,源日志改为隔离,MEMORY.md不被改动(详见 PII 隔离)。 - 会话间备忘录:心跳。
HEARTBEAT.md记录last_seen、session_id、surface、turn_count、近期open_loops以及用户所有的## Schedule。写入器在每次写入时完整保留## Schedule章节(schedule_raw往返同步),操作者注释、空行分组和未知字段均不丢失。
Dreaming 合并
Dreaming 是 Agent 的"夜间图书管理员"。自动来源 turn 仍保留在日志中供审计,但在调用 LLM 前会被确定性移除;Autopilot、Strategy Studio、workflow、cron 或来源不明的 perps 执行不会被提升为人工偏好、决策或案例。其余持久信号以 ## Dreamed YYYY-MM-DD 章节追加到 MEMORY.md。
流程
每次 tick 按以下闸门顺序执行;任一闸门提前返回则跳过其余步骤:
- 抢锁
<workspace>/.dreaming.lock(O_EXCL)。锁被活进程持有时跳过本次 tick。 - 读状态:从
.dreamed-state.json加载 mtime 水位线,从.dreamed-quarantine.json加载已隔离的源文件名集合。 - 选取:mtime 大于水位线、不在隔离集合内、在
windowDays窗口内的每日日志。按"最新优先连续后缀"挑选,使总字节数不超过WORKSPACE_DREAM_TOTAL_INPUT_BYTES(默认 256 KB)。每份日志在拼入提示词前先尾部截断到 64 KB。 - 读取:当前
MEMORY.md(同样尾部截断到 64 KB),让 LLM 在提示词内对已有内容去重。 - 调用 LLM,使用 curator 提示词:抽取持久信号而非噪声、不重复已有事实、不要包含任何看似敏感的内容。
- 若返回(trim 后)严格等于
NOTHING_TO_PROMOTE,推进水位线后退出,下次 tick 不再将同一批日志传给 LLM。 - PII 闸门:将响应跑过密钥 / 凭据正则集合。命中则将源日志文件名追加到
.dreamed-quarantine.json,按error级别记录日志后退出。水位线不推进(详见 PII 隔离)。 - 追加
## Dreamed YYYY-MM-DD章节到MEMORY.md(仅追加,操作者在它之上的编辑永远不会被覆盖)。 - 推进水位线并释放锁。
整个流程包在 try/finally 中,所有退出路径(包括 LLM 报错与 PII 隔离)均会释放锁。
状态文件
工作区根目录下三个 sibling JSON 文件承载 dreaming 任务的状态。三者均可在 tick 间隙用编辑器查看或修改:
| 文件 | 作用 | 生命周期 |
|---|---|---|
.dreaming.lock | 多进程互斥锁,包含 pid、hostname、ISO 格式 started_at、不透明 token | 抢到锁时创建,释放时删除。超过 WORKSPACE_DREAM_LOCK_TTL_MS 或同主机进程已死时视为陈旧 |
.dreamed-state.json | mtime 水位线 last_processed_mtime | 成功 append 或 NOTHING_TO_PROMOTE 后推进。命中 PII 时不推进 |
.dreamed-quarantine.json | { quarantined_at, pattern, log_filenames, max_mtime } 条目数组 | PII 闸门追加。操作者手动删除条目(或整文件)以重新启用 |
多进程安全
REPL(npm run dev)与 HTTP 网关(npm run serve)各自启动一个调度器,跑在同一份工作区上。若不做串行化,两个进程的 tick 窗口可能撞在一起,将同一批每日日志重复 promote 到 MEMORY.md。.dreaming.lock 防止此情况:每次 dream pass 持锁直到 LLM 调用结束,所有退出路径均释放锁。
两条接管陈旧锁的路径处理崩溃的 peer:
- 超时陈旧:若
Date.now() - started_at > WORKSPACE_DREAM_LOCK_TTL_MS(默认 30 分钟,与 tick 间隔解耦),锁可被接管。 - PID 陈旧(仅同主机):若锁的
hostname与本机匹配,且kill -0 pid报告该进程已死,锁可被接管。
接管前会重新 stat 锁文件并重读 token,确认仍是同一把锁。若另一进程在"判定陈旧"和"unlink"之间刚好刷新了锁,本进程会让出本次 tick 而不删除新锁(关闭 read-then-unlink 的 TOCTOU 竞态)。
NFS 不被支持:O_EXCL 在部分 NFS 客户端下不保证原子性。共享存储挂载的工作区请将 dreaming 限制在单一主机上启用。
预算与选取
长窗口的密集每日日志会撑爆模型上下文。两层上限做防护:
- 每文件:每份每日日志尾部截断到 64 KB(可通过 dreaming 任务的
maxFileBytes依赖配置)。日志是仅追加的,尾部(最近的轮次内容)正是 curator 提示词关心的部分;早期的头部截断会丢掉最新内容。被截断的文件前缀加[...truncated head],让 LLM 知道这是片段。 - 总量:
WORKSPACE_DREAM_TOTAL_INPUT_BYTES(默认 256 KB)封顶所有选中日志的合并字节数。选取算法是最新优先连续后缀:从最新往最旧累计字节数,下一份会让总数超预算时停止。最新一份日志一定保留(单独超过总预算时截断到 64 KB)。LLM 不会看到"day 1 + day 30 中间空一片"的非连续时间线。
MEMORY.md 在传给 LLM 做提示词内去重时也尾部截断到 64 KB,因为最新的 ## Dreamed 章节(最可能被重复提议的内容)位于文件末尾。
NOTHING_TO_PROMOTE 严格匹配
当 curator 提示词判定日志中没有值得 promote 的内容时,它返回单独一行字面量 NOTHING_TO_PROMOTE。Dreaming 任务只接受严格相等(trim 后):响应 ### Facts\n- discussed the NOTHING_TO_PROMOTE marker behaviour 会被正常追加,因为它是合法输出,只是顺便提到了该 marker。早期版本的 substring 检查会静默吞掉任何含此字符串的响应。
PII 隔离
正则后置过滤在 append 之前对 LLM 响应跑一次。命中的 pattern 都是高置信度的密钥 / 凭据,绝不能落到 MEMORY.md(它会被注入每个未来会话的系统提示词,泄漏会持续放大):
| 类别 | Pattern 形态 |
|---|---|
anthropic_key | sk-ant-...(必须排在 openai_key 之前) |
openai_key | sk-...(20+ 字母数字) |
stripe_key | sk_live_...、rk_live_... |
github_pat | ghp_、github_pat_、gho_、ghu_、ghs_、ghr_ |
google_api_key | AIza + 35 个 url-safe 字符 |
slack_token | xoxb-、xoxp-、xoxa-、xoxr-、xoxs- |
aws_access_key | AKIA + 16 个大写字母 / 数字 |
evm_address | 0x + 40 hex |
btc_bech32 | bc1... |
btc_legacy | 1 或 3 开头的 base58 |
pem_private | -----BEGIN ... PRIVATE KEY----- 块 |
命中时:
- 响应不追加到
MEMORY.md。 - 涉及的源文件名追加到
.dreamed-quarantine.json,记录命中的 pattern 名。 - 一条结构化
error级日志包含 pattern、文件名、工作区路径。 - mtime 水位线不推进。推进等于将安全事件静默归档;改用按文件名过滤的方式,让这批日志在操作者清理前不再进入选取。
Solana base58(32-44 字符)、BIP-39 助记词、裸 64-hex 字符串均被故意排除在 pattern 列表之外。它们对哈希、交易签名、自然语言段落的误报率太高,不适合做硬闸门。提示词层面的指令("不要包含任何看似敏感的内容")仍要求 LLM 自行抑制这些类别。
清理隔离条目:编辑 <workspace>/.dreamed-quarantine.json 删除条目,或直接删除整个文件。下个 tick 会重新考虑那些每日日志。若泄漏本身在源日志中,需先编辑对应的 memory/YYYY-MM-DD-<session>.md。
操作者调试入口
常见场景与查看顺序:
- "昨天 dream 跑了吗?" 看
.dreamed-state.json的last_run_at。在 Agent 日志中查module: "workspace/dreaming-task"的行:appended/nothing_to_promote/pii_detected_quarantine/lock_held_skip/llm_error。 - "为什么这条事实没被 promote?" 源日志的 mtime 是否大于
last_processed_mtime?源文件名是否在.dreamed-quarantine.json中?LLM 是否返回了NOTHING_TO_PROMOTE?结构化日志行说明每种原因。 - "两个进程同时跑 dream 但没看到重复章节。" 锁机制在工作。失败方记录
lock_held_skip后干净退出。 - "PII 隔离触发了。" 检查
.dreamed-quarantine.json中的 pattern 名与源文件名。打开源日志判断:是真实泄漏(脱敏后清理条目),还是文本对话误报(直接删条目让下次 tick 重试)。 - "我的 LLM 调用经常 45 分钟。" 将
WORKSPACE_DREAM_LOCK_TTL_MS调到大于最坏情况的墙钟,避免另一进程将仍在工作的 dream 当成陈旧锁接管。
配置
五个环境变量控制 dreaming 行为。完整参考连同默认值与影响见环境变量:工作区:
WORKSPACE_DREAM_ENABLED:总开关(默认关闭)。WORKSPACE_DREAM_INTERVAL_HOURS:tick 间隔(默认 24 小时)。WORKSPACE_DREAM_TOTAL_INPUT_BYTES:提示词总字节预算(默认 256 KB)。WORKSPACE_DREAM_LOCK_TTL_MS:锁陈旧 TTL(默认 30 分钟)。WORKSPACE_DAILY_LOG_INTERVAL:每日日志写入间隔(dreaming 的输入来源)。
SOUL.md 变更披露
运行时在启动时对 SOUL.md 计算哈希,并将结果持久化到 <workspaceDir>/.soul-state.json。若两次会话之间哈希发生变化,Agent 循环将收到一次性提示词级通知,要求在下次回复中向操作者确认该变更。单次确认完成后,变更不再被提及;检测器在每次读取时重新建立基准。
BOOTSTRAP.md 两阶段引导
全新工作区附带 BOOTSTRAP.md。流程如下:
- 第一轮:Agent 读取
BOOTSTRAP.md,向操作者提出引导问题(姓名、时区、关注点、风险承受度、默认交易场所)。此轮不输出BOOTSTRAP_DONE。 - 第二轮:操作者回答后,Agent 使用
write_file填充USER.md,简要确认写入内容,然后在回复末尾单独一行输出字面 tokenBOOTSTRAP_DONE。
bootstrap 处理钩子(轮次结束时)检测到该 token 后,将 BOOTSTRAP.md 重命名为 BOOTSTRAP.md.done.<timestamp>(保留审计跟踪)。下一轮时文件已不存在,动态提示词块将自动去掉引导指令。
工作区作为事实来源(优先级)
Agent 循环组装系统提示词时,工作区 Markdown 位于最前面,优先于派生层:
identity块(缓存):SOUL.md+AGENTS.md。memorySnapshot(动态,位于动态块最前):USER.md、MEMORY.md、HEARTBEAT.md、近期日记条目。bootstrapInstructions(动态,仅当BOOTSTRAP.md存在时):首次运行剧本。- 技能目录、场景剧本、个性化、方法论提示及其他派生层位于其后。
该顺序是有意为之:若 MEMORY.md 中写明"用户在 BTC 上偏好现货而非永续合约",而个性化层推断出"用户偏好永续合约",则 MEMORY.md 优先。这一优先级规则在 apps/agent/src/core/prompt-builder.ts 中强制执行,并在 AGENTS.md 的 Soul 章节中明文规定。
配置
默认值经过调整,确保新安装即可直接使用:
- 心跳:开启(
WORKSPACE_HEARTBEAT_ENABLED=1) - 每日日志:关闭(
WORKSPACE_DAILY_LOG_ENABLED=0) - Dreaming:关闭(
WORKSPACE_DREAM_ENABLED=0)
完整参数列表(含默认值与说明)见环境变量参考。
产物与文件
工作区的 Markdown 是持久化的状态。另有两个并列存储保存持久化的内容:产物(Agent 在某轮中生成的图表、电子表格和报告)和文件(用户上传的二进制内容)。两者与工作区和沙盒并列,但契约各不相同:沙盒是 Agent 在单轮中可随意覆写的临时空间,而产物与文件必须跨会话留存、带有稳定 id,且不会被偶发的 write_file 改动。它们与沙盒从不共享路径,因此写入临时输出的工具无法覆盖已存储的图表或用户上传文件。
产物存储
产物存储在 chat_artifacts SQLite 表中,由 artifacts/artifact-store.ts 拥有。三种类型共享同一行结构:
| 类型 | id 前缀 | data payload | 构建器 |
|---|---|---|---|
chart | x- | { charts: [...] } ECharts 选项 | chart-builder.ts |
spreadsheet | x- | { csvContent, title?, description? } | CSV,物化为 .csv |
report | r- | 完整 HTML / markdown 报告 | 深度研究 |
每个构建器都走同一条状态路径:insert()(running)→ markCompleted(data)(completed)或 markError(msg)(error)。REPL 渲染 (running) 占位符并就地更新;审计日志记录每次状态转换。
模型通过 URI 引用已完成的产物,绝不臆造 id:chart://x-…、report://r-…、spreadsheet://x-…。臆造的 id 会解析为结构化的 ArtifactNotFoundError,模型看到后自行纠正。轮次结束时,gateway/render/artifact-materializer.ts 扫描最终消息中的这些 URI,将可浏览文件(自包含 HTML 查看器、原始 JSON,以及每张图表的 2 倍 PNG;每个电子表格一个 .csv)写入 $dataDir/artifacts/<id>/。PNG 渲染为尽力而为:若未安装 Playwright Chromium,物化器记录 chart_png_skipped 并继续。
文件存储
文件是 Agent 存储并引用、但不解析的不透明字节,由 files/file-store.ts 拥有。默认的 LocalFileStore 写入 $dataDir/uploads/,键为 chat/files/<userId>/<unix_ms>-<random_id>.<ext>(unix_ms 前缀让上传文件天然按时间排序)。
StoredFile 携带 key(写入聊天消息的稳定 id)和 url(LLM 提供方为多模态消息抓取的地址)。上传(POST /v1/files)经 fileStore.put(...) 处理,将 key 附加到待处理消息,并强制默认 20 MB 上限,与各提供方的多模态上限一致。files/message-translator.ts 在进入循环时将附件 key 映射为各提供方偏好的多模态形态,在写回存储时再映射回 key。消息文本存于 sessions 行;二进制内容存于文件存储;翻译器在两者之间桥接。sessions 中从不存储原始二进制。上传的电子表格由 files/spreadsheet-parser.ts 转换为结构化文本,使模型无需完整附件往返即可对其内容进行推理。
安全态势
- 文件从不执行。该存储是 blob 缓存,没有任何代码路径会 eval 上传内容。
- key 与产物 id 是稳定引用,而非机密。将 URL 或
chart://id 视为持有即可访问的凭据;网关在其上叠加会话级鉴权。 - 删除是显式的。
LocalFileStore.delete(key)从磁盘移除文件;GDPR 式清除请通过它进行,使移除有意且可追溯。
返回这些内容的 HTTP 端点见 API → /files 及产物抓取路由。
OpenClaw 兼容性
工作区格式与 OpenClaw 的 AGENTS.default 规范在字节层面兼容:文件名、章节标题、frontmatter 结构完全一致。为任一工具编写的工作区均可直接移植,无需转换;仅有各工具各自独立维护的运行时产物在本质上有所不同。
将现有 OpenClaw 工作区导入 Minara 的三种方式:
- 启动时传入
--workspace ~/.openclaw/workspace。 - 在环境中设置
MINARA_WORKSPACE_DIR=~/.openclaw/workspace。 - 运行
OpenClawWorkspaceImporter,将文件复制到 Minara 的默认位置。
BOOTSTRAP.md 和 HEARTBEAT.md 有意被导入器跳过:前者是一次性引导剧本(Minara 附带自己的初始模板);后者由 Agent 在每轮结束时改写,导入过期心跳会对会话连续性造成误导。