xitto-kernel 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ **「執行中沉澱經驗」五層完整**(反射 / 事實 / 程序 / 情節 / 結晶)——里程碑。
6
+
7
+ - **事實自動萃取(事實層,本版收尾)**:每輪後自動把持久事實抽進記憶,不再只靠 agent 自覺。
8
+ - 新增 `extract.js`:`extractFacts`(輕量 LLM 單次呼叫,只抽偏好/身分/長期決策/穩定設定)
9
+ - `runTurn` 在 `config.autoExtractMemory` 開啟時**非阻塞**萃取(掛 `result.memoryExtraction` promise,可 await);發 `memory_extracted` 事件
10
+ - 略過一次性任務細節/閒聊;以 existing + memory.save 去重;萃取失敗容錯不影響主流程
11
+ - `api.extractMemory({messages})` 手動觸發;CLI 預設開啟並顯示「✓ 自動記住 N 條」
12
+ - 4 個測試(解析 / 去重 / 容錯 / runTurn 鉤子)+ 真實 model 端到端(持久事實存、閒聊略過)
13
+ - 至此五層全齊;README 補上五層總表。
14
+
15
+ ## 0.3.9
16
+
17
+ - **情節記憶 + 相關性召回(情節層)**:記「做過什麼任務」,相似任務時自動召回最相關的幾筆。
18
+ - 新增 kernel 內建 `episodes.js`:`episode_record`(記摘要+tags+成敗,Jaccard 去重)、`episode_recall`(按相關性召回)
19
+ - **自動召回**:`runTurn` 把與本輪 input 最相關的 top-K 過往情節注入該輪 prompt(`config.recallEpisodes=false` 可關)
20
+ - **相關性評分引擎**(重點):關鍵詞 + 中文 bigram 重疊 + tag 加權(×2) + 近期微傾;只回 score>0、top-K
21
+ ——零依賴、可解釋,非黑箱 embedding;解掉記憶系統真正的瓶頸「召回對的那幾條」
22
+ - 落地 `.xitto-kernel/<pack>/episodes.jsonl`;綁 cwd → 天然只召回該專案
23
+ - `/episodes`(列近期)、`/episodes <關鍵詞>`(測召回)、`/episodes clear`;`api.episodes.*`
24
+ - 6 個測試(斷詞/評分/去重/召回排序/recallSection/runTurn 自動注入)+ 真實 model 端到端
25
+ (cors 查詢只召回 cors 情節、DB 查詢只召回 DB 情節、agent 用上召回的解法)
26
+ - 「沉澱經驗」五層至此完成四層(反射/程序/結晶/情節);僅餘事實層自動萃取
27
+
3
28
  ## 0.3.8
4
29
 
5
30
  - **技能自我維護(用量戳記 + 漂移偵測)**:結晶後不再靜止,技能庫會自我體檢。
package/README.md CHANGED
@@ -56,6 +56,22 @@ xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
56
56
 
57
57
  **自我結晶技能(結晶層,須驗證)**:摸出一套可重複的操作流程/SOP 時,agent 用 `skill_save` 把它**寫成新技能**(markdown)存進 `.xitto-kernel/<pack>/skills/`。**政策閘門:每個技能新增時必須附 (1) `goal` 明確目標 (2) `verify` 一條驗證指令——verify 會在沙箱實際執行,通過(exit 0)才落地**,否則拒絕並回傳輸出讓 agent 修正(危險指令一律擋下)。確保結晶的是「已驗證的成功」而非「宣稱的成功」。**本 session 立即可用 `skill` 按名載入(熱掃描),未來 session 自動列入「可用技能」**(漸進揭露:prompt 只列名稱+簡述,需要時才載全文)。**自我維護**:載入會記用量(`usedCount`);`skills_check`/`/skills check` 重跑每個技能存的 verify 偵測**漂移**——專案變動後失效的標 `⚠ stale` 浮上來讓你修或刪,保持技能庫可信(失效的在 prompt 標注、別誤用)。`/skills` 查看(含用量/失效)、`/skills forget <名>` 移除。分工:`playbook` 是專案事實性 know-how,`skill` 是可跨任務複用且**已驗證**的操作流程。這層讓 agent 像 Voyager 一樣**長出自己的技能庫**——但每條都經驗證、會自我體檢,且跑在 kernel 的沙箱 + 漸進信任治理裡。
58
58
 
59
+ **情節記憶 + 相關性召回(情節層)**:完成有價值的任務後,agent 用 `episode_record` 記一筆情節(摘要 + tags + 成敗)進 `.xitto-kernel/<pack>/episodes.jsonl`。**關鍵在召回不在存**:遇到相似任務時,kernel **自動**把與當前 input 最相關的 top-K 過往情節(相關性評分:關鍵詞/中文 bigram 重疊 + tag 加權 + 近期微傾)注入該輪 prompt——**只注入最相關的幾條,不全量倒**(避免稀釋 context、誤導)。也可主動 `episode_recall`。記錄時做 Jaccard 去重避免膨脹。`/episodes` 列近期、`/episodes <關鍵詞>` 測召回、`/episodes clear`。這直接解掉所有記憶系統的真正瓶頸——**召回對的那幾條**(零依賴、可解釋的評分,非黑箱 embedding)。
60
+
61
+ **事實自動萃取(事實層)**:每輪對話後,kernel 用一次輕量 LLM **自動**把「值得跨 session 記住的持久事實」(偏好、身分、長期決策、穩定設定)抽出來存進 `memory`——不再只靠 agent 自覺呼叫 `memory_save`。一次性的任務細節/閒聊會被略過(那是情節層的事),已知事實會過濾不重複。**非阻塞**(掛在 `runTurn` 回傳的 `memoryExtraction` promise,不卡回覆);`config.autoExtractMemory` 開關(CLI 預設開),`api.extractMemory()` 也可手動觸發。對標 xitto 的 extractMemory。
62
+
63
+ ### 沉澱經驗:五層完整
64
+
65
+ agent 執行中自動累積經驗,且每層都有治理:
66
+
67
+ | 層 | 沉澱什麼 | 機制 |
68
+ |---|---|---|
69
+ | 反射層 | 什麼安全 | 漸進信任(per-pattern,跨 session) |
70
+ | 事實層 | 記住的事 | 每輪自動萃取持久事實進 memory |
71
+ | 程序層 | 這專案怎麼做 | playbook(按 topic,自動注入) |
72
+ | 情節層 | 做過什麼 | episodes + **相關性召回**(只注入最相關幾條) |
73
+ | 結晶層 | 可複用流程 | 自寫 skill(須驗證 + 自我體檢漂移) |
74
+
59
75
  **通用自主 agent(給目標、自己做到完成)**
60
76
  ```bash
61
77
  xitto-kernel --pack general --yes --goal "抓取 example.com 摘要成繁中寫進 summary.txt"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/cli.js CHANGED
@@ -35,6 +35,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
35
35
  sandbox: { enabled: sandboxOn }, // 提供策略(blockNetwork/allowWritePrefixes)
36
36
  getSandbox: () => sandboxOn, // on/off 由 CLI 即時切換
37
37
  getPlanMode: () => planMode, // 計劃模式:守衛擋 mutating 工具
38
+ autoExtractMemory: true, // 事實層:每輪後自動萃取持久事實進記憶(非阻塞)
38
39
  confirm: askConfirm, // 互動權限確認(mutating/危險工具執行前)
39
40
  onTrusted: ({ name, signature, scope }) => { // 漸進放權:自動放行時標示「已信任」(維持可理解)
40
41
  endStream();
@@ -156,6 +157,9 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
156
157
  endStream();
157
158
  out(c.gray(` ⊙ 已壓縮上下文:${ev.tokensBefore}→${ev.tokensAfter} tokens(摘要 ${ev.summarized} 則,保留 ${ev.kept} 則)\n`));
158
159
  break;
160
+ case 'memory_extracted':
161
+ out(c.gray(` ✓ 自動記住 ${ev.facts.length} 條:${ev.facts.map((f) => f.slice(0, 24)).join(';')}\n`));
162
+ break;
159
163
  }
160
164
  };
161
165
 
@@ -177,6 +181,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
177
181
  ' /memory 顯示跨 session 記憶',
178
182
  ' /playbook [forget <主題>|clear] 專案手冊(agent 沉澱的程序知識,跨 session)',
179
183
  ' /skills [check|forget <名>] 已結晶技能(用量/失效標示;check 重跑 verify 偵測漂移)',
184
+ ' /episodes [查詢|clear] 過往任務情節(無參數列近期;給查詢測相關性召回)',
180
185
  ' /sessions 列出已保存的對話',
181
186
  ' /resume [id] 續接對話(不給 id=最近一次)',
182
187
  ' /clear 清除歷史(開新 session)',
@@ -225,6 +230,21 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
225
230
  if (kernel.skills.path) out(c.gray(` ↳ ${kernel.skills.path}(複查:/skills check · 移除:/skills forget <名>)\n`));
226
231
  return true;
227
232
  }
233
+ case '/episodes': {
234
+ const rest = input.trim().slice(cmd.length).trim();
235
+ if (rest === 'clear') { const { cleared } = kernel.episodes.clear(); out(c.gray(`(已清空情節,移除 ${cleared} 筆)\n`)); return true; }
236
+ if (rest) { // 給查詢 → 測相關性召回
237
+ const hits = kernel.episodes.recall(rest, 8);
238
+ if (!hits.length) { out(c.gray(`(沒召回到與「${rest}」相關的情節)\n`)); return true; }
239
+ out(hits.map((h) => c.cyan(` • [${h.score}] `) + h.summary + c.gray(`${h.outcome ? ` (${h.outcome})` : ''}${h.tags?.length ? ` [${h.tags.join(', ')}]` : ''}`)).join('\n') + '\n');
240
+ return true;
241
+ }
242
+ const eps = kernel.episodes.list(15);
243
+ if (!eps.length) { out(c.gray('(尚無情節;完成有價值的任務時 agent 會用 episode_record 記下)\n')); return true; }
244
+ out(eps.map((e) => c.cyan(' • ') + e.summary + c.gray(`${e.outcome ? ` (${e.outcome})` : ''}${e.tags?.length ? ` [${e.tags.join(', ')}]` : ''}`)).join('\n') + '\n');
245
+ out(c.gray(` ↳ ${kernel.episodes.count()} 筆 · 試召回:/episodes <關鍵詞>\n`));
246
+ return true;
247
+ }
228
248
  case '/trust': {
229
249
  const rest = input.trim().slice(cmd.length).trim();
230
250
  if (rest === 'clear') { kernel.permissions.clear(); out(c.gray('(已清除全部信任)\n')); return true; }
@@ -290,7 +310,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
290
310
  };
291
311
 
292
312
  // 斜線指令 tab 補全
293
- const SLASH = ['/help', '/goal ', '/sandbox', '/auto', '/plan', '/undo', '/tools', '/trust', '/memory', '/playbook', '/skills', '/sessions', '/resume', '/clear', '/exit'];
313
+ const SLASH = ['/help', '/goal ', '/sandbox', '/auto', '/plan', '/undo', '/tools', '/trust', '/memory', '/playbook', '/skills', '/episodes', '/sessions', '/resume', '/clear', '/exit'];
294
314
  const completer = (line) => {
295
315
  if (!line.startsWith('/')) return [[], line];
296
316
  const hits = SLASH.filter((s) => s.startsWith(line));
@@ -0,0 +1,100 @@
1
+ // 情節層沉澱 — kernel 內建。記「做過什麼任務、結果如何」,新任務時**按相關性召回**最相關的幾筆。
2
+ // 關鍵不在存,在召回對的那幾條:相關性評分(關鍵詞/中文 bigram 重疊 + tag 加權 + 近期微傾),只注入 top-K。
3
+ // 落地 <cwd>/.xitto-kernel/<pack>/episodes.jsonl(一行一筆);綁 cwd → 天然只召回這個專案的過往。
4
+ import { existsSync, readFileSync, appendFileSync, writeFileSync, mkdirSync } from 'node:fs';
5
+ import { dirname } from 'node:path';
6
+
7
+ const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
8
+
9
+ // 斷詞:ASCII 詞(≥2)+ 中文 bigram(無空格語言用 2-gram 抓近似)。回傳 term 陣列。
10
+ export function episodeTerms(text) {
11
+ const s = String(text || '').toLowerCase();
12
+ const out = [];
13
+ for (const w of s.match(/[a-z0-9]+/g) || []) if (w.length >= 2) out.push(w);
14
+ for (const run of s.match(/[一-鿿]+/g) || []) {
15
+ if (run.length === 1) out.push(run);
16
+ for (let i = 0; i + 1 < run.length; i++) out.push(run.slice(i, i + 2));
17
+ }
18
+ return out;
19
+ }
20
+
21
+ const jaccard = (a, b) => { if (!a.size || !b.size) return 0; let inter = 0; for (const x of a) if (b.has(x)) inter++; return inter / (a.size + b.size - inter); };
22
+
23
+ // 相關性評分:summary 重疊 + tag 重疊(權重 2) + 近期微傾(相關度為主,近期只做平手時的微調)。
24
+ export function scoreEpisode(qSet, ep, now) {
25
+ if (!qSet.size) return 0;
26
+ const sum = ep._sum || new Set(episodeTerms(ep.summary));
27
+ const tag = ep._tag || new Set((ep.tags || []).flatMap((t) => episodeTerms(t)));
28
+ let overlap = 0; for (const q of qSet) if (sum.has(q)) overlap++;
29
+ let tagHit = 0; for (const q of qSet) if (tag.has(q)) tagHit++;
30
+ const base = overlap + 2 * tagHit;
31
+ if (base <= 0) return 0;
32
+ const ageDays = Math.max(0, (now - Date.parse(ep.ts || 0)) / 86400000) || 0;
33
+ const recency = Number.isFinite(ageDays) ? Math.exp(-ageDays / 30) : 0; // ~30 天半衰
34
+ return base * (1 + 0.3 * recency);
35
+ }
36
+
37
+ export function createEpisodes(file) {
38
+ const all = () => {
39
+ if (!existsSync(file)) return [];
40
+ const out = [];
41
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
42
+ const t = line.trim(); if (!t) continue;
43
+ try { const e = JSON.parse(t); if (e && e.summary) out.push(e); } catch { /* 跳壞行 */ }
44
+ }
45
+ return out;
46
+ };
47
+
48
+ const record = ({ summary, tags = [], outcome = null } = {}) => {
49
+ const s = String(summary || '').trim();
50
+ if (!s) return { error: 'summary 不可為空' };
51
+ const cleanTags = (Array.isArray(tags) ? tags : [tags]).map((t) => String(t).trim()).filter(Boolean).slice(0, 8);
52
+ const newTerms = new Set(episodeTerms(s));
53
+ // 去重:與既有情節 term 集 Jaccard > 0.85 視為重複,跳過
54
+ for (const ep of all()) { if (jaccard(newTerms, new Set(episodeTerms(ep.summary))) > 0.85) return { skipped: true, similarTo: ep.id }; }
55
+ const ep = { id: 'e' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), ts: new Date().toISOString(), summary: s, tags: cleanTags, outcome: outcome || null };
56
+ mkdirSync(dirname(file), { recursive: true });
57
+ appendFileSync(file, JSON.stringify(ep) + '\n');
58
+ return { recorded: ep.id };
59
+ };
60
+
61
+ const recall = (query, limit = 5) => {
62
+ const qSet = new Set(episodeTerms(query));
63
+ if (!qSet.size) return [];
64
+ const now = Date.now();
65
+ return all()
66
+ .map((ep) => ({ ep, score: scoreEpisode(qSet, ep, now) }))
67
+ .filter((x) => x.score > 0)
68
+ .sort((a, b) => b.score - a.score)
69
+ .slice(0, Math.max(1, limit))
70
+ .map(({ ep, score }) => ({ id: ep.id, ts: ep.ts, summary: ep.summary, tags: ep.tags, outcome: ep.outcome, score: Math.round(score * 100) / 100 }));
71
+ };
72
+
73
+ // 自動召回:把與 input 最相關的 top-K 過往情節格成 prompt 區塊(無相關回 '')。
74
+ const recallSection = (query, limit = 4) => {
75
+ const hits = recall(query, limit);
76
+ if (!hits.length) return '';
77
+ return '\n\n# 相關的過往經驗(按相關性召回,僅供參考,未必適用當前情境)\n' +
78
+ hits.map((h) => `- ${h.summary}${h.outcome ? `(結果:${h.outcome})` : ''}${h.tags?.length ? ` [${h.tags.join(', ')}]` : ''}`).join('\n');
79
+ };
80
+
81
+ const list = (n = 20) => all().slice(-n).reverse().map(({ id, ts, summary, tags, outcome }) => ({ id, ts, summary, tags, outcome }));
82
+ const clear = () => { const n = all().length; try { if (existsSync(file)) writeFileSync(file, ''); } catch { /* 略 */ } return { cleared: n }; };
83
+
84
+ const tools = [
85
+ {
86
+ name: 'episode_record', label: '記情節', readOnly: true,
87
+ description: '完成一個有價值的任務後,記一筆「情節」:做了什麼、結果如何。給 summary(自給自足的一兩句)、tags(關鍵詞,助日後召回)、outcome(success/fail/可省)。日後遇到相似任務會被自動召回參考。與 memory/playbook 的差別:那兩個存「事實/做法」,episode 存「這次做過什麼」的事件。',
88
+ parameters: { type: 'object', properties: { summary: { type: 'string' }, tags: { type: 'array', items: { type: 'string' } }, outcome: { type: 'string', description: 'success | fail(可省)' } }, required: ['summary'] },
89
+ execute: async (_id, args) => txt(record(args)),
90
+ },
91
+ {
92
+ name: 'episode_recall', label: '召回情節', readOnly: true,
93
+ description: '按相關性召回過往做過的相似任務(關鍵詞/語意重疊評分,回最相關幾筆)。開工前想參考「以前類似的怎麼處理」時用。',
94
+ parameters: { type: 'object', properties: { query: { type: 'string' }, limit: { type: 'number' } }, required: ['query'] },
95
+ execute: async (_id, { query, limit }) => txt({ recalled: recall(query, limit || 5) }),
96
+ },
97
+ ];
98
+
99
+ return { record, recall, recallSection, list, all, clear, tools, count: () => all().length };
100
+ }
@@ -0,0 +1,62 @@
1
+ // 事實層自動萃取 — 每輪後用一次輕量 LLM,把「值得跨 session 記住的事實」抽出來存進記憶,
2
+ // 不再只靠 agent 自覺呼叫 memory_save。對標 xitto 的 extractMemory。非阻塞、盡力而為。
3
+ // 只萃取持久事實(偏好/身分/長期決策/穩定設定),略過一次性任務細節(那是情節層的事)。
4
+
5
+ const EXTRACT_SYSTEM = [
6
+ '你是記憶萃取器。從對話中抽出「值得跨 session 長期記住的事實」:使用者偏好、身分、長期決策、穩定的專案設定。',
7
+ '規則:',
8
+ '- 只抽持久、可重用的事實;略過一次性任務細節、過程、寒暄、臨時數據。',
9
+ '- 每條一句、自給自足(脫離上下文也看得懂)。',
10
+ '- 已知事實(見下)不要重複,也不要抽語意重複的。',
11
+ '- 沒有值得記的就輸出 []。',
12
+ '只輸出 JSON 字串陣列,例如 ["使用者偏好繁體中文","專案用 pnpm 不是 npm"]。不要任何其他文字。',
13
+ ].join('\n');
14
+
15
+ // 從模型輸出解析事實陣列:優先 JSON 陣列;非陣列/解析失敗 → 空(保守,避免抓雜訊)。
16
+ export function parseFacts(text) {
17
+ if (!text) return [];
18
+ const m = String(text).match(/\[[\s\S]*\]/);
19
+ if (!m) return [];
20
+ try {
21
+ const a = JSON.parse(m[0]);
22
+ if (!Array.isArray(a)) return [];
23
+ return a.map((x) => String(x).trim()).filter((x) => x.length >= 1 && x.length <= 200);
24
+ } catch { return []; }
25
+ }
26
+
27
+ // 把最近對話壓成萃取輸入(取末端幾則 user/assistant 文字)。
28
+ function conversationText(messages) {
29
+ return (messages || [])
30
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
31
+ .slice(-6)
32
+ .map((m) => `${m.role === 'user' ? '使用者' : '助手'}:${(m.content || []).filter((c) => c.type === 'text').map((c) => c.text).join(' ').slice(0, 800)}`)
33
+ .filter((l) => l.length > 4)
34
+ .join('\n');
35
+ }
36
+
37
+ // 單次 LLM 呼叫(無工具),收完整文字。沿用 kernel 的 streamFn 契約。
38
+ async function runOnce({ model, getApiKey, streamFn, systemPrompt, userText }) {
39
+ const apiKey = getApiKey ? await getApiKey(model.provider) : undefined;
40
+ const llmContext = { systemPrompt, messages: [{ role: 'user', content: [{ type: 'text', text: userText }] }], tools: [] };
41
+ const response = await streamFn(model, llmContext, { model, transport: 'sse', apiKey });
42
+ for await (const _ of response) { /* 排空事件 */ }
43
+ const final = await response.result();
44
+ return (final?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
45
+ }
46
+
47
+ /**
48
+ * 從對話萃取持久事實(已過濾掉 existing 中已有的)。
49
+ * @returns {Promise<string[]>}
50
+ */
51
+ export async function extractFacts({ model, getApiKey, streamFn, messages, existing = [] }) {
52
+ const convo = conversationText(messages);
53
+ if (!convo.trim()) return [];
54
+ const sys = EXTRACT_SYSTEM + (existing.length ? `\n已知事實:\n${existing.map((e) => `- ${e}`).join('\n')}` : '');
55
+ let text;
56
+ try { text = await runOnce({ model, getApiKey, streamFn, systemPrompt: sys, userText: convo }); }
57
+ catch { return []; } // 萃取失敗不影響主流程
58
+ const have = new Set(existing.map((e) => e.trim()));
59
+ const out = [];
60
+ for (const f of parseFacts(text)) { if (!have.has(f) && !out.includes(f)) out.push(f); }
61
+ return out;
62
+ }
@@ -14,6 +14,8 @@ import { dangerousReason } from './security/danger.js';
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import { createMemory } from './memory.js';
16
16
  import { createPlaybook } from './playbook.js';
17
+ import { createEpisodes } from './episodes.js';
18
+ import { extractFacts } from './extract.js';
17
19
  import { createTodo } from './todo.js';
18
20
  import { createSpawnTool } from './subagent.js';
19
21
  import { createSkills } from './skills.js';
@@ -48,6 +50,9 @@ const DEFAULT_MEMORY_GUIDE =
48
50
  const DEFAULT_PLAYBOOK_GUIDE =
49
51
  '摸清這個專案的「做事方法」(如何建置/測試/執行/部署、慣例、必經步驟、坑與修法)時,用 playbook_update 按 topic 記下來(同 topic 覆蓋);過時就用 playbook_remove 清掉。下次自動載入,不必重新摸索。分工:memory 存事實/偏好/決策,playbook 存可重複的程序步驟。';
50
52
 
53
+ const DEFAULT_EPISODE_GUIDE =
54
+ '完成有價值的任務後,用 episode_record 記一筆情節(做了什麼+結果+tags);遇到相似任務時系統會自動召回最相關的幾筆供參考,也可主動用 episode_recall 查。';
55
+
51
56
  // 把 sandboxable 工具的命令在執行期包進 Seatbelt(macOS OS 級隔離)。
52
57
  // 非 macOS / 沙箱關閉 / 無 command → wrapWithSeatbelt 回 null,跑原命令(仍受第 5 格靜態策略保護)。
53
58
  function wrapSandboxable(tool, { cwd, getSandbox, getSandboxConfig }) {
@@ -108,6 +113,18 @@ export function createKernel(pack, config = {}) {
108
113
  const dataDir = join(cwd, '.xitto-kernel', pack.name);
109
114
  const memory = createMemory(join(dataDir, 'memory.md'));
110
115
  const playbook = createPlaybook(join(dataDir, 'playbook.md'));
116
+ const episodes = createEpisodes(join(dataDir, 'episodes.jsonl'));
117
+
118
+ // 事實層自動萃取:從對話抽持久事實存進 memory(去重靠 memory.save + existing 過濾)。
119
+ let lastMessages = [];
120
+ const doExtract = async (messages) => {
121
+ if (!config.model || !config.getApiKey) return { extracted: [] };
122
+ const streamFn = config.streamFn || (await import('./provider.js')).defaultStreamFn();
123
+ const facts = await extractFacts({ model: config.model, getApiKey: config.getApiKey, streamFn, messages: messages || [], existing: memory.list() });
124
+ const saved = [];
125
+ for (const f of facts) { if (memory.save(f).saved) saved.push(f); }
126
+ return { extracted: saved };
127
+ };
111
128
  const todo = createTodo();
112
129
  const sessionsDir = join(dataDir, 'sessions');
113
130
  const hooks = loadHooks(join(dataDir, 'settings.json')); // PreToolUse/PostToolUse
@@ -142,6 +159,7 @@ export function createKernel(pack, config = {}) {
142
159
  ...pack.tools().map((t) => wrapUndo(wrapSandboxable(t, { cwd, getSandbox, getSandboxConfig }), { cwd, undoStack })),
143
160
  ...memory.tools,
144
161
  ...playbook.tools,
162
+ ...episodes.tools,
145
163
  todo.tool,
146
164
  ...skills.tools,
147
165
  ...(config.extraTools || []), // 外部注入(MCP 工具等):由 app 層先 async 載入再傳入
@@ -169,7 +187,7 @@ export function createKernel(pack, config = {}) {
169
187
  const systemPrompt =
170
188
  pack.systemPrompt +
171
189
  loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
172
- '\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE +
190
+ '\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE + '\n' + DEFAULT_EPISODE_GUIDE +
173
191
  (memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
174
192
  (pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
175
193
  skills.promptSection();
@@ -217,10 +235,14 @@ export function createKernel(pack, config = {}) {
217
235
  },
218
236
  sandbox: { isOn: () => getSandbox(), config: () => getSandboxConfig() },
219
237
  memory,
238
+ // 事實層自動萃取:從指定(或上一輪)對話抽持久事實存進 memory,回 { extracted: [...] }。
239
+ extractMemory: (opts = {}) => doExtract(opts.messages || lastMessages),
220
240
  // 專案手冊(程序層沉澱):列出 / 更新 / 移除 / 全清;path 為落地檔。
221
241
  playbook: { list: playbook.list, update: playbook.update, remove: playbook.remove, clear: playbook.clear, load: playbook.load, path: join(dataDir, 'playbook.md') },
222
242
  // 技能(結晶層 + 自我維護):列出 / 移除 / 重掃 / 漂移複查;path 為技能資料夾。
223
243
  skills: { list: skills.list, remove: skills.remove, reload: skills.reload, check: skills.check, path: join(dataDir, 'skills') },
244
+ // 情節(情節層 + 相關性召回):記錄 / 召回 / 列出 / 清空;path 為落地檔。
245
+ episodes: { record: episodes.record, recall: episodes.recall, list: episodes.list, clear: episodes.clear, count: episodes.count, path: join(dataDir, 'episodes.jsonl') },
224
246
  todo: { get: todo.get },
225
247
  /** 撤銷上一次檔案改動(write/edit):還原內容,新建的檔則刪除。 */
226
248
  undo: () => {
@@ -271,9 +293,12 @@ export function createKernel(pack, config = {}) {
271
293
  const streamFn = config.streamFn || (await import('./provider.js')).defaultStreamFn();
272
294
  const model = config.model;
273
295
 
296
+ // 自動相關性召回:把與本輪 input 最相關的過往情節注入 prompt(只 top-K,不全量倒)
297
+ const turnSystemPrompt = systemPrompt + (config.recallEpisodes === false ? '' : episodes.recallSection(input));
298
+
274
299
  const agent = new Agent({
275
300
  initialState: {
276
- systemPrompt,
301
+ systemPrompt: turnSystemPrompt,
277
302
  model,
278
303
  tools: registry.all(),
279
304
  messages: opts.history || [], // 多輪對話:延續歷史
@@ -334,10 +359,18 @@ export function createKernel(pack, config = {}) {
334
359
  }
335
360
 
336
361
  const messages = agent.state.messages;
362
+ lastMessages = messages;
337
363
  const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
338
364
  const text = (lastAssistant?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
339
365
  const aborted = lastAssistant?.stopReason === 'aborted';
340
- return { text, messages, agent, aborted, turnModified };
366
+ const result = { text, messages, agent, aborted, turnModified };
367
+ // 事實層自動萃取:非阻塞,把 promise 掛在 result 上供需要者 await(測試/headless)。
368
+ if (config.autoExtractMemory && !aborted) {
369
+ result.memoryExtraction = doExtract(messages)
370
+ .then((r) => { if (r.extracted.length) opts.onEvent?.({ type: 'memory_extracted', facts: r.extracted }); return r; })
371
+ .catch(() => ({ extracted: [] }));
372
+ }
373
+ return result;
341
374
  },
342
375
 
343
376
  /**