xitto-kernel 0.1.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/xitto-kernel.js +3 -0
  4. package/docs/01-architecture.md +105 -0
  5. package/docs/02-domain-pack-spec.md +109 -0
  6. package/docs/03-kernel-contract.md +79 -0
  7. package/docs/04-migration-from-xitto-code.md +70 -0
  8. package/docs/05-example-packs.md +95 -0
  9. package/docs/06-authoring-a-pack.md +86 -0
  10. package/package.json +55 -0
  11. package/src/app/cli.js +243 -0
  12. package/src/app/index.js +5 -0
  13. package/src/app/main.js +87 -0
  14. package/src/app/markdown.js +36 -0
  15. package/src/app/providers.js +39 -0
  16. package/src/app/scaffold.js +40 -0
  17. package/src/app/templates/README.md.tmpl +32 -0
  18. package/src/app/templates/gitignore.tmpl +4 -0
  19. package/src/app/templates/index.js.tmpl +7 -0
  20. package/src/app/templates/pack.js.tmpl +32 -0
  21. package/src/app/templates/package.json.tmpl +12 -0
  22. package/src/index.js +15 -0
  23. package/src/kernel/agent-loop.js +285 -0
  24. package/src/kernel/compaction.js +65 -0
  25. package/src/kernel/guard-chain.js +42 -0
  26. package/src/kernel/hooks.js +47 -0
  27. package/src/kernel/index.js +291 -0
  28. package/src/kernel/mcp.js +54 -0
  29. package/src/kernel/memory.js +45 -0
  30. package/src/kernel/pack-loader.js +45 -0
  31. package/src/kernel/provider.js +20 -0
  32. package/src/kernel/security/allow.js +41 -0
  33. package/src/kernel/security/danger.js +37 -0
  34. package/src/kernel/security/permission-step.js +63 -0
  35. package/src/kernel/security/sandbox.js +111 -0
  36. package/src/kernel/session.js +36 -0
  37. package/src/kernel/skills.js +37 -0
  38. package/src/kernel/subagent.js +46 -0
  39. package/src/kernel/tool-registry.js +45 -0
  40. package/src/packs/coding/index.js +157 -0
  41. package/src/packs/data-query/index.js +67 -0
  42. package/src/packs/notes/index.js +79 -0
  43. package/src/types.js +79 -0
@@ -0,0 +1,291 @@
1
+ // Kernel 組裝 — 把一個 DomainPack 接上領域無關的執行期。
2
+ // 確定性那半部:pack 載入、工具註冊、mutatingTools 推導、固定順序守衛鏈、systemPrompt 組裝、
3
+ // 單一工具呼叫(runTool)。LLM 那半部:runTurn 驅動移植自 xitto-code 的 Agent loop
4
+ // (串流 + 多步工具循環 + 守衛接線)。壓縮/TUI 仍為後續接縫。
5
+ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
6
+ import { join, dirname, isAbsolute } from 'node:path';
7
+ import { loadPack } from './pack-loader.js';
8
+ import { createToolRegistry, deriveMutatingTools, isSandboxable } from './tool-registry.js';
9
+ import { composeGuards } from './guard-chain.js';
10
+ import { createPermissionStep } from './security/permission-step.js';
11
+ import { normalizeSandbox, wrapWithSeatbelt } from './security/sandbox.js';
12
+ import { createMemory } from './memory.js';
13
+ import { createSpawnTool } from './subagent.js';
14
+ import { createSkills } from './skills.js';
15
+ import { loadHooks, runPreToolHooks, runPostToolHooks } from './hooks.js';
16
+ import { maybeCompact, resolveCompactionSettings } from './compaction.js';
17
+ import { newSessionId, saveSession, loadSession, listSessions, latestSession } from './session.js';
18
+
19
+ // 載入 pack.contextFiles:從 cwd 逐層往上找每個檔名,找到就讀入並注入 system prompt(領域規範)。
20
+ // 對標 xitto-code 的 CLAUDE.md/AGENTS.md 載入;但檔名由 pack 決定(kernel 不認識具體檔名)。
21
+ function loadContextFiles(cwd, names) {
22
+ if (!Array.isArray(names) || !names.length) return '';
23
+ const found = [];
24
+ for (const name of names) {
25
+ let dir = cwd;
26
+ for (;;) {
27
+ const p = join(dir, name);
28
+ if (existsSync(p)) { try { found.push({ name, text: readFileSync(p, 'utf8') }); } catch { /* 略 */ } break; }
29
+ const parent = dirname(dir);
30
+ if (parent === dir) break; // 到根目錄
31
+ dir = parent;
32
+ }
33
+ }
34
+ if (!found.length) return '';
35
+ return '\n\n# 專案規範(讀自下列檔案,請遵守)\n' +
36
+ found.map((f) => `## ${f.name}\n${f.text.trim()}`).join('\n\n');
37
+ }
38
+
39
+ const DEFAULT_MEMORY_GUIDE =
40
+ '遇到值得跨 session 記住的事實(使用者偏好、建置/測試指令、踩過的坑、專案決策)時,當下就存一條。';
41
+
42
+ // 把 sandboxable 工具的命令在執行期包進 Seatbelt(macOS OS 級隔離)。
43
+ // 非 macOS / 沙箱關閉 / 無 command → wrapWithSeatbelt 回 null,跑原命令(仍受第 5 格靜態策略保護)。
44
+ function wrapSandboxable(tool, { cwd, getSandbox, getSandboxConfig }) {
45
+ if (!isSandboxable(tool) || typeof tool.execute !== 'function') return tool;
46
+ const orig = tool.execute.bind(tool);
47
+ return {
48
+ ...tool,
49
+ execute: (id, params, ...rest) => {
50
+ if (getSandbox?.() && params?.command) {
51
+ const wrapped = wrapWithSeatbelt(params.command, { cwd, cfg: getSandboxConfig?.() });
52
+ if (wrapped) params = { ...params, command: wrapped };
53
+ }
54
+ return orig(id, params, ...rest);
55
+ },
56
+ };
57
+ }
58
+
59
+ // undo 快照:mutating 且帶 args.path 的工具(檔案編輯類),執行前記錄檔案原狀,供 kernel.undo() 還原。
60
+ // 「以 path 指涉被改檔案」是常見約定;非檔案型 mutating 工具(bash/sql_exec 無 path)不受影響。
61
+ function wrapUndo(tool, { cwd, undoStack }) {
62
+ if (tool.mutating !== true || typeof tool.execute !== 'function') return tool;
63
+ const orig = tool.execute.bind(tool);
64
+ return {
65
+ ...tool,
66
+ execute: (id, params, ...rest) => {
67
+ if (params?.path) {
68
+ const p = isAbsolute(params.path) ? params.path : join(cwd, params.path);
69
+ try {
70
+ undoStack.push({ path: p, rel: params.path, before: existsSync(p) ? readFileSync(p, 'utf8') : null });
71
+ if (undoStack.length > 50) undoStack.shift();
72
+ } catch { /* 略 */ }
73
+ }
74
+ return orig(id, params, ...rest);
75
+ },
76
+ };
77
+ }
78
+
79
+ /**
80
+ * 建立 kernel 執行期。
81
+ * @param {import('../types.js').DomainPack} pack
82
+ * @param {Object} [config]
83
+ * @param {string} [config.cwd]
84
+ * @param {() => boolean} [config.getPlanMode] 計劃模式狀態(預設關)
85
+ * @param {(ctx: object) => import('../types.js').PolicyDecision} [config.circuitBreaker] 上下文熔斷(預設不熔斷)
86
+ * @param {(ctx: object) => import('../types.js').PolicyDecision} [config.preToolHooks] 使用者 PreToolUse hooks(預設無)
87
+ * @param {(name: string, args: object) => Promise<'yes'|'no'>} [config.confirm] mutating 工具的確認(預設放行)
88
+ * @param {Partial<import('../types.js').KernelServices>} [config.services]
89
+ * @param {object} [config.model] LLM model 物件(runTurn 需要)
90
+ * @param {(provider: string) => string|Promise<string>} [config.getApiKey] 取 API key(runTurn 需要)
91
+ * @param {Function} [config.streamFn] 注入串流(測試用 fake provider);預設用 pi-ai
92
+ * @param {'off'|'low'|'medium'|'high'} [config.thinkingLevel] 推理強度(預設依 model.reasoning)
93
+ */
94
+ export function createKernel(pack, config = {}) {
95
+ loadPack(pack);
96
+ const cwd = config.cwd || process.cwd();
97
+
98
+ // 每個 pack 在 cwd 下有獨立資料夾(記憶、session 分領域存放,互不混)
99
+ const dataDir = join(cwd, '.xitto-kernel', pack.name);
100
+ const memory = createMemory(join(dataDir, 'memory.md'));
101
+ const sessionsDir = join(dataDir, 'sessions');
102
+ const hooks = loadHooks(join(dataDir, 'settings.json')); // PreToolUse/PostToolUse
103
+ const skills = createSkills(join(dataDir, 'skills')); // 漸進揭露技能
104
+
105
+ // 沙箱策略:config.sandbox > pack.permissionPolicy.sandbox > 預設(關)。
106
+ const sandboxCfg = normalizeSandbox(config.sandbox ?? pack.permissionPolicy?.sandbox);
107
+ const getSandbox = config.getSandbox || (() => !!sandboxCfg.enabled);
108
+ const getSandboxConfig = () => sandboxCfg;
109
+
110
+ // 工具:pack 工具(sandboxable 包 Seatbelt、mutating+path 加 undo 快照)+ kernel 內建記憶工具 + spawn_agent。
111
+ const undoStack = [];
112
+ const baseTools = [
113
+ ...pack.tools().map((t) => wrapUndo(wrapSandboxable(t, { cwd, getSandbox, getSandboxConfig }), { cwd, undoStack })),
114
+ ...memory.tools,
115
+ ...(skills.tool ? [skills.tool] : []),
116
+ ...(config.extraTools || []), // 外部注入(MCP 工具等):由 app 層先 async 載入再傳入
117
+ ];
118
+ // spawn_agent:派唯讀子 agent。其可用工具 = 所有唯讀工具(不含 spawn_agent 自己,避免遞迴)。
119
+ let allTools = baseTools;
120
+ const spawnTool = createSpawnTool({
121
+ getModel: () => config.model,
122
+ getApiKey: config.getApiKey,
123
+ getReadOnlyTools: () => allTools.filter((t) => t.readOnly === true && t.name !== 'spawn_agent'),
124
+ });
125
+ const tools = [...baseTools, spawnTool];
126
+ allTools = tools;
127
+ const registry = createToolRegistry(tools);
128
+ const mutatingTools = new Set(deriveMutatingTools(pack, tools));
129
+ const services = {
130
+ cwd,
131
+ memory: { save: memory.save, list: memory.list },
132
+ sandbox: { isOn: () => getSandbox(), config: () => getSandboxConfig() },
133
+ ...(config.services || {}),
134
+ };
135
+
136
+ const memText = memory.load();
137
+ const systemPrompt =
138
+ pack.systemPrompt +
139
+ loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
140
+ '\n\n# 記憶\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) +
141
+ (memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
142
+ skills.promptSection();
143
+
144
+ const getPlanMode = config.getPlanMode || (() => false);
145
+
146
+ // 守衛鏈第 5 格:真實權限/沙箱(A 半部:靜態策略 deny/網路/提權/越界寫入 + 危險命令)。
147
+ const permission = createPermissionStep({
148
+ registry, getSandbox, getSandboxConfig,
149
+ deny: pack.permissionPolicy?.deny || [],
150
+ confirm: config.confirm,
151
+ });
152
+
153
+ const guard = composeGuards({
154
+ planGuard: (ctx) => (getPlanMode() && mutatingTools.has(ctx.name)
155
+ ? { block: true, reason: '計劃模式:只能規劃不能執行。請描述你打算怎麼做。' }
156
+ : undefined),
157
+ circuitBreaker: config.circuitBreaker || (() => undefined),
158
+ packPreTool: pack.preToolPolicy,
159
+ preToolHooks: config.preToolHooks || ((ctx) => runPreToolHooks(hooks, ctx.name, cwd)), // 第 4 格:PreToolUse
160
+ permission,
161
+ services,
162
+ });
163
+
164
+ return {
165
+ pack,
166
+ registry,
167
+ mutatingTools,
168
+ systemPrompt,
169
+ services,
170
+ permissionPolicy: pack.permissionPolicy || {},
171
+ sandbox: { isOn: () => getSandbox(), config: () => getSandboxConfig() },
172
+ memory,
173
+ /** 撤銷上一次檔案改動(write/edit):還原內容,新建的檔則刪除。 */
174
+ undo: () => {
175
+ const snap = undoStack.pop();
176
+ if (!snap) return { undone: false, reason: '沒有可撤銷的改動' };
177
+ try {
178
+ if (snap.before === null) { if (existsSync(snap.path)) unlinkSync(snap.path); }
179
+ else writeFileSync(snap.path, snap.before, 'utf8');
180
+ return { undone: true, path: snap.rel, created: snap.before === null };
181
+ } catch (e) { return { undone: false, reason: e.message }; }
182
+ },
183
+ // session 持久化 / resume(按 pack 分目錄)。CLI 用它存檔與續接。
184
+ session: {
185
+ dir: sessionsDir,
186
+ newId: () => newSessionId(),
187
+ save: (id, messages) => saveSession(sessionsDir, id, { messages, model: config.model }),
188
+ load: (id) => loadSession(sessionsDir, id),
189
+ list: () => listSessions(sessionsDir),
190
+ latest: () => latestSession(sessionsDir),
191
+ },
192
+ /** 對一次工具呼叫跑守衛鏈,回傳決策(不執行)。 */
193
+ guardToolCall: (toolCall) => guard(toolCall),
194
+ /**
195
+ * 跑守衛鏈,通過才執行工具。回 { blocked, reason } 或 { result }。
196
+ * 這是 agent loop 中「一次工具呼叫」的確定性切片,可不靠 LLM 測試/示範。
197
+ */
198
+ runTool: async (name, args = {}, extra = {}) => {
199
+ const ctx = { name, args, ...extra };
200
+ const decision = await guard(ctx);
201
+ if (decision?.block) return { blocked: true, reason: decision.reason };
202
+ const tool = registry.get(name);
203
+ const result = await tool.execute(`call-${name}`, args, extra.signal, extra.onUpdate, services);
204
+ return { result };
205
+ },
206
+ /**
207
+ * 跑一輪:把使用者輸入交給 LLM,驅動「串流 → 工具呼叫(過守衛鏈)→ 回灌 → 再串流」多步循環,
208
+ * 直到模型不再呼叫工具。移植自 xitto-code 的 Agent loop;守衛鏈接到 Agent.beforeToolCall。
209
+ * @param {string} input
210
+ * @param {{ onEvent?: (e: object) => void, onAgent?: (agent: object) => void, history?: object[] }} [opts]
211
+ * onAgent:建立 Agent 後、prompt 前同步回呼(讓 CLI 拿到 agent 以便 Ctrl+C abort)。
212
+ * history:延續上一輪的 messages(多輪對話)。
213
+ * @returns {Promise<{ text: string, messages: object[], agent: object, aborted: boolean }>}
214
+ */
215
+ runTurn: async (input, opts = {}) => {
216
+ if (!config.model) throw new Error('runTurn 需要 config.model(LLM model 物件)。');
217
+ if (!config.getApiKey) throw new Error('runTurn 需要 config.getApiKey。');
218
+ const { Agent } = await import('./agent-loop.js');
219
+ const streamFn = config.streamFn || (await import('./provider.js')).defaultStreamFn();
220
+ const model = config.model;
221
+
222
+ const agent = new Agent({
223
+ initialState: {
224
+ systemPrompt,
225
+ model,
226
+ tools: registry.all(),
227
+ messages: opts.history || [], // 多輪對話:延續歷史
228
+ thinkingLevel: config.thinkingLevel || (model.reasoning ? 'medium' : 'off'),
229
+ },
230
+ getApiKey: config.getApiKey,
231
+ streamFn,
232
+ // 守衛鏈接線:Agent 的 ctx 形狀 → kernel guard 的 { name, args }
233
+ beforeToolCall: async (ctx) => guard({
234
+ name: ctx.toolCall?.name,
235
+ args: ctx.args,
236
+ assistantMessage: ctx.assistantMessage,
237
+ }),
238
+ // 回合內壓縮:每次串流前若逼近視窗,就地摘要較舊對話、保留最近,繼續同回合
239
+ maybeCompactInTurn: async () => {
240
+ const s = resolveCompactionSettings(config.compaction, model.contextWindow);
241
+ if (!s.enabled) return null;
242
+ let apiKey; try { apiKey = await config.getApiKey(model.provider); } catch { return null; }
243
+ const info = await maybeCompact(agent, model, apiKey, s);
244
+ if (info && !info.error) opts.onEvent?.({ type: 'compact', ...info });
245
+ return info;
246
+ },
247
+ toolExecution: 'sequential',
248
+ });
249
+ // 追蹤本輪改動 + PostToolUse hooks(成功工具後跑命令,失敗回灌讓 agent 修正)
250
+ let turnModified = false;
251
+ agent.subscribe((e) => {
252
+ if (e.type !== 'tool_execution_end' || e.isError) return;
253
+ if (mutatingTools.has(e.toolName)) turnModified = true;
254
+ for (const f of runPostToolHooks(hooks, e.toolName, cwd)) {
255
+ opts.onEvent?.({ type: 'hook_fail', command: f.command, output: f.output });
256
+ agent.steer({ role: 'user', content: `[PostToolUse] \`${f.command}\` 失敗:\n${(f.output || '').slice(0, 2000)}\n請修正後再繼續。` });
257
+ }
258
+ });
259
+ if (opts.onEvent) agent.subscribe((e) => opts.onEvent(e));
260
+ opts.onAgent?.(agent); // 讓呼叫端拿到 agent(Ctrl+C → agent.abort())
261
+
262
+ await agent.prompt(input);
263
+ const wasAborted = () => [...agent.state.messages].reverse().find((m) => m.role === 'assistant')?.stopReason === 'aborted';
264
+
265
+ // 收尾自我驗收(pack.verify):失敗則把輸出回灌讓 agent 修正,最多 maxRounds 輪。
266
+ // 對標 xitto-code 的 runAutoVerify;機制在 kernel、「跑什麼/何時跑」由 pack 決定。
267
+ if (pack.verify && !wasAborted()) {
268
+ const maxRounds = Number.isInteger(pack.verify.maxRounds) ? pack.verify.maxRounds : 2;
269
+ for (let round = 0; round < maxRounds; round++) {
270
+ const vctx = { turnModified, cwd };
271
+ const shouldRun = pack.verify.shouldRun ? pack.verify.shouldRun(vctx) : turnModified;
272
+ if (!shouldRun) break;
273
+ opts.onEvent?.({ type: 'verify_start' });
274
+ let v;
275
+ try { v = await pack.verify.run(vctx); } catch (e) { v = { ok: false, output: e?.message || String(e) }; }
276
+ opts.onEvent?.({ type: 'verify_end', ok: !!v?.ok, output: v?.output });
277
+ if (v?.ok) break;
278
+ turnModified = false;
279
+ await agent.prompt(`[自動驗收] 驗證失敗,輸出如下:\n${String(v?.output || '').slice(0, 4000)}\n請修正使其通過。`);
280
+ if (wasAborted() || !turnModified) break; // 中斷或 agent 沒再改 → 停止避免空轉
281
+ }
282
+ }
283
+
284
+ const messages = agent.state.messages;
285
+ const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
286
+ const text = (lastAssistant?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
287
+ const aborted = lastAssistant?.stopReason === 'aborted';
288
+ return { text, messages, agent, aborted };
289
+ },
290
+ };
291
+ }
@@ -0,0 +1,54 @@
1
+ // MCP 工具接入 — kernel 內建。.xitto-kernel/<pack>/mcp.json 連 MCP server(stdio),
2
+ // 把 server 的工具以 mcp__<server>__<tool> 注入。連線失敗的 server 自動略過。
3
+ // 非同步(連線需 await),故由 app 層在啟動時載入後以 config.extraTools 傳入 createKernel。
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+
6
+ const noop = { tools: [], close: async () => {} };
7
+
8
+ /**
9
+ * @param {string} mcpConfigPath .xitto-kernel/<pack>/mcp.json
10
+ * @param {(msg: string) => void} [onLog]
11
+ * @returns {Promise<{ tools: import('../types.js').Tool[], close: () => Promise<void> }>}
12
+ */
13
+ export async function loadMcpTools(mcpConfigPath, onLog = () => {}) {
14
+ if (!existsSync(mcpConfigPath)) return noop;
15
+ let cfg;
16
+ try { cfg = JSON.parse(readFileSync(mcpConfigPath, 'utf8')); } catch { onLog('mcp.json 解析失敗,略過'); return noop; }
17
+ const servers = cfg.mcpServers || {};
18
+ if (!Object.keys(servers).length) return noop;
19
+
20
+ let Client, StdioClientTransport;
21
+ try {
22
+ ({ Client } = await import('@modelcontextprotocol/sdk/client/index.js'));
23
+ ({ StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js'));
24
+ } catch { onLog('未安裝 @modelcontextprotocol/sdk,略過 MCP'); return noop; }
25
+
26
+ const clients = [];
27
+ const tools = [];
28
+ for (const [name, sc] of Object.entries(servers)) {
29
+ if (!sc || typeof sc.command !== 'string') continue;
30
+ try {
31
+ const transport = new StdioClientTransport({ command: sc.command, args: sc.args || [], env: { ...process.env, ...(sc.env || {}) } });
32
+ const client = new Client({ name: 'xitto-kernel', version: '0.0.1' }, { capabilities: {} });
33
+ await client.connect(transport);
34
+ const { tools: mcpTools } = await client.listTools();
35
+ for (const t of mcpTools) {
36
+ tools.push({
37
+ name: `mcp__${name}__${t.name}`, label: `${name}:${t.name}`,
38
+ mutating: true, // 外部工具保守視為有副作用:走權限確認、計劃模式擋下
39
+ description: t.description || `MCP ${name} 的 ${t.name}`,
40
+ parameters: t.inputSchema || { type: 'object', properties: {} },
41
+ execute: async (_id, args) => {
42
+ try {
43
+ const r = await client.callTool({ name: t.name, arguments: args || {} });
44
+ return { content: r.content || [{ type: 'text', text: JSON.stringify(r) }] };
45
+ } catch (e) { return { content: [{ type: 'text', text: JSON.stringify({ error: e?.message || String(e) }) }] }; }
46
+ },
47
+ });
48
+ }
49
+ clients.push(client);
50
+ onLog(`MCP ${name}:載入 ${mcpTools.length} 個工具`);
51
+ } catch (e) { onLog(`MCP ${name} 連線失敗,略過:${e?.message || e}`); }
52
+ }
53
+ return { tools, close: async () => { for (const c of clients) { try { await c.close(); } catch { /* 略 */ } } } };
54
+ }
@@ -0,0 +1,45 @@
1
+ // 跨 session 持久記憶 — kernel 內建能力(任何 pack 都自動獲得 memory_save / memory_list)。
2
+ // 存成 markdown 一行一條,啟動時載入注入 system prompt。對標 xitto-code memory.js,但領域無關。
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
5
+
6
+ const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
7
+
8
+ /**
9
+ * @param {string} file 記憶檔路徑(如 <cwd>/.xitto-kernel/<pack>/memory.md)
10
+ */
11
+ export function createMemory(file) {
12
+ const readLines = () => (existsSync(file)
13
+ ? readFileSync(file, 'utf8').split('\n').map((l) => l.replace(/^-\s*/, '').trim()).filter(Boolean)
14
+ : []);
15
+
16
+ const load = () => (existsSync(file) ? readFileSync(file, 'utf8').trim() : '');
17
+ const list = () => readLines();
18
+ const save = (value) => {
19
+ const v = String(value || '').trim();
20
+ if (!v) return { error: 'value 不可為空' };
21
+ const lines = readLines();
22
+ if (lines.includes(v)) return { skipped: true };
23
+ mkdirSync(dirname(file), { recursive: true });
24
+ writeFileSync(file, lines.concat(v).map((l) => `- ${l}`).join('\n') + '\n');
25
+ return { saved: v };
26
+ };
27
+
28
+ // memory_save/list 只動 kernel 自己的記憶檔(agent 簿記),標 readOnly → 守衛鏈自動放行
29
+ const tools = [
30
+ {
31
+ name: 'memory_save', label: '存記憶', readOnly: true,
32
+ description: '把值得跨 session 記住的事實存起來(使用者偏好、建置/測試指令、踩過的坑、決策)。一句、自給自足。',
33
+ parameters: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] },
34
+ execute: async (_id, { value }) => txt(save(value)),
35
+ },
36
+ {
37
+ name: 'memory_list', label: '讀記憶', readOnly: true,
38
+ description: '列出已記住的事實(回憶過往偏好/決策時用)。',
39
+ parameters: { type: 'object', properties: {} },
40
+ execute: async () => txt({ memories: list() }),
41
+ },
42
+ ];
43
+
44
+ return { load, list, save, tools };
45
+ }
@@ -0,0 +1,45 @@
1
+ // DomainPack 載入與驗證 — kernel 對 pack 的入口契約。
2
+ // 必填:name / tools / systemPrompt。選填欄位若提供則檢查型別。
3
+ // 對應 docs/02-domain-pack-spec.md 的「必填/選填總表」。
4
+
5
+ /**
6
+ * 驗證一個 DomainPack,回傳錯誤訊息陣列(空陣列 = 合法)。
7
+ * @param {import('../types.js').DomainPack} pack
8
+ * @returns {string[]}
9
+ */
10
+ export function validatePack(pack) {
11
+ if (!pack || typeof pack !== 'object') return ['pack 必須是物件'];
12
+ const errors = [];
13
+
14
+ // ── 必填 ──
15
+ if (typeof pack.name !== 'string' || !pack.name.trim()) errors.push('name:必填,需為非空字串');
16
+ if (typeof pack.tools !== 'function') errors.push('tools:必填,需為函數 () => Tool[]');
17
+ if (typeof pack.systemPrompt !== 'string' || !pack.systemPrompt.trim()) errors.push('systemPrompt:必填,需為非空字串');
18
+
19
+ // ── 選填(提供才檢查型別)──
20
+ if (pack.contextFiles !== undefined && !isStringArray(pack.contextFiles)) errors.push('contextFiles:需為字串陣列');
21
+ if (pack.mutatingTools !== undefined && !isStringArray(pack.mutatingTools)) errors.push('mutatingTools:需為字串陣列');
22
+ if (pack.verify !== undefined && typeof pack.verify?.run !== 'function') errors.push('verify.run:需為函數');
23
+ if (pack.preToolPolicy !== undefined && typeof pack.preToolPolicy?.check !== 'function') errors.push('preToolPolicy.check:需為函數');
24
+ if (pack.permissionPolicy !== undefined && (typeof pack.permissionPolicy !== 'object' || pack.permissionPolicy === null)) errors.push('permissionPolicy:需為物件');
25
+ if (pack.memoryGuide !== undefined && typeof pack.memoryGuide !== 'string') errors.push('memoryGuide:需為字串');
26
+
27
+ return errors;
28
+ }
29
+
30
+ /**
31
+ * 載入並驗證 pack;不合法則丟出聚合錯誤。回傳原 pack(不變更)。
32
+ * @param {import('../types.js').DomainPack} pack
33
+ * @returns {import('../types.js').DomainPack}
34
+ */
35
+ export function loadPack(pack) {
36
+ const errors = validatePack(pack);
37
+ if (errors.length) {
38
+ throw new Error(`DomainPack「${pack?.name ?? '?'}」不合法:\n- ${errors.join('\n- ')}`);
39
+ }
40
+ return pack;
41
+ }
42
+
43
+ function isStringArray(v) {
44
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
45
+ }
@@ -0,0 +1,20 @@
1
+ // Provider 呼叫適配 — kernel 怎麼正確地調用 LLM provider(與「provider 設定」不同,後者屬 app)。
2
+ // 預設 streamFn 包 pi-ai 的 streamSimple,並處理 anthropic 相容端點的 prompt caching 相容性。
3
+ import { streamSimple } from '@mariozechner/pi-ai';
4
+
5
+ // 該 model 是否該關掉 prompt caching:'none' = 關閉。
6
+ // pi-ai 對所有 anthropic-messages provider 預設加 cache_control,但只有「真正的 Anthropic」端點支援;
7
+ // MiniMax 等 anthropic 相容端點會因此回 500,需關閉。(沿用 xitto-code config.cacheRetentionFor)
8
+ export function cacheRetentionFor(model) {
9
+ if (!model) return undefined;
10
+ if (model.cache === false) return 'none';
11
+ if (model.cache === true) return undefined;
12
+ if (model.api !== 'anthropic-messages') return undefined;
13
+ return /(^|\/\/|\.)anthropic\.com/.test(model.baseUrl || '') ? undefined : 'none';
14
+ }
15
+
16
+ // 預設 streamFn:Agent loop 用它串流一次 assistant 回應。
17
+ export function defaultStreamFn() {
18
+ return (model, ctx, opts) =>
19
+ streamSimple(model, ctx, { ...opts, cacheRetention: cacheRetentionFor(model) || opts.cacheRetention });
20
+ }
@@ -0,0 +1,41 @@
1
+ // 細粒度權限:bash 命令「簽章」與 allow.json 的解析/序列化(向後相容舊格式)。
2
+ // 對標 Claude Code 的 Bash(npm test) 規則 —— 放行某類命令而非整個 bash 工具。
3
+
4
+ // 帶子命令的工具:簽章取前兩詞(npm test、git status、docker compose),其餘取首詞(ls、cat)。
5
+ // 這讓「允許 npm test」只放行 npm test 系列,npm install / npm run build 仍需確認。
6
+ const SUBCMD = new Set([
7
+ 'npm', 'pnpm', 'yarn', 'npx', 'bun', 'deno',
8
+ 'git', 'cargo', 'go', 'docker', 'make', 'kubectl',
9
+ 'python', 'python3', 'pip', 'pip3', 'node', 'dotnet', 'mvn', 'gradle',
10
+ ]);
11
+
12
+ export function commandSignature(cmd) {
13
+ if (typeof cmd !== 'string') return '';
14
+ const t = cmd.trim().split(/\s+/).filter(Boolean);
15
+ if (!t.length) return '';
16
+ // env 前綴(VAR=val cmd)跳過
17
+ let i = 0;
18
+ while (i < t.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[i])) i++;
19
+ const head = t.slice(i);
20
+ if (!head.length) return '';
21
+ if (head.length >= 2 && SUBCMD.has(head[0])) return `${head[0]} ${head[1]}`;
22
+ return head[0];
23
+ }
24
+
25
+ // 解析 allow.json:向後相容。
26
+ // 舊格式:工具名字串陣列 ["bash","write"]
27
+ // 新格式:{ tools: ["write"], bash: ["npm test","git status"] }
28
+ export function parseAllowFile(raw) {
29
+ if (Array.isArray(raw)) return { tools: raw.filter((x) => typeof x === 'string'), bash: [] };
30
+ if (raw && typeof raw === 'object') {
31
+ return {
32
+ tools: Array.isArray(raw.tools) ? raw.tools.filter((x) => typeof x === 'string') : [],
33
+ bash: Array.isArray(raw.bash) ? raw.bash.filter((x) => typeof x === 'string') : [],
34
+ };
35
+ }
36
+ return { tools: [], bash: [] };
37
+ }
38
+
39
+ export function serializeAllow(tools, bash) {
40
+ return JSON.stringify({ tools: [...tools], bash: [...bash] }, null, 2);
41
+ }
@@ -0,0 +1,37 @@
1
+ // 危險 bash 命令偵測 — 即使使用者對 bash 選了「本次全部允許」,命中這些高破壞性模式時
2
+ // 仍強制二次確認(且不因「always」而永久放行)。高精準、低誤報為原則:只攔真正不可逆/系統級操作。
3
+ // 回傳一句中文原因字串,安全則回傳 null。
4
+
5
+ const RULES = [
6
+ // rm 遞迴 + 強制刪除(-rf / -fr / -r -f 任意組合)
7
+ [(c) => /\brm\b/.test(c) && /-\w*r/.test(c) && /-\w*f/.test(c), 'rm 遞迴強制刪除(不可復原)'],
8
+ // 刪到根目錄 / 家目錄 / 萬用字元根
9
+ [(c) => /\brm\b[\s\S]*\s(-\w+\s+)*(\/|~|\$home|\/\*)(\s|$)/.test(c), 'rm 目標為根/家目錄'],
10
+ // 下載內容直接交給 shell 執行(curl|sh、wget|bash、… | python)
11
+ [(c) => /\b(curl|wget|fetch)\b[\s\S]*\|\s*(sudo\s+)?(sh|bash|zsh|dash|python\d?|node|perl|ruby)\b/.test(c), '下載內容直接管道給 shell 執行'],
12
+ // fork bomb :(){ :|:& };:
13
+ [(c, raw) => /:\(\)\s*\{[\s\S]*\|[\s\S]*&[\s\S]*\}/.test(raw) || /:\(\)\{:\|:&\};:/.test(raw.replace(/\s/g, '')), 'fork bomb'],
14
+ // 格式化檔案系統
15
+ [(c) => /\bmkfs(\.\w+)?\b/.test(c), '格式化檔案系統(mkfs)'],
16
+ // dd 寫入裝置
17
+ [(c) => /\bdd\b[\s\S]*\bof=\/dev\//.test(c), 'dd 直接寫入裝置'],
18
+ // 重導向覆寫區塊裝置
19
+ [(c) => />\s*\/dev\/(sd|nvme|disk|hd|mmcblk)/.test(c), '覆寫區塊裝置'],
20
+ // 對根遞迴改權限/擁有者
21
+ [(c) => /\b(chmod|chown)\b[\s\S]*-\w*r[\s\S]*\s\/(\s|$)/.test(c), '對根目錄遞迴 chmod/chown'],
22
+ // 關機 / 重啟
23
+ [(c) => /\b(shutdown|reboot|halt|poweroff|init\s+0)\b/.test(c), '關機/重啟'],
24
+ // 清空磁碟 via /dev/zero|null 寫整顆盤
25
+ [(c) => /\b(cat|cp)\b[\s\S]*\/dev\/(zero|random|urandom)[\s\S]*>\s*\/dev\//.test(c), '寫入裝置(清空磁碟)'],
26
+ ];
27
+
28
+ export function dangerousReason(cmd) {
29
+ if (typeof cmd !== 'string') return null;
30
+ const raw = cmd;
31
+ const c = cmd.toLowerCase();
32
+ if (!c.trim()) return null;
33
+ for (const [test, reason] of RULES) {
34
+ try { if (test(c, raw)) return reason; } catch { /* 規則出錯不阻塞 */ }
35
+ }
36
+ return null;
37
+ }
@@ -0,0 +1,63 @@
1
+ // 守衛鏈第 5 格:真實權限/沙箱(領域無關,metadata 驅動)。
2
+ // 對標 xitto-code permissions.js,但不寫死 READ_ONLY/SHELL_TOOLS 名單——
3
+ // 「唯讀」「可沙箱」皆由工具自帶 metadata 決定,故任何領域通用。
4
+ // 檢查順序:deny → 沙箱靜態策略違規 → 危險命令 → 命令簽章白名單 / 確認。
5
+ import { sandboxViolation } from './sandbox.js';
6
+ import { dangerousReason } from './danger.js';
7
+ import { commandSignature } from './allow.js';
8
+
9
+ /**
10
+ * @param {Object} o
11
+ * @param {{ get: (name: string) => object }} o.registry
12
+ * @param {() => boolean} [o.getSandbox] 沙箱是否開啟
13
+ * @param {() => object} [o.getSandboxConfig] 沙箱策略(blockNetwork/allowWritePrefixes)
14
+ * @param {string[]} [o.deny] 禁止的工具名 / "bash:<簽章>"
15
+ * @param {(name: string, args: object, danger: string|null) => Promise<'yes'|'no'|'always'|'command'>} [o.confirm]
16
+ * 互動確認;不提供(headless)時:危險命令一律擋、其餘放行(沙箱靜態違規仍先擋)。
17
+ * @returns {(ctx: { name: string, args: object }) => Promise<import('../../types.js').PolicyDecision>}
18
+ */
19
+ export function createPermissionStep({ registry, getSandbox, getSandboxConfig, deny = [], confirm }) {
20
+ const denySet = new Set(deny);
21
+ const allowedSignatures = new Set(); // session 內「允許此命令簽章全部」
22
+ const alwaysTools = new Set(); // session 內「允許此工具全部」(使用者選 always)
23
+
24
+ return async function permission(ctx) {
25
+ const name = ctx.name;
26
+ const tool = registry.get(name);
27
+ if (!tool) return { block: true, reason: `未知工具:${name}` };
28
+ if (tool.readOnly === true) return undefined; // metadata 驅動:唯讀自動放行
29
+
30
+ const cmd = ctx.args?.command || ctx.args?.cmd || '';
31
+ const isShell = tool.sandboxable === true || typeof cmd === 'string' && cmd.length > 0;
32
+ const sig = isShell ? commandSignature(cmd) : null;
33
+
34
+ // 1) deny 規則優先於一切
35
+ if (denySet.has(name) || (sig && denySet.has(`bash:${sig}`))) {
36
+ return { block: true, reason: `${name}${sig ? `(${sig})` : ''} 已被 deny 規則禁止。` };
37
+ }
38
+
39
+ // 2) 沙箱靜態策略:違規(網路/提權/越界寫入)直接擋,不執行也不詢問
40
+ if (isShell && getSandbox?.()) {
41
+ const v = sandboxViolation(cmd, getSandboxConfig?.() || {});
42
+ if (v) return { block: true, reason: `${v}。可關閉沙箱或調整策略。` };
43
+ }
44
+
45
+ // 3) 危險命令:即使 always-allow / 無 confirm 也強制把關(headless 直接擋)
46
+ const danger = isShell ? dangerousReason(cmd) : null;
47
+
48
+ // 4) 非危險:本工具/命令簽章已 always-allow → 直接過;headless(無 confirm)→ 放行
49
+ if (!danger) {
50
+ if (alwaysTools.has(name) || (sig && allowedSignatures.has(sig))) return undefined;
51
+ if (!confirm) return undefined;
52
+ } else if (!confirm) {
53
+ return { block: true, reason: `偵測到危險命令(${danger}),headless 模式下拒絕執行。` };
54
+ }
55
+
56
+ // 5) 互動確認(危險命令即使選 always 也只放行這次,不永久放行)
57
+ const decision = await confirm(name, ctx.args, danger);
58
+ if (decision === 'always' && !danger) { alwaysTools.add(name); return undefined; }
59
+ if (decision === 'command' && sig && !danger) { allowedSignatures.add(sig); return undefined; }
60
+ if (decision === 'yes') return undefined;
61
+ return { block: true, reason: `使用者拒絕執行 ${name}。` };
62
+ };
63
+ }