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,32 @@
1
+ // __NAME__ 領域 agent 的 DomainPack —— 你只需要寫「會什麼、守什麼、像什麼」。
2
+ // runtime(多步循環/串流/權限/沙箱/CLI)由 xitto-kernel 提供,不用碰。
3
+ import { defineDomainPack } from 'xitto-kernel';
4
+
5
+ export function createPack({ cwd = process.cwd() } = {}) {
6
+ return defineDomainPack({
7
+ name: '__NAME__',
8
+ systemPrompt: '你是 __NAME__ 領域的 agent。在這裡寫行為準則、口吻、流程規矩。',
9
+
10
+ // 工具 = 這個 agent 能對世界做的動作。metadata 決定安全行為:
11
+ // readOnly:true → 自動放行 | mutating:true → 計劃模式擋、算進 mutatingTools
12
+ // sandboxable:true → 走 shell,沙箱開時自動包 Seatbelt(需 params.command)
13
+ tools: () => [
14
+ {
15
+ name: 'example',
16
+ label: '範例工具',
17
+ description: '把這個換成你的領域動作(描述要清楚,模型靠它決定何時呼叫)',
18
+ readOnly: true,
19
+ parameters: { type: 'object', properties: { input: { type: 'string' } }, required: ['input'] },
20
+ execute: async (_id, { input }) => ({ content: [{ type: 'text', text: `(範例)收到:${input}` }] }),
21
+ },
22
+ ],
23
+
24
+ // ── 選填插槽(要更專業時才填;完整說明見 xitto-kernel docs/06)──
25
+ // contextFiles: ['__NAME__.md'], // 啟動載入的領域規範檔
26
+ // preToolPolicy: { // 領域硬規矩(程式擋,不靠模型自律)
27
+ // check: (ctx) => { /* 例:做 X 前必先 Y,回 { block:true, reason } 擋下 */ },
28
+ // },
29
+ // verify: { run: async () => ({ ok: true }) }, // 每輪收尾自我驗收
30
+ // permissionPolicy: { sandbox: { enabled: false } },
31
+ });
32
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "__NAME__",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "private": true,
6
+ "scripts": {
7
+ "start": "node index.js"
8
+ },
9
+ "dependencies": {
10
+ "xitto-kernel": "file:__KERNEL_PATH__"
11
+ }
12
+ }
package/src/index.js ADDED
@@ -0,0 +1,15 @@
1
+ // xitto-kernel 公開 API。
2
+ export { createKernel } from './kernel/index.js';
3
+ export { loadPack, validatePack } from './kernel/pack-loader.js';
4
+ export { createToolRegistry, deriveMutatingTools, isReadOnly, isMutating, isSandboxable } from './kernel/tool-registry.js';
5
+ export { composeGuards } from './kernel/guard-chain.js';
6
+
7
+ /**
8
+ * 小幫手:定義一個 DomainPack(立即驗證,型別提示用)。
9
+ * @param {import('./types.js').DomainPack} pack
10
+ * @returns {import('./types.js').DomainPack}
11
+ */
12
+ export function defineDomainPack(pack) {
13
+ // 不在此丟錯,交給 createKernel/loadPack 時驗證;此函數僅為可讀性與型別錨點。
14
+ return pack;
15
+ }
@@ -0,0 +1,285 @@
1
+ // 自寫 agent loop — 取代 @mariozechner/pi-agent-core 的 Agent,保留 pi-ai 串流(streamFn)。
2
+ // 依 docs/agent-loop-spec.md 對齊契約。關鍵差異:直接在 live this._state.messages 上 push/replace
3
+ // (不像 pi 的 createContextSnapshot 做 slice),每輪迭代用當下 messages 建 llmContext——
4
+ // 為「回合內真壓縮」(階段二)鋪路。階段一行為先對齊 pi。
5
+
6
+ const EMPTY_USAGE = {
7
+ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0,
8
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
9
+ };
10
+ const DEFAULT_MODEL = {
11
+ id: 'unknown', name: 'unknown', api: 'unknown', provider: 'unknown', baseUrl: '',
12
+ reasoning: false, input: [], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
13
+ contextWindow: 0, maxTokens: 0,
14
+ };
15
+
16
+ // 預設:送給 LLM 的訊息過濾成 user/assistant/toolResult
17
+ function defaultConvertToLlm(messages) {
18
+ return messages.filter((m) => m.role === 'user' || m.role === 'assistant' || m.role === 'toolResult');
19
+ }
20
+ const errResult = (msg) => ({ content: [{ type: 'text', text: msg }], details: {} });
21
+
22
+ // steering / followUp 佇列(預設 one-at-a-time)
23
+ class Queue {
24
+ constructor(mode = 'one-at-a-time') { this.mode = mode; this.items = []; }
25
+ enqueue(m) { this.items.push(m); }
26
+ hasItems() { return this.items.length > 0; }
27
+ drain() {
28
+ if (this.mode === 'all') { const d = this.items; this.items = []; return d; }
29
+ return this.items.length ? [this.items.shift()] : [];
30
+ }
31
+ clear() { this.items = []; }
32
+ }
33
+
34
+ export class Agent {
35
+ constructor(options = {}) {
36
+ const init = options.initialState || {};
37
+ let messages = (init.messages || []).slice();
38
+ let tools = (init.tools || []).slice();
39
+ this._state = {
40
+ systemPrompt: init.systemPrompt ?? '',
41
+ model: init.model ?? DEFAULT_MODEL,
42
+ thinkingLevel: init.thinkingLevel ?? 'off',
43
+ get tools() { return tools; },
44
+ set tools(v) { tools = (v || []).slice(); },
45
+ get messages() { return messages; }, // getter 回 live 陣列;可直接 push/replace
46
+ set messages(v) { messages = (v || []).slice(); }, // setter 才 slice(對齊 pi)
47
+ isStreaming: false,
48
+ streamingMessage: undefined,
49
+ errorMessage: undefined,
50
+ };
51
+ this.listeners = new Set();
52
+ this.streamFn = options.streamFn; // 必傳(xitto 都注入;缺則無法串流)
53
+ this.getApiKey = options.getApiKey;
54
+ this.beforeToolCall = options.beforeToolCall;
55
+ this.afterToolCall = options.afterToolCall;
56
+ this.onPayload = options.onPayload;
57
+ this.convertToLlm = options.convertToLlm || defaultConvertToLlm;
58
+ // 回合內真壓縮鉤子(可選):async () => info|null。每次串流前呼叫,可就地改寫 this._state.messages。
59
+ // pi-agent-core 因 snapshot 做不到回合中途壓縮;自寫 loop 直接操作 live messages,故此鉤子有效。
60
+ this.maybeCompactInTurn = options.maybeCompactInTurn;
61
+ this.toolExecution = options.toolExecution || 'parallel';
62
+ this.steeringQueue = new Queue(options.steeringMode);
63
+ this.followUpQueue = new Queue(options.followUpMode);
64
+ this.activeRun = undefined;
65
+ }
66
+
67
+ get state() { return this._state; }
68
+ get signal() { return this.activeRun?.abortController.signal; }
69
+ subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l); }
70
+ steer(m) { this.steeringQueue.enqueue(m); }
71
+ followUp(m) { this.followUpQueue.enqueue(m); }
72
+ abort() { this.activeRun?.abortController.abort(); }
73
+ waitForIdle() { return this.activeRun?.promise ?? Promise.resolve(); }
74
+ reset() {
75
+ this._state.messages = [];
76
+ this._state.isStreaming = false;
77
+ this._state.streamingMessage = undefined;
78
+ this._state.errorMessage = undefined;
79
+ this.steeringQueue.clear();
80
+ this.followUpQueue.clear();
81
+ }
82
+
83
+ // 依序 await 所有 listener(對齊 pi processEvents);同時維護 streamingMessage
84
+ async emit(event) {
85
+ if (event.type === 'message_start' || event.type === 'message_update') this._state.streamingMessage = event.message;
86
+ else if (event.type === 'message_end' || event.type === 'agent_end') this._state.streamingMessage = undefined;
87
+ else if (event.type === 'turn_end' && event.message?.errorMessage) this._state.errorMessage = event.message.errorMessage;
88
+ const signal = this.activeRun?.abortController.signal;
89
+ for (const l of this.listeners) await l(event, signal);
90
+ }
91
+
92
+ async prompt(input) {
93
+ if (this.activeRun) throw new Error('Agent is already processing a prompt. Use steer()/followUp() or wait.');
94
+ let msgs;
95
+ if (Array.isArray(input)) msgs = input;
96
+ else if (typeof input === 'string') msgs = [{ role: 'user', content: [{ type: 'text', text: input }], timestamp: Date.now() }];
97
+ else msgs = [input];
98
+ await this.runWithLifecycle((signal) => this.runLoop(msgs, signal));
99
+ }
100
+
101
+ async runWithLifecycle(executor) {
102
+ const abortController = new AbortController();
103
+ let resolve = () => {};
104
+ const promise = new Promise((r) => { resolve = r; });
105
+ this.activeRun = { promise, resolve, abortController };
106
+ this._state.isStreaming = true;
107
+ this._state.streamingMessage = undefined;
108
+ this._state.errorMessage = undefined;
109
+ try { await executor(abortController.signal); }
110
+ catch (err) { await this.handleRunFailure(err, abortController.signal.aborted); }
111
+ finally {
112
+ this._state.isStreaming = false;
113
+ this._state.streamingMessage = undefined;
114
+ this.activeRun?.resolve();
115
+ this.activeRun = undefined;
116
+ }
117
+ }
118
+
119
+ async handleRunFailure(error, aborted) {
120
+ const m = this._state.model;
121
+ const failureMessage = {
122
+ role: 'assistant', content: [{ type: 'text', text: '' }],
123
+ api: m.api, provider: m.provider, model: m.id, usage: EMPTY_USAGE,
124
+ stopReason: aborted ? 'aborted' : 'error',
125
+ errorMessage: error instanceof Error ? error.message : String(error),
126
+ timestamp: Date.now(),
127
+ };
128
+ this._state.messages.push(failureMessage);
129
+ this._state.errorMessage = failureMessage.errorMessage;
130
+ await this.emit({ type: 'agent_end', messages: [failureMessage] });
131
+ }
132
+
133
+ // 主迴圈(對齊 spec §6)
134
+ async runLoop(initialMessages, signal) {
135
+ const newMessages = [];
136
+ let pending = initialMessages.slice();
137
+ let firstTurn = true;
138
+ while (true) {
139
+ let hasMoreToolCalls = true;
140
+ while (hasMoreToolCalls || pending.length) {
141
+ if (!firstTurn) await this.emit({ type: 'turn_start' }); else firstTurn = false;
142
+ for (const m of pending) {
143
+ await this.emit({ type: 'message_start', message: m });
144
+ await this.emit({ type: 'message_end', message: m });
145
+ this._state.messages.push(m); newMessages.push(m);
146
+ }
147
+ pending = [];
148
+
149
+ // abort 守衛:迴圈邊界若已中止,立即走 handleRunFailure(aborted)。
150
+ // 真實 provider 由 fetch signal 中止串流;此守衛確保 fake/工具觸發的 abort 也確定性收尾。
151
+ if (signal.aborted) throw new Error('Aborted');
152
+ const message = await this.streamAssistant(signal);
153
+ newMessages.push(message);
154
+ if (message.stopReason === 'error' || message.stopReason === 'aborted') {
155
+ await this.emit({ type: 'turn_end', message, toolResults: [] });
156
+ await this.emit({ type: 'agent_end', messages: newMessages });
157
+ return;
158
+ }
159
+ const toolCalls = (message.content || []).filter((c) => c.type === 'toolCall');
160
+ const toolResults = [];
161
+ hasMoreToolCalls = false;
162
+ if (toolCalls.length) {
163
+ const batch = await this.executeTools(message, toolCalls, signal);
164
+ for (const r of batch.messages) { this._state.messages.push(r); newMessages.push(r); toolResults.push(r); }
165
+ hasMoreToolCalls = !batch.terminate;
166
+ }
167
+ await this.emit({ type: 'turn_end', message, toolResults });
168
+ pending = this.steeringQueue.drain();
169
+ }
170
+ const followUps = this.followUpQueue.drain();
171
+ if (followUps.length) { pending = followUps; continue; }
172
+ break;
173
+ }
174
+ await this.emit({ type: 'agent_end', messages: newMessages });
175
+ }
176
+
177
+ // 串流一次 assistant 回應(消費 streamFn 事件)。用 live messages 建 llmContext。
178
+ async streamAssistant(signal) {
179
+ // 回合內真壓縮:串流前若上下文逼近上限,就地壓縮 live this._state.messages 後續跑同一回合。
180
+ // 失敗不阻斷回合(由上層熔斷 fallback 兜底)。這是自寫 loop 相對 pi-agent-core 的核心優勢。
181
+ if (this.maybeCompactInTurn) {
182
+ try { await this.maybeCompactInTurn(); } catch { /* 壓縮失敗不應阻斷回合 */ }
183
+ }
184
+ const llmMessages = await this.convertToLlm(this._state.messages);
185
+ const llmContext = { systemPrompt: this._state.systemPrompt, messages: llmMessages, tools: this._state.tools };
186
+ const apiKey = this.getApiKey ? await this.getApiKey(this._state.model.provider) : undefined;
187
+ const opts = {
188
+ model: this._state.model,
189
+ reasoning: this._state.thinkingLevel === 'off' ? undefined : this._state.thinkingLevel,
190
+ transport: 'sse',
191
+ onPayload: this.onPayload,
192
+ toolExecution: this.toolExecution,
193
+ apiKey,
194
+ signal,
195
+ };
196
+ const response = await this.streamFn(this._state.model, llmContext, opts);
197
+
198
+ let partial = null;
199
+ let addedPartial = false;
200
+ const finalize = async () => {
201
+ const finalMessage = await response.result();
202
+ if (addedPartial) this._state.messages[this._state.messages.length - 1] = finalMessage;
203
+ else { this._state.messages.push(finalMessage); await this.emit({ type: 'message_start', message: { ...finalMessage } }); }
204
+ await this.emit({ type: 'message_end', message: finalMessage });
205
+ return finalMessage;
206
+ };
207
+
208
+ for await (const event of response) {
209
+ switch (event.type) {
210
+ case 'start':
211
+ partial = event.partial; this._state.messages.push(partial); addedPartial = true;
212
+ await this.emit({ type: 'message_start', message: { ...partial } });
213
+ break;
214
+ case 'text_start': case 'text_delta': case 'text_end':
215
+ case 'thinking_start': case 'thinking_delta': case 'thinking_end':
216
+ case 'toolcall_start': case 'toolcall_delta': case 'toolcall_end':
217
+ if (partial) {
218
+ partial = event.partial;
219
+ this._state.messages[this._state.messages.length - 1] = partial;
220
+ await this.emit({ type: 'message_update', assistantMessageEvent: event, message: { ...partial } });
221
+ }
222
+ break;
223
+ case 'done': case 'error':
224
+ return finalize();
225
+ default:
226
+ break;
227
+ }
228
+ }
229
+ return finalize(); // 串流自然結束(無 done/error)
230
+ }
231
+
232
+ // 執行一批 tool call(sequential:xitto 用;parallel 亦支援)
233
+ async executeTools(assistantMessage, toolCalls, signal) {
234
+ const sequential = this.toolExecution === 'sequential'
235
+ || toolCalls.some((tc) => this._state.tools.find((t) => t.name === tc.name)?.executionMode === 'sequential');
236
+
237
+ const runOne = async (toolCall) => {
238
+ await this.emit({ type: 'tool_execution_start', toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments });
239
+ const prep = await this.prepareTool(assistantMessage, toolCall, signal);
240
+ let result; let isError;
241
+ if (prep.kind === 'immediate') { result = prep.result; isError = prep.isError; }
242
+ else {
243
+ try {
244
+ result = await prep.tool.execute(toolCall.id, prep.args, signal, (partialResult) => {
245
+ this.emit({ type: 'tool_execution_update', toolCallId: toolCall.id, toolName: toolCall.name, args: toolCall.arguments, partialResult });
246
+ });
247
+ isError = false;
248
+ } catch (e) { result = errResult(e instanceof Error ? e.message : String(e)); isError = true; }
249
+ }
250
+ if (result == null || typeof result !== 'object') result = errResult('工具未回傳有效結果');
251
+ await this.emit({ type: 'tool_execution_end', toolCallId: toolCall.id, toolName: toolCall.name, result, isError });
252
+ const trMsg = {
253
+ role: 'toolResult', toolCallId: toolCall.id, toolName: toolCall.name,
254
+ content: result.content, details: result.details, isError, timestamp: Date.now(),
255
+ };
256
+ return { result, trMsg };
257
+ };
258
+
259
+ let finalized;
260
+ if (sequential) {
261
+ finalized = [];
262
+ for (const tc of toolCalls) finalized.push(await runOne(tc));
263
+ } else {
264
+ finalized = await Promise.all(toolCalls.map(runOne));
265
+ }
266
+ const terminate = finalized.length > 0 && finalized.every((f) => f.result.terminate === true);
267
+ return { messages: finalized.map((f) => f.trMsg), terminate };
268
+ }
269
+
270
+ async prepareTool(assistantMessage, toolCall, signal) {
271
+ const tool = this._state.tools.find((t) => t.name === toolCall.name);
272
+ if (!tool) return { kind: 'immediate', result: errResult(`Tool ${toolCall.name} not found`), isError: true };
273
+ try {
274
+ const args = toolCall.arguments; // pi-ai 已解析;工具層另有 coerceArgs 包裝
275
+ if (this.beforeToolCall) {
276
+ const ctx = { assistantMessage, toolCall, args, context: { systemPrompt: this._state.systemPrompt, messages: this._state.messages, tools: this._state.tools } };
277
+ const before = await this.beforeToolCall(ctx, signal);
278
+ if (before?.block) return { kind: 'immediate', result: errResult(before.reason || 'Tool execution was blocked'), isError: true };
279
+ }
280
+ return { kind: 'prepared', toolCall, tool, args };
281
+ } catch (e) {
282
+ return { kind: 'immediate', result: errResult(e instanceof Error ? e.message : String(e)), isError: true };
283
+ }
284
+ }
285
+ }
@@ -0,0 +1,65 @@
1
+ // 回合內上下文壓縮 — kernel 內建。上下文逼近 model 視窗時,把較舊對話摘要成一段、保留最近數輪,
2
+ // 避免長對話爆窗。對標 xitto-code compaction.js(自足版:以字元/4 粗估 tokens,不依賴 pi-coding-agent)。
3
+ import { completeSimple } from '@mariozechner/pi-ai';
4
+ import { cacheRetentionFor } from './provider.js';
5
+
6
+ export const DEFAULT_COMPACTION = { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 };
7
+
8
+ const asText = (m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'text').map((c) => c.text).join(' ') : String(m?.content || ''));
9
+ const estimateTokens = (m) => { try { return Math.ceil(JSON.stringify(m.content ?? m).length / 4); } catch { return 0; } };
10
+ const totalTokens = (messages) => messages.reduce((s, m) => s + estimateTokens(m), 0);
11
+ const shouldCompact = (tokens, win, s) => s.enabled !== false && tokens > (win || 32000) - (s.reserveTokens || DEFAULT_COMPACTION.reserveTokens);
12
+
13
+ export function isContextOverThreshold(messages, win, s = DEFAULT_COMPACTION) {
14
+ return shouldCompact(totalTokens(messages), win, s);
15
+ }
16
+
17
+ // 切點:從最新往回累加,達 keepRecentTokens 後切在最近的 user 訊息邊界(保留完整輪次)
18
+ export function findCutIndex(messages, keepRecent) {
19
+ let acc = 0;
20
+ for (let i = messages.length - 1; i > 0; i--) {
21
+ acc += estimateTokens(messages[i]);
22
+ if (acc >= keepRecent && messages[i].role === 'user') return i;
23
+ }
24
+ return -1;
25
+ }
26
+
27
+ async function summarize(older, model, reserveTokens, apiKey) {
28
+ const text = older.map((m) => `${m.role}: ${asText(m).slice(0, 1500)}`).join('\n').slice(0, 24000);
29
+ const ctx = {
30
+ systemPrompt: '把以下對話濃縮成要點摘要:決策、已確認的事實、待辦、檔案/狀態改動,供後續延續。只輸出摘要本身。',
31
+ messages: [{ role: 'user', content: [{ type: 'text', text }], timestamp: Date.now() }],
32
+ };
33
+ const res = await completeSimple(model, ctx, { maxTokens: Math.floor(0.8 * (reserveTokens || 2000)), apiKey, cacheRetention: cacheRetentionFor(model) });
34
+ if (res.stopReason === 'error') return null;
35
+ return res.content.filter((c) => c.type === 'text').map((c) => c.text).join('').trim();
36
+ }
37
+
38
+ // 就地壓縮 agent.state.messages。回 info(壓了)/ null(未達閾值)/ {error}(摘要失敗)。
39
+ export async function maybeCompact(agent, model, apiKey, settings = DEFAULT_COMPACTION) {
40
+ const messages = agent.state.messages;
41
+ const tokens = totalTokens(messages);
42
+ if (!shouldCompact(tokens, model.contextWindow || 32000, settings)) return null;
43
+ const cut = findCutIndex(messages, settings.keepRecentTokens || DEFAULT_COMPACTION.keepRecentTokens);
44
+ if (cut <= 0) return null; // 切不動(最近輪次已佔滿)→ 交由上層熔斷
45
+ const older = messages.slice(0, cut);
46
+ const recent = messages.slice(cut);
47
+ let summary;
48
+ try { summary = await summarize(older, model, settings.reserveTokens, apiKey); } catch { return { error: true }; }
49
+ if (!summary) return { error: true };
50
+ const summaryMsg = { role: 'user', content: [{ type: 'text', text: `# 先前對話摘要(已壓縮)\n${summary}` }], timestamp: Date.now() };
51
+ agent.state.messages = [summaryMsg, ...recent];
52
+ return { tokensBefore: tokens, tokensAfter: totalTokens(agent.state.messages), summarized: older.length, kept: recent.length };
53
+ }
54
+
55
+ // 正規化設定 + 安全 clamp(reserve+keepRecent 不逼近視窗,否則永遠切不動)
56
+ export function resolveCompactionSettings(override = {}, contextWindow) {
57
+ const s = { ...DEFAULT_COMPACTION, ...(override || {}) };
58
+ const win = (Number.isFinite(contextWindow) && contextWindow > 0) ? contextWindow : null;
59
+ if (win) {
60
+ const cap = Math.floor(win * 0.9);
61
+ if (s.reserveTokens > cap) s.reserveTokens = cap;
62
+ if (s.reserveTokens + s.keepRecentTokens > cap) s.keepRecentTokens = Math.max(0, cap - s.reserveTokens);
63
+ }
64
+ return s;
65
+ }
@@ -0,0 +1,42 @@
1
+ // beforeToolCall 守衛鏈 — kernel 固定順序,pack 只能在第 3 格插領域守衛。
2
+ // 安全性靠順序保證:pack 無法重排或跳過 permission/hooks(只能「額外多擋」)。
3
+ // 對應 docs/03-kernel-contract.md「C. beforeToolCall 守衛鏈」。
4
+
5
+ /**
6
+ * 固定六步順序:
7
+ * 1. planGuard 計劃模式擋 mutating 工具(kernel)
8
+ * 2. circuitBreaker 上下文熔斷(kernel)
9
+ * 3. packPreTool 領域守衛(PACK 唯一插槽,如 read-before-edit)
10
+ * 4. preToolHooks 使用者 PreToolUse hooks(kernel)
11
+ * 5. permission 權限/沙箱/危險命令/白名單(kernel)
12
+ * 任一步回 {block:true,reason} 即短路擋下;全通過回 undefined(放行)。
13
+ *
14
+ * 各步驟以注入函數提供(領域無關、可 fake 測試)。未提供的步驟自動略過。
15
+ *
16
+ * @param {Object} steps
17
+ * @param {Function} [steps.planGuard]
18
+ * @param {Function} [steps.circuitBreaker]
19
+ * @param {import('../types.js').PreToolPolicy} [steps.packPreTool]
20
+ * @param {Function} [steps.preToolHooks]
21
+ * @param {Function} [steps.permission]
22
+ * @param {import('../types.js').KernelServices} [steps.services]
23
+ * @returns {(ctx: object) => Promise<import('../types.js').PolicyDecision>}
24
+ */
25
+ export function composeGuards({ planGuard, circuitBreaker, packPreTool, preToolHooks, permission, services } = {}) {
26
+ // 固定順序;第 3 格是 pack 的 preToolPolicy.check(注入 services)
27
+ const chain = [
28
+ planGuard,
29
+ circuitBreaker,
30
+ packPreTool ? (ctx) => packPreTool.check(ctx, services) : null,
31
+ preToolHooks,
32
+ permission,
33
+ ].filter((step) => typeof step === 'function');
34
+
35
+ return async function runGuards(ctx) {
36
+ for (const step of chain) {
37
+ const decision = await step(ctx);
38
+ if (decision && decision.block) return decision; // 短路:擋下即停,後續步驟不執行
39
+ }
40
+ return undefined; // 全通過 → 放行
41
+ };
42
+ }
@@ -0,0 +1,47 @@
1
+ // PreToolUse / PostToolUse hooks — kernel 內建,由 .xitto-kernel/<pack>/settings.json 驅動。
2
+ // Pre:matched 工具執行前跑命令,非零退出→擋下。Post:matched 工具成功後跑命令,失敗→回灌讓 agent 修正。
3
+ // 對標 xitto-code hooks.js。settings.json: { "hooks": { "PreToolUse": [{matcher, command, timeout}], "PostToolUse": [...] } }
4
+ import { execSync } from 'node:child_process';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+
7
+ const asRules = (x) => (Array.isArray(x) ? x.filter((r) => r && typeof r.command === 'string') : []);
8
+
9
+ export function loadHooks(settingsPath) {
10
+ if (!existsSync(settingsPath)) return { PreToolUse: [], PostToolUse: [] };
11
+ try {
12
+ const h = (JSON.parse(readFileSync(settingsPath, 'utf8')).hooks) || {};
13
+ return { PreToolUse: asRules(h.PreToolUse), PostToolUse: asRules(h.PostToolUse) };
14
+ } catch { return { PreToolUse: [], PostToolUse: [] }; }
15
+ }
16
+
17
+ const matches = (rule, name) => { try { return new RegExp(rule.matcher || '.*').test(name); } catch { return false; } };
18
+
19
+ function runRule(rule, cwd) {
20
+ try {
21
+ const out = execSync(rule.command, { cwd, encoding: 'utf8', timeout: rule.timeout || 60000, stdio: ['ignore', 'pipe', 'pipe'] });
22
+ return { ok: true, output: out || '' };
23
+ } catch (e) {
24
+ return { ok: false, output: (`${e.stdout || ''}${e.stderr || ''}` || e.message || '').toString() };
25
+ }
26
+ }
27
+
28
+ // PreToolUse:任一 matched hook 非零退出 → 回 block(理由含輸出)
29
+ export function runPreToolHooks(hooks, name, cwd) {
30
+ for (const rule of hooks.PreToolUse) {
31
+ if (!matches(rule, name)) continue;
32
+ const r = runRule(rule, cwd);
33
+ if (!r.ok) return { block: true, reason: `PreToolUse hook 阻止 ${name}:\n${r.output.slice(0, 1000)}` };
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ // PostToolUse:回失敗清單 [{command, output}](供呼叫端回灌給 agent)
39
+ export function runPostToolHooks(hooks, name, cwd) {
40
+ const fails = [];
41
+ for (const rule of hooks.PostToolUse) {
42
+ if (!matches(rule, name)) continue;
43
+ const r = runRule(rule, cwd);
44
+ if (!r.ok) fails.push({ command: rule.command, output: r.output });
45
+ }
46
+ return fails;
47
+ }