工作區
基於 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 在每輪結束時改寫,導入過期心跳會對會話連續性造成誤導。