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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ 把底座的「能力」與「體驗」補到接近 Claude Code,並擴充領域 pack 與評測。
6
+
7
+ ### 新增
8
+
9
+ - **完整 Ink TUI**(`--tui`):持久底部狀態列(model/cwd/git/權限/sandbox/plan/ctx)、`Static` 捲動轉錄、即時串流重繪、Esc 中斷、markdown/程式碼語法高亮、Select 式權限詢問;非真實終端自動退回 readline CLI
10
+ - **新領域 pack**:`deep-research`(多源檢索→深讀→綜整)、`devops`
11
+ - **agent loop 內建化**:把 xitto-code 的 agent loop 移植進 `runTurn`(自帶、用 pi-ai streamFn)
12
+ - **真實 sandbox 接入守衛鏈第 5 格**:macOS Seatbelt(sandbox-exec)OS 層隔離
13
+ - **server app PoC**:把 kernel 包成 HTTP 服務(`/v1/run`、`/v1/stream` SSE、bearer 驗證、per-session workdir)
14
+ - **評測框架**:scorers(answerMatch/stateCheck/toolCalled/allOf)+ SWE-bench mini 與真實 SWE-bench Verified adapter;各 pack eval 套件
15
+ - 套件匯出補上 `./packs/general`、`./packs/deep-research`、`./packs/devops`
16
+
17
+ ### 驗證
18
+
19
+ - 真實 SWE-bench Verified:coding pack(MiniMax)可重現解出 flask-5014、requests-1142 等
20
+ - 測試 116/116 通過(含 Ink TUI 煙霧測試)
21
+
3
22
  ## 0.2.0
4
23
 
5
24
  第一個功能完整版 —— 從 0.1.0(基礎 kernel + CLI + 腳手架)補齊近乎 xitto-code 等價的能力,
package/README.md CHANGED
@@ -50,6 +50,22 @@ xitto-kernel --pack general --yes --goal "抓取 example.com 摘要成繁中寫
50
50
  ```
51
51
  `general` pack(檔案/shell/web_fetch)+ kernel 的 **goal loop**(反覆 runTurn + LLM 自我驗收,直到達成/無進展/上限)。互動模式用 `/goal <目標>`。
52
52
 
53
+ ## 當成服務跑(不只 CLI)
54
+
55
+ kernel 是 UI 無關的,CLI 只是其中一個 app。`src/app/server.js` 是把它包成 **HTTP 服務**的 PoC
56
+ (零依賴 `node:http`)—— 證明「個人工具 → 可服務化底座」:
57
+
58
+ ```bash
59
+ XITTO_SERVER_TOKEN=secret npm run serve # http://localhost:8787
60
+ curl -s localhost:8787/health
61
+ curl -s -XPOST localhost:8787/v1/run -H "Authorization: Bearer secret" \
62
+ -H content-type:application/json -d '{"pack":"general","sessionId":"s1","input":"..."}'
63
+ ```
64
+
65
+ 特性:bearer token 認證、**per-session 隔離工作目錄 + 歷史**(多輪記得上文)、沙箱(Seatbelt)、
66
+ 結構化 JSON 日誌(審計/觀測)、6 個 pack 可選、JSON 或 SSE(`/v1/stream`)串流。
67
+ 「個人 vs 生產」是 **app 層**的事 —— 同一個 kernel,CLI 與 server 是兩個 app。
68
+
53
69
  ## 做你自己的領域 agent(不固化)
54
70
 
55
71
  kernel 是**被依賴的套件**,不是被 clone 的範本。你的 agent 是獨立小專案:
@@ -101,7 +117,9 @@ xitto-kernel/
101
117
  │ ├── coding/ ✅ 參考 pack(read/ls/write/edit/bash/git)
102
118
  │ ├── data-query/ ✅ 第二領域(證明正交)
103
119
  │ ├── notes/ ✅ 第三領域(知識庫)
104
- └── general/ ✅ 通用自主 agent(檔案/shell/web_fetch + goal loop)
120
+ ├── general/ ✅ 通用自主 agent(檔案/shell/web/http + goal loop)
121
+ │ ├── deep-research/ ✅ 深度研究(多來源搜尋→查證→有引用結論)
122
+ │ └── devops/ ✅ 維運/SRE(shell + bash_bg + 設定 + 日誌 + 健康檢查)
105
123
  ├── bin/xitto-kernel.js ✅ CLI 進入點(run / new-agent)
106
124
  ├── test/ ✅ 41 測試全綠(runTurn + Seatbelt 隔離 + 腳手架 + …)
107
125
  └── examples/
@@ -138,6 +156,23 @@ xitto-kernel/
138
156
 
139
157
  **設計取向**:沿用 Node ESM + pi-ai provider 抽象;不重寫 xitto-code(kernel 是抽象,xitto-code 仍可獨立存在)。
140
158
 
159
+ ## 評估(能力可量化)
160
+
161
+ 每個 pack 配一個 EvalSuite(`eval/`,共用 `eval/framework.js`,不進 npm 包)。
162
+ 範式:**新領域 agent = 新 pack(會什麼)+ 新 EvalSuite(怎麼打分)**。
163
+
164
+ | Suite | 對標 | 評分方式 | 跑法 | 參考結果* |
165
+ |------|------|------|------|------|
166
+ | coding | SWE-bench Verified | 隱藏測試 fail→pass(Docker)| `eval/swebench-generate.js` + 官方 harness | 3/8 resolved(真實子集)|
167
+ | coding(迷你)| SWE-bench 風格 | 隱藏測試(免 Docker)| `npm run eval` | 4/4 |
168
+ | general | GAIA 風格 | 答案比對 / 狀態檢查 | `node eval/general-run.js` | 4/4 |
169
+ | data-query | Spider/BIRD 風格 | 真實 SQLite + 答案比對 | `node eval/data-query-run.js` | 4/4 |
170
+ | deep-research | GAIA/研究 | 事實正確 + 真的查證(allOf)| `node eval/deep-research-run.js` | 3/3 |
171
+ | devops | Terminal-Bench 風格 | 狀態檢查(系統/檔案達標)| `node eval/devops-run.js` | 4/4 |
172
+ | 工具呼叫 | BFCL 風格 | 軌跡檢查(呼叫對工具/參數)| `node eval/tool-calling-run.js` | 6/6 |
173
+
174
+ \* 用 MiniMax-M2.7 跑的參考數字(小樣本);換模型/擴樣本見 `eval/README.md`。scorer 型:`answerMatch` / `stateCheck` / `toolCalled`。
175
+
141
176
  ## 貢獻
142
177
 
143
178
  見 [CONTRIBUTING.md](CONTRIBUTING.md)。核心原則:kernel 必須領域無關(安全行為靠工具 metadata,不寫死領域名單);新領域 = 新增一個 pack,kernel 零改動。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
@@ -40,6 +40,8 @@
40
40
  "scripts": {
41
41
  "test": "node --test",
42
42
  "demo": "node examples/demo.js",
43
+ "eval": "node eval/run.js",
44
+ "serve": "node src/app/server.js",
43
45
  "start": "node bin/xitto-kernel.js"
44
46
  },
45
47
  "exports": {
@@ -47,10 +49,21 @@
47
49
  "./app": "./src/app/index.js",
48
50
  "./packs/coding": "./src/packs/coding/index.js",
49
51
  "./packs/data-query": "./src/packs/data-query/index.js",
50
- "./packs/notes": "./src/packs/notes/index.js"
52
+ "./packs/notes": "./src/packs/notes/index.js",
53
+ "./packs/general": "./src/packs/general/index.js",
54
+ "./packs/deep-research": "./src/packs/deep-research/index.js",
55
+ "./packs/devops": "./src/packs/devops/index.js"
51
56
  },
52
57
  "dependencies": {
53
58
  "@mariozechner/pi-ai": "^0.70.6",
54
- "@modelcontextprotocol/sdk": "^1.29.0"
59
+ "@modelcontextprotocol/sdk": "^1.29.0",
60
+ "cli-highlight": "^2.1.11",
61
+ "ink": "^5.2.1",
62
+ "marked": "^12.0.2",
63
+ "marked-terminal": "^7.3.0",
64
+ "react": "^18.3.1"
65
+ },
66
+ "devDependencies": {
67
+ "ink-testing-library": "^4.0.0"
55
68
  }
56
69
  }
package/src/app/cli.js CHANGED
@@ -41,6 +41,26 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
41
41
  let history = [];
42
42
  let currentAgent = null;
43
43
  let streaming = false;
44
+ const turnUsage = { input: 0, output: 0 }; // 本輪 token 累計(顯示頁腳)
45
+
46
+ // 等待 LLM 時的 spinner(首個可見輸出出現即停)
47
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
48
+ let spinTimer = null;
49
+ const startSpin = () => {
50
+ if (!process.stdout.isTTY) return; // 非互動(管線)不顯示 spinner,避免 \r 雜訊
51
+ let i = 0; const t0 = Date.now();
52
+ spinTimer = setInterval(() => { process.stdout.write(`\r\x1b[2m${FRAMES[i++ % FRAMES.length]} 思考中… ${Math.round((Date.now() - t0) / 1000)}s\x1b[0m\x1b[K`); }, 100);
53
+ };
54
+ const stopSpin = () => { if (spinTimer) { clearInterval(spinTimer); spinTimer = null; process.stdout.write('\r\x1b[K'); } };
55
+
56
+ // 目前模式標記(提示列前綴,讓使用者隨時看到狀態)
57
+ const modeTag = () => {
58
+ const t = [];
59
+ if (planMode) t.push('plan');
60
+ if (sandboxOn) t.push('🔒');
61
+ if (autoApprove) t.push('⚡');
62
+ return t.length ? c.gray('[' + t.join(' ') + '] ') : '';
63
+ };
44
64
 
45
65
  // 互動權限確認:守衛鏈第 5 格對 mutating/危險工具呼叫此函數。autoApprove → 一律放行。
46
66
  // 回 'yes'(允許一次)/ 'always'(此工具全部)/ 'no'(拒絕)。
@@ -88,13 +108,15 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
88
108
  };
89
109
 
90
110
  const onEvent = (ev) => {
111
+ if (ev.type === 'message_end' && ev.message?.usage) { turnUsage.input += ev.message.usage.input || 0; turnUsage.output += ev.message.usage.output || 0; }
91
112
  switch (ev.type) {
92
113
  case 'message_update': {
93
114
  const a = ev.assistantMessageEvent;
94
- if (a?.type === 'text_delta' && a.delta) { md.push(a.delta); streaming = true; }
115
+ if (a?.type === 'text_delta' && a.delta) { stopSpin(); md.push(a.delta); streaming = true; }
95
116
  break;
96
117
  }
97
118
  case 'tool_execution_start':
119
+ stopSpin();
98
120
  endStream();
99
121
  if (ev.toolName === 'todo_write' && Array.isArray(ev.args?.todos)) {
100
122
  out(c.cyan('☑ 待辦更新\n'));
@@ -202,7 +224,14 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
202
224
  }
203
225
  };
204
226
 
205
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: c.blue('› ') });
227
+ // 斜線指令 tab 補全
228
+ const SLASH = ['/help', '/goal ', '/sandbox', '/auto', '/plan', '/undo', '/tools', '/memory', '/sessions', '/resume', '/clear', '/exit'];
229
+ const completer = (line) => {
230
+ if (!line.startsWith('/')) return [[], line];
231
+ const hits = SLASH.filter((s) => s.startsWith(line));
232
+ return [hits.length ? hits : SLASH, line];
233
+ };
234
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: c.blue('› '), completer, historySize: 200 });
206
235
  let closed = false;
207
236
  const cleanup = () => { try { rl.close(); } catch { /* 略 */ } try { onExit?.(); } catch { /* 略 */ } };
208
237
  const finish = () => { cleanup(); process.exit(0); };
@@ -210,13 +239,13 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
210
239
 
211
240
  // Ctrl+C:執行中→中斷該輪;閒置→離開
212
241
  process.on('SIGINT', () => {
213
- if (currentAgent) { currentAgent.abort(); out(c.yellow('\n⏹ 已中斷本輪\n')); }
242
+ if (currentAgent) { stopSpin(); currentAgent.abort(); out(c.yellow('\n⏹ 已中斷本輪\n')); }
214
243
  else { out(c.gray('\n再見。\n')); cleanup(); process.exit(0); }
215
244
  });
216
245
 
217
246
  // 橫幅
218
247
  out('\n' + c.cyan('✻ ') + c.bold('xitto-kernel') + c.gray(` · ${pack.name} pack · ${model.id}`) + '\n');
219
- out(c.gray(` 沙箱 ${sandboxOn ? '開' : '關'}${seatbeltAvailable() ? '(Seatbelt 可用)' : ''} · /help 看指令 · Ctrl+C 中斷/離開`) + '\n');
248
+ out(c.gray(` 沙箱 ${sandboxOn ? '開' : '關'}${seatbeltAvailable() ? '(Seatbelt 可用)' : ''} · /help · Tab 補全 · ↑↓ 歷史 · Ctrl+C 中斷/離開`) + '\n');
220
249
  if (resumedNote) out(c.gray(' ' + resumedNote) + '\n');
221
250
  out('\n');
222
251
 
@@ -224,7 +253,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
224
253
  if (closed) return finish();
225
254
  let q;
226
255
  try { q = rl.question.bind(rl); } catch { return finish(); }
227
- q(c.blue('› '), async (raw) => {
256
+ q(modeTag() + c.blue('› '), async (raw) => {
228
257
  const input = (raw || '').trim();
229
258
  if (!input) return loop();
230
259
  // /goal <目標>:目標驅動自主循環(在此 await,避免與下一個提示交錯)
@@ -233,13 +262,16 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
233
262
  if (!goal) { out(c.gray('用法 /goal <目標>\n')); return loop(); }
234
263
  try {
235
264
  out(c.cyan('🎯 目標:') + goal + '\n');
265
+ turnUsage.input = 0; turnUsage.output = 0;
266
+ const t0 = Date.now();
236
267
  const r = await kernel.runGoal(goal, {
237
268
  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`)),
269
+ onRound: ({ round, maxRounds }) => { stopSpin(); out(c.yellow(`\n🔁 第 ${round}/${maxRounds} `)); startSpin(); },
270
+ onCheck: ({ done, remaining }) => { stopSpin(); out(done ? c.green(' ✓ 驗收:已達成\n') : c.gray(` ↻ ${remaining}\n`)); },
240
271
  onEvent, onAgent: (a) => { currentAgent = a; },
241
272
  });
242
- endStream(); history = r.history; persist();
273
+ stopSpin(); endStream(); history = r.history; persist();
274
+ out(c.gray(`↳ ${((Date.now() - t0) / 1000).toFixed(1)}s · ${turnUsage.input + turnUsage.output} tokens · ${r.rounds} 輪`) + '\n');
243
275
  const why = r.stalled ? '無進展' : r.aborted ? '中斷' : r.verifyBroken ? '驗收持續失敗' : '到上限';
244
276
  out('\n' + (r.done ? c.green(`✅ 目標達成(${r.rounds} 輪)`) : c.yellow(`⚠ 未達成(${why},${r.rounds} 輪)`)) + '\n');
245
277
  } catch (err) { endStream(); out(c.red('錯誤:' + err.message) + '\n'); }
@@ -251,13 +283,19 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
251
283
  const text = planMode
252
284
  ? `[計劃模式:只制定計劃,列出你打算做的步驟與會改動的檔案,不要實際寫檔或執行命令]\n\n${input}`
253
285
  : input;
286
+ turnUsage.input = 0; turnUsage.output = 0;
287
+ const t0 = Date.now();
288
+ startSpin();
254
289
  const r = await kernel.runTurn(text, {
255
290
  history, onEvent, onAgent: (a) => { currentAgent = a; },
256
291
  });
292
+ stopSpin();
257
293
  endStream();
258
294
  history = r.messages;
259
295
  persist(); // 每輪結束自動存檔(可 /resume 續接)
296
+ out(c.gray(`↳ ${((Date.now() - t0) / 1000).toFixed(1)}s · ${turnUsage.input + turnUsage.output} tokens`) + '\n');
260
297
  } catch (err) {
298
+ stopSpin();
261
299
  endStream();
262
300
  out(c.red('錯誤:' + err.message) + '\n');
263
301
  } finally {
package/src/app/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // app 子路徑公開 API(xitto-kernel/app):外部 agent 專案 import 這些來啟動,不需改 kernel。
2
2
  export { runCli } from './cli.js';
3
+ export { createServerApp, startServer } from './server.js';
3
4
  export { loadModel, loadProvidersConfig, buildModel } from './providers.js';
4
5
  export { newAgent } from './scaffold.js';
5
6
  export { main } from './main.js';
package/src/app/main.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { join } from 'node:path';
4
4
  import { loadModel } from './providers.js';
5
5
  import { runCli } from './cli.js';
6
+ import { runTui } from './tui-run.js';
6
7
  import { newAgent } from './scaffold.js';
7
8
  import { createKernel } from '../kernel/index.js';
8
9
  import { loadMcpTools } from '../kernel/mcp.js';
@@ -10,6 +11,8 @@ import { createCodingPack } from '../packs/coding/index.js';
10
11
  import { createDataQueryPack } from '../packs/data-query/index.js';
11
12
  import { createNotesPack } from '../packs/notes/index.js';
12
13
  import { createGeneralPack } from '../packs/general/index.js';
14
+ import { createDeepResearchPack } from '../packs/deep-research/index.js';
15
+ import { createDevopsPack } from '../packs/devops/index.js';
13
16
 
14
17
  const e = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
15
18
  const green = e(32); const gray = e(90); const red = e(31); const cyan = e(36); const yellow = e(33);
@@ -19,6 +22,8 @@ const PACKS = {
19
22
  'data-query': createDataQueryPack,
20
23
  notes: createNotesPack,
21
24
  general: createGeneralPack,
25
+ 'deep-research': createDeepResearchPack,
26
+ devops: createDevopsPack,
22
27
  };
23
28
 
24
29
  export async function main(argv = process.argv.slice(2)) {
@@ -73,6 +78,12 @@ export async function main(argv = process.argv.slice(2)) {
73
78
  process.exit(res.done ? 0 : 1);
74
79
  }
75
80
 
81
+ if (opts.tui && process.stdin.isTTY) {
82
+ runTui({ pack: make({ cwd }), model, getApiKey, sandbox: opts.sandbox, resume: opts.resume });
83
+ return;
84
+ }
85
+ if (opts.tui) console.error(gray('(--tui 需要真實終端,退回一般 CLI)'));
86
+
76
87
  runCli({
77
88
  pack: make({ cwd }), model, getApiKey,
78
89
  sandbox: opts.sandbox, resume: opts.resume, auto: opts.yes,
@@ -89,6 +100,7 @@ function parse(argv) {
89
100
  else if (a === '--model') o.model = argv[++i];
90
101
  else if (a === '--sandbox') o.sandbox = true;
91
102
  else if (a === '--yes' || a === '-y') o.yes = true;
103
+ else if (a === '--tui') o.tui = true;
92
104
  else if (a === '--goal') o.goal = argv[++i];
93
105
  else if (a === '--resume') { const nxt = argv[i + 1]; if (nxt && !nxt.startsWith('--')) { o.resume = nxt; i++; } else o.resume = true; }
94
106
  }
@@ -104,10 +116,13 @@ function printHelp() {
104
116
  ' xitto-kernel --pack general --goal "..." [--yes] 目標驅動自主循環(headless)',
105
117
  ' xitto-kernel new-agent <name> 產出依賴 kernel 的獨立 agent 專案',
106
118
  '',
107
- ' --pack <name> 選擇內建 DomainPack(coding | data-query | notes | general;預設 coding)',
119
+ ' --pack <name> 選擇內建 DomainPack(coding | data-query | notes | general | deep-research | devops;預設 coding)',
108
120
  ' --goal "..." 給目標,agent 自主反覆做到完成(建議搭配 --pack general)',
109
121
  ' --model <id> 指定 model(預設用 providers.json 的 defaultModel)',
110
122
  ' --sandbox 啟動即開啟沙箱(macOS=Seatbelt 真隔離)',
123
+ ' --tui 完整 Ink TUI(持久狀態列、串流轉錄、Esc 中斷;需真實終端)',
124
+ ' --resume [id] 接續上次 session(不給 id 接最近一次)',
125
+ ' --yes, -y 自動核准 mutating 工具(headless / 自主循環常用)',
111
126
  ' --help 顯示說明',
112
127
  '',
113
128
  '需要 ~/.xitto-code/providers.json(與 xitto-code 共用)。',
@@ -0,0 +1,95 @@
1
+ // Markdown → 終端 ANSI 渲染(對標 Claude Code 的回覆排版)
2
+ // 用 marked 解析、marked-terminal 轉 ANSI(標題、粗體、列表、表格、程式碼區塊語法高亮)。
3
+ // Claude Code 的做法:每來一個 token 就把「目前累積的整段 markdown」重新解析重繪,
4
+ // 半截的 **粗體 / 未閉合的 ``` 會在下一幀補齊後自動修正。本檔提供純函式 md(),
5
+ // 重繪由 Ink 的 re-render 負責(見 tui.js)。
6
+ import { Marked } from 'marked';
7
+ import { markedTerminal } from 'marked-terminal';
8
+ import { highlight as hl } from 'cli-highlight';
9
+
10
+ // 終端寬度(隨視窗;最小 40 避免表格擠壞)
11
+ function termWidth() {
12
+ const w = process.stdout?.columns || 80;
13
+ return Math.max(40, Math.min(w, 120));
14
+ }
15
+
16
+ // ANSI 樣式小工具(marked-terminal 的樣式選項接受 (str)=>str 函式)
17
+ const a = (open, close) => (s) => `\x1b[${open}m${s}\x1b[${close}m`;
18
+ const bold = a(1, 22);
19
+ const cyan = a(36, 39);
20
+ const yellow = a(33, 39);
21
+ const gray = a(90, 39);
22
+
23
+ // 程式碼區塊本體:語法高亮 + 左側豎線邊框(+ 可選語言標籤)。不含前後空行,方便外部逐塊拼接。
24
+ // withHeader=false 用於「同一個 code block 的延續塊」(串流逐行提交時避免重複 ┌─ lang 標頭)。
25
+ export function codeChunk(text, lang, withHeader = true) {
26
+ let body;
27
+ try { body = hl(text, { language: lang || 'plaintext', ignoreIllegals: true }); }
28
+ catch { body = text; }
29
+ const bar = gray('│') + ' ';
30
+ const label = (withHeader && lang) ? gray(`┌─ ${lang}`) + '\n' : '';
31
+ return label + body.replace(/\n$/, '').split('\n').map((l) => bar + l).join('\n');
32
+ }
33
+
34
+ // 程式碼區塊(marked renderer 用):前後補空行與內文隔開
35
+ function codeBlock(text, lang) {
36
+ return '\n' + codeChunk(text, lang, true) + '\n';
37
+ }
38
+
39
+ // 依寬度快取 Marked 實例:終端 resize 後寬度改變 → 換一個實例,markdown 自動以新寬度重排。
40
+ // 上限保護:resize 反覆拖動會堆積不同寬度的實例,超過上限即淘汰最舊的(LRU 近似)。
41
+ const cache = new Map();
42
+ const MAX_CACHE = 32;
43
+ function renderer(width) {
44
+ let m = cache.get(width);
45
+ if (!m) {
46
+ m = new Marked();
47
+ m.use(
48
+ markedTerminal({
49
+ width,
50
+ reflowText: true,
51
+ tab: 2,
52
+ showSectionPrefix: false, // 不顯示標題的 # 記號(對標 Claude Code)
53
+ firstHeading: (s) => bold(cyan(s)), // 一級標題:粗體青
54
+ heading: (s) => bold(s), // 其餘標題:粗體
55
+ strong: (s) => bold(s),
56
+ codespan: (s) => yellow(s),
57
+ blockquote: (s) => gray(s),
58
+ listitem: (s) => s,
59
+ }),
60
+ );
61
+ // 覆寫 code 渲染:加左側邊框(marked-terminal 內建無邊框)
62
+ m.use({
63
+ renderer: {
64
+ code(token, infostring) {
65
+ const text = typeof token === 'object' ? token.text : token;
66
+ const lang = (typeof token === 'object' ? token.lang : infostring) || '';
67
+ return codeBlock(text, lang);
68
+ },
69
+ },
70
+ });
71
+ if (cache.size >= MAX_CACHE) cache.delete(cache.keys().next().value); // 淘汰最舊
72
+ cache.set(width, m);
73
+ }
74
+ return m;
75
+ }
76
+
77
+ // 用 marked 的 block lexer 把 markdown 切成頂層區塊 token(含 .raw,串接即原文)。
78
+ // 串流增量提交用:提交「除最後一塊外」的完整區塊,最後一塊(可能還沒打完)留在動態區。
79
+ const lexMarked = new Marked();
80
+ export function lexBlocks(text) {
81
+ try { return lexMarked.lexer(text); }
82
+ catch { return [{ type: 'paragraph', raw: text }]; }
83
+ }
84
+
85
+ // 把 markdown 字串渲染成帶 ANSI 的終端字串。容錯:解析失敗就回原文。
86
+ export function md(text) {
87
+ if (!text) return '';
88
+ try {
89
+ const out = renderer(termWidth()).parse(text);
90
+ // 無序列表符號 * → •(marked-terminal 的 BULLET_POINT 寫死為 '* ')
91
+ return (typeof out === 'string' ? out : String(out)).replace(/\n+$/, '').replace(/^(\s*)\* /gm, '$1• ');
92
+ } catch {
93
+ return text;
94
+ }
95
+ }
@@ -0,0 +1,102 @@
1
+ // Server app(PoC)— 把 kernel 包成 HTTP 服務(零依賴 node:http)。
2
+ // 證明 kernel 能脫離 CLI 跑成服務:bearer token 認證、per-session 隔離工作目錄、沙箱、結構化日誌、
3
+ // JSON 或 SSE 串流。這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
4
+ import { createServer } from 'node:http';
5
+ import { mkdirSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { createKernel } from '../kernel/index.js';
9
+ import { loadModel } from './providers.js';
10
+ import { createCodingPack } from '../packs/coding/index.js';
11
+ import { createDataQueryPack } from '../packs/data-query/index.js';
12
+ import { createNotesPack } from '../packs/notes/index.js';
13
+ import { createGeneralPack } from '../packs/general/index.js';
14
+ import { createDeepResearchPack } from '../packs/deep-research/index.js';
15
+ import { createDevopsPack } from '../packs/devops/index.js';
16
+
17
+ const PACKS = {
18
+ coding: createCodingPack, 'data-query': createDataQueryPack, notes: createNotesPack,
19
+ general: createGeneralPack, 'deep-research': createDeepResearchPack, devops: createDevopsPack,
20
+ };
21
+
22
+ const lastText = (history) => ([...(history || [])].reverse().find((m) => m.role === 'assistant')?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
23
+ const newId = () => 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
24
+
25
+ /**
26
+ * @param {Object} o
27
+ * @param {object} o.model
28
+ * @param {Function} o.getApiKey
29
+ * @param {string} [o.token] bearer token(未設=不驗證,僅 PoC)
30
+ * @param {string} [o.baseDir] 每個 session 的隔離工作目錄根
31
+ * @param {boolean} [o.sandbox] 是否沙箱(預設 true:服務端跑 agent 應隔離)
32
+ * @returns {import('node:http').Server}
33
+ */
34
+ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true } = {}) {
35
+ const sessions = new Map(); // sessionId -> { pack, history }
36
+ mkdirSync(baseDir, { recursive: true });
37
+
38
+ const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); };
39
+ const authed = (req) => !token || (req.headers.authorization === `Bearer ${token}`);
40
+ const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
41
+ const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } }); });
42
+
43
+ return createServer(async (req, res) => {
44
+ const url = new URL(req.url, 'http://localhost');
45
+ if (req.method === 'GET' && url.pathname === '/health') return json(res, 200, { ok: true, packs: Object.keys(PACKS), model: model.id });
46
+ if (!authed(req)) return json(res, 401, { error: 'unauthorized(帶 Authorization: Bearer <token>)' });
47
+
48
+ if (req.method === 'POST' && (url.pathname === '/v1/run' || url.pathname === '/v1/stream')) {
49
+ const body = await readBody(req);
50
+ const make = PACKS[body.pack || 'general'];
51
+ if (!make) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
52
+ const sessionId = body.sessionId || newId();
53
+ const sess = sessions.get(sessionId) || { pack: body.pack || 'general', history: [] };
54
+ const workdir = join(baseDir, sessionId); mkdirSync(workdir, { recursive: true });
55
+ const kernel = createKernel(make({ cwd: workdir }), { cwd: workdir, model, getApiKey, sandbox: { enabled: sandbox }, getSandbox: () => sandbox, confirm: async () => 'yes' });
56
+
57
+ const usage = { input: 0, output: 0 };
58
+ const streaming = url.pathname === '/v1/stream';
59
+ if (streaming) res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
60
+ const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
61
+ const onEvent = (ev) => {
62
+ if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; }
63
+ if (!streaming) return;
64
+ if (ev.type === 'tool_execution_start') sse({ type: 'tool', name: ev.toolName, args: ev.args });
65
+ else if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') sse({ type: 'text', delta: ev.assistantMessageEvent.delta });
66
+ };
67
+
68
+ const t0 = Date.now();
69
+ try {
70
+ const r = (body.mode === 'goal')
71
+ ? await kernel.runGoal(body.goal || body.input || '', { history: sess.history, onEvent })
72
+ : await kernel.runTurn(body.input || '', { history: sess.history, onEvent });
73
+ sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
74
+ const text = r.text ?? lastText(sess.history);
75
+ log({ pack: sess.pack, session: sessionId, mode: body.mode || 'turn', tokens: usage.input + usage.output, rounds: r.rounds, ms: Date.now() - t0 });
76
+ const payload = { sessionId, text, usage, rounds: r.rounds, done: r.done };
77
+ if (streaming) { sse({ type: 'done', ...payload }); res.end(); }
78
+ else json(res, 200, payload);
79
+ } catch (e) {
80
+ log({ pack: sess.pack, session: sessionId, error: e.message });
81
+ if (streaming) { sse({ type: 'error', error: e.message }); res.end(); } else json(res, 500, { error: e.message });
82
+ }
83
+ return;
84
+ }
85
+ json(res, 404, { error: 'not found' });
86
+ });
87
+ }
88
+
89
+ export function startServer() {
90
+ const port = Number(process.env.PORT || 8787);
91
+ const token = process.env.XITTO_SERVER_TOKEN || 'dev-token';
92
+ const sandbox = process.env.XITTO_SERVER_SANDBOX !== 'off';
93
+ const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
94
+ const server = createServerApp({ model, getApiKey, token, sandbox });
95
+ server.listen(port, () => {
96
+ console.log(`xitto-kernel server · http://localhost:${port} · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'}`);
97
+ console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
98
+ });
99
+ return server;
100
+ }
101
+
102
+ if (process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url)) startServer();