xitto-kernel 0.6.1 → 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,32 @@
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
+
3
30
  ## 0.6.1
4
31
 
5
32
  - **成品溯源/位置**:分邏輯與實體兩層。
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,6 +102,7 @@ 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
  - **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.6.1",
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
@@ -27,6 +27,9 @@ const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(
27
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
28
  export function contentTypeFor(name) { const ext = (String(name).split('.').pop() || '').toLowerCase(); return MIME[ext] || 'application/octet-stream'; }
29
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
+
30
33
  // 交付檔案路徑解析(防穿越):rel 必須是 workdir 內的相對路徑,否則回 null。
31
34
  export function resolveArtifact(workdir, rel) {
32
35
  if (typeof rel !== 'string' || !rel || isAbsolute(rel)) return null;
@@ -41,7 +44,7 @@ const webHtml = () => (_webHtml ??= readFileSync(join(dirname(fileURLToPath(impo
41
44
  // 把原始 kernel 事件壓成精簡的對外事件(串流端與背景任務共用,避免重複映射)
42
45
  export const mapEvent = (ev) => {
43
46
  if (ev.type === 'tool_execution_start') return { type: 'tool', name: ev.toolName, args: ev.args };
44
- 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 };
45
48
  if (ev.type === 'message_update' && ev.assistantMessageEvent?.type === 'text_delta') return { type: 'text', delta: ev.assistantMessageEvent.delta };
46
49
  if (ev.type === 'round') return { type: 'round', round: ev.round, maxRounds: ev.maxRounds };
47
50
  if (ev.type === 'verify_start') return { type: 'phase', phase: 'verifying' };
@@ -70,9 +73,16 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
70
73
  t.events.push(ev);
71
74
  if (t.events.length > maxEvents) t.events.shift();
72
75
  // 進度追蹤(給 UI 顯示「正在做什麼」,不要只顯示進行中;排除 text 雜訊)
73
- 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: [] });
74
77
  if (ev.type === 'tool' && ev.name === 'todo_write') { if (Array.isArray(ev.args?.todos)) p.todos = ev.args.todos; } // 待辦清單(給 UI 打勾)
75
- 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
+ }
76
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); }
77
87
  else if (ev.type === 'round') { p.round = ev.round; if (ev.maxRounds) p.maxRounds = ev.maxRounds; p.thinking = ''; t._textbuf = ''; }
78
88
  else if (ev.type === 'phase') p.phase = ev.phase;
@@ -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;
@@ -42,6 +42,15 @@
42
42
  .wsbadge { display:inline-block; font-size:12px; color:var(--dim); margin-left:8px; }
43
43
  .loc { margin-top:10px; font-size:12px; color:var(--dim); }
44
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; }
45
54
  .dots::after { content:""; animation:dots 1.4s steps(4,end) infinite; }
46
55
  @keyframes dots { 0%{content:""} 25%{content:"·"} 50%{content:"··"} 75%{content:"···"} }
47
56
  .summary { margin-top:10px; white-space:pre-wrap; }
@@ -146,6 +155,22 @@ function mdRender(src){
146
155
  }
147
156
  const IMG=/\.(png|jpe?g|gif|webp|svg)$/i, MD=/\.(md|markdown)$/i, HTMLF=/\.html?$/i, JSONF=/\.json$/i;
148
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
+
149
174
  async function viewFile(id, encPath, name){
150
175
  const path=decodeURIComponent(encPath); const v=$("#fview"); v.style.display="block";
151
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>`;
@@ -192,12 +217,15 @@ async function poll() {
192
217
 
193
218
  function renderCurrent(t) {
194
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>`:""}` : "";
195
222
  $("#current").innerHTML = `<div class="card">
196
223
  <div class="goal">${esc(t.goal||"任務")}<span class="wsbadge">📁 ${esc(t.workspace||"default")}</span></div>
197
224
  <span class="status ${statusClass(t.status)}">${statusText(t.status)}${t.rounds?` · ${t.rounds} 輪`:""}</span>
198
225
  ${CANCELLABLE.includes(t.status)?`<button class="cancel" onclick="cancelTask('${t.taskId}')">停止</button>`:""}
199
226
  ${t.status==="running"||t.status==="queued"?progressHtml(t):""}
200
227
  ${todosHtml(t.progress)}
228
+ ${logSec}
201
229
  ${t.status==="needs-input"?`<div class="qbox"><div class="q">❓ ${esc(t.pending?.question)}</div>
202
230
  <input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
203
231
  ${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</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';
@@ -94,20 +95,31 @@ function wrapSandboxable(tool, { cwd, getSandbox, getSandboxConfig }) {
94
95
 
95
96
  // undo 快照:mutating 且帶 args.path 的工具(檔案編輯類),執行前記錄檔案原狀,供 kernel.undo() 還原。
96
97
  // 「以 path 指涉被改檔案」是常見約定;非檔案型 mutating 工具(bash/sql_exec 無 path)不受影響。
98
+ const isTextContent = (s) => s == null || !String(s).includes("\u0000");
97
99
  function wrapUndo(tool, { cwd, undoStack }) {
98
100
  if (tool.mutating !== true || typeof tool.execute !== 'function') return tool;
99
101
  const orig = tool.execute.bind(tool);
100
102
  return {
101
103
  ...tool,
102
- execute: (id, params, ...rest) => {
104
+ execute: async (id, params, ...rest) => {
105
+ let abs = null, before = null;
103
106
  if (params?.path) {
104
- const p = isAbsolute(params.path) ? params.path : join(cwd, params.path);
107
+ abs = isAbsolute(params.path) ? params.path : join(cwd, params.path);
105
108
  try {
106
- 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 });
107
111
  if (undoStack.length > 50) undoStack.shift();
108
112
  } catch { /* 略 */ }
109
113
  }
110
- 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;
111
123
  },
112
124
  };
113
125
  }