xitto-kernel 0.5.0 → 0.6.4

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,56 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.4
4
+
5
+ - **許願台「展開過程」+ 彩色 diff(借 Claude Code 的工具卡/⌥展開,翻譯成非技術版)**:
6
+ - 任務 `progress.log` 累積**完整步驟**(人話動作 + 參數摘要 + isError + 編輯的 `diff`);`mapEvent` 的 `tool_end` 帶 `diff`
7
+ - 網頁加「**展開過程(N 步)**」摺疊:預設安靜(只給進度+成品),展開才顯示步驟卡 + **綠 +/紅 - 彩色 diff** → 同畫面服務「只要結果」與「想看細節」兩種人
8
+ - `_diff` 由 kernel 算好(v0.6.3),這版只是把它帶進網頁
9
+ - 3 個新測試(tool_end 帶 diff / log 累積 + diff 補齊)+ 真實 server 端到端(建檔→改檔,改檔步驟帶 `- a-b` / `+ a+b`)。測試 182/182
10
+
11
+ ## 0.6.3
12
+
13
+ - **彩色 diff(編輯一目了然)**:
14
+ - kernel 新增 `diff.js`(LCS 行級 diff);**在 `wrapUndo` 集中計算**——用既有的 undo 快照(before)+ 改後內容(after),
15
+ 把 `_diff` 掛在工具結果上(不進 LLM content,僅供 app 渲染)。**所有 pack 的 edit/write 免改**,二進位/超大檔自動跳過
16
+ - TUI 渲染 `diffBlock`:`⎿ +N -N 行` + 綠 `+` / 紅 `-` 變更行(過長摺疊)
17
+ - 4 個測試(lineDiff 增刪/新檔/超大、diffBlock 渲染、kernel edit 自動掛 _diff)+ 視覺驗證。測試 180/180
18
+ - 註:`_diff` 已在 kernel 算好,日後接到許願台網頁很容易(目前先做 TUI)
19
+
20
+ ## 0.6.2
21
+
22
+ - **TUI 補強(對標 Claude Code 的工具卡)**:
23
+ - **工具卡**:`⏺ name(args)` 標頭 + `⎿` **多行**結果(首行對齊、續行縮排),過長摺疊成「… +N 行」(取代原本單行截斷)
24
+ - **參數摘要人性化**:`bash(npm test)`、`edit(src/a.js)`——取最有意義的參數,不再倒整包 JSON
25
+ - **待辦清單** ☑/◐/☐ 渲染(已有,微調配色)
26
+ - 成功 `⎿ ✓`(綠)/ 失敗 `⎿ ✗`(紅,多顯示幾行)
27
+ - `summarize` / `toolBlock` 抽為純函數並匯出 + 2 個測試。測試 176/176。
28
+ - 仍缺(後續):編輯的彩色 diff(需 edit 工具回傳前後內容)、底部模式/快捷鍵提示列強化
29
+
30
+ ## 0.6.1
31
+
32
+ - **成品溯源/位置**:分邏輯與實體兩層。
33
+ - **邏輯位置(workspace)**:成品卡永遠標出 `📁 所屬空間`,一眼知道每份成品屬於哪個專案
34
+ - **實體路徑**:預設**不外露**(託管不洩漏伺服器絕對路徑);僅**本地模式**(`XITTO_SERVER_LOCAL=1` 或 `createServerApp({local:true})`)在 result 附 `workspaceDir`,網頁顯示「📂 檔案位置」(點擊複製,供到 Finder/Explorer 找檔)
35
+ - 真實 live 驗證:本地模式回絕對路徑 `/…/ws/<workspace>`、託管模式 `workspaceDir` 為 undefined。測試 174/174。
36
+
37
+ ## 0.6.0
38
+
39
+ 成品管理 + 類型感知呈現 + 專案空間(一次補上三組優化)。
40
+
41
+ - **成品/過程檔管理**:
42
+ - 系統提示引導 agent:成品放工作目錄根用好檔名,暫存/草稿放 `tmp/`
43
+ - `runOutcome` 的成品掃描排除 `tmp/`(過程檔不污染交付清單);job 完成後 server 自動清 `tmp/`
44
+ - **類型感知的成品呈現**:
45
+ - file 端點按副檔名給對的 `content-type`(圖片能顯示、md/html 能渲染),支援 `?download=1`、二進位正確回傳
46
+ - 網頁類型感知檢視:markdown **排版渲染**(零依賴內嵌渲染器)、圖片 `<img>`、HTML 沙箱 iframe、JSON 美化、其餘下載;每個檔有「開新分頁/下載」
47
+ - `?token=` 查詢參數認證(img/iframe/下載這類無法帶 header 的瀏覽器 GET)
48
+ - **專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設)**:
49
+ - 網頁加「專案」下拉 + 新專案;不同空間的**檔案與五層沉澱各自獨立**;歷史按空間過濾
50
+ - 任務 view 帶 `workspace`;POST `/v1/tasks` 接受 `workspace`(修:原本被 enqueue 丟棄)
51
+ - 3 個新測試(content-type / view.workspace / tmp 不算成品)+ 真實 server 端到端
52
+ (markdown 成品渲染、tmp 清理、download header、query token、workspace 隔離)。測試 174/174。
53
+
3
54
  ## 0.5.0
4
55
 
5
56
  - **持久工作空間(許願台成品間的關係)**:每個成品仍是獨立對話,但共用一個持久工作空間。
package/README.md CHANGED
@@ -42,7 +42,7 @@ xitto-kernel init
42
42
  **3. 跑內建 pack(互動 CLI)**
43
43
  ```bash
44
44
  xitto-kernel # coding agent(讀寫檔案、跑命令)
45
- xitto-kernel --tui # 完整 Ink TUI(持久狀態列、串流、Esc 中斷;需真實終端)
45
+ xitto-kernel --tui # 完整 Ink TUI(持久狀態列、串流、Esc 中斷、工具卡⏺/⎿、彩色 diff、待辦☑;需真實終端)
46
46
  xitto-kernel --pack notes # 筆記 / 知識庫 agent
47
47
  xitto-kernel --pack data-query
48
48
  xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
@@ -102,11 +102,14 @@ XITTO_SERVER_TOKEN=secret npm run serve # 然後瀏覽器開 http://localhost:
102
102
  - **進行中**:**即時進度 + 活著的證明**——每秒跳動的「已進行 Ns」心跳時鐘、目前階段(思考中/執行中/驗收中)、agent 當下的**思考文字**(💭)、工具動作翻成人話、第幾輪 + 動作數。看得到它在想什麼、做什麼
103
103
  - **待辦打勾**:agent 用 `todo_write` 規劃多步任務時,顯示 ☐/◐/☑ 清單,把「未知時長」變成「看得到的剩餘步數」(對標 Claude Code)
104
104
  - **隨時可停**:每個進行中任務有「停止」鈕 → `POST /v1/tasks/:id/cancel`(abort 正在跑的 agent)。控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
105
+ - **展開過程**:預設安靜(只給進度與成品);想看細節按「展開過程」→ 完整步驟卡(讀/改/跑,人話)+ **編輯的彩色 diff**(綠 +/紅 -)。同一畫面服務「只要結果」與「想看細節」兩種人(對標 Claude Code 的 ⏺/⎿ + ctrl+r 展開)
105
106
  - **需要你回答**:agent 暫停提問時,跳出問題 + 回答框(澄清通道)
106
107
  - **收成品**:完成後顯示摘要 + **產出的檔案**,點檔名可直接看內容(`GET /v1/tasks/:id/file`,防路徑穿越)
107
108
  - **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
108
109
 
109
- **持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個)
110
+ **持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個);網頁有「專案」下拉切換,每份成品卡標出 `📁 所屬空間`。
111
+
112
+ **溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
110
113
 
111
114
  零依賴單一 HTML(`src/app/web/index.html`),polling 不靠 SSE。token 注入頁面供同源呼叫——本地自用零設定;**正式部署請前置真實認證**。
112
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.5.0",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/server.js CHANGED
@@ -3,8 +3,8 @@
3
3
  // JSON 或 SSE 串流,以及「背景任務 + 完成通知(webhook)」—— 派任務出去、做完回呼,不用一直盯著。
4
4
  // 這是「另一個 app 消費同一組 kernel 事件」—— 不動 kernel 核心。
5
5
  import { createServer } from 'node:http';
6
- import { mkdirSync, readFileSync, existsSync } from 'node:fs';
7
- import { join, dirname, isAbsolute, relative } from 'node:path';
6
+ import { mkdirSync, readFileSync, existsSync, rmSync } from 'node:fs';
7
+ import { join, dirname, isAbsolute, relative, basename, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { createKernel } from '../kernel/index.js';
10
10
  import { loadModel } from './providers.js';
@@ -23,6 +23,13 @@ const PACKS = {
23
23
  const lastText = (history) => ([...(history || [])].reverse().find((m) => m.role === 'assistant')?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
24
24
  const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
25
25
 
26
+ // 交付檔案的 content-type(讓圖片能顯示、md/html 能渲染、其餘可下載)。
27
+ const MIME = { md: 'text/markdown', markdown: 'text/markdown', txt: 'text/plain', log: 'text/plain', json: 'application/json', csv: 'text/csv', html: 'text/html', htm: 'text/html', js: 'text/javascript', mjs: 'text/javascript', ts: 'text/plain', py: 'text/plain', sh: 'text/plain', css: 'text/css', xml: 'application/xml', yaml: 'text/plain', yml: 'text/plain', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' };
28
+ export function contentTypeFor(name) { const ext = (String(name).split('.').pop() || '').toLowerCase(); return MIME[ext] || 'application/octet-stream'; }
29
+
30
+ // 工具參數摘要(給「展開過程」步驟卡):取最有意義的參數。
31
+ const argSummary = (args) => { if (!args || typeof args !== 'object') return ''; const v = args.command ?? args.path ?? args.pattern ?? args.query ?? args.url ?? args.name ?? args.topic; return (v != null && v !== '') ? String(v).replace(/\s+/g, ' ').slice(0, 80) : ''; };
32
+
26
33
  // 交付檔案路徑解析(防穿越):rel 必須是 workdir 內的相對路徑,否則回 null。
27
34
  export function resolveArtifact(workdir, rel) {
28
35
  if (typeof rel !== 'string' || !rel || isAbsolute(rel)) return null;
@@ -37,7 +44,7 @@ const webHtml = () => (_webHtml ??= readFileSync(join(dirname(fileURLToPath(impo
37
44
  // 把原始 kernel 事件壓成精簡的對外事件(串流端與背景任務共用,避免重複映射)
38
45
  export const mapEvent = (ev) => {
39
46
  if (ev.type === 'tool_execution_start') return { type: 'tool', name: ev.toolName, args: ev.args };
40
- if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError };
47
+ if (ev.type === 'tool_execution_end') return { type: 'tool_end', name: ev.toolName, isError: !!ev.isError, diff: ev.result?._diff || undefined };
41
48
  if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') return { type: 'text', delta: ev.assistantMessageEvent.delta };
42
49
  if (ev.type === 'round') return { type: 'round', round: ev.round, maxRounds: ev.maxRounds };
43
50
  if (ev.type === 'verify_start') return { type: 'phase', phase: 'verifying' };
@@ -60,15 +67,22 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
60
67
  const subs = new Map(); // id -> Set<(ev)=>void>
61
68
  let active = 0;
62
69
 
63
- const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', goal: t.spec.goal || t.spec.input || '', sessionId: t.result?.sessionId || t.spec.sessionId || null, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
70
+ const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', mode: t.spec.mode || 'turn', workspace: t.spec.workspace || 'default', goal: t.spec.goal || t.spec.input || '', sessionId: t.result?.sessionId || t.spec.sessionId || null, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
64
71
 
65
72
  const emit = (t, ev) => {
66
73
  t.events.push(ev);
67
74
  if (t.events.length > maxEvents) t.events.shift();
68
75
  // 進度追蹤(給 UI 顯示「正在做什麼」,不要只顯示進行中;排除 text 雜訊)
69
- const p = (t.progress ||= { steps: 0, round: 0, maxRounds: 0, recent: [], phase: 'starting', thinking: '', todos: [] });
76
+ const p = (t.progress ||= { steps: 0, round: 0, maxRounds: 0, recent: [], phase: 'starting', thinking: '', todos: [], log: [] });
70
77
  if (ev.type === 'tool' && ev.name === 'todo_write') { if (Array.isArray(ev.args?.todos)) p.todos = ev.args.todos; } // 待辦清單(給 UI 打勾)
71
- else if (ev.type === 'tool') { p.steps++; p.phase = 'acting'; p.thinking = ''; t._textbuf = ''; p.recent.push({ name: ev.name, args: ev.args }); if (p.recent.length > 6) p.recent.shift(); }
78
+ else if (ev.type === 'tool') {
79
+ p.steps++; p.phase = 'acting'; p.thinking = ''; t._textbuf = '';
80
+ p.recent.push({ name: ev.name, args: ev.args }); if (p.recent.length > 6) p.recent.shift();
81
+ if (p.log.length < 100) p.log.push({ name: ev.name, summary: argSummary(ev.args) }); // 完整步驟(給「展開過程」)
82
+ } else if (ev.type === 'tool_end') {
83
+ const last = p.log[p.log.length - 1];
84
+ if (last && last.name === ev.name && !('isError' in last)) { last.isError = ev.isError; if (ev.diff) last.diff = ev.diff; }
85
+ }
72
86
  else if (ev.type === 'text') { p.phase = 'thinking'; t._textbuf = ((t._textbuf || '') + (ev.delta || '')).slice(-400); p.thinking = t._textbuf.replace(/\s+/g, ' ').trim().slice(-150); }
73
87
  else if (ev.type === 'round') { p.round = ev.round; if (ev.maxRounds) p.maxRounds = ev.maxRounds; p.thinking = ''; t._textbuf = ''; }
74
88
  else if (ev.type === 'phase') p.phase = ev.phase;
@@ -158,12 +172,13 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
158
172
  * @param {number} [o.concurrency] 背景任務同時數(預設 2)
159
173
  * @returns {import('node:http').Server}
160
174
  */
161
- export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2 } = {}) {
175
+ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2, local = false } = {}) {
162
176
  const sessions = new Map(); // sessionId -> { pack, history }
163
177
  mkdirSync(baseDir, { recursive: true });
164
178
 
165
179
  const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(obj)); };
166
- const authed = (req) => !token || (req.headers.authorization === `Bearer ${token}`);
180
+ // header bearer 為主;img/iframe/下載這類瀏覽器發起的 GET 無法帶 header,允許 ?token=(同源、PoC)
181
+ const authed = (req) => { if (!token) return true; if (req.headers.authorization === `Bearer ${token}`) return true; try { return new URL(req.url, 'http://x').searchParams.get('token') === token; } catch { return false; } };
167
182
  const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
168
183
  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({}); } }); });
169
184
  const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
@@ -186,11 +201,13 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
186
201
  // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
187
202
  const o = await kernel.runOutcome(spec.goal || spec.input || '', { history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
188
203
  sess.history = o.history || []; sessions.set(sessionId, sess);
189
- return { sessionId, workspace, text: o.summary || lastText(sess.history), usage, rounds: o.rounds, done: o.done, aborted: o.aborted, artifacts: o.artifacts };
204
+ try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
205
+ // 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
206
+ return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: o.summary || lastText(sess.history), usage, rounds: o.rounds, done: o.done, aborted: o.aborted, artifacts: o.artifacts };
190
207
  }
191
208
  const r = await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped, onAgent });
192
209
  sess.history = r.messages || r.history || []; sessions.set(sessionId, sess);
193
- return { sessionId, workspace, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
210
+ return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
194
211
  }
195
212
 
196
213
  // 完成通知:POST 結果到 spec.webhook(http/https),單次嘗試、失敗記日誌不重試(PoC)
@@ -245,7 +262,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
245
262
  const body = await readBody(req);
246
263
  if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
247
264
  if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
248
- const t = tasks.enqueue({ pack: body.pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook });
265
+ const t = tasks.enqueue({ pack: body.pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook, workspace: body.workspace });
249
266
  log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
250
267
  return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
251
268
  }
@@ -280,11 +297,17 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
280
297
  if (req.method === 'GET' && mFile) {
281
298
  const t = tasks.get(mFile[1]); const ws = t?.result?.workspace;
282
299
  if (!ws) return json(res, 404, { error: '無交付物(任務尚未完成?)' });
283
- const full = resolveArtifact(join(baseDir, 'ws', ws), url.searchParams.get('path'));
300
+ const rel = url.searchParams.get('path');
301
+ const full = resolveArtifact(join(baseDir, 'ws', ws), rel);
284
302
  if (!full) return json(res, 400, { error: 'path 不合法' });
285
303
  if (!existsSync(full)) return json(res, 404, { error: '檔案不存在' });
286
- try { res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' }); return res.end(readFileSync(full)); }
287
- catch (e) { return json(res, 500, { error: e.message }); }
304
+ try {
305
+ const ct = contentTypeFor(rel);
306
+ const isText = /^text\/|json|xml|javascript|svg/.test(ct);
307
+ const headers = { 'content-type': ct + (isText ? '; charset=utf-8' : '') };
308
+ if (url.searchParams.get('download')) headers['content-disposition'] = `attachment; filename="${encodeURIComponent(basename(rel))}"`;
309
+ res.writeHead(200, headers); return res.end(readFileSync(full));
310
+ } catch (e) { return json(res, 500, { error: e.message }); }
288
311
  }
289
312
 
290
313
  // 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
@@ -310,11 +333,12 @@ export function startServer() {
310
333
  const token = process.env.XITTO_SERVER_TOKEN || 'dev-token';
311
334
  const sandbox = process.env.XITTO_SERVER_SANDBOX !== 'off';
312
335
  const concurrency = Number(process.env.XITTO_SERVER_CONCURRENCY || 2);
336
+ const local = process.env.XITTO_SERVER_LOCAL === '1' || process.env.XITTO_SERVER_LOCAL === 'true';
313
337
  const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
314
- const server = createServerApp({ model, getApiKey, token, sandbox, concurrency });
338
+ const server = createServerApp({ model, getApiKey, token, sandbox, concurrency, local });
315
339
  server.listen(port, () => {
316
340
  console.log(`🪄 許願台:http://localhost:${port}/ (瀏覽器打開即用——說出目標、交付成品)`);
317
- console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}`);
341
+ console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}${local ? ' · 本地模式(顯示檔案位置)' : ''}`);
318
342
  console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
319
343
  console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
320
344
  });
@@ -8,8 +8,39 @@ import { createKernel } from '../kernel/index.js';
8
8
  import { createStore, mountTui, gutter } from './tui.js';
9
9
  import { md } from './md-render.js';
10
10
 
11
- const summarize = (args) => { const s = JSON.stringify(args ?? {}); return s.length > 60 ? s.slice(0, 57) + '…' : s; };
12
- const Y = (s) => `\x1b[33m${s}\x1b[39m`; const G = (s) => `\x1b[90m${s}\x1b[39m`; const R = (s) => `\x1b[31m${s}\x1b[39m`; const C = (s) => `\x1b[36m${s}\x1b[39m`;
11
+ // 取最有意義的參數當摘要(像 Claude Code:bash(npm test) 而非 bash({"command":...})
12
+ export const summarize = (args) => {
13
+ if (!args || typeof args !== 'object') return '';
14
+ const v = args.command ?? args.path ?? args.pattern ?? args.query ?? args.url ?? args.name ?? args.topic ?? args.file;
15
+ if (v != null && v !== '') return String(v).replace(/\s+/g, ' ').slice(0, 60);
16
+ const s = JSON.stringify(args); return s === '{}' ? '' : (s.length > 60 ? s.slice(0, 57) + '…' : s);
17
+ };
18
+ const Y = (s) => `\x1b[33m${s}\x1b[39m`; const G = (s) => `\x1b[90m${s}\x1b[39m`; const R = (s) => `\x1b[31m${s}\x1b[39m`; const C = (s) => `\x1b[36m${s}\x1b[39m`; const Gn = (s) => `\x1b[32m${s}\x1b[39m`;
19
+
20
+ // 工具卡(對標 Claude Code):⏺ name(args) 標頭 + ⎿ 多行結果,過長摺疊成「… +N 行」。純函數,可測。
21
+ export function toolBlock(name, summary, result, isError) {
22
+ const head = Y(`⏺ ${name}`) + (summary ? G(`(${summary})`) : '');
23
+ const raw = (result?.content || []).map((c) => c.text || '').join('\n').replace(/\s+$/, '');
24
+ if (!raw.trim()) return head + '\n' + (isError ? R(' ⎿ ✗') : Gn(' ⎿ ✓'));
25
+ const lines = raw.split('\n');
26
+ const MAX = isError ? 12 : 6;
27
+ const shown = lines.slice(0, MAX).map((l, i) => ' ' + (i === 0 ? '⎿ ' : ' ') + l.slice(0, 200));
28
+ let out = head + '\n' + (isError ? R : G)(shown.join('\n'));
29
+ if (lines.length > MAX) out += '\n' + G(` … +${lines.length - MAX} 行`);
30
+ return out;
31
+ }
32
+
33
+ // 彩色 diff 區塊(綠 + / 紅 -):渲染 kernel 掛在 result._diff 的行級 diff。
34
+ export function diffBlock(d) {
35
+ if (!d) return '';
36
+ const head = G(' ⎿ ') + Gn(`+${d.added}`) + ' ' + R(`-${d.removed}`) + G(' 行');
37
+ if (d.tooBig) return head + G('(差異過大,省略內容)');
38
+ const changed = (d.lines || []).filter((l) => l.t !== ' ');
39
+ if (!changed.length) return '';
40
+ const MAX = 30;
41
+ const body = changed.slice(0, MAX).map((l) => (l.t === '+' ? Gn(' + ' + l.s.slice(0, 200)) : R(' - ' + l.s.slice(0, 200)))).join('\n');
42
+ return head + '\n' + body + (changed.length > MAX ? '\n' + G(` … +${changed.length - MAX} 行變更`) : '');
43
+ }
13
44
 
14
45
  const SLASH = { '/help': '說明', '/goal': '目標循環', '/sandbox': '沙箱', '/auto': '自動核准', '/plan': '計劃模式', '/undo': '撤銷', '/tools': '工具', '/memory': '記憶', '/sessions': '對話', '/resume': '續接', '/cost': '成本', '/clear': '清除', '/exit': '離開' };
15
46
 
@@ -48,10 +79,7 @@ export function runTui({ pack, model, getApiKey, sandbox = false, resume = null
48
79
  const persist = () => { try { kernel.session.save(sessionId, history); } catch { /* 略 */ } };
49
80
 
50
81
  // ── kernel 事件 → store ──
51
- const toolBlock = (name, result, isError) => {
52
- const text = (result?.content || []).map((c) => c.text || '').join(' ').replace(/\s+/g, ' ').trim();
53
- return Y(`⏺ ${name}`) + '\n' + (isError ? R(' ⎿ ✗ ' + text.slice(0, 200)) : G(' ⎿ ✓ ' + text.slice(0, 120)));
54
- };
82
+ let pendingSummary = '';
55
83
  const onEvent = (ev) => {
56
84
  switch (ev.type) {
57
85
  case 'message_update': {
@@ -68,15 +96,25 @@ export function runTui({ pack, model, getApiKey, sandbox = false, resume = null
68
96
  case 'tool_execution_start':
69
97
  store.finalizeLive();
70
98
  if (ev.toolName === 'todo_write' && Array.isArray(ev.args?.todos)) {
71
- store.pushBlock(C('☑ 待辦') + '\n' + ev.args.todos.map((t) => ' ' + (t.status === 'completed' ? '\x1b[32m☑\x1b[39m ' + G(t.content) : t.status === 'in_progress' ? Y('◐ ') + t.content : G('☐ ' + t.content))).join('\n'));
99
+ store.pushBlock(C('☑ 待辦') + '\n' + ev.args.todos.map((t) => ' ' + (t.status === 'completed' ? Gn(' ') + G(t.content) : t.status === 'in_progress' ? Y('◐ ') + t.content : G('☐ ' + t.content))).join('\n'));
72
100
  } else {
73
- store.setTool({ name: ev.toolName, summary: summarize(ev.args) });
101
+ pendingSummary = summarize(ev.args);
102
+ store.setTool({ name: ev.toolName, summary: pendingSummary });
74
103
  }
75
104
  break;
76
- case 'tool_execution_end':
105
+ case 'tool_execution_end': {
77
106
  store.setTool(null);
78
- if (ev.toolName !== 'todo_write') store.pushBlock(toolBlock(ev.toolName, ev.result, ev.isError));
107
+ if (ev.toolName !== 'todo_write') {
108
+ const d = ev.result?._diff;
109
+ if (d && !ev.isError && (d.added || d.removed || d.tooBig)) {
110
+ store.pushBlock(Y(`⏺ ${ev.toolName}`) + (pendingSummary ? G(`(${pendingSummary})`) : '') + '\n' + diffBlock(d));
111
+ } else {
112
+ store.pushBlock(toolBlock(ev.toolName, pendingSummary, ev.result, ev.isError));
113
+ }
114
+ }
115
+ pendingSummary = '';
79
116
  break;
117
+ }
80
118
  case 'verify_start': store.finalizeLive(); store.pushBlock(G(' 🔎 自動驗收…')); break;
81
119
  case 'verify_end': store.pushBlock(ev.ok ? G(' ✓ 驗收通過') : Y(' ✗ 驗收失敗,修正中…')); break;
82
120
  case 'compact': store.pushBlock(G(` ⊙ 已壓縮上下文:${ev.tokensBefore}→${ev.tokensAfter} tokens`)); break;
@@ -39,6 +39,18 @@
39
39
  .todo.completed { color:var(--ok); }
40
40
  button.cancel { margin-left:10px; padding:3px 12px; font-size:12px; background:transparent; color:#f08a8a; border:1px solid #5b3030; }
41
41
  button.cancel:hover { background:#2a1818; }
42
+ .wsbadge { display:inline-block; font-size:12px; color:var(--dim); margin-left:8px; }
43
+ .loc { margin-top:10px; font-size:12px; color:var(--dim); }
44
+ .loc code { background:#0c0e12; border:1px solid var(--line); border-radius:6px; padding:2px 7px; color:var(--fg); cursor:pointer; }
45
+ .logtoggle { margin-top:12px; font-size:13px; color:var(--accent); cursor:pointer; user-select:none; }
46
+ .loglist { margin-top:6px; border-left:2px solid var(--line); padding-left:12px; }
47
+ .logstep { font-size:13px; color:var(--fg); padding:3px 0; }
48
+ .logstep.err { color:#f08a8a; }
49
+ .logstep .dim { color:var(--dim); }
50
+ .diff { margin:5px 0 6px 14px; font:12px/1.5 ui-monospace,Menlo,monospace; }
51
+ .dh { color:var(--dim); font-size:11px; margin-bottom:2px; }
52
+ .dl { white-space:pre-wrap; }
53
+ .dl.add { color:var(--ok); } .dl.del { color:#f08a8a; }
42
54
  .dots::after { content:""; animation:dots 1.4s steps(4,end) infinite; }
43
55
  @keyframes dots { 0%{content:""} 25%{content:"·"} 50%{content:"··"} 75%{content:"···"} }
44
56
  .summary { margin-top:10px; white-space:pre-wrap; }
@@ -52,8 +64,17 @@
52
64
  .hist { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:11px 14px; margin:8px 0; cursor:pointer; }
53
65
  .hist:hover { border-color:var(--accent); }
54
66
  .hist .g { font-size:14px; } .hist .m { color:var(--dim); font-size:12px; }
55
- pre.viewer { background:#0c0e12; border:1px solid var(--line); border-radius:10px; padding:12px; overflow:auto; max-height:360px; white-space:pre-wrap; font:13px/1.5 ui-monospace,Menlo,monospace; }
56
67
  .empty { color:var(--dim); font-size:13px; }
68
+ .viewer { margin-top:10px; border:1px solid var(--line); border-radius:10px; padding:12px; background:#0c0e12; }
69
+ .vbar { font-size:12px; color:var(--dim); margin-bottom:8px; }
70
+ .vbar a { color:var(--accent); text-decoration:none; }
71
+ .vimg { max-width:100%; border-radius:8px; }
72
+ .vframe { width:100%; height:420px; border:0; background:#fff; border-radius:8px; }
73
+ .viewer-pre { white-space:pre-wrap; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; color:var(--fg); overflow:auto; max-height:420px; }
74
+ .md { line-height:1.65; } .md h1,.md h2,.md h3,.md h4 { margin:.7em 0 .3em; line-height:1.3; }
75
+ .md code { background:#1a1d24; padding:1px 5px; border-radius:4px; font-size:.9em; }
76
+ .md pre.code { background:#1a1d24; padding:10px; border-radius:8px; overflow:auto; }
77
+ .md ul { margin:.3em 0 .3em 1.2em; } .md a { color:var(--accent); } .md p { margin:.5em 0; }
57
78
  </style>
58
79
  </head>
59
80
  <body>
@@ -61,6 +82,9 @@
61
82
  <header>
62
83
  <h1>🪄 xitto 許願台</h1>
63
84
  <span class="sub">說出你想完成的事,交給它去做、做完給你成品</span>
85
+ <span class="spacer"></span>
86
+ <select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
87
+ <button class="ghost" id="newspace" title="新專案">+</button>
64
88
  </header>
65
89
 
66
90
  <div class="ask">
@@ -105,12 +129,69 @@ setInterval(() => { if (liveTask && (liveTask.status==="running"||liveTask.statu
105
129
  const LABELS = { general:"通用", coding:"程式", "data-query":"查資料", notes:"筆記", "deep-research":"研究", devops:"維運" };
106
130
  $("#pack").innerHTML = PACKS.map(p=>`<option value="${p}" ${p==="general"?"selected":""}>${LABELS[p]||p}</option>`).join("");
107
131
 
132
+ // 專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設;不同空間的檔案與沉澱各自獨立)
133
+ let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
134
+ let curSpace = localStorage.getItem("xk_space")||"default";
135
+ function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option ${s===curSpace?"selected":""}>${esc(s)}</option>`).join(""); }
136
+ $("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; loadHistory(); };
137
+ $("#newspace").onclick = () => { const n=(prompt("新專案名稱(英數/底線/連字號):")||"").trim().replace(/[^a-zA-Z0-9_-]/g,""); if(!n)return; if(!spaces.includes(n))spaces.push(n); curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); $("#current").innerHTML=""; loadHistory(); };
138
+ renderSpaces();
139
+
140
+ // 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
141
+ function mdRender(src){
142
+ const lines=String(src).replace(/\r/g,"").split("\n"); const out=[]; let inCode=false,buf=[],inList=false;
143
+ const inline=s=>esc(s).replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>').replace(/\*([^*]+)\*/g,'<em>$1</em>').replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g,'<a href="$2" target="_blank">$1</a>');
144
+ const closeL=()=>{ if(inList){out.push("</ul>");inList=false;} };
145
+ for(const ln of lines){
146
+ if(/^```/.test(ln)){ if(inCode){out.push("<pre class='code'>"+esc(buf.join("\n"))+"</pre>");buf=[];inCode=false;}else{closeL();inCode=true;} continue; }
147
+ if(inCode){ buf.push(ln); continue; }
148
+ const h=ln.match(/^(#{1,4})\s+(.*)/); if(h){ closeL(); out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); continue; }
149
+ const li=ln.match(/^\s*(?:[-*]|\d+\.)\s+(.*)/); if(li){ if(!inList){out.push("<ul>");inList=true;} out.push("<li>"+inline(li[1])+"</li>"); continue; }
150
+ if(ln.trim()===""){ closeL(); continue; }
151
+ closeL(); out.push("<p>"+inline(ln)+"</p>");
152
+ }
153
+ closeL(); if(inCode)out.push("<pre class='code'>"+esc(buf.join("\n"))+"</pre>");
154
+ return out.join("");
155
+ }
156
+ const IMG=/\.(png|jpe?g|gif|webp|svg)$/i, MD=/\.(md|markdown)$/i, HTMLF=/\.html?$/i, JSONF=/\.json$/i;
157
+ const fileUrl=(id,path,extra="")=>"/v1/tasks/"+id+"/file?path="+encodeURIComponent(path)+"&token="+encodeURIComponent(TOKEN)+extra;
158
+ // 彩色 diff(綠 +/紅 -):渲染 kernel 算好的 _diff
159
+ function diffHtml(d){
160
+ if(!d) return "";
161
+ if(d.tooBig) return `<div class="diff"><span class="dh">+${d.added} -${d.removed} 行(差異過大,省略)</span></div>`;
162
+ const ch=(d.lines||[]).filter(l=>l.t!==" ").slice(0,40);
163
+ if(!ch.length) return "";
164
+ return `<div class="diff"><div class="dh">+${d.added} -${d.removed}</div>${ch.map(l=>`<div class="dl ${l.t==="+"?"add":"del"}">${esc(l.t+" "+l.s)}</div>`).join("")}</div>`;
165
+ }
166
+ // 完整步驟卡(展開過程):人話動作 + 編輯的彩色 diff
167
+ function logHtml(p){
168
+ if(!p||!(p.log||[]).length) return `<div class="empty">(尚無步驟)</div>`;
169
+ return p.log.map(s=>`<div class="logstep ${s.isError?"err":""}">▸ ${esc(TOOL_ZH[s.name]||s.name)}${s.summary?` <span class="dim">${esc(s.summary)}</span>`:""}${s.diff?diffHtml(s.diff):""}</div>`).join("");
170
+ }
171
+ let expandedLog=false;
172
+ function toggleLog(){ expandedLog=!expandedLog; if(liveTask) renderCurrent(liveTask); }
173
+
174
+ async function viewFile(id, encPath, name){
175
+ const path=decodeURIComponent(encPath); const v=$("#fview"); v.style.display="block";
176
+ const bar=`<div class="vbar">📄 ${esc(name)} · <a href="${fileUrl(id,path)}" target="_blank">開新分頁</a> · <a href="${fileUrl(id,path,'&download=1')}">下載</a></div>`;
177
+ if(IMG.test(name)){ v.innerHTML=bar+`<img class="vimg" src="${fileUrl(id,path)}">`; return; }
178
+ if(HTMLF.test(name)){ v.innerHTML=bar+`<iframe class="vframe" sandbox src="${fileUrl(id,path)}"></iframe>`; return; }
179
+ v.innerHTML=bar+`<div class="empty">載入中…</div>`;
180
+ const txt=await fetch(fileUrl(id,path)).then(r=>r.ok?r.text():null).catch(()=>null);
181
+ if(txt==null){ v.innerHTML=bar+`<div class="empty">(無法以文字呈現,請下載)</div>`; return; }
182
+ let body;
183
+ if(MD.test(name)) body=`<div class="md">${mdRender(txt)}</div>`;
184
+ else if(JSONF.test(name)){ try{ body=`<pre class="viewer-pre">${esc(JSON.stringify(JSON.parse(txt),null,2))}</pre>`; }catch{ body=`<pre class="viewer-pre">${esc(txt)}</pre>`; } }
185
+ else body=`<pre class="viewer-pre">${esc(txt)}</pre>`;
186
+ v.innerHTML=bar+body;
187
+ }
188
+
108
189
  let activeId = null, polling = null;
109
190
 
110
191
  $("#go").onclick = async () => {
111
192
  const goal = $("#goal").value.trim(); if (!goal) return;
112
193
  $("#go").disabled = true;
113
- const r = await api("/v1/tasks", { method:"POST", body: JSON.stringify({ pack:$("#pack").value, mode:"goal", goal }) }).then(r=>r.json());
194
+ const r = await api("/v1/tasks", { method:"POST", body: JSON.stringify({ pack:$("#pack").value, mode:"goal", goal, workspace: curSpace }) }).then(r=>r.json());
114
195
  $("#go").disabled = false;
115
196
  if (r.error) { alert(r.error); return; }
116
197
  $("#goal").value = "";
@@ -136,18 +217,22 @@ async function poll() {
136
217
 
137
218
  function renderCurrent(t) {
138
219
  const a = t.result?.artifacts, files = a ? [...(a.created||[]).map(f=>[f,"new"]), ...(a.modified||[]).map(f=>[f,"mod"])] : [];
220
+ const p = t.progress||{}, nLog=(p.log||[]).length;
221
+ const logSec = nLog ? `<div class="logtoggle" onclick="toggleLog()">${expandedLog?"▾ 收合過程":"▸ 展開過程("+nLog+" 步)"}</div>${expandedLog?`<div class="loglist">${logHtml(p)}</div>`:""}` : "";
139
222
  $("#current").innerHTML = `<div class="card">
140
- <div class="goal">${esc(t.goal||"任務")}</div>
223
+ <div class="goal">${esc(t.goal||"任務")}<span class="wsbadge">📁 ${esc(t.workspace||"default")}</span></div>
141
224
  <span class="status ${statusClass(t.status)}">${statusText(t.status)}${t.rounds?` · ${t.rounds} 輪`:""}</span>
142
225
  ${CANCELLABLE.includes(t.status)?`<button class="cancel" onclick="cancelTask('${t.taskId}')">停止</button>`:""}
143
226
  ${t.status==="running"||t.status==="queued"?progressHtml(t):""}
144
227
  ${todosHtml(t.progress)}
228
+ ${logSec}
145
229
  ${t.status==="needs-input"?`<div class="qbox"><div class="q">❓ ${esc(t.pending?.question)}</div>
146
230
  <input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
147
231
  ${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</div>`:""}
148
232
  ${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
149
- ${files.length?`<div class="files">📦 成品:${files.map(([f,k])=>`<span class="file ${k==="mod"?"mod":""}" onclick="viewFile('${t.taskId}','${esc(f)}')">${k==="mod"?"~":"+"} ${esc(f)}</span>`).join("")}</div>`:""}
150
- <pre class="viewer" id="fview" style="display:none"></pre>
233
+ ${files.length?`<div class="files">📦 成品:${files.map(([f,k])=>`<span class="file ${k==="mod"?"mod":""}" onclick="viewFile('${t.taskId}','${encodeURIComponent(f)}','${esc(f)}')">${k==="mod"?"~":"+"} ${esc(f)}</span>`).join("")}</div>`:""}
234
+ ${t.result&&t.result.workspaceDir?`<div class="loc">📂 檔案位置:<code title="點擊複製" onclick="navigator.clipboard&&navigator.clipboard.writeText('${esc(t.result.workspaceDir)}')">${esc(t.result.workspaceDir)}</code></div>`:""}
235
+ <div class="viewer" id="fview" style="display:none"></div>
151
236
  </div>`;
152
237
  if (t.status==="needs-input") {
153
238
  const inp = $("#ans"); inp.focus();
@@ -159,15 +244,9 @@ function renderCurrent(t) {
159
244
  }
160
245
  }
161
246
 
162
- async function viewFile(id, path) {
163
- const v = $("#fview"); v.style.display="block"; v.textContent="載入中…";
164
- const r = await api("/v1/tasks/"+id+"/file?path="+encodeURIComponent(path));
165
- v.textContent = r.ok ? await r.text() : "(無法讀取)";
166
- }
167
-
168
247
  async function loadHistory() {
169
248
  const r = await api("/v1/tasks").then(r=>r.json());
170
- const list = (r.tasks||[]).filter(t=>t.mode==="goal").reverse();
249
+ const list = (r.tasks||[]).filter(t=>t.mode==="goal" && (t.workspace||"default")===curSpace).reverse();
171
250
  $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
172
251
  <div class="g">${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
173
252
  <div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
@@ -0,0 +1,30 @@
1
+ // 行級 diff(LCS)— 給 TUI/app 渲染彩色 diff。回傳 { lines:[{t,s}], added, removed } 或 { tooBig } 或 null(無變化)。
2
+ // kernel 在 wrapUndo 已抓到 before(undo 快照),改完讀 after,集中算 diff,所有 pack 的 edit/write 免改。
3
+ export function lineDiff(before, after, { maxLines = 600 } = {}) {
4
+ if (before === after) return null;
5
+ const a = before == null ? [] : String(before).replace(/\n$/, '').split('\n');
6
+ const b = after == null ? [] : String(after).replace(/\n$/, '').split('\n');
7
+ if (a.length === 1 && a[0] === '' && before == null) a.length = 0;
8
+ if (b.length === 1 && b[0] === '' && after == null) b.length = 0;
9
+ const m = a.length, n = b.length;
10
+ if (m > maxLines || n > maxLines) return { tooBig: true, added: n, removed: m };
11
+
12
+ // LCS DP(自後往前)
13
+ const dp = Array.from({ length: m + 1 }, () => new Uint32Array(n + 1));
14
+ for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
15
+
16
+ const lines = [];
17
+ let i = 0, j = 0;
18
+ while (i < m && j < n) {
19
+ if (a[i] === b[j]) { lines.push({ t: ' ', s: a[i] }); i++; j++; }
20
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { lines.push({ t: '-', s: a[i] }); i++; }
21
+ else { lines.push({ t: '+', s: b[j] }); j++; }
22
+ }
23
+ while (i < m) lines.push({ t: '-', s: a[i++] });
24
+ while (j < n) lines.push({ t: '+', s: b[j++] });
25
+
26
+ const added = lines.filter((l) => l.t === '+').length;
27
+ const removed = lines.filter((l) => l.t === '-').length;
28
+ if (!added && !removed) return null;
29
+ return { lines, added, removed };
30
+ }
@@ -11,6 +11,7 @@ import { createPermissionStep } from './security/permission-step.js';
11
11
  import { fileAllowStore, memoryAllowStore } from './security/allow-store.js';
12
12
  import { normalizeSandbox, wrapWithSeatbelt, sandboxViolation } from './security/sandbox.js';
13
13
  import { dangerousReason } from './security/danger.js';
14
+ import { lineDiff } from './diff.js';
14
15
  import { spawnSync } from 'node:child_process';
15
16
  import { createMemory } from './memory.js';
16
17
  import { createPlaybook } from './playbook.js';
@@ -45,7 +46,7 @@ function loadContextFiles(cwd, names) {
45
46
  }
46
47
 
47
48
  // 交付物偵測:掃工作目錄前後快照,diff 出「產出/改動的檔案」(pack 無關,連 bash 寫的也抓得到)。
48
- const SKIP_SCAN = new Set(['.xitto-kernel', 'node_modules', '.git', '.swebench-repos', '.xitto-server']);
49
+ const SKIP_SCAN = new Set(['.xitto-kernel', 'node_modules', '.git', '.swebench-repos', '.xitto-server', 'tmp']);
49
50
  function scanWorkdir(dir, base = dir, acc = new Map(), depth = 0) {
50
51
  if (depth > 8 || acc.size > 5000) return acc;
51
52
  let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
@@ -72,6 +73,9 @@ const DEFAULT_PLAYBOOK_GUIDE =
72
73
  const DEFAULT_EPISODE_GUIDE =
73
74
  '完成有價值的任務後,用 episode_record 記一筆情節(做了什麼+結果+tags);遇到相似任務時系統會自動召回最相關的幾筆供參考,也可主動用 episode_recall 查。';
74
75
 
76
+ const DEFAULT_OUTPUT_GUIDE =
77
+ '產出檔案時:最終成品放工作目錄根、用清楚好懂的檔名(如 report.md、budget.csv,別用 tmp_3.txt);中間/暫存檔(下載、草稿、解壓內容、爬到的原始資料)一律放 tmp/ 目錄——那是過程檔,不算成品也可能被清掉。';
78
+
75
79
  // 把 sandboxable 工具的命令在執行期包進 Seatbelt(macOS OS 級隔離)。
76
80
  // 非 macOS / 沙箱關閉 / 無 command → wrapWithSeatbelt 回 null,跑原命令(仍受第 5 格靜態策略保護)。
77
81
  function wrapSandboxable(tool, { cwd, getSandbox, getSandboxConfig }) {
@@ -91,20 +95,31 @@ function wrapSandboxable(tool, { cwd, getSandbox, getSandboxConfig }) {
91
95
 
92
96
  // undo 快照:mutating 且帶 args.path 的工具(檔案編輯類),執行前記錄檔案原狀,供 kernel.undo() 還原。
93
97
  // 「以 path 指涉被改檔案」是常見約定;非檔案型 mutating 工具(bash/sql_exec 無 path)不受影響。
98
+ const isTextContent = (s) => s == null || !String(s).includes("\u0000");
94
99
  function wrapUndo(tool, { cwd, undoStack }) {
95
100
  if (tool.mutating !== true || typeof tool.execute !== 'function') return tool;
96
101
  const orig = tool.execute.bind(tool);
97
102
  return {
98
103
  ...tool,
99
- execute: (id, params, ...rest) => {
104
+ execute: async (id, params, ...rest) => {
105
+ let abs = null, before = null;
100
106
  if (params?.path) {
101
- const p = isAbsolute(params.path) ? params.path : join(cwd, params.path);
107
+ abs = isAbsolute(params.path) ? params.path : join(cwd, params.path);
102
108
  try {
103
- undoStack.push({ path: p, rel: params.path, before: existsSync(p) ? readFileSync(p, 'utf8') : null });
109
+ before = existsSync(abs) ? readFileSync(abs, 'utf8') : null;
110
+ undoStack.push({ path: abs, rel: params.path, before });
104
111
  if (undoStack.length > 50) undoStack.shift();
105
112
  } catch { /* 略 */ }
106
113
  }
107
- return orig(id, params, ...rest);
114
+ const result = await orig(id, params, ...rest);
115
+ // 集中算 diff:用 before 快照 + 改後內容,掛在 result._diff(不進 LLM content,僅供 app 渲染)
116
+ if (abs && result && typeof result === 'object' && isTextContent(before)) {
117
+ try {
118
+ const after = existsSync(abs) ? readFileSync(abs, 'utf8') : null;
119
+ if (isTextContent(after)) { const d = lineDiff(before, after); if (d) result._diff = { path: params.path, ...d }; }
120
+ } catch { /* 略 */ }
121
+ }
122
+ return result;
108
123
  },
109
124
  };
110
125
  }
@@ -221,6 +236,7 @@ export function createKernel(pack, config = {}) {
221
236
  pack.systemPrompt +
222
237
  loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
223
238
  '\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE + '\n' + DEFAULT_EPISODE_GUIDE +
239
+ '\n\n# 成品與暫存\n' + DEFAULT_OUTPUT_GUIDE +
224
240
  (memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
225
241
  (pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
226
242
  (askUserTool ? '\n\n# 詢問\n盡量自主完成目標。只在缺少關鍵資訊、無法合理推斷、或決策會明顯改變結果時,才用 ask_user 問使用者;能用合理預設就別問。' : '') +