xitto-kernel 0.3.7 → 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 +36 -0
- package/README.md +17 -1
- package/package.json +1 -1
- package/src/app/cli.js +32 -4
- package/src/kernel/episodes.js +100 -0
- package/src/kernel/extract.js +62 -0
- package/src/kernel/index.js +38 -5
- package/src/kernel/skills.js +75 -22
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
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
|
+
|
|
28
|
+
## 0.3.8
|
|
29
|
+
|
|
30
|
+
- **技能自我維護(用量戳記 + 漂移偵測)**:結晶後不再靜止,技能庫會自我體檢。
|
|
31
|
+
- **A 用量戳記**:`skill` 載入時記 `usedCount` / `lastUsedAt`(寫進 frontmatter,不動 body)
|
|
32
|
+
- **B 漂移偵測**:新增 `skills_check` 工具 + `api.skills.check()`——重跑每個技能存的 verify,
|
|
33
|
+
仍 exit 0 標 `ok`、失效標 `stale`(清/設 frontmatter);無 verify 區塊的技能回 `no-verify`(不誤判)
|
|
34
|
+
- prompt 與 `/skills` 清單顯示用量與 `⚠ 已失效待修`;`/skills check` 觸發複查
|
|
35
|
+
- frontmatter 簡易解析/patch(splitFront/joinFront/extractVerify);複用 v0.3.7 存下的 verify
|
|
36
|
+
- 4 個新測試(用量累加 / ok→stale→修復 / no-verify 不誤判 / api.skills.check)+ 真實 verify 端到端
|
|
37
|
+
(載入計次、刪檔→stale、復原→ok)。測試 143/143。
|
|
38
|
+
|
|
3
39
|
## 0.3.7
|
|
4
40
|
|
|
5
41
|
- **技能結晶政策閘門(驗證才算數)**:每個自寫技能新增時必須有明確目標 + 通過的驗證,否則不落地。
|
package/README.md
CHANGED
|
@@ -54,7 +54,23 @@ xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
|
|
|
54
54
|
|
|
55
55
|
**執行中沉澱經驗(專案手冊)**:agent 摸清「這個專案怎麼做事」(建置/測試/部署指令、慣例、必經步驟、踩過的坑與修法)時,會用 `playbook_update` 按 topic 記進 `.xitto-kernel/<pack>/playbook.md`(同 topic 覆蓋,天然去重);**下次 session 自動載入系統提示,不必重新摸索**。因檔案綁 cwd,手冊天然只對這個專案生效。`/playbook` 查看、`/playbook forget <主題>`、`/playbook clear`。分工:`memory` 存事實/偏好/決策(扁平),`playbook` 存可重複的程序知識(按主題)。
|
|
56
56
|
|
|
57
|
-
**自我結晶技能(結晶層,須驗證)**:摸出一套可重複的操作流程/SOP 時,agent 用 `skill_save` 把它**寫成新技能**(markdown)存進 `.xitto-kernel/<pack>/skills/`。**政策閘門:每個技能新增時必須附 (1) `goal` 明確目標 (2) `verify` 一條驗證指令——verify 會在沙箱實際執行,通過(exit 0)才落地**,否則拒絕並回傳輸出讓 agent 修正(危險指令一律擋下)。確保結晶的是「已驗證的成功」而非「宣稱的成功」。**本 session 立即可用 `skill` 按名載入(熱掃描),未來 session 自動列入「可用技能」**(漸進揭露:prompt 只列名稱+簡述,需要時才載全文)。`/skills`
|
|
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
|
+
|
|
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(須驗證 + 自我體檢漂移) |
|
|
58
74
|
|
|
59
75
|
**通用自主 agent(給目標、自己做到完成)**
|
|
60
76
|
```bash
|
package/package.json
CHANGED
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
|
|
|
@@ -176,7 +180,8 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
176
180
|
' /trust [forget <項>|clear] 已信任的工具/命令(漸進放權,跨 session)',
|
|
177
181
|
' /memory 顯示跨 session 記憶',
|
|
178
182
|
' /playbook [forget <主題>|clear] 專案手冊(agent 沉澱的程序知識,跨 session)',
|
|
179
|
-
' /skills [forget <名>]
|
|
183
|
+
' /skills [check|forget <名>] 已結晶技能(用量/失效標示;check 重跑 verify 偵測漂移)',
|
|
184
|
+
' /episodes [查詢|clear] 過往任務情節(無參數列近期;給查詢測相關性召回)',
|
|
180
185
|
' /sessions 列出已保存的對話',
|
|
181
186
|
' /resume [id] 續接對話(不給 id=最近一次)',
|
|
182
187
|
' /clear 清除歷史(開新 session)',
|
|
@@ -211,10 +216,33 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
211
216
|
out(r.removed ? c.gray(`(已移除技能「${r.removed}」)\n`) : c.yellow(`找不到技能「${n}」\n`));
|
|
212
217
|
return true;
|
|
213
218
|
}
|
|
219
|
+
if (rest === 'check') {
|
|
220
|
+
out(c.gray('複查中(重跑各技能 verify)…\n'));
|
|
221
|
+
kernel.skills.check().then((res) => {
|
|
222
|
+
if (!res.length) { out(c.gray('(尚無技能可複查)\n')); return; }
|
|
223
|
+
out(res.map((r) => (r.status === 'ok' ? c.green(' ✓ ') : r.status === 'stale' ? c.red(' ✗ ') : c.gray(' - ')) + r.name + c.gray(`(${r.status})`)).join('\n') + '\n');
|
|
224
|
+
});
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
214
227
|
const sk = kernel.skills.list();
|
|
215
228
|
if (!sk.length) { out(c.gray('(尚無技能;agent 摸出可重複流程時會用 skill_save 結晶)\n')); return true; }
|
|
216
|
-
out(sk.map((s) => c.cyan(
|
|
217
|
-
if (kernel.skills.path) out(c.gray(` ↳ ${kernel.skills.path}
|
|
229
|
+
out(sk.map((s) => (s.stale ? c.red(' ⚠ ') : c.cyan(' • ')) + s.name + c.gray(`:${s.desc}${s.used ? ` · 用過 ${s.used} 次` : ''}${s.stale ? ' · 已失效待修' : ''}`)).join('\n') + '\n');
|
|
230
|
+
if (kernel.skills.path) out(c.gray(` ↳ ${kernel.skills.path}(複查:/skills check · 移除:/skills forget <名>)\n`));
|
|
231
|
+
return true;
|
|
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`));
|
|
218
246
|
return true;
|
|
219
247
|
}
|
|
220
248
|
case '/trust': {
|
|
@@ -282,7 +310,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
282
310
|
};
|
|
283
311
|
|
|
284
312
|
// 斜線指令 tab 補全
|
|
285
|
-
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'];
|
|
286
314
|
const completer = (line) => {
|
|
287
315
|
if (!line.startsWith('/')) return [[], line];
|
|
288
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
|
+
}
|
package/src/kernel/index.js
CHANGED
|
@@ -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
|
-
//
|
|
223
|
-
skills: { list: skills.list, remove: skills.remove, reload: skills.reload, path: join(dataDir, 'skills') },
|
|
242
|
+
// 技能(結晶層 + 自我維護):列出 / 移除 / 重掃 / 漂移複查;path 為技能資料夾。
|
|
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
|
-
|
|
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
|
/**
|
package/src/kernel/skills.js
CHANGED
|
@@ -1,40 +1,70 @@
|
|
|
1
|
-
// Skills(漸進揭露 +
|
|
1
|
+
// Skills(漸進揭露 + 結晶層 + 自我維護)— kernel 內建。.xitto-kernel/<pack>/skills/*.md 每檔一個技能。
|
|
2
2
|
// system prompt 只列「名稱 + 簡述」;agent 用 skill 工具按名載入完整步驟。對標 xitto-code skills。
|
|
3
|
-
// 結晶層:agent
|
|
4
|
-
//
|
|
3
|
+
// 結晶層:agent 把重複流程用 skill_save 寫成新技能(須附 goal + 通過 verify 才落地)。
|
|
4
|
+
// 自我維護:載入時記使用戳記(usedCount/lastUsedAt);skills_check 重跑各技能 verify 偵測漂移(stale)。
|
|
5
|
+
// 技能是 markdown 指令(非可執行碼),自寫安全——名稱 slug 化防穿越,內容只是日後注入的提示文字。
|
|
5
6
|
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
6
7
|
import { join } from 'node:path';
|
|
7
8
|
|
|
8
9
|
const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
|
|
9
10
|
|
|
11
|
+
// 簡易 frontmatter(key: value 行)解析/序列化 + 不動 body 的 patch。
|
|
12
|
+
function splitFront(md) {
|
|
13
|
+
const m = md.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
14
|
+
if (!m) return { fm: {}, body: md };
|
|
15
|
+
const fm = {};
|
|
16
|
+
for (const line of m[1].split('\n')) { const i = line.indexOf(':'); if (i > 0) fm[line.slice(0, i).trim()] = line.slice(i + 1).trim(); }
|
|
17
|
+
return { fm, body: m[2] };
|
|
18
|
+
}
|
|
19
|
+
function joinFront(fm, body) {
|
|
20
|
+
const keys = Object.keys(fm);
|
|
21
|
+
if (!keys.length) return body;
|
|
22
|
+
return '---\n' + keys.map((k) => `${k}: ${fm[k]}`).join('\n') + '\n---\n\n' + body.replace(/^\n+/, '');
|
|
23
|
+
}
|
|
24
|
+
// verify 指令取自 `## 驗證…` 的 fenced sh 區塊(skill_save 寫入的格式;保留原樣含多行)。
|
|
25
|
+
function extractVerify(md) {
|
|
26
|
+
const m = md.match(/##\s*驗證[^\n]*\n```(?:sh|bash)?\n([\s\S]*?)\n```/);
|
|
27
|
+
return m ? m[1].trim() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
const firstDesc = (body) => {
|
|
11
31
|
const fm = body.match(/^description:\s*(.+)$/mi);
|
|
12
32
|
if (fm) return fm[1].trim();
|
|
13
33
|
return (body.split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean)) || '';
|
|
14
34
|
};
|
|
15
35
|
|
|
16
|
-
// 技能名 → 安全檔名 slug(防 ../ 穿越;保留中英數與連字號)
|
|
17
36
|
const slug = (s) => String(s || '').trim().toLowerCase()
|
|
18
37
|
.replace(/[^a-z0-9一-龥_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 64);
|
|
19
38
|
|
|
20
39
|
export function createSkills(dir, { verifyRunner } = {}) {
|
|
40
|
+
const fileOf = (name) => join(dir, `${name}.md`);
|
|
21
41
|
const readAll = () => {
|
|
22
42
|
const out = [];
|
|
23
43
|
if (existsSync(dir)) {
|
|
24
44
|
for (const f of readdirSync(dir).filter((x) => x.endsWith('.md'))) {
|
|
25
|
-
try {
|
|
45
|
+
try {
|
|
46
|
+
const md = readFileSync(join(dir, f), 'utf8');
|
|
47
|
+
const { fm } = splitFront(md);
|
|
48
|
+
out.push({ name: f.replace(/\.md$/, ''), desc: firstDesc(md), body: md, used: Number(fm.usedCount) || 0, stale: fm.stale === 'true' });
|
|
49
|
+
} catch { /* 略 */ }
|
|
26
50
|
}
|
|
27
51
|
}
|
|
28
52
|
return out;
|
|
29
53
|
};
|
|
54
|
+
const patch = (name, p) => {
|
|
55
|
+
const file = fileOf(name);
|
|
56
|
+
if (!existsSync(file)) return false;
|
|
57
|
+
try { const { fm, body } = splitFront(readFileSync(file, 'utf8')); Object.assign(fm, p); writeFileSync(file, joinFront(fm, body)); return true; } catch { return false; }
|
|
58
|
+
};
|
|
30
59
|
|
|
31
|
-
let skills = readAll(); // 啟動快照(供 system prompt
|
|
60
|
+
let skills = readAll(); // 啟動快照(供 system prompt 列名用)
|
|
61
|
+
const label = (s) => `- ${s.name}:${s.desc}${s.used ? `(用過 ${s.used} 次)` : ''}${s.stale ? ' ⚠ 已失效待修' : ''}`;
|
|
32
62
|
|
|
33
63
|
const promptSection = () => (skills.length
|
|
34
|
-
? '\n\n# 可用技能(需要時用 skill
|
|
35
|
-
: '\n\n# 技能\n尚無已存技能。摸出一套可重複的流程時,用 skill_save
|
|
64
|
+
? '\n\n# 可用技能(需要時用 skill 按名載入全文;摸出可重複流程可用 skill_save 結晶;⚠ 失效的先別用,可 skills_check 複查)\n' + skills.map(label).join('\n')
|
|
65
|
+
: '\n\n# 技能\n尚無已存技能。摸出一套可重複的流程時,用 skill_save 把它結晶成技能(須附 goal + 通過 verify),之後即可按名複用。');
|
|
36
66
|
|
|
37
|
-
//
|
|
67
|
+
// 載入技能:rescan → 找到剛存的;記使用戳記(A)
|
|
38
68
|
const loadTool = {
|
|
39
69
|
name: 'skill', label: '載入技能', readOnly: true,
|
|
40
70
|
description: '按名載入一個技能的完整步驟(漸進揭露:prompt 只列名稱+簡述,需要時才載全文)。',
|
|
@@ -42,15 +72,17 @@ export function createSkills(dir, { verifyRunner } = {}) {
|
|
|
42
72
|
execute: async (_id, { name }) => {
|
|
43
73
|
skills = readAll();
|
|
44
74
|
const s = skills.find((x) => x.name === name) || skills.find((x) => x.name === slug(name));
|
|
45
|
-
|
|
75
|
+
if (!s) return txt({ error: '找不到技能', name, available: skills.map((x) => x.name) });
|
|
76
|
+
patch(s.name, { usedCount: s.used + 1, lastUsedAt: new Date().toISOString() });
|
|
77
|
+
skills = readAll();
|
|
78
|
+
return txt(s.body);
|
|
46
79
|
},
|
|
47
80
|
};
|
|
48
81
|
|
|
49
|
-
//
|
|
50
|
-
// verify 指令會在沙箱實跑,exit 0 才落地(結晶=已驗證的成功,不是宣稱的成功)。下次 session 自動列入。
|
|
82
|
+
// 結晶層:把可重複流程寫成新技能。政策——須附 goal + 通過的 verify 才落地(verify 在沙箱實跑)。
|
|
51
83
|
const saveTool = {
|
|
52
84
|
name: 'skill_save', label: '結晶技能', readOnly: true,
|
|
53
|
-
description: '把一套你摸出來、會重複用到的流程「結晶」成可複用技能。政策:每個技能必須附 (1) goal 明確目標 (2) verify 一條可驗證它有效的指令——verify 會被實際執行,通過(exit 0)
|
|
85
|
+
description: '把一套你摸出來、會重複用到的流程「結晶」成可複用技能。政策:每個技能必須附 (1) goal 明確目標 (2) verify 一條可驗證它有效的指令——verify 會被實際執行,通過(exit 0)才會新增,否則拒絕並回傳輸出讓你修正。確保結晶的是「已驗證的成功」。與 playbook 的差別:playbook 是專案事實性 know-how,skill 是可複用且已驗證的操作流程/SOP。',
|
|
54
86
|
parameters: {
|
|
55
87
|
type: 'object',
|
|
56
88
|
properties: {
|
|
@@ -70,7 +102,6 @@ export function createSkills(dir, { verifyRunner } = {}) {
|
|
|
70
102
|
if (!verify || !String(verify).trim()) return txt({ error: '缺 verify:必須提供可驗證有效的指令(測試完成才能新增)' });
|
|
71
103
|
if (typeof verifyRunner !== 'function') return txt({ error: '此環境不支援技能驗證,無法新增(須在 kernel 內執行)' });
|
|
72
104
|
|
|
73
|
-
// 政策閘門:先實跑驗證,通過才落地
|
|
74
105
|
const vr = await verifyRunner(String(verify).trim());
|
|
75
106
|
if (vr.blocked) return txt({ error: '驗證被安全策略擋下,未新增', reason: vr.reason, verify });
|
|
76
107
|
if (!vr.ok) return txt({ error: '驗證未通過,未新增技能。請修正步驟或指令後重試。', exitCode: vr.code, output: vr.output, verify });
|
|
@@ -82,27 +113,49 @@ export function createSkills(dir, { verifyRunner } = {}) {
|
|
|
82
113
|
`## 目標\n${String(goal).trim()}\n\n${String(body).trim()}\n\n## 驗證(已通過 exit 0)\n\`\`\`sh\n${String(verify).trim()}\n\`\`\`\n`;
|
|
83
114
|
try {
|
|
84
115
|
mkdirSync(dir, { recursive: true });
|
|
85
|
-
const existed = existsSync(
|
|
86
|
-
writeFileSync(
|
|
116
|
+
const existed = existsSync(fileOf(nm));
|
|
117
|
+
writeFileSync(fileOf(nm), content);
|
|
87
118
|
skills = readAll();
|
|
88
119
|
return txt({ [existed ? 'updated' : 'saved']: nm, verified: true, verifyOutput: vr.output, hint: '驗證通過,已結晶為技能;本 session 可用 skill 按名載入,下次 session 自動列入。' });
|
|
89
120
|
} catch (e) { return txt({ error: e.message }); }
|
|
90
121
|
},
|
|
91
122
|
};
|
|
92
123
|
|
|
124
|
+
// 漂移偵測(B):重跑每個技能存的 verify → 標 ✓ 仍有效 / ✗ 已失效(stale)。
|
|
125
|
+
const check = async () => {
|
|
126
|
+
const now = new Date().toISOString();
|
|
127
|
+
const results = [];
|
|
128
|
+
for (const s of readAll()) {
|
|
129
|
+
const verify = extractVerify(s.body);
|
|
130
|
+
if (!verify) { results.push({ name: s.name, status: 'no-verify' }); continue; }
|
|
131
|
+
if (typeof verifyRunner !== 'function') { results.push({ name: s.name, status: 'unchecked' }); continue; }
|
|
132
|
+
const vr = await verifyRunner(verify);
|
|
133
|
+
const ok = vr.ok && !vr.blocked;
|
|
134
|
+
patch(s.name, ok ? { stale: 'false', lastCheckedAt: now } : { stale: 'true', staleSince: now });
|
|
135
|
+
results.push({ name: s.name, status: vr.blocked ? 'blocked' : ok ? 'ok' : 'stale', exitCode: vr.code });
|
|
136
|
+
}
|
|
137
|
+
skills = readAll();
|
|
138
|
+
return results;
|
|
139
|
+
};
|
|
140
|
+
const checkTool = {
|
|
141
|
+
name: 'skills_check', label: '複查技能', readOnly: true,
|
|
142
|
+
description: '重新驗證所有已結晶技能(重跑各自的 verify),回報哪些仍有效、哪些已失效(stale)。專案變動後用來清理過時技能,避免誤用。',
|
|
143
|
+
parameters: { type: 'object', properties: {} },
|
|
144
|
+
execute: async () => txt({ checked: await check() }),
|
|
145
|
+
};
|
|
146
|
+
|
|
93
147
|
const remove = (name) => {
|
|
94
|
-
const nm = slug(name);
|
|
95
|
-
const file = join(dir, `${nm}.md`);
|
|
148
|
+
const nm = slug(name); const file = fileOf(nm);
|
|
96
149
|
if (!existsSync(file)) return { error: '找不到技能', name };
|
|
97
150
|
try { unlinkSync(file); skills = readAll(); return { removed: nm }; } catch (e) { return { error: e.message }; }
|
|
98
151
|
};
|
|
99
152
|
|
|
100
153
|
return {
|
|
101
154
|
skills, promptSection,
|
|
102
|
-
tool: loadTool,
|
|
103
|
-
tools: [loadTool, saveTool],
|
|
104
|
-
list: () => readAll().map(({ name, desc }) => ({ name, desc })),
|
|
105
|
-
remove,
|
|
155
|
+
tool: loadTool,
|
|
156
|
+
tools: [loadTool, saveTool, checkTool],
|
|
157
|
+
list: () => readAll().map(({ name, desc, used, stale }) => ({ name, desc, used, stale })),
|
|
158
|
+
check, remove,
|
|
106
159
|
reload: () => { skills = readAll(); return skills; },
|
|
107
160
|
};
|
|
108
161
|
}
|