MINARA

工作区

基于 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

编辑工作区

有三种方式可编辑工作区文件:

  1. 直接在磁盘上编辑:在 Agent 会话间隙,用任意编辑器打开文件。下次会话将读取保存的内容。
  2. REPL 斜杠命令:/soul/identity/heartbeat 以及 /workspace [soul|agents|identity|user|memory|heartbeat|bootstrap] 可打印当前文件内容。完整命令集见斜杠命令参考
  3. Web UI 设置 → 工作区:从白名单中选择文件,在文本框中编辑后手动保存。保存操作通过网关进行,采用 sha256 乐观并发控制:每次 PUT 携带编辑器加载时的 sha256 值;若网关返回 409,则弹出"覆盖或重新加载"对话框,确保跨浏览器标签页或跨会话的并发编辑不会静默覆盖彼此的内容。

Web UI 编辑器不直接操作文件系统,所有操作均通过 /v1/workspace/files* 路由(见 HTTP API 参考)。

记忆的三个层次

工作区与 SQLite 记忆存储协同工作(见记忆系统),分为三个时间维度:

  • 短期:每日日志。每隔 WORKSPACE_DAILY_LOG_INTERVAL 轮,写入器按 SQLite chat_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_seensession_idsurfaceturn_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 按以下闸门顺序执行;任一闸门提前返回则跳过其余步骤:

  1. 抢锁 <workspace>/.dreaming.lock(O_EXCL)。锁被活进程持有时跳过本次 tick。
  2. 读状态:从 .dreamed-state.json 加载 mtime 水位线,从 .dreamed-quarantine.json 加载已隔离的源文件名集合。
  3. 选取:mtime 大于水位线、不在隔离集合内、在 windowDays 窗口内的每日日志。按"最新优先连续后缀"挑选,使总字节数不超过 WORKSPACE_DREAM_TOTAL_INPUT_BYTES(默认 256 KB)。每份日志在拼入提示词前先尾部截断到 64 KB。
  4. 读取:当前 MEMORY.md(同样尾部截断到 64 KB),让 LLM 在提示词内对已有内容去重。
  5. 调用 LLM,使用 curator 提示词:抽取持久信号而非噪声、不重复已有事实、不要包含任何看似敏感的内容。
  6. 若返回(trim 后)严格等于 NOTHING_TO_PROMOTE,推进水位线后退出,下次 tick 不再将同一批日志传给 LLM。
  7. PII 闸门:将响应跑过密钥 / 凭据正则集合。命中则将源日志文件名追加到 .dreamed-quarantine.json,按 error 级别记录日志后退出。水位线推进(详见 PII 隔离)。
  8. 追加 ## Dreamed YYYY-MM-DD 章节到 MEMORY.md(仅追加,操作者在它之上的编辑永远不会被覆盖)。
  9. 推进水位线并释放锁。

整个流程包在 try/finally 中,所有退出路径(包括 LLM 报错与 PII 隔离)均会释放锁。

状态文件

工作区根目录下三个 sibling JSON 文件承载 dreaming 任务的状态。三者均可在 tick 间隙用编辑器查看或修改:

文件作用生命周期
.dreaming.lock多进程互斥锁,包含 pidhostname、ISO 格式 started_at、不透明 token抢到锁时创建,释放时删除。超过 WORKSPACE_DREAM_LOCK_TTL_MS 或同主机进程已死时视为陈旧
.dreamed-state.jsonmtime 水位线 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_keysk-ant-...(必须排在 openai_key 之前)
openai_keysk-...(20+ 字母数字)
stripe_keysk_live_...rk_live_...
github_patghp_github_pat_gho_ghu_ghs_ghr_
google_api_keyAIza + 35 个 url-safe 字符
slack_tokenxoxb-xoxp-xoxa-xoxr-xoxs-
aws_access_keyAKIA + 16 个大写字母 / 数字
evm_address0x + 40 hex
btc_bech32bc1...
btc_legacy13 开头的 base58
pem_private-----BEGIN ... PRIVATE KEY-----

命中时:

  1. 响应追加到 MEMORY.md
  2. 涉及的源文件名追加到 .dreamed-quarantine.json,记录命中的 pattern 名。
  3. 一条结构化 error 级日志包含 pattern、文件名、工作区路径。
  4. 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.jsonlast_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。流程如下:

  1. 第一轮:Agent 读取 BOOTSTRAP.md,向操作者提出引导问题(姓名、时区、关注点、风险承受度、默认交易场所)。此轮输出 BOOTSTRAP_DONE
  2. 第二轮:操作者回答后,Agent 使用 write_file 填充 USER.md,简要确认写入内容,然后在回复末尾单独一行输出字面 token BOOTSTRAP_DONE

bootstrap 处理钩子(轮次结束时)检测到该 token 后,将 BOOTSTRAP.md 重命名为 BOOTSTRAP.md.done.<timestamp>(保留审计跟踪)。下一轮时文件已不存在,动态提示词块将自动去掉引导指令。

工作区作为事实来源(优先级)

Agent 循环组装系统提示词时,工作区 Markdown 位于最前面,优先于派生层:

  1. identity 块(缓存):SOUL.md + AGENTS.md
  2. memorySnapshot(动态,位于动态块最前):USER.mdMEMORY.mdHEARTBEAT.md、近期日记条目。
  3. bootstrapInstructions(动态,仅当 BOOTSTRAP.md 存在时):首次运行剧本。
  4. 技能目录、场景剧本、个性化、方法论提示及其他派生层位于其后。

该顺序是有意为之:若 MEMORY.md 中写明"用户在 BTC 上偏好现货而非永续合约",而个性化层推断出"用户偏好永续合约",则 MEMORY.md 优先。这一优先级规则在 apps/agent/src/core/prompt-builder.ts 中强制执行,并在 AGENTS.mdSoul 章节中明文规定。

配置

默认值经过调整,确保新安装即可直接使用:

  • 心跳:开启(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构建器
chartx-{ charts: [...] } ECharts 选项chart-builder.ts
spreadsheetx-{ csvContent, title?, description? }CSV,物化为 .csv
reportr-完整 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.mdHEARTBEAT.md 有意被导入器跳过:前者是一次性引导剧本(Minara 附带自己的初始模板);后者由 Agent 在每轮结束时改写,导入过期心跳会对会话连续性造成误导。

本页目录