xitto-kernel 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ 第一個功能完整版 —— 從 0.1.0(基礎 kernel + CLI + 腳手架)補齊近乎 xitto-code 等價的能力,
6
+ 並加入通用自主 agent。
7
+
8
+ ### 新增
9
+
10
+ - **記憶 + session resume**:`memory_save`/`memory_list` 工具自動注入;`--resume [id]`、`/sessions`、`/resume`、`/memory`
11
+ - **互動權限確認**:mutating/危險工具執行前彈確認(y/a/n);`always` 記住;`--yes`/`/auto` 自動核准(危險仍把關)
12
+ - **計劃模式 + 撤銷**:`/plan`(只規劃、擋 mutating)、`/undo`(還原上次 write/edit)
13
+ - **git 能力**(coding pack):`git_status` / `git_diff` / `git_log` / `git_commit`
14
+ - **子 agent**:`spawn_agent` 派唯讀子 agent 做聚焦調查
15
+ - **hooks**:PreToolUse / PostToolUse(`.xitto-kernel/<pack>/settings.json`)
16
+ - **skills**:漸進揭露(`.xitto-kernel/<pack>/skills/*.md` + `skill` 工具)
17
+ - **MCP**:連 MCP server(stdio),工具以 `mcp__<server>__<tool>` 注入
18
+ - **回合內上下文壓縮**:逼近視窗時摘要較舊對話、保留最近
19
+ - **輕量串流 markdown 渲染** + edit/write 彩色 diff(CLI)
20
+ - **通用自主 agent**:`general` pack(檔案/shell/`web_fetch`/`web_search`)+ **goal loop**
21
+ (`runGoal` / `--goal "..."` / `/goal`:給目標、反覆做到完成、LLM 自我驗收)
22
+ - **code agent 工具升級(達 Claude Code 等級)**:
23
+ - `grep`(正則搜內容、`path:line`、glob 過濾)、`glob`(`**` 遞迴找檔)
24
+ - `read` 附行號 + `offset`/`limit`;`edit` 唯一性檢查 + `replaceAll`(避免改錯位置)
25
+ - `bash` timeout 參數;`bash_bg` / `bash_output` / `bash_kill`(後台 dev server/watch)
26
+ - `web_fetch`(coding pack 也能查線上文件)
27
+ - **TodoWrite**(`todo_write`):多步任務規劃/追蹤,CLI 即時清單渲染(☐/◐/☑)
28
+
29
+ ### 變更
30
+
31
+ - `new-agent` 產出的專案預設依賴 `^<version>`(正式版本),`--local` 用 `file:` 開發
32
+ - pack.verify / pack.contextFiles slot 接通 runtime
33
+
34
+ ### 修正
35
+
36
+ - test script 改 `node --test`(Node 20 不支援 `--test` glob)
37
+ - goal loop 驗收健壯性:寬鬆 JSON 解析 + 連續失敗停止
38
+
39
+ ## 0.1.0
40
+
41
+ 首發:kernel(pack 系統 / 工具 metadata / 守衛鏈 / agent loop / 真實 sandbox(Seatbelt))、
42
+ 互動 CLI、腳手架(`new-agent` 產出獨立專案)、三個範例 pack(coding / data-query / notes)。
package/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # xitto-kernel
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/xitto-kernel.svg)](https://www.npmjs.com/package/xitto-kernel)
3
4
  [![CI](https://github.com/ishoplus/xitto-kernel/actions/workflows/ci.yml/badge.svg)](https://github.com/ishoplus/xitto-kernel/actions/workflows/ci.yml)
4
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
5
6
  [![Node](https://img.shields.io/badge/node-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org)
@@ -27,13 +28,11 @@ xitto-code 經掃描後,約 **8 成已是領域無關的 kernel**;真正跟
27
28
  - `~/.xitto-code/providers.json` —— LLM provider 設定(與 xitto-code 共用,內含 API key)。
28
29
  沒有的話,複製一份填入 key 即可(格式見 xitto-code 的 `providers.example.json`)。
29
30
 
30
- **一次性設定**(讓 `xitto-kernel` 成為全域命令)
31
+ **安裝**(已發佈 npm)
31
32
  ```bash
32
- cd xitto-kernel
33
- npm install
34
- npm link # 之後任何目錄都能用 xitto-kernel 命令
33
+ npm install -g xitto-kernel # 全域命令 xitto-kernel
35
34
  ```
36
- > 不想 link 也行:直接 `node /路徑/xitto-kernel/bin/xitto-kernel.js …` 或在 repo `npm start`。
35
+ > 開發本倉庫:`cd xitto-kernel && npm install && npm link`。
37
36
 
38
37
  **跑內建 pack(互動 CLI)**
39
38
  ```bash
@@ -43,7 +42,13 @@ xitto-kernel --pack data-query
43
42
  xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
44
43
  ```
45
44
 
46
- **CLI 內操作**:直接打需求(模型會自己呼叫工具);指令 `/help` `/sandbox [on|off]` `/tools` `/clear` `/exit`;`Ctrl+C` 中斷該輪、閒置時再按一次離開。
45
+ **CLI 內操作**:直接打需求(模型會自己呼叫工具);指令 `/help` `/goal <目標>` `/sandbox` `/plan` `/undo` `/tools` `/memory` `/sessions` `/resume` `/exit`;`Ctrl+C` 中斷該輪、閒置時再按一次離開。
46
+
47
+ **通用自主 agent(給目標、自己做到完成)**
48
+ ```bash
49
+ xitto-kernel --pack general --yes --goal "抓取 example.com 摘要成繁中寫進 summary.txt"
50
+ ```
51
+ `general` pack(檔案/shell/web_fetch)+ kernel 的 **goal loop**(反覆 runTurn + LLM 自我驗收,直到達成/無進展/上限)。互動模式用 `/goal <目標>`。
47
52
 
48
53
  ## 做你自己的領域 agent(不固化)
49
54
 
@@ -93,9 +98,10 @@ xitto-kernel/
93
98
  │ │ ├── templates/ ✅ 獨立專案樣板(package.json/index.js/pack.js…)
94
99
  │ │ └── providers.js ✅ providers.json 載入(provider 設定屬 app,非 kernel)
95
100
  │ └── packs/
96
- │ ├── coding/ ✅ 參考 pack(read/ls/write/edit/bash 真實工具)
101
+ │ ├── coding/ ✅ 參考 pack(read/ls/write/edit/bash/git)
97
102
  │ ├── data-query/ ✅ 第二領域(證明正交)
98
- └── notes/ ✅ 第三領域(知識庫;示範「怎麼做新領域 agent」)
103
+ ├── notes/ ✅ 第三領域(知識庫)
104
+ │ └── general/ ✅ 通用自主 agent(檔案/shell/web_fetch + goal loop)
99
105
  ├── bin/xitto-kernel.js ✅ CLI 進入點(run / new-agent)
100
106
  ├── test/ ✅ 41 測試全綠(runTurn + Seatbelt 隔離 + 腳手架 + …)
101
107
  └── examples/
@@ -127,7 +133,8 @@ xitto-kernel/
127
133
  **git 能力**(coding pack)、**spawn_agent 子 agent**、**PreToolUse/PostToolUse hooks**、
128
134
  **skills 漸進揭露**、**MCP 工具接入**、互動 CLI、腳手架(`new-agent` 產出獨立專案)。75 測試全綠。
129
135
 
130
- **仍為接縫(後續)**:發佈到 npm(讓 `file:` 依賴變正式版本;版本已備 0.1.0)。Ink 全功能 TUI 可作為另一個 app(目前 CLI 已有輕量串流 markdown + 彩色 diff)。
136
+ **已發佈 npm**:`npm install -g xitto-kernel`;`new-agent` 產出的專案預設依賴 `^0.1.0`(`--local` file: 開發)。
137
+ **可選後續**:Ink 全功能 TUI 可作為另一個 app(目前 CLI 已有輕量串流 markdown + 彩色 diff)。
131
138
 
132
139
  **設計取向**:沿用 Node ESM + pi-ai provider 抽象;不重寫 xitto-code(kernel 是抽象,xitto-code 仍可獨立存在)。
133
140
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
@@ -34,7 +34,8 @@
34
34
  "bin",
35
35
  "docs",
36
36
  "README.md",
37
- "LICENSE"
37
+ "LICENSE",
38
+ "CHANGELOG.md"
38
39
  ],
39
40
  "scripts": {
40
41
  "test": "node --test",
package/src/app/cli.js CHANGED
@@ -96,6 +96,14 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
96
96
  }
97
97
  case 'tool_execution_start':
98
98
  endStream();
99
+ if (ev.toolName === 'todo_write' && Array.isArray(ev.args?.todos)) {
100
+ out(c.cyan('☑ 待辦更新\n'));
101
+ for (const t of ev.args.todos) {
102
+ const mark = t.status === 'completed' ? c.green('☑') : t.status === 'in_progress' ? c.yellow('◐') : c.gray('☐');
103
+ out(` ${mark} ${t.status === 'completed' ? c.gray(t.content) : t.content}\n`);
104
+ }
105
+ break;
106
+ }
99
107
  out(c.yellow('⚙ ' + ev.toolName) + c.gray('(' + summarize(ev.args) + ')\n'));
100
108
  diffPreview(ev.toolName, ev.args);
101
109
  break;
@@ -130,6 +138,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
130
138
  ' /sandbox [on|off] 切換沙箱(macOS=Seatbelt 真隔離)',
131
139
  ' /auto [on|off] 自動核准 mutating 工具(危險命令仍把關)',
132
140
  ' /plan [on|off] 計劃模式(只規劃、擋下實際改動)',
141
+ ' /goal <目標> 目標驅動自主循環(反覆做到完成)',
133
142
  ' /undo 撤銷上一次檔案改動(write/edit)',
134
143
  ' /tools 列出此 pack 的工具',
135
144
  ' /memory 顯示跨 session 記憶',
@@ -218,6 +227,25 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
218
227
  q(c.blue('› '), async (raw) => {
219
228
  const input = (raw || '').trim();
220
229
  if (!input) return loop();
230
+ // /goal <目標>:目標驅動自主循環(在此 await,避免與下一個提示交錯)
231
+ if (input.startsWith('/goal ') || input === '/goal') {
232
+ const goal = input.slice(5).trim();
233
+ if (!goal) { out(c.gray('用法 /goal <目標>\n')); return loop(); }
234
+ try {
235
+ out(c.cyan('🎯 目標:') + goal + '\n');
236
+ const r = await kernel.runGoal(goal, {
237
+ history,
238
+ onRound: ({ round, maxRounds }) => out(c.yellow(`\n🔁 第 ${round}/${maxRounds} 輪\n`)),
239
+ onCheck: ({ done, remaining }) => out(done ? c.green(' ✓ 驗收:已達成\n') : c.gray(` ↻ ${remaining}\n`)),
240
+ onEvent, onAgent: (a) => { currentAgent = a; },
241
+ });
242
+ endStream(); history = r.history; persist();
243
+ const why = r.stalled ? '無進展' : r.aborted ? '中斷' : r.verifyBroken ? '驗收持續失敗' : '到上限';
244
+ out('\n' + (r.done ? c.green(`✅ 目標達成(${r.rounds} 輪)`) : c.yellow(`⚠ 未達成(${why},${r.rounds} 輪)`)) + '\n');
245
+ } catch (err) { endStream(); out(c.red('錯誤:' + err.message) + '\n'); }
246
+ finally { currentAgent = null; }
247
+ out('\n'); return loop();
248
+ }
221
249
  if (handleSlash(input)) return loop();
222
250
  try {
223
251
  const text = planMode
package/src/app/main.js CHANGED
@@ -4,28 +4,32 @@ import { join } from 'node:path';
4
4
  import { loadModel } from './providers.js';
5
5
  import { runCli } from './cli.js';
6
6
  import { newAgent } from './scaffold.js';
7
+ import { createKernel } from '../kernel/index.js';
7
8
  import { loadMcpTools } from '../kernel/mcp.js';
8
9
  import { createCodingPack } from '../packs/coding/index.js';
9
10
  import { createDataQueryPack } from '../packs/data-query/index.js';
10
11
  import { createNotesPack } from '../packs/notes/index.js';
12
+ import { createGeneralPack } from '../packs/general/index.js';
11
13
 
12
14
  const e = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
13
- const green = e(32); const gray = e(90); const red = e(31);
15
+ const green = e(32); const gray = e(90); const red = e(31); const cyan = e(36); const yellow = e(33);
14
16
 
15
17
  const PACKS = {
16
18
  coding: createCodingPack,
17
19
  'data-query': createDataQueryPack,
18
20
  notes: createNotesPack,
21
+ general: createGeneralPack,
19
22
  };
20
23
 
21
24
  export async function main(argv = process.argv.slice(2)) {
22
25
  // 子指令:new-agent <name> —— 產出獨立 agent 專案(不碰 kernel)
23
26
  if (argv[0] === 'new-agent') {
24
- const name = argv[1];
27
+ const name = argv.find((a, i) => i >= 1 && !a.startsWith('--'));
28
+ const local = argv.includes('--local');
25
29
  try {
26
- const { target, files } = newAgent(name);
30
+ const { target, files, dep } = newAgent(name, { local });
27
31
  console.log(green(`✓ 已建立獨立 agent 專案:${target}`));
28
- console.log(gray(` ${files.join(' ')}`));
32
+ console.log(gray(` ${files.join(' ')} · 依賴 xitto-kernel@${dep}`));
29
33
  console.log('\n下一步:');
30
34
  console.log(gray(` cd ${name} && npm install && npm start`));
31
35
  console.log(gray(' (改 pack.js 換成你的領域;npm update xitto-kernel 升級底座,不固化)'));
@@ -47,6 +51,28 @@ export async function main(argv = process.argv.slice(2)) {
47
51
  const cwd = process.cwd();
48
52
  const mcp = await loadMcpTools(join(cwd, '.xitto-kernel', opts.pack, 'mcp.json'), (m) => console.log(gray(` [MCP] ${m}`)));
49
53
 
54
+ // --goal "...":headless 自主循環(給目標、自己做到完成)後退出,不進互動 CLI
55
+ if (opts.goal) {
56
+ const kernel = createKernel(make({ cwd }), {
57
+ model, getApiKey, extraTools: mcp.tools,
58
+ sandbox: { enabled: opts.sandbox }, getSandbox: () => opts.sandbox,
59
+ confirm: opts.yes ? (async () => 'yes') : undefined, // headless:--yes 才自動核准 mutating
60
+ });
61
+ console.log(cyan('🎯 目標:') + opts.goal + gray(` · ${opts.pack} pack · ${model.id}`));
62
+ const res = await kernel.runGoal(opts.goal, {
63
+ onRound: ({ round, maxRounds }) => console.log(yellow(`\n🔁 第 ${round}/${maxRounds} 輪`)),
64
+ onCheck: ({ done, remaining }) => console.log(done ? green(' ✓ 驗收:已達成') : gray(` ↻ 驗收:${remaining}`)),
65
+ onEvent: (ev) => {
66
+ if (ev.type === 'tool_execution_start') console.log(yellow(` ⚙ ${ev.toolName}`) + gray('(' + JSON.stringify(ev.args).slice(0, 80) + ')'));
67
+ if (ev.type === 'tool_execution_end') console.log(ev.isError ? red(' ⎿ ✗') : gray(' ⎿ ✓'));
68
+ },
69
+ });
70
+ const why = res.stalled ? '無進展停止' : res.aborted ? '中斷' : res.verifyBroken ? '驗收持續失敗' : '到上限';
71
+ console.log('\n' + (res.done ? green(`✅ 目標達成(${res.rounds} 輪)`) : yellow(`⚠ 未達成(${why},${res.rounds} 輪)`)));
72
+ try { await mcp.close(); } catch { /* 略 */ }
73
+ process.exit(res.done ? 0 : 1);
74
+ }
75
+
50
76
  runCli({
51
77
  pack: make({ cwd }), model, getApiKey,
52
78
  sandbox: opts.sandbox, resume: opts.resume, auto: opts.yes,
@@ -55,7 +81,7 @@ export async function main(argv = process.argv.slice(2)) {
55
81
  }
56
82
 
57
83
  function parse(argv) {
58
- const o = { pack: 'coding', model: undefined, sandbox: false, help: false, resume: null, yes: false };
84
+ const o = { pack: 'coding', model: undefined, sandbox: false, help: false, resume: null, yes: false, goal: null };
59
85
  for (let i = 0; i < argv.length; i++) {
60
86
  const a = argv[i];
61
87
  if (a === '--help' || a === '-h') o.help = true;
@@ -63,6 +89,7 @@ function parse(argv) {
63
89
  else if (a === '--model') o.model = argv[++i];
64
90
  else if (a === '--sandbox') o.sandbox = true;
65
91
  else if (a === '--yes' || a === '-y') o.yes = true;
92
+ else if (a === '--goal') o.goal = argv[++i];
66
93
  else if (a === '--resume') { const nxt = argv[i + 1]; if (nxt && !nxt.startsWith('--')) { o.resume = nxt; i++; } else o.resume = true; }
67
94
  }
68
95
  return o;
@@ -74,9 +101,11 @@ function printHelp() {
74
101
  '',
75
102
  '用法:',
76
103
  ' xitto-kernel [--pack <name>] [--model <id>] [--sandbox] [--resume [id]] [--yes] 互動跑內建 pack',
104
+ ' xitto-kernel --pack general --goal "..." [--yes] 目標驅動自主循環(headless)',
77
105
  ' xitto-kernel new-agent <name> 產出依賴 kernel 的獨立 agent 專案',
78
106
  '',
79
- ' --pack <name> 選擇內建 DomainPack(coding | data-query | notes;預設 coding)',
107
+ ' --pack <name> 選擇內建 DomainPack(coding | data-query | notes | general;預設 coding)',
108
+ ' --goal "..." 給目標,agent 自主反覆做到完成(建議搭配 --pack general)',
80
109
  ' --model <id> 指定 model(預設用 providers.json 的 defaultModel)',
81
110
  ' --sandbox 啟動即開啟沙箱(macOS=Seatbelt 真隔離)',
82
111
  ' --help 顯示說明',
@@ -17,24 +17,32 @@ const FILES = [
17
17
  ['gitignore.tmpl', '.gitignore'],
18
18
  ];
19
19
 
20
+ const kernelVersion = () => {
21
+ try { return JSON.parse(readFileSync(join(KERNEL_ROOT, 'package.json'), 'utf8')).version || '0.1.0'; }
22
+ catch { return '0.1.0'; }
23
+ };
24
+
20
25
  /**
21
26
  * 產生一個獨立 agent 專案。
22
27
  * @param {string} name agent 名(字母/數字/連字號)
23
- * @param {{ dir?: string, kernelPath?: string }} [opts]
24
- * dir:產出根目錄(預設 cwd);kernelPath:寫進 package.json 的 file: 依賴路徑(預設此 kernel
25
- * @returns {{ target: string, files: string[] }}
28
+ * @param {{ dir?: string, local?: boolean, kernelPath?: string }} [opts]
29
+ * dir:產出根目錄(預設 cwd);local:用 file: 依賴本機 kernel(開發用,預設用 npm 正式版本)
30
+ * @returns {{ target: string, files: string[], dep: string }}
26
31
  */
27
- export function newAgent(name, { dir = process.cwd(), kernelPath = KERNEL_ROOT } = {}) {
32
+ export function newAgent(name, { dir = process.cwd(), local = false, kernelPath = KERNEL_ROOT } = {}) {
28
33
  if (!name || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
29
34
  throw new Error(`agent 名稱不合法:「${name}」(只能用字母/數字/連字號,且不以連字號開頭)`);
30
35
  }
31
36
  const target = join(dir, name);
32
37
  if (existsSync(target)) throw new Error(`目錄已存在:${target}`);
33
38
 
39
+ // 依賴:預設用 npm 正式版本(^x.y.z);--local 用 file: 指向本機 kernel(開發測試用)
40
+ const dep = local ? `file:${kernelPath}` : `^${kernelVersion()}`;
41
+
34
42
  mkdirSync(target, { recursive: true });
35
- const subst = (s) => s.replaceAll('__NAME__', name).replaceAll('__KERNEL_PATH__', kernelPath);
43
+ const subst = (s) => s.replaceAll('__NAME__', name).replaceAll('__KERNEL_DEP__', dep);
36
44
  for (const [tmpl, outName] of FILES) {
37
45
  writeFileSync(join(target, outName), subst(readFileSync(join(TEMPLATES, tmpl), 'utf8')));
38
46
  }
39
- return { target, files: FILES.map(([, o]) => o) };
47
+ return { target, files: FILES.map(([, o]) => o), dep };
40
48
  }
@@ -7,6 +7,6 @@
7
7
  "start": "node index.js"
8
8
  },
9
9
  "dependencies": {
10
- "xitto-kernel": "file:__KERNEL_PATH__"
10
+ "xitto-kernel": "__KERNEL_DEP__"
11
11
  }
12
12
  }
@@ -0,0 +1,70 @@
1
+ // 後台進程 — bash_bg / bash_output / bash_kill。對標 Claude Code 的 run_in_background + BashOutput + KillShell。
2
+ // 讓 agent 啟動 dev server / watch / build 而不阻塞對話。輸出緩衝在記憶體(上限裁切前段)。
3
+ import { spawn } from 'node:child_process';
4
+
5
+ const OUTPUT_CAP = 256 * 1024;
6
+ const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
7
+
8
+ // 全域只註冊一次 process 退出清理(避免每個 pack 各註冊造成 listener 洩漏)
9
+ const cleanups = new Set();
10
+ let registered = false;
11
+ const ensureCleanup = () => { if (registered) return; registered = true; const run = () => { for (const c of cleanups) try { c(); } catch { /* 略 */ } }; process.once('exit', run); process.once('SIGTERM', run); };
12
+
13
+ export function createBackgroundTools(cwd) {
14
+ const procs = new Map();
15
+ let seq = 0;
16
+ const append = (proc, d) => {
17
+ proc.buf += d.toString();
18
+ if (proc.buf.length > OUTPUT_CAP) { const drop = proc.buf.length - OUTPUT_CAP; proc.buf = proc.buf.slice(drop); proc.readPos = Math.max(0, proc.readPos - drop); proc.truncated = true; }
19
+ };
20
+ const killAll = () => { for (const p of procs.values()) if (p.status === 'running') { try { p.child?.kill('SIGTERM'); } catch { /* 略 */ } } };
21
+ cleanups.add(killAll); ensureCleanup();
22
+
23
+ const bashBg = {
24
+ name: 'bash_bg', label: '後台執行', mutating: true, sandboxable: true,
25
+ description: '在後台啟動長時間/常駐命令(dev server、watch、build),立即回傳 id 不阻塞。之後 bash_output 讀新輸出、bash_kill 終止。一次性快命令用一般 bash。',
26
+ parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
27
+ execute: async (_id, { command }) => {
28
+ const cmd = (command || '').trim();
29
+ if (!cmd) return txt({ error: 'command 不可為空' });
30
+ const id = 'bg' + (++seq);
31
+ const proc = { id, command: cmd, status: 'running', exitCode: null, buf: '', readPos: 0, truncated: false };
32
+ let child;
33
+ try { child = spawn(cmd, { shell: true, cwd, stdio: ['ignore', 'pipe', 'pipe'] }); }
34
+ catch (e) { proc.status = 'error'; proc.error = e.message; procs.set(id, proc); return txt({ error: e.message }); }
35
+ proc.child = child;
36
+ child.stdout?.on('data', (d) => append(proc, d));
37
+ child.stderr?.on('data', (d) => append(proc, d));
38
+ child.on('exit', (code) => { proc.status = 'exited'; proc.exitCode = code; });
39
+ child.on('error', (e) => { proc.status = 'error'; proc.error = e.message; });
40
+ procs.set(id, proc);
41
+ return txt({ id, status: 'running', hint: `bash_output("${id}") 讀輸出、bash_kill("${id}") 終止` });
42
+ },
43
+ };
44
+
45
+ const bashOutput = {
46
+ name: 'bash_output', label: '讀後台輸出', readOnly: true,
47
+ description: '讀取某後台進程(bash_bg 啟動)自上次以來的新輸出與狀態(running/exited/error)。',
48
+ parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
49
+ execute: async (_id, { id }) => {
50
+ const p = procs.get(id);
51
+ if (!p) return txt({ error: `找不到後台進程 ${id}` });
52
+ const out = p.buf.slice(p.readPos); p.readPos = p.buf.length;
53
+ return txt({ id, status: p.status, exitCode: p.exitCode, ...(p.error ? { error: p.error } : {}), ...(p.truncated ? { truncatedFront: true } : {}), output: out });
54
+ },
55
+ };
56
+
57
+ const bashKill = {
58
+ name: 'bash_kill', label: '終止後台', readOnly: true,
59
+ description: '終止一個仍在運行的後台進程(bash_bg 啟動的)。',
60
+ parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
61
+ execute: async (_id, { id }) => {
62
+ const p = procs.get(id);
63
+ if (!p) return txt({ error: `找不到後台進程 ${id}` });
64
+ if (p.status !== 'running') return txt({ id, status: p.status, note: '進程已結束' });
65
+ try { p.child?.kill('SIGTERM'); return txt({ id, killed: true }); } catch (e) { return txt({ id, error: e.message }); }
66
+ },
67
+ };
68
+
69
+ return { tools: [bashBg, bashOutput, bashKill], killAll };
70
+ }
@@ -0,0 +1,34 @@
1
+ // 目標驅動自主循環 — kernel 內建(領域無關)。給目標 → 反覆 runTurn + LLM 自我驗收,
2
+ // 直到達成 / 到上限 / 無進展。對標 xitto-code 的 /loop。checkGoal 用 LLM 判斷是否完成。
3
+ import { completeSimple } from '@mariozechner/pi-ai';
4
+ import { cacheRetentionFor } from './provider.js';
5
+
6
+ const JUDGE_SYS = '你是嚴格的驗收員。依「目標」與「對話進展」判斷目標是否已達成。' +
7
+ '只輸出 JSON:{"done": true|false, "remaining": "若未達成,還差什麼(一句)"}。不要任何多餘文字。';
8
+
9
+ const asText = (m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'text').map((c) => c.text).join(' ') : String(m?.content || ''));
10
+
11
+ export function normalizeFeedback(s) { return String(s || '').toLowerCase().replace(/\s+/g, ' ').trim(); }
12
+
13
+ /**
14
+ * 用 LLM 判斷目標是否達成。回 { done, remaining, error? };任何失敗都保守回 done:false(續跑)。
15
+ */
16
+ export async function checkGoal(goal, messages, model, apiKey, signal) {
17
+ if (!apiKey) return { done: false, remaining: '(無 API key)', error: true };
18
+ const recent = messages.slice(-8).map((m) => `${m.role}: ${asText(m).slice(0, 800)}`).join('\n').slice(0, 6000);
19
+ const ctx = {
20
+ systemPrompt: JUDGE_SYS,
21
+ messages: [{ role: 'user', content: [{ type: 'text', text: `目標:\n${goal}\n\n對話進展:\n${recent}\n\n是否已達成?只輸出 JSON。` }], timestamp: Date.now() }],
22
+ };
23
+ try {
24
+ const res = await completeSimple(model, ctx, { maxTokens: 220, apiKey, signal, cacheRetention: cacheRetentionFor(model) });
25
+ if (res.stopReason === 'error') return { done: false, remaining: '(驗收呼叫失敗)', error: true };
26
+ const t = res.content.filter((c) => c.type === 'text').map((c) => c.text).join('');
27
+ // 寬鬆解析:先試 JSON;失敗再用關鍵字判斷完成訊號(MiniMax 等 JSON 輸出不一定乾淨)
28
+ const m = t.match(/\{[\s\S]*\}/);
29
+ if (m) { try { const o = JSON.parse(m[0]); return { done: !!o.done, remaining: String(o.remaining || '') }; } catch { /* 落到下方 fallback */ } }
30
+ if (/"?done"?\s*[:=]\s*true|已達成|已完成|目標(已)?達成/i.test(t)) return { done: true, remaining: '' };
31
+ if (/"?done"?\s*[:=]\s*false|尚未|未達成|還(需|要|差)/i.test(t)) return { done: false, remaining: t.slice(0, 200) };
32
+ return { done: false, remaining: '(驗收輸出無法解析)', error: true };
33
+ } catch (e) { return { done: false, remaining: `(驗收例外:${e?.message || e})`, error: true }; }
34
+ }
@@ -10,10 +10,12 @@ import { composeGuards } from './guard-chain.js';
10
10
  import { createPermissionStep } from './security/permission-step.js';
11
11
  import { normalizeSandbox, wrapWithSeatbelt } from './security/sandbox.js';
12
12
  import { createMemory } from './memory.js';
13
+ import { createTodo } from './todo.js';
13
14
  import { createSpawnTool } from './subagent.js';
14
15
  import { createSkills } from './skills.js';
15
16
  import { loadHooks, runPreToolHooks, runPostToolHooks } from './hooks.js';
16
17
  import { maybeCompact, resolveCompactionSettings } from './compaction.js';
18
+ import { checkGoal, normalizeFeedback } from './goal-loop.js';
17
19
  import { newSessionId, saveSession, loadSession, listSessions, latestSession } from './session.js';
18
20
 
19
21
  // 載入 pack.contextFiles:從 cwd 逐層往上找每個檔名,找到就讀入並注入 system prompt(領域規範)。
@@ -98,6 +100,7 @@ export function createKernel(pack, config = {}) {
98
100
  // 每個 pack 在 cwd 下有獨立資料夾(記憶、session 分領域存放,互不混)
99
101
  const dataDir = join(cwd, '.xitto-kernel', pack.name);
100
102
  const memory = createMemory(join(dataDir, 'memory.md'));
103
+ const todo = createTodo();
101
104
  const sessionsDir = join(dataDir, 'sessions');
102
105
  const hooks = loadHooks(join(dataDir, 'settings.json')); // PreToolUse/PostToolUse
103
106
  const skills = createSkills(join(dataDir, 'skills')); // 漸進揭露技能
@@ -112,6 +115,7 @@ export function createKernel(pack, config = {}) {
112
115
  const baseTools = [
113
116
  ...pack.tools().map((t) => wrapUndo(wrapSandboxable(t, { cwd, getSandbox, getSandboxConfig }), { cwd, undoStack })),
114
117
  ...memory.tools,
118
+ todo.tool,
115
119
  ...(skills.tool ? [skills.tool] : []),
116
120
  ...(config.extraTools || []), // 外部注入(MCP 工具等):由 app 層先 async 載入再傳入
117
121
  ];
@@ -161,7 +165,7 @@ export function createKernel(pack, config = {}) {
161
165
  services,
162
166
  });
163
167
 
164
- return {
168
+ const api = {
165
169
  pack,
166
170
  registry,
167
171
  mutatingTools,
@@ -170,6 +174,7 @@ export function createKernel(pack, config = {}) {
170
174
  permissionPolicy: pack.permissionPolicy || {},
171
175
  sandbox: { isOn: () => getSandbox(), config: () => getSandboxConfig() },
172
176
  memory,
177
+ todo: { get: todo.get },
173
178
  /** 撤銷上一次檔案改動(write/edit):還原內容,新建的檔則刪除。 */
174
179
  undo: () => {
175
180
  const snap = undoStack.pop();
@@ -285,7 +290,51 @@ export function createKernel(pack, config = {}) {
285
290
  const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
286
291
  const text = (lastAssistant?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
287
292
  const aborted = lastAssistant?.stopReason === 'aborted';
288
- return { text, messages, agent, aborted };
293
+ return { text, messages, agent, aborted, turnModified };
294
+ },
295
+
296
+ /**
297
+ * 目標驅動自主循環:反覆 runTurn + LLM 自我驗收,直到達成 / 到上限 / 連續無進展。
298
+ * @param {string} goal
299
+ * @param {{ maxRounds?: number, history?: object[], onRound?, onCheck?, onEvent?, onAgent?, signal? }} [opts]
300
+ * @returns {Promise<{ done: boolean, rounds: number, history: object[], stalled?: boolean, aborted?: boolean }>}
301
+ */
302
+ runGoal: async (goal, opts = {}) => {
303
+ if (!config.model) throw new Error('runGoal 需要 config.model。');
304
+ const maxRounds = opts.maxRounds || 12;
305
+ const NO_PROGRESS_CAP = 3;
306
+ let history = opts.history || [];
307
+ let instruction = `目標:${goal}\n\n請著手完成這個目標;可自由使用工具(讀寫檔/跑命令/抓網頁/子 agent…)。完成後簡述你做了什麼、如何驗證。`;
308
+ let lastRemaining = null;
309
+ let noProgress = 0;
310
+ let verifyErrors = 0;
311
+ for (let round = 1; round <= maxRounds; round++) {
312
+ opts.onRound?.({ round, maxRounds });
313
+ const r = await api.runTurn(instruction, { history, onEvent: opts.onEvent, onAgent: opts.onAgent });
314
+ history = r.messages;
315
+ if (r.aborted) return { done: false, aborted: true, rounds: round, history };
316
+ let apiKey; try { apiKey = await config.getApiKey(config.model.provider); } catch { /* 略 */ }
317
+ const judge = config.checkGoal || checkGoal; // 可注入自訂驗收(測試 / app 客製)
318
+ const v = await judge(goal, history, config.model, apiKey, opts.signal);
319
+ opts.onCheck?.({ round, done: v.done, remaining: v.remaining });
320
+ if (v.done) return { done: true, rounds: round, history };
321
+ noProgress = r.turnModified ? 0 : noProgress + 1;
322
+ if (noProgress >= NO_PROGRESS_CAP) return { done: false, stalled: true, rounds: round, history };
323
+ // 驗收壞掉(網路/解析):remaining 是噪音,不拿來比對;連續壞 3 次就停(別空轉到上限)
324
+ if (v.error) {
325
+ verifyErrors += 1;
326
+ if (verifyErrors >= 3) return { done: false, verifyBroken: true, rounds: round, history };
327
+ instruction = `(驗收暫時無法判定)請繼續完成目標並自我檢查:${goal}`;
328
+ continue;
329
+ }
330
+ verifyErrors = 0;
331
+ const rem = normalizeFeedback(v.remaining);
332
+ if (!r.turnModified && rem && rem === lastRemaining) return { done: false, stalled: true, rounds: round, history };
333
+ lastRemaining = rem;
334
+ instruction = `目標尚未達成。驗收回饋:${v.remaining}\n請繼續完成目標:${goal}`;
335
+ }
336
+ return { done: false, maxedOut: true, rounds: maxRounds, history };
289
337
  },
290
338
  };
339
+ return api;
291
340
  }
@@ -0,0 +1,31 @@
1
+ // 任務待辦清單 — kernel 內建(對標 Claude Code 的 TodoWrite)。多步任務時規劃 + 追蹤進度,
2
+ // 讓使用者看到 agent 在做什麼。傳入完整清單覆蓋(同 Claude Code 語意)。
3
+ const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
4
+
5
+ export function createTodo() {
6
+ let list = [];
7
+ const tool = {
8
+ name: 'todo_write', label: '待辦', readOnly: true,
9
+ description: '建立/更新任務待辦清單(傳入完整清單覆蓋)。3 步以上的任務建議用它規劃並隨進度更新狀態;'
10
+ + '同時最多一個 in_progress。status:pending | in_progress | completed。',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ todos: {
15
+ type: 'array',
16
+ items: {
17
+ type: 'object',
18
+ properties: { content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] } },
19
+ required: ['content', 'status'],
20
+ },
21
+ },
22
+ },
23
+ required: ['todos'],
24
+ },
25
+ execute: async (_id, { todos }) => {
26
+ list = Array.isArray(todos) ? todos.filter((t) => t && typeof t.content === 'string').map((t) => ({ content: t.content, status: t.status || 'pending' })) : [];
27
+ return txt({ ok: true, count: list.length, todos: list });
28
+ },
29
+ };
30
+ return { tool, get: () => list };
31
+ }
@@ -5,12 +5,46 @@
5
5
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
6
6
  import { isAbsolute, join, relative } from 'node:path';
7
7
  import { execSync } from 'node:child_process';
8
+ import { createBackgroundTools } from '../../kernel/bg.js';
8
9
 
9
10
  const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
10
11
 
12
+ const IGNORE = new Set(['node_modules', '.git', '.xitto-kernel', '.xitto-code', 'dist', 'build', '.next', 'coverage']);
13
+
14
+ // 遞迴收集檔案路徑(跳過 IGNORE 目錄),上限保護
15
+ function walkFiles(dir, out, limit) {
16
+ if (out.length >= limit) return;
17
+ let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
18
+ for (const e of entries) {
19
+ if (out.length >= limit) return;
20
+ if (e.name.startsWith('.') && e.name !== '.env') { if (IGNORE.has(e.name)) continue; }
21
+ if (IGNORE.has(e.name)) continue;
22
+ const full = join(dir, e.name);
23
+ if (e.isDirectory()) walkFiles(full, out, limit);
24
+ else out.push(full);
25
+ }
26
+ }
27
+
28
+ // glob 樣式 → 正則(支援 ** 遞迴、* 與 ?)
29
+ function globToRegex(pattern) {
30
+ let re = '';
31
+ for (let i = 0; i < pattern.length; i++) {
32
+ const ch = pattern[i];
33
+ if (ch === '*') { if (pattern[i + 1] === '*') { re += '.*'; i++; if (pattern[i + 1] === '/') i++; } else re += '[^/]*'; }
34
+ else if (ch === '?') re += '[^/]';
35
+ else if ('.+^${}()|[]\\'.includes(ch)) re += '\\' + ch;
36
+ else re += ch;
37
+ }
38
+ return new RegExp('^' + re + '$');
39
+ }
40
+
11
41
  const SYSTEM_PROMPT = [
12
42
  '你是嚴謹的編碼 agent。準則:',
43
+ '- 探索 codebase:用 glob 找檔、grep 搜內容、read 讀檔(附行號)。',
13
44
  '- 編輯既有檔案前必先 read 它的當前內容,不基於臆測修改。',
45
+ '- edit 的 oldText 要夠精確且唯一(含足夠上下文);要全部取代用 replaceAll。',
46
+ '- 多步任務(3 步以上)先用 todo_write 規劃並隨進度更新。',
47
+ '- 長時間/常駐命令(dev server、watch)用 bash_bg 後台執行,再用 bash_output 看輸出。',
14
48
  '- 一次做一件事,改動後說明你做了什麼、如何驗證。',
15
49
  '- 破壞性操作先確認。',
16
50
  '- 要提交時:先 git_diff 看變更,再用 git_commit 寫一則簡潔、說明「為何」的 commit 訊息。',
@@ -24,15 +58,23 @@ const SYSTEM_PROMPT = [
24
58
  export function createCodingPack({ cwd = process.cwd() } = {}) {
25
59
  const readFiles = new Set(); // 已 read 過的絕對路徑(read 工具寫入、read-before-edit 守衛讀取)
26
60
  const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
61
+ const bg = createBackgroundTools(cwd); // bash_bg / bash_output / bash_kill
27
62
 
28
63
  const readTool = {
29
- name: 'read', label: '讀檔', description: '讀取檔案內容', readOnly: true,
30
- parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
31
- execute: async (_id, { path }) => {
64
+ name: 'read', label: '讀檔', readOnly: true,
65
+ description: '讀取檔案內容(每行附行號,方便對照與編輯)。大檔可用 offset(起始行,1-based)+limit(行數) 讀一段。',
66
+ parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number' }, limit: { type: 'number' } }, required: ['path'] },
67
+ execute: async (_id, { path, offset, limit }) => {
32
68
  const p = abs(path);
33
69
  if (!existsSync(p)) return txt({ error: '檔案不存在', path });
34
70
  readFiles.add(p);
35
- return txt(readFileSync(p, 'utf8'));
71
+ const lines = readFileSync(p, 'utf8').split('\n');
72
+ const start = Math.max(0, (offset || 1) - 1);
73
+ const count = limit && limit > 0 ? limit : 2000;
74
+ const slice = lines.slice(start, start + count);
75
+ const numbered = slice.map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`).join('\n');
76
+ const remain = lines.length - (start + count);
77
+ return txt(numbered + (remain > 0 ? `\n… 還有 ${remain} 行(用 offset=${start + count + 1} 繼續)` : ''));
36
78
  },
37
79
  };
38
80
 
@@ -58,28 +100,86 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
58
100
  };
59
101
 
60
102
  const editTool = {
61
- name: 'edit', label: '編輯', description: '把檔案中的 oldText 換成 newText', mutating: true,
62
- parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
63
- execute: async (_id, { path, oldText, newText }) => {
103
+ name: 'edit', label: '編輯', mutating: true,
104
+ description: '把檔案中的 oldText 換成 newText。oldText 必須唯一(出現多次會失敗,請加上下文;或設 replaceAll:true 全部取代)。',
105
+ parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
106
+ execute: async (_id, { path, oldText, newText, replaceAll }) => {
64
107
  const p = abs(path);
65
108
  if (!existsSync(p)) return txt({ error: '檔案不存在', path });
66
109
  const before = readFileSync(p, 'utf8');
67
- if (!before.includes(oldText)) return txt({ error: 'oldText 未找到', path });
68
- writeFileSync(p, before.replace(oldText, newText), 'utf8');
69
- return txt({ edited: path });
110
+ const occurrences = before.split(oldText).length - 1;
111
+ if (occurrences === 0) return txt({ error: 'oldText 未找到(請先 read 確認當前內容)', path });
112
+ if (occurrences > 1 && !replaceAll) return txt({ error: `oldText 出現 ${occurrences} 次,請提供更精確、唯一的 oldText(含上下文),或設 replaceAll:true`, path });
113
+ const after = replaceAll ? before.split(oldText).join(newText) : before.replace(oldText, newText);
114
+ writeFileSync(p, after, 'utf8');
115
+ return txt({ edited: path, replaced: replaceAll ? occurrences : 1 });
70
116
  },
71
117
  };
72
118
 
73
119
  const bashTool = {
74
- name: 'bash', label: 'bash', description: '執行 shell 命令', mutating: true, sandboxable: true,
75
- parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
76
- execute: async (_id, { command }) => {
77
- // 註:真實的串流/逾時/沙箱包裹由移植 xitto-code bash 工具取得(見 docs/04 3 項)。
78
- try { return txt(execSync(command, { cwd, encoding: 'utf8', timeout: 120000 }) || '(no output)'); }
120
+ name: 'bash', label: 'bash', description: '執行 shell 命令(可選 timeout 秒數,預設 120)。長時間/常駐的命令請改用 bash_bg。', mutating: true, sandboxable: true,
121
+ parameters: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'number' } }, required: ['command'] },
122
+ execute: async (_id, { command, timeout }) => {
123
+ const ms = Math.min(600, Math.max(1, timeout || 120)) * 1000;
124
+ try { return txt(execSync(command, { cwd, encoding: 'utf8', timeout: ms, maxBuffer: 16 * 1024 * 1024 }) || '(no output)'); }
79
125
  catch (e) { return txt({ error: e.message, stdout: e.stdout, stderr: e.stderr }); }
80
126
  },
81
127
  };
82
128
 
129
+ // ── codebase 導航:grep(搜內容)+ glob(找檔)──
130
+ const grepTool = {
131
+ name: 'grep', label: '搜尋內容', readOnly: true,
132
+ description: '在檔案內容用正則搜尋,回 path:line:文字。可選 path(起點目錄)、glob(檔名過濾如 *.js)。自動跳過 node_modules/.git。',
133
+ parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' }, glob: { type: 'string' }, ignoreCase: { type: 'boolean' } }, required: ['pattern'] },
134
+ execute: async (_id, { pattern, path, glob, ignoreCase }) => {
135
+ let re; try { re = new RegExp(pattern, ignoreCase ? 'i' : ''); } catch (e) { return txt({ error: '正則無效:' + e.message }); }
136
+ const base = path ? abs(path) : cwd;
137
+ if (!existsSync(base)) return txt({ error: '目錄不存在', path });
138
+ const files = []; walkFiles(base, files, 8000);
139
+ const gre = glob ? globToRegex(glob) : null;
140
+ const hits = [];
141
+ for (const f of files) {
142
+ if (gre && !gre.test(f.split('/').pop())) continue;
143
+ let content; try { content = readFileSync(f, 'utf8'); } catch { continue; }
144
+ if (content.includes('')) continue; // 跳過二進位
145
+ const lines = content.split('\n');
146
+ for (let i = 0; i < lines.length; i++) {
147
+ if (re.test(lines[i])) { hits.push(`${relative(cwd, f)}:${i + 1}: ${lines[i].trim().slice(0, 200)}`); if (hits.length >= 200) break; }
148
+ }
149
+ if (hits.length >= 200) break;
150
+ }
151
+ return txt(hits.length ? hits.join('\n') + (hits.length >= 200 ? '\n…(結果已截斷至 200)' : '') : '(無匹配)');
152
+ },
153
+ };
154
+
155
+ const globTool = {
156
+ name: 'glob', label: '找檔', readOnly: true,
157
+ description: '用萬用字元樣式找檔(** 遞迴、* ?),相對路徑比對。如 "src/**/*.js"、"**/*.test.js"。',
158
+ parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] },
159
+ execute: async (_id, { pattern, path }) => {
160
+ const base = path ? abs(path) : cwd;
161
+ if (!existsSync(base)) return txt({ error: '目錄不存在', path });
162
+ const files = []; walkFiles(base, files, 8000);
163
+ const re = globToRegex(pattern);
164
+ const matched = files.map((f) => relative(cwd, f)).filter((rel) => re.test(rel)).slice(0, 200);
165
+ return txt({ pattern, count: matched.length, files: matched });
166
+ },
167
+ };
168
+
169
+ const webFetch = {
170
+ name: 'web_fetch', label: '抓網頁', readOnly: true,
171
+ description: '抓取一個 URL 的內容並回傳純文字(HTML 去標籤、截斷)。查線上文件/API 用。',
172
+ parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
173
+ execute: async (_id, { url }) => {
174
+ try {
175
+ const res = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 xitto-kernel' }, signal: AbortSignal.timeout(20000) });
176
+ const html = await res.text();
177
+ const text = html.replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 8000);
178
+ return txt({ url, status: res.status, text: text || '(空)' });
179
+ } catch (e) { return txt({ error: e?.message || String(e), url }); }
180
+ },
181
+ };
182
+
83
183
  // ── git 工具(編碼領域):kernel 不認識 git,這些是 coding pack 提供的領域能力 ──
84
184
  const git = (a) => { try { return execSync(`git ${a}`, { cwd, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); } catch { return null; } };
85
185
  const isRepo = () => { try { execSync('git rev-parse --is-inside-work-tree', { cwd, stdio: 'ignore' }); return true; } catch { return false; } };
@@ -113,7 +213,7 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
113
213
 
114
214
  return {
115
215
  name: 'coding',
116
- tools: () => [readTool, lsTool, writeTool, editTool, bashTool, gitStatus, gitDiff, gitLog, gitCommit],
216
+ tools: () => [readTool, lsTool, globTool, grepTool, writeTool, editTool, bashTool, ...bg.tools, webFetch, gitStatus, gitDiff, gitLog, gitCommit],
117
217
  systemPrompt: SYSTEM_PROMPT,
118
218
  contextFiles: ['CLAUDE.md', 'AGENTS.md', 'XITTO.md', '.xitto-code.md'],
119
219
  // mutatingTools 省略 → kernel 從工具 metadata 推導(write/edit/bash)
@@ -0,0 +1,100 @@
1
+ // general pack — 通用自主 agent。廣的 system prompt + 檔案/shell/web 工具。
2
+ // 搭配 kernel 的 runGoal(目標循環)+ 子 agent + MCP,即為「給目標、自己做到完成」的通用 agent。
3
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { execSync } from 'node:child_process';
6
+
7
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
8
+
9
+ const SYSTEM_PROMPT = [
10
+ '你是通用自主 agent。給你一個目標,你用工具反覆推進直到完成:',
11
+ '- 可讀寫檔案、跑 shell 命令、搜尋網路(web_search)、抓網頁(web_fetch)、派子 agent 做聚焦調查。',
12
+ '- 先想清楚步驟再動手;每步簡述你在做什麼。',
13
+ '- 需要外部資料:先 web_search 找來源,再 web_fetch 讀全文,不要憑空編造。',
14
+ '- 編輯既有檔案前先 read;破壞性/對外操作前確認。',
15
+ '- 完成後明確說「已完成」並總結結果與如何驗證。',
16
+ ].join('\n');
17
+
18
+ export function createGeneralPack({ cwd = process.cwd() } = {}) {
19
+ const readFiles = new Set();
20
+ const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
21
+
22
+ const read = {
23
+ name: 'read', label: '讀檔', description: '讀取檔案內容', readOnly: true,
24
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
25
+ execute: async (_id, { path }) => { const p = abs(path); if (!existsSync(p)) return txt({ error: '檔案不存在', path }); readFiles.add(p); return txt(readFileSync(p, 'utf8')); },
26
+ };
27
+ const ls = {
28
+ name: 'ls', label: '列目錄', description: '列出目錄內容', readOnly: true,
29
+ parameters: { type: 'object', properties: { path: { type: 'string' } } },
30
+ execute: async (_id, { path = '.' }) => { const p = abs(path); if (!existsSync(p)) return txt({ error: '目錄不存在', path }); return txt(readdirSync(p).map((n) => n + (statSync(join(p, n)).isDirectory() ? '/' : '')).join('\n')); },
31
+ };
32
+ const write = {
33
+ name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
34
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
35
+ execute: async (_id, { path, content }) => { const p = abs(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
36
+ };
37
+ const edit = {
38
+ name: 'edit', label: '編輯', description: '把檔案中的 oldText 換成 newText', mutating: true,
39
+ parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
40
+ execute: async (_id, { path, oldText, newText }) => { const p = abs(path); if (!existsSync(p)) return txt({ error: '檔案不存在', path }); const b = readFileSync(p, 'utf8'); if (!b.includes(oldText)) return txt({ error: 'oldText 未找到', path }); writeFileSync(p, b.replace(oldText, newText), 'utf8'); return txt({ edited: path }); },
41
+ };
42
+ const bash = {
43
+ name: 'bash', label: 'bash', description: '執行 shell 命令', mutating: true, sandboxable: true,
44
+ parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
45
+ execute: async (_id, { command }) => { try { return txt(execSync(command, { cwd, encoding: 'utf8', timeout: 120000 }) || '(no output)'); } catch (e) { return txt({ error: e.message, stdout: e.stdout, stderr: e.stderr }); } },
46
+ };
47
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
48
+ const stripTags = (s) => String(s).replace(/<[^>]+>/g, '').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
49
+
50
+ const webFetch = {
51
+ name: 'web_fetch', label: '抓網頁', description: '抓取一個 URL 的內容並回傳純文字(HTML 去標籤、截斷)。讀某頁全文用。', readOnly: true,
52
+ parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
53
+ execute: async (_id, { url }) => {
54
+ try {
55
+ const res = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(20000) });
56
+ const html = await res.text();
57
+ const text = html
58
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
59
+ .replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 8000);
60
+ return txt({ url, status: res.status, text: text || '(空)' });
61
+ } catch (e) { return txt({ error: e?.message || String(e), url }); }
62
+ },
63
+ };
64
+
65
+ const webSearch = {
66
+ name: 'web_search', label: '網路搜尋', readOnly: true,
67
+ description: '用關鍵字搜尋網路,回傳前幾筆結果(標題 + URL + 摘要)。先 search 找來源,再用 web_fetch 讀全文。',
68
+ parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
69
+ execute: async (_id, { query }) => {
70
+ try {
71
+ const res = await fetch('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query), { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(20000) });
72
+ const html = await res.text();
73
+ const links = [...html.matchAll(/<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g)];
74
+ const snippets = [...html.matchAll(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => stripTags(m[1]));
75
+ const decode = (href) => { const u = /uddg=([^&]+)/.exec(href); return u ? decodeURIComponent(u[1]) : href; };
76
+ const results = links.slice(0, 6).map((m, i) => ({ title: stripTags(m[2]), url: decode(m[1]), snippet: snippets[i] || '' }));
77
+ return txt({ query, count: results.length, results });
78
+ } catch (e) { return txt({ error: e?.message || String(e), query }); }
79
+ },
80
+ };
81
+
82
+ return {
83
+ name: 'general',
84
+ tools: () => [read, ls, write, edit, bash, webFetch, webSearch],
85
+ systemPrompt: SYSTEM_PROMPT,
86
+ contextFiles: ['AGENTS.md', 'GENERAL.md'],
87
+ preToolPolicy: {
88
+ check: (ctx) => {
89
+ if ((ctx.name === 'edit' || ctx.name === 'write') && ctx.args?.path) {
90
+ const p = abs(ctx.args.path);
91
+ if (existsSync(p) && !readFiles.has(p)) return { block: true, reason: `請先 read ${ctx.args.path} 再編輯。` };
92
+ }
93
+ return undefined;
94
+ },
95
+ },
96
+ permissionPolicy: { defaultMode: 'default' },
97
+ };
98
+ }
99
+
100
+ export const generalPack = createGeneralPack();