xitto-kernel 0.1.0 → 0.3.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.
@@ -4,13 +4,19 @@
4
4
  // 對應 docs/05-example-packs.md「A. coding pack」。
5
5
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
6
6
  import { isAbsolute, join, relative } from 'node:path';
7
- import { execSync } from 'node:child_process';
7
+ import { execSync, spawnSync } from 'node:child_process';
8
+ import { createBackgroundTools } from '../../kernel/bg.js';
9
+ import { createGrepTool, createGlobTool } from '../shared/code-nav.js';
8
10
 
9
11
  const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
10
12
 
11
13
  const SYSTEM_PROMPT = [
12
14
  '你是嚴謹的編碼 agent。準則:',
15
+ '- 探索 codebase:用 glob 找檔、grep 搜內容、read 讀檔(附行號)。',
13
16
  '- 編輯既有檔案前必先 read 它的當前內容,不基於臆測修改。',
17
+ '- edit 的 oldText 要夠精確且唯一(含足夠上下文);要全部取代用 replaceAll。',
18
+ '- 多步任務(3 步以上)先用 todo_write 規劃並隨進度更新。',
19
+ '- 長時間/常駐命令(dev server、watch)用 bash_bg 後台執行,再用 bash_output 看輸出。',
14
20
  '- 一次做一件事,改動後說明你做了什麼、如何驗證。',
15
21
  '- 破壞性操作先確認。',
16
22
  '- 要提交時:先 git_diff 看變更,再用 git_commit 寫一則簡潔、說明「為何」的 commit 訊息。',
@@ -24,15 +30,23 @@ const SYSTEM_PROMPT = [
24
30
  export function createCodingPack({ cwd = process.cwd() } = {}) {
25
31
  const readFiles = new Set(); // 已 read 過的絕對路徑(read 工具寫入、read-before-edit 守衛讀取)
26
32
  const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
33
+ const bg = createBackgroundTools(cwd); // bash_bg / bash_output / bash_kill
27
34
 
28
35
  const readTool = {
29
- name: 'read', label: '讀檔', description: '讀取檔案內容', readOnly: true,
30
- parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
31
- execute: async (_id, { path }) => {
36
+ name: 'read', label: '讀檔', readOnly: true,
37
+ description: '讀取檔案內容(每行附行號,方便對照與編輯)。大檔可用 offset(起始行,1-based)+limit(行數) 讀一段。',
38
+ parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number' }, limit: { type: 'number' } }, required: ['path'] },
39
+ execute: async (_id, { path, offset, limit }) => {
32
40
  const p = abs(path);
33
41
  if (!existsSync(p)) return txt({ error: '檔案不存在', path });
34
42
  readFiles.add(p);
35
- return txt(readFileSync(p, 'utf8'));
43
+ const lines = readFileSync(p, 'utf8').split('\n');
44
+ const start = Math.max(0, (offset || 1) - 1);
45
+ const count = limit && limit > 0 ? limit : 2000;
46
+ const slice = lines.slice(start, start + count);
47
+ const numbered = slice.map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`).join('\n');
48
+ const remain = lines.length - (start + count);
49
+ return txt(numbered + (remain > 0 ? `\n… 還有 ${remain} 行(用 offset=${start + count + 1} 繼續)` : ''));
36
50
  },
37
51
  };
38
52
 
@@ -58,25 +72,51 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
58
72
  };
59
73
 
60
74
  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 }) => {
75
+ name: 'edit', label: '編輯', mutating: true,
76
+ description: '把檔案中的 oldText 換成 newText。oldText 必須唯一(出現多次會失敗,請加上下文;或設 replaceAll:true 全部取代)。',
77
+ parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
78
+ execute: async (_id, { path, oldText, newText, replaceAll }) => {
64
79
  const p = abs(path);
65
80
  if (!existsSync(p)) return txt({ error: '檔案不存在', path });
66
81
  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 });
82
+ const occurrences = before.split(oldText).length - 1;
83
+ if (occurrences === 0) return txt({ error: 'oldText 未找到(請先 read 確認當前內容)', path });
84
+ if (occurrences > 1 && !replaceAll) return txt({ error: `oldText 出現 ${occurrences} 次,請提供更精確、唯一的 oldText(含上下文),或設 replaceAll:true`, path });
85
+ const after = replaceAll ? before.split(oldText).join(newText) : before.replace(oldText, newText);
86
+ writeFileSync(p, after, 'utf8');
87
+ return txt({ edited: path, replaced: replaceAll ? occurrences : 1 });
70
88
  },
71
89
  };
72
90
 
73
91
  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)'); }
79
- catch (e) { return txt({ error: e.message, stdout: e.stdout, stderr: e.stderr }); }
92
+ name: 'bash', label: 'bash', description: '執行 shell 命令(可選 timeout 秒數,預設 120)。長時間/常駐的命令請改用 bash_bg。', mutating: true, sandboxable: true,
93
+ parameters: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'number' } }, required: ['command'] },
94
+ execute: async (_id, { command, timeout }) => {
95
+ const ms = Math.min(600, Math.max(1, timeout || 120)) * 1000;
96
+ // spawnSync 同時捕捉 stdout+stderr(不漏到終端,agent 也看得到錯誤輸出)
97
+ const r = spawnSync(command, { shell: true, cwd, encoding: 'utf8', timeout: ms, maxBuffer: 16 * 1024 * 1024 });
98
+ const output = ((r.stdout || '') + (r.stderr || '')).trim();
99
+ if (r.error) return txt({ error: r.error.message, output });
100
+ if (r.status !== 0) return txt({ error: `命令結束碼 ${r.status}`, output: output || '(no output)' });
101
+ return txt(output || '(no output)');
102
+ },
103
+ };
104
+
105
+ // ── codebase 導航:grep / glob(共用模組)──
106
+ const grepTool = createGrepTool(cwd);
107
+ const globTool = createGlobTool(cwd);
108
+
109
+ const webFetch = {
110
+ name: 'web_fetch', label: '抓網頁', readOnly: true,
111
+ description: '抓取一個 URL 的內容並回傳純文字(HTML 去標籤、截斷)。查線上文件/API 用。',
112
+ parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
113
+ execute: async (_id, { url }) => {
114
+ try {
115
+ const res = await fetch(url, { headers: { 'user-agent': 'Mozilla/5.0 xitto-kernel' }, signal: AbortSignal.timeout(20000) });
116
+ const html = await res.text();
117
+ 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);
118
+ return txt({ url, status: res.status, text: text || '(空)' });
119
+ } catch (e) { return txt({ error: e?.message || String(e), url }); }
80
120
  },
81
121
  };
82
122
 
@@ -113,7 +153,7 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
113
153
 
114
154
  return {
115
155
  name: 'coding',
116
- tools: () => [readTool, lsTool, writeTool, editTool, bashTool, gitStatus, gitDiff, gitLog, gitCommit],
156
+ tools: () => [readTool, lsTool, globTool, grepTool, writeTool, editTool, bashTool, ...bg.tools, webFetch, gitStatus, gitDiff, gitLog, gitCommit],
117
157
  systemPrompt: SYSTEM_PROMPT,
118
158
  contextFiles: ['CLAUDE.md', 'AGENTS.md', 'XITTO.md', '.xitto-code.md'],
119
159
  // mutatingTools 省略 → kernel 從工具 metadata 推導(write/edit/bash)
@@ -1,66 +1,72 @@
1
- // data-query pack — 第二個範例領域,用來證明「同介面、kernel 零改動」。
2
- // 工具為示意 stub(回傳假資料),重點是六個插槽用法與 coding 完全一樣,只是內容換了。
3
- // 對照:schema-before-query 之於資料查詢 == read-before-edit 之於編碼(同 preToolPolicy 插槽)。
1
+ // data-query pack — 真實資料查詢 agent(用 sqlite3 CLI,零依賴)。
2
+ // 工具對一個 SQLite .db 跑真實 SQL;schema-before-query 守衛(對照 read-before-edit)。
4
3
  // 對應 docs/05-example-packs.md「B. data-query pack」。
4
+ import { spawnSync } from 'node:child_process';
5
+ import { isAbsolute, join } from 'node:path';
5
6
 
6
7
  const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
7
8
 
9
+ // 用 sqlite3 CLI 跑 SQL(sql 以 argv 傳入,非 shell 內插 → 無 shell 注入)
10
+ function sqlite(dbPath, sql, opts = []) {
11
+ const r = spawnSync('sqlite3', [...opts, dbPath, sql], { encoding: 'utf8', timeout: 30000, maxBuffer: 16 * 1024 * 1024 });
12
+ if (r.error) return { error: r.error.code === 'ENOENT' ? '找不到 sqlite3 命令(請先安裝)' : r.error.message };
13
+ if (r.status !== 0) return { error: (r.stderr || '').trim() || `exit ${r.status}` };
14
+ return { out: (r.stdout || '').trim() };
15
+ }
16
+
8
17
  const SYSTEM_PROMPT = [
9
- '你是資料分析 agent。準則:',
18
+ '你是資料分析 agent,對一個 SQLite 資料庫工作。準則:',
10
19
  '- 下查詢前先用 list_tables / describe_table 了解結構。',
11
- '- 破壞性 SQL(DROP/TRUNCATE/DELETE 無 WHERE)一律先確認。',
20
+ '- 唯讀查詢用 sql_query;寫入(INSERT/UPDATE/DELETE/建表)用 sql_exec。',
21
+ '- 破壞性 SQL(DROP/TRUNCATE/無 WHERE 的 DELETE)先確認。',
22
+ '- 回答問題時根據真實查詢結果,不要臆測數字。',
12
23
  ].join('\n');
13
24
 
14
- /**
15
- * @param {{ schema?: Record<string,string[]> }} [opts]
16
- * @returns {import('../../types.js').DomainPack}
17
- */
18
- export function createDataQueryPack({ schema = { orders: ['id', 'amount', 'user_id'], users: ['id', 'name'] } } = {}) {
19
- let schemaLoaded = false; // describe/list 後設為 true,schema-before-query 守衛據此放行
25
+ export function createDataQueryPack({ cwd = process.cwd(), db } = {}) {
26
+ const dbPath = db ? (isAbsolute(db) ? db : join(cwd, db)) : join(cwd, 'data.db');
27
+ let schemaSeen = false;
20
28
 
21
29
  const listTables = {
22
- name: 'list_tables', label: '列表', description: '列出所有資料表', readOnly: true,
30
+ name: 'list_tables', label: '列表', readOnly: true, description: '列出資料庫所有資料表',
23
31
  parameters: { type: 'object', properties: {} },
24
- execute: async () => { schemaLoaded = true; return txt(Object.keys(schema)); },
32
+ execute: async () => { schemaSeen = true; const r = sqlite(dbPath, '.tables'); return txt(r.error ? { error: r.error } : { tables: (r.out || '').split(/\s+/).filter(Boolean) }); },
25
33
  };
26
34
  const describeTable = {
27
- name: 'describe_table', label: '表結構', description: '看某表欄位', readOnly: true,
35
+ name: 'describe_table', label: '表結構', readOnly: true, description: '看某表的欄位定義(CREATE 語句)',
28
36
  parameters: { type: 'object', properties: { table: { type: 'string' } }, required: ['table'] },
29
- execute: async (_id, { table }) => { schemaLoaded = true; return txt(schema[table] || { error: '無此表' }); },
37
+ execute: async (_id, { table }) => { schemaSeen = true; const r = sqlite(dbPath, `.schema ${table}`); return txt(r.error ? { error: r.error } : (r.out || '(無此表)')); },
30
38
  };
31
39
  const sqlQuery = {
32
- name: 'sql_query', label: '查詢', description: '執行唯讀 SQL 查詢', readOnly: true,
40
+ name: 'sql_query', label: '查詢', readOnly: true, description: '執行唯讀 SQL 查詢(SELECT/WITH/PRAGMA),回 CSV(含表頭)。',
33
41
  parameters: { type: 'object', properties: { sql: { type: 'string' } }, required: ['sql'] },
34
- execute: async (_id, { sql }) => txt({ note: '(示意)查詢結果', sql, rows: [] }),
42
+ execute: async (_id, { sql }) => {
43
+ if (/\b(insert|update|delete|drop|create|alter|replace)\b/i.test(sql)) return txt({ error: '這是寫入型 SQL,請改用 sql_exec' });
44
+ const r = sqlite(dbPath, sql, ['-header', '-csv']);
45
+ return txt(r.error ? { error: r.error } : (r.out || '(空結果)'));
46
+ },
35
47
  };
36
48
  const sqlExec = {
37
- name: 'sql_exec', label: '寫入', description: '執行寫入型 SQL(INSERT/UPDATE/DELETE', mutating: true,
49
+ name: 'sql_exec', label: '寫入', mutating: true, description: '執行寫入型 SQL(INSERT/UPDATE/DELETE/CREATE/ALTER)。',
38
50
  parameters: { type: 'object', properties: { sql: { type: 'string' } }, required: ['sql'] },
39
- execute: async (_id, { sql }) => txt({ note: '(示意)已執行', sql }),
40
- };
41
- const chartRender = {
42
- name: 'chart_render', label: '畫圖', description: '把查詢結果渲染成圖表', readOnly: true,
43
- parameters: { type: 'object', properties: { spec: { type: 'object' } }, required: ['spec'] },
44
- execute: async (_id, { spec }) => txt({ note: '(示意)已渲染', spec }),
51
+ execute: async (_id, { sql }) => { const r = sqlite(dbPath, sql); return txt(r.error ? { error: r.error } : { ok: true, out: r.out || '' }); },
45
52
  };
46
53
 
47
54
  return {
48
55
  name: 'data-query',
49
- tools: () => [listTables, describeTable, sqlQuery, sqlExec, chartRender],
56
+ tools: () => [listTables, describeTable, sqlQuery, sqlExec],
50
57
  systemPrompt: SYSTEM_PROMPT,
51
58
  contextFiles: ['SCHEMA.md', 'METRICS.md'],
52
- // sql_query 唯讀、sql_exec mutating → 從 metadata 自動推導 mutatingTools=['sql_exec']
59
+ // 只有 sql_exec mutating → kernel 從 metadata 推導
53
60
  preToolPolicy: {
54
- // schema-before-query:沒先看過 schema 就下查詢
61
+ // schema-before-query:沒先看過結構就下 SQL擋(對照 read-before-edit)
55
62
  check: (ctx) => {
56
- if ((ctx.name === 'sql_query' || ctx.name === 'sql_exec') && !schemaLoaded) {
63
+ if ((ctx.name === 'sql_query' || ctx.name === 'sql_exec') && !schemaSeen) {
57
64
  return { block: true, reason: '請先用 list_tables / describe_table 了解結構,再下 SQL。' };
58
65
  }
59
66
  return undefined;
60
67
  },
61
68
  },
62
69
  permissionPolicy: { deny: ['bash:DROP', 'bash:TRUNCATE'], defaultMode: 'default' },
63
- // verify 省略 → 查詢無「自我驗收」概念
64
70
  };
65
71
  }
66
72
 
@@ -0,0 +1,41 @@
1
+ // deep-research pack — 深度研究 agent:拆問題 → 多來源搜尋 → 讀全文查證 → 有引用的結論。
2
+ // 工具:web_search/web_fetch(共用)+ write/read(存/讀報告)。搭配 kernel 的 spawn_agent 可並行子研究。
3
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { createWebSearchTool, createWebFetchTool } from '../shared/web-tools.js';
6
+
7
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
8
+
9
+ const SYSTEM_PROMPT = [
10
+ '你是深度研究 agent。給你一個問題,做法:',
11
+ '- 把問題拆成幾個子查詢,用 web_search 找多個來源(不要只查一次)。',
12
+ '- 用 web_fetch 讀來源全文查證,不只看搜尋摘要。',
13
+ '- 交叉比對多個來源;關鍵事實要標注來源 URL。',
14
+ '- 最後給有引用的結論(重要論點附 [來源: URL]);可用 write 存成報告檔。',
15
+ '- 來源衝突或查不到時明說,不杜撰。',
16
+ ].join('\n');
17
+
18
+ export function createDeepResearchPack({ cwd = process.cwd() } = {}) {
19
+ const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
20
+
21
+ const writeReport = {
22
+ name: 'write', label: '存報告', mutating: true, description: '把研究報告/筆記寫入檔案(建立或覆寫)。',
23
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
24
+ execute: async (_id, { path, content }) => { writeFileSync(abs(path), content ?? '', 'utf8'); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
25
+ };
26
+ const readTool = {
27
+ name: 'read', label: '讀檔', readOnly: true, description: '讀回已存的報告/筆記',
28
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
29
+ execute: async (_id, { path }) => { const p = abs(path); return existsSync(p) ? txt(readFileSync(p, 'utf8')) : txt({ error: '檔案不存在', path }); },
30
+ };
31
+
32
+ return {
33
+ name: 'deep-research',
34
+ tools: () => [createWebSearchTool(), createWebFetchTool(), writeReport, readTool],
35
+ systemPrompt: SYSTEM_PROMPT,
36
+ contextFiles: ['RESEARCH.md'],
37
+ permissionPolicy: { defaultMode: 'default' },
38
+ };
39
+ }
40
+
41
+ export const deepResearchPack = createDeepResearchPack();
@@ -0,0 +1,30 @@
1
+ // devops/SRE pack — 維運自動化 agent。shell 為主 + 後台服務 + 設定檔 + 日誌搜尋 + 健康檢查。
2
+ // 工具全由共用模組組成(fs-tools / code-nav / web-tools / bg)。
3
+ import { createFsTools } from '../shared/fs-tools.js';
4
+ import { createGrepTool, createGlobTool } from '../shared/code-nav.js';
5
+ import { createHttpTool } from '../shared/web-tools.js';
6
+ import { createBackgroundTools } from '../../kernel/bg.js';
7
+
8
+ const SYSTEM_PROMPT = [
9
+ '你是 DevOps/SRE agent,做系統維運與自動化。準則:',
10
+ '- 改動前先看現況(read 設定、bash 查狀態、grep 日誌),不要盲改。',
11
+ '- 操作盡量「冪等」(重跑不會壞);寫腳本記得處理錯誤與權限。',
12
+ '- 長時間/常駐服務(dev server、watch、daemon)用 bash_bg,再用 bash_output 看輸出。',
13
+ '- 部署/操作後用 http 或命令做健康檢查,確認真的好了。',
14
+ '- 對正式環境的破壞性操作(刪資料、重啟服務)先確認。',
15
+ ].join('\n');
16
+
17
+ export function createDevopsPack({ cwd = process.cwd() } = {}) {
18
+ const fs = createFsTools(cwd);
19
+ const bg = createBackgroundTools(cwd);
20
+ return {
21
+ name: 'devops',
22
+ tools: () => [fs.read, fs.ls, createGlobTool(cwd), createGrepTool(cwd), fs.write, fs.edit, fs.bash, ...bg.tools, createHttpTool()],
23
+ systemPrompt: SYSTEM_PROMPT,
24
+ contextFiles: ['RUNBOOK.md', 'AGENTS.md'],
25
+ preToolPolicy: { check: fs.readBeforeEdit },
26
+ permissionPolicy: { defaultMode: 'default' },
27
+ };
28
+ }
29
+
30
+ export const devopsPack = createDevopsPack();
@@ -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, extname } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { createGrepTool, createGlobTool } from '../shared/code-nav.js';
7
+ import { createWebFetchTool, createWebSearchTool, createHttpTool } from '../shared/web-tools.js';
8
+
9
+ const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
10
+
11
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
12
+
13
+ const SYSTEM_PROMPT = [
14
+ '你是通用自主 agent。給你一個目標,你用工具反覆推進直到完成:',
15
+ '- 可讀寫檔案、grep/glob 找東西、跑 shell、搜尋網路(web_search)、抓網頁(web_fetch)、',
16
+ ' 串接 API(http)、讀圖(read_image)、派子 agent、用待辦(todo_write)規劃多步任務。',
17
+ '- 先想清楚步驟再動手;每步簡述你在做什麼。',
18
+ '- 需要外部資料:先 web_search 找來源,再 web_fetch 讀全文;串 API 用 http。不要憑空編造。',
19
+ '- 還缺的能力(瀏覽器點擊、特定服務)可由使用者掛 MCP server 補上。',
20
+ '- 編輯既有檔案前先 read;破壞性/對外操作前確認。',
21
+ '- 完成後明確說「已完成」並總結結果與如何驗證。',
22
+ ].join('\n');
23
+
24
+ export function createGeneralPack({ cwd = process.cwd() } = {}) {
25
+ const readFiles = new Set();
26
+ const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
27
+
28
+ const read = {
29
+ name: 'read', label: '讀檔', description: '讀取檔案內容', readOnly: true,
30
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
31
+ execute: async (_id, { path }) => { const p = abs(path); if (!existsSync(p)) return txt({ error: '檔案不存在', path }); readFiles.add(p); return txt(readFileSync(p, 'utf8')); },
32
+ };
33
+ const ls = {
34
+ name: 'ls', label: '列目錄', description: '列出目錄內容', readOnly: true,
35
+ parameters: { type: 'object', properties: { path: { type: 'string' } } },
36
+ 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')); },
37
+ };
38
+ const write = {
39
+ name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
40
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
41
+ execute: async (_id, { path, content }) => { const p = abs(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
42
+ };
43
+ const edit = {
44
+ name: 'edit', label: '編輯', description: '把檔案中的 oldText 換成 newText', mutating: true,
45
+ parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
46
+ 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 }); },
47
+ };
48
+ const bash = {
49
+ name: 'bash', label: 'bash', description: '執行 shell 命令(可選 timeout 秒數,預設 120)', mutating: true, sandboxable: true,
50
+ parameters: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'number' } }, required: ['command'] },
51
+ execute: async (_id, { command, timeout }) => {
52
+ const ms = Math.min(600, Math.max(1, timeout || 120)) * 1000;
53
+ const r = spawnSync(command, { shell: true, cwd, encoding: 'utf8', timeout: ms, maxBuffer: 16 * 1024 * 1024 });
54
+ const output = ((r.stdout || '') + (r.stderr || '')).trim();
55
+ if (r.error) return txt({ error: r.error.message, output });
56
+ if (r.status !== 0) return txt({ error: `命令結束碼 ${r.status}`, output: output || '(no output)' });
57
+ return txt(output || '(no output)');
58
+ },
59
+ };
60
+ const webFetch = createWebFetchTool();
61
+ const webSearch = createWebSearchTool();
62
+ const http = createHttpTool();
63
+
64
+ // 多模態:讀圖交給模型「看」(需模型支援影像輸入)
65
+ const readImage = {
66
+ name: 'read_image', label: '讀圖', readOnly: true,
67
+ description: '讀取一張圖片(png/jpg/gif/webp)交給模型分析(截圖/設計稿/圖表)。需模型支援影像輸入。',
68
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
69
+ execute: async (_id, { path }) => {
70
+ const p = abs(path);
71
+ if (!existsSync(p)) return txt({ error: '檔案不存在', path });
72
+ const mimeType = MIME[extname(p).toLowerCase()];
73
+ if (!mimeType) return txt({ error: '不支援的格式(png/jpg/gif/webp)', path });
74
+ try { return { content: [{ type: 'image', data: readFileSync(p).toString('base64'), mimeType }] }; }
75
+ catch (e) { return txt({ error: e.message }); }
76
+ },
77
+ };
78
+
79
+ const grepTool = createGrepTool(cwd);
80
+ const globTool = createGlobTool(cwd);
81
+
82
+ return {
83
+ name: 'general',
84
+ tools: () => [read, ls, globTool, grepTool, write, edit, bash, webSearch, webFetch, http, readImage],
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();
Binary file
@@ -0,0 +1,82 @@
1
+ // 共用檔案/shell 工具(read/ls/write/edit/bash)+ read-before-edit 守衛。
2
+ // 多個 pack(coding/devops…)共用;read 附行號、edit 唯一性檢查、bash 用 spawnSync 捕捉 stderr。
3
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+
7
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
8
+
9
+ /**
10
+ * @param {string} cwd
11
+ * @returns {{ read, ls, write, edit, bash, readBeforeEdit }}
12
+ */
13
+ export function createFsTools(cwd) {
14
+ const readFiles = new Set();
15
+ const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
16
+
17
+ const read = {
18
+ name: 'read', label: '讀檔', readOnly: true,
19
+ description: '讀取檔案內容(每行附行號)。大檔可用 offset(起始行,1-based)+limit(行數)。',
20
+ parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number' }, limit: { type: 'number' } }, required: ['path'] },
21
+ execute: async (_id, { path, offset, limit }) => {
22
+ const p = abs(path);
23
+ if (!existsSync(p)) return txt({ error: '檔案不存在', path });
24
+ readFiles.add(p);
25
+ const lines = readFileSync(p, 'utf8').split('\n');
26
+ const start = Math.max(0, (offset || 1) - 1);
27
+ const count = limit && limit > 0 ? limit : 2000;
28
+ const numbered = lines.slice(start, start + count).map((l, i) => `${String(start + i + 1).padStart(6)}\t${l}`).join('\n');
29
+ const remain = lines.length - (start + count);
30
+ return txt(numbered + (remain > 0 ? `\n… 還有 ${remain} 行(offset=${start + count + 1})` : ''));
31
+ },
32
+ };
33
+ const ls = {
34
+ name: 'ls', label: '列目錄', readOnly: true, description: '列出目錄內容',
35
+ parameters: { type: 'object', properties: { path: { type: 'string' } } },
36
+ 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')); },
37
+ };
38
+ const write = {
39
+ name: 'write', label: '寫檔', mutating: true, description: '建立或覆寫檔案',
40
+ parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
41
+ execute: async (_id, { path, content }) => { const p = abs(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
42
+ };
43
+ const edit = {
44
+ name: 'edit', label: '編輯', mutating: true,
45
+ description: '把 oldText 換成 newText。oldText 須唯一(多次出現需 replaceAll)。',
46
+ parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
47
+ execute: async (_id, { path, oldText, newText, replaceAll }) => {
48
+ const p = abs(path);
49
+ if (!existsSync(p)) return txt({ error: '檔案不存在', path });
50
+ const before = readFileSync(p, 'utf8');
51
+ const n = before.split(oldText).length - 1;
52
+ if (n === 0) return txt({ error: 'oldText 未找到(先 read 確認)', path });
53
+ if (n > 1 && !replaceAll) return txt({ error: `oldText 出現 ${n} 次,請更精確或設 replaceAll`, path });
54
+ writeFileSync(p, replaceAll ? before.split(oldText).join(newText) : before.replace(oldText, newText), 'utf8');
55
+ return txt({ edited: path, replaced: replaceAll ? n : 1 });
56
+ },
57
+ };
58
+ const bash = {
59
+ name: 'bash', label: 'bash', mutating: true, sandboxable: true,
60
+ description: '執行 shell 命令(可選 timeout 秒數,預設 120)。長時間/常駐用 bash_bg。',
61
+ parameters: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'number' } }, required: ['command'] },
62
+ execute: async (_id, { command, timeout }) => {
63
+ const ms = Math.min(600, Math.max(1, timeout || 120)) * 1000;
64
+ const r = spawnSync(command, { shell: true, cwd, encoding: 'utf8', timeout: ms, maxBuffer: 16 * 1024 * 1024 });
65
+ const output = ((r.stdout || '') + (r.stderr || '')).trim();
66
+ if (r.error) return txt({ error: r.error.message, output });
67
+ if (r.status !== 0) return txt({ error: `命令結束碼 ${r.status}`, output: output || '(no output)' });
68
+ return txt(output || '(no output)');
69
+ },
70
+ };
71
+
72
+ // read-before-edit 守衛(給 pack 的 preToolPolicy 用)
73
+ const readBeforeEdit = (ctx) => {
74
+ if ((ctx.name === 'edit' || ctx.name === 'write') && ctx.args?.path) {
75
+ const p = abs(ctx.args.path);
76
+ if (existsSync(p) && !readFiles.has(p)) return { block: true, reason: `請先 read ${ctx.args.path} 再編輯。` };
77
+ }
78
+ return undefined;
79
+ };
80
+
81
+ return { read, ls, write, edit, bash, readBeforeEdit };
82
+ }
@@ -0,0 +1,57 @@
1
+ // 共用 web 工具(web_search / web_fetch / http)— general 與 deep-research pack 共用。
2
+ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
3
+ 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';
4
+ const stripTags = (s) => String(s).replace(/<[^>]+>/g, '').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
5
+
6
+ export function createWebFetchTool() {
7
+ return {
8
+ name: 'web_fetch', label: '抓網頁', readOnly: true,
9
+ description: '抓取一個 URL 的內容並回傳純文字(HTML 去標籤、截斷)。讀某頁全文用。',
10
+ parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] },
11
+ execute: async (_id, { url }) => {
12
+ try {
13
+ const res = await fetch(url, { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(20000) });
14
+ const html = await res.text();
15
+ const text = html
16
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ')
17
+ .replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 8000);
18
+ return txt({ url, status: res.status, text: text || '(空)' });
19
+ } catch (e) { return txt({ error: e?.message || String(e), url }); }
20
+ },
21
+ };
22
+ }
23
+
24
+ export function createWebSearchTool() {
25
+ return {
26
+ name: 'web_search', label: '網路搜尋', readOnly: true,
27
+ description: '用關鍵字搜尋網路,回傳前幾筆結果(標題 + URL + 摘要)。先 search 找來源,再用 web_fetch 讀全文。',
28
+ parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] },
29
+ execute: async (_id, { query }) => {
30
+ try {
31
+ const res = await fetch('https://html.duckduckgo.com/html/?q=' + encodeURIComponent(query), { headers: { 'user-agent': UA }, signal: AbortSignal.timeout(20000) });
32
+ const html = await res.text();
33
+ const links = [...html.matchAll(/<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g)];
34
+ const snippets = [...html.matchAll(/class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)].map((m) => stripTags(m[1]));
35
+ const decode = (href) => { const u = /uddg=([^&]+)/.exec(href); return u ? decodeURIComponent(u[1]) : href; };
36
+ const results = links.slice(0, 6).map((m, i) => ({ title: stripTags(m[2]), url: decode(m[1]), snippet: snippets[i] || '' }));
37
+ return txt({ query, count: results.length, results });
38
+ } catch (e) { return txt({ error: e?.message || String(e), query }); }
39
+ },
40
+ };
41
+ }
42
+
43
+ export function createHttpTool() {
44
+ return {
45
+ name: 'http', label: 'HTTP 請求',
46
+ description: '發 HTTP 請求串接 API:method(預設 GET)、headers、body。回 status + headers + body(截斷)。',
47
+ parameters: { type: 'object', properties: { url: { type: 'string' }, method: { type: 'string' }, headers: { type: 'object' }, body: { type: 'string' } }, required: ['url'] },
48
+ execute: async (_id, { url, method = 'GET', headers, body }) => {
49
+ try {
50
+ const m = String(method).toUpperCase();
51
+ const res = await fetch(url, { method: m, headers: headers || {}, body: (m === 'GET' || m === 'HEAD') ? undefined : body, signal: AbortSignal.timeout(20000) });
52
+ const text = (await res.text()).slice(0, 8000);
53
+ return txt({ url, method: m, status: res.status, headers: Object.fromEntries(res.headers), body: text });
54
+ } catch (e) { return txt({ error: e?.message || String(e), url }); }
55
+ },
56
+ };
57
+ }