xitto-kernel 0.2.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,40 +4,12 @@
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
8
  import { createBackgroundTools } from '../../kernel/bg.js';
9
+ import { createGrepTool, createGlobTool } from '../shared/code-nav.js';
9
10
 
10
11
  const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
11
12
 
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
-
41
13
  const SYSTEM_PROMPT = [
42
14
  '你是嚴謹的編碼 agent。準則:',
43
15
  '- 探索 codebase:用 glob 找檔、grep 搜內容、read 讀檔(附行號)。',
@@ -121,50 +93,18 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
121
93
  parameters: { type: 'object', properties: { command: { type: 'string' }, timeout: { type: 'number' } }, required: ['command'] },
122
94
  execute: async (_id, { command, timeout }) => {
123
95
  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)'); }
125
- catch (e) { return txt({ error: e.message, stdout: e.stdout, stderr: e.stderr }); }
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)');
126
102
  },
127
103
  };
128
104
 
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
- };
105
+ // ── codebase 導航:grep / glob(共用模組)──
106
+ const grepTool = createGrepTool(cwd);
107
+ const globTool = createGlobTool(cwd);
168
108
 
169
109
  const webFetch = {
170
110
  name: 'web_fetch', label: '抓網頁', readOnly: true,
@@ -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();
@@ -1,16 +1,22 @@
1
1
  // general pack — 通用自主 agent。廣的 system prompt + 檔案/shell/web 工具。
2
2
  // 搭配 kernel 的 runGoal(目標循環)+ 子 agent + MCP,即為「給目標、自己做到完成」的通用 agent。
3
3
  import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
4
- import { isAbsolute, join } from 'node:path';
5
- import { execSync } from 'node:child_process';
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' };
6
10
 
7
11
  const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
8
12
 
9
13
  const SYSTEM_PROMPT = [
10
14
  '你是通用自主 agent。給你一個目標,你用工具反覆推進直到完成:',
11
- '- 可讀寫檔案、跑 shell 命令、搜尋網路(web_search)、抓網頁(web_fetch)、派子 agent 做聚焦調查。',
15
+ '- 可讀寫檔案、grep/glob 找東西、跑 shell、搜尋網路(web_search)、抓網頁(web_fetch)、',
16
+ ' 串接 API(http)、讀圖(read_image)、派子 agent、用待辦(todo_write)規劃多步任務。',
12
17
  '- 先想清楚步驟再動手;每步簡述你在做什麼。',
13
- '- 需要外部資料:先 web_search 找來源,再 web_fetch 讀全文,不要憑空編造。',
18
+ '- 需要外部資料:先 web_search 找來源,再 web_fetch 讀全文;串 API 用 http。不要憑空編造。',
19
+ '- 還缺的能力(瀏覽器點擊、特定服務)可由使用者掛 MCP server 補上。',
14
20
  '- 編輯既有檔案前先 read;破壞性/對外操作前確認。',
15
21
  '- 完成後明確說「已完成」並總結結果與如何驗證。',
16
22
  ].join('\n');
@@ -40,48 +46,42 @@ export function createGeneralPack({ cwd = process.cwd() } = {}) {
40
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 }); },
41
47
  };
42
48
  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 }); }
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)');
62
58
  },
63
59
  };
60
+ const webFetch = createWebFetchTool();
61
+ const webSearch = createWebSearchTool();
62
+ const http = createHttpTool();
64
63
 
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 }); }
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 }); }
79
76
  },
80
77
  };
81
78
 
79
+ const grepTool = createGrepTool(cwd);
80
+ const globTool = createGlobTool(cwd);
81
+
82
82
  return {
83
83
  name: 'general',
84
- tools: () => [read, ls, write, edit, bash, webFetch, webSearch],
84
+ tools: () => [read, ls, globTool, grepTool, write, edit, bash, webSearch, webFetch, http, readImage],
85
85
  systemPrompt: SYSTEM_PROMPT,
86
86
  contextFiles: ['AGENTS.md', 'GENERAL.md'],
87
87
  preToolPolicy: {
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
+ }