xitto-kernel 0.5.0 → 0.6.1

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,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ - **成品溯源/位置**:分邏輯與實體兩層。
6
+ - **邏輯位置(workspace)**:成品卡永遠標出 `📁 所屬空間`,一眼知道每份成品屬於哪個專案
7
+ - **實體路徑**:預設**不外露**(託管不洩漏伺服器絕對路徑);僅**本地模式**(`XITTO_SERVER_LOCAL=1` 或 `createServerApp({local:true})`)在 result 附 `workspaceDir`,網頁顯示「📂 檔案位置」(點擊複製,供到 Finder/Explorer 找檔)
8
+ - 真實 live 驗證:本地模式回絕對路徑 `/…/ws/<workspace>`、託管模式 `workspaceDir` 為 undefined。測試 174/174。
9
+
10
+ ## 0.6.0
11
+
12
+ 成品管理 + 類型感知呈現 + 專案空間(一次補上三組優化)。
13
+
14
+ - **成品/過程檔管理**:
15
+ - 系統提示引導 agent:成品放工作目錄根用好檔名,暫存/草稿放 `tmp/`
16
+ - `runOutcome` 的成品掃描排除 `tmp/`(過程檔不污染交付清單);job 完成後 server 自動清 `tmp/`
17
+ - **類型感知的成品呈現**:
18
+ - file 端點按副檔名給對的 `content-type`(圖片能顯示、md/html 能渲染),支援 `?download=1`、二進位正確回傳
19
+ - 網頁類型感知檢視:markdown **排版渲染**(零依賴內嵌渲染器)、圖片 `<img>`、HTML 沙箱 iframe、JSON 美化、其餘下載;每個檔有「開新分頁/下載」
20
+ - `?token=` 查詢參數認證(img/iframe/下載這類無法帶 header 的瀏覽器 GET)
21
+ - **專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設)**:
22
+ - 網頁加「專案」下拉 + 新專案;不同空間的**檔案與五層沉澱各自獨立**;歷史按空間過濾
23
+ - 任務 view 帶 `workspace`;POST `/v1/tasks` 接受 `workspace`(修:原本被 enqueue 丟棄)
24
+ - 3 個新測試(content-type / view.workspace / tmp 不算成品)+ 真實 server 端到端
25
+ (markdown 成品渲染、tmp 清理、download header、query token、workspace 隔離)。測試 174/174。
26
+
3
27
  ## 0.5.0
4
28
 
5
29
  - **持久工作空間(許願台成品間的關係)**:每個成品仍是獨立對話,但共用一個持久工作空間。
package/README.md CHANGED
@@ -106,7 +106,9 @@ XITTO_SERVER_TOKEN=secret npm run serve # 然後瀏覽器開 http://localhost:
106
106
  - **收成品**:完成後顯示摘要 + **產出的檔案**,點檔名可直接看內容(`GET /v1/tasks/:id/file`,防路徑穿越)
107
107
  - **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
108
108
 
109
- **持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個)
109
+ **持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個);網頁有「專案」下拉切換,每份成品卡標出 `📁 所屬空間`。
110
+
111
+ **溯源/檔案位置**:成品記錄它的**邏輯位置(workspace)**;**實體絕對路徑**預設不外露(託管不洩漏伺服器路徑),只在**本地模式**(`XITTO_SERVER_LOCAL=1`)才在成品附「📂 檔案位置」供你到 Finder/Explorer 找檔。
110
112
 
111
113
  零依賴單一 HTML(`src/app/web/index.html`),polling 不靠 SSE。token 注入頁面供同源呼叫——本地自用零設定;**正式部署請前置真實認證**。
112
114
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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,10 @@ 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
+
26
30
  // 交付檔案路徑解析(防穿越):rel 必須是 workdir 內的相對路徑,否則回 null。
27
31
  export function resolveArtifact(workdir, rel) {
28
32
  if (typeof rel !== 'string' || !rel || isAbsolute(rel)) return null;
@@ -60,7 +64,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
60
64
  const subs = new Map(); // id -> Set<(ev)=>void>
61
65
  let active = 0;
62
66
 
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 });
67
+ 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
68
 
65
69
  const emit = (t, ev) => {
66
70
  t.events.push(ev);
@@ -158,12 +162,13 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
158
162
  * @param {number} [o.concurrency] 背景任務同時數(預設 2)
159
163
  * @returns {import('node:http').Server}
160
164
  */
161
- export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2 } = {}) {
165
+ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-server', sandbox = true, concurrency = 2, local = false } = {}) {
162
166
  const sessions = new Map(); // sessionId -> { pack, history }
163
167
  mkdirSync(baseDir, { recursive: true });
164
168
 
165
169
  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}`);
170
+ // header bearer 為主;img/iframe/下載這類瀏覽器發起的 GET 無法帶 header,允許 ?token=(同源、PoC)
171
+ 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
172
  const log = (o) => console.log(JSON.stringify({ ts: new Date().toISOString(), ...o }));
168
173
  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
174
  const sseHead = (res) => res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-cache', connection: 'keep-alive' });
@@ -186,11 +191,13 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
186
191
  // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
187
192
  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
193
  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 };
194
+ try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
195
+ // 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
196
+ 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
197
  }
191
198
  const r = await kernel.runTurn(spec.input || '', { history: sess.history, onEvent: wrapped, onAgent });
192
199
  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 };
200
+ return { sessionId, workspace, workspaceDir: local ? resolve(workdir) : undefined, text: r.text ?? lastText(sess.history), usage, rounds: r.rounds, done: r.done };
194
201
  }
195
202
 
196
203
  // 完成通知:POST 結果到 spec.webhook(http/https),單次嘗試、失敗記日誌不重試(PoC)
@@ -245,7 +252,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
245
252
  const body = await readBody(req);
246
253
  if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
247
254
  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 });
255
+ 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
256
  log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
250
257
  return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
251
258
  }
@@ -280,11 +287,17 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
280
287
  if (req.method === 'GET' && mFile) {
281
288
  const t = tasks.get(mFile[1]); const ws = t?.result?.workspace;
282
289
  if (!ws) return json(res, 404, { error: '無交付物(任務尚未完成?)' });
283
- const full = resolveArtifact(join(baseDir, 'ws', ws), url.searchParams.get('path'));
290
+ const rel = url.searchParams.get('path');
291
+ const full = resolveArtifact(join(baseDir, 'ws', ws), rel);
284
292
  if (!full) return json(res, 400, { error: 'path 不合法' });
285
293
  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 }); }
294
+ try {
295
+ const ct = contentTypeFor(rel);
296
+ const isText = /^text\/|json|xml|javascript|svg/.test(ct);
297
+ const headers = { 'content-type': ct + (isText ? '; charset=utf-8' : '') };
298
+ if (url.searchParams.get('download')) headers['content-disposition'] = `attachment; filename="${encodeURIComponent(basename(rel))}"`;
299
+ res.writeHead(200, headers); return res.end(readFileSync(full));
300
+ } catch (e) { return json(res, 500, { error: e.message }); }
288
301
  }
289
302
 
290
303
  // 附掛背景任務的事件流(replay 緩衝 + 即時;已結束則回放後關閉)
@@ -310,11 +323,12 @@ export function startServer() {
310
323
  const token = process.env.XITTO_SERVER_TOKEN || 'dev-token';
311
324
  const sandbox = process.env.XITTO_SERVER_SANDBOX !== 'off';
312
325
  const concurrency = Number(process.env.XITTO_SERVER_CONCURRENCY || 2);
326
+ const local = process.env.XITTO_SERVER_LOCAL === '1' || process.env.XITTO_SERVER_LOCAL === 'true';
313
327
  const { model, getApiKey } = loadModel(process.env.XITTO_MODEL);
314
- const server = createServerApp({ model, getApiKey, token, sandbox, concurrency });
328
+ const server = createServerApp({ model, getApiKey, token, sandbox, concurrency, local });
315
329
  server.listen(port, () => {
316
330
  console.log(`🪄 許願台:http://localhost:${port}/ (瀏覽器打開即用——說出目標、交付成品)`);
317
- console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}`);
331
+ console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}${local ? ' · 本地模式(顯示檔案位置)' : ''}`);
318
332
  console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
319
333
  console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
320
334
  });
@@ -39,6 +39,9 @@
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; }
42
45
  .dots::after { content:""; animation:dots 1.4s steps(4,end) infinite; }
43
46
  @keyframes dots { 0%{content:""} 25%{content:"·"} 50%{content:"··"} 75%{content:"···"} }
44
47
  .summary { margin-top:10px; white-space:pre-wrap; }
@@ -52,8 +55,17 @@
52
55
  .hist { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:11px 14px; margin:8px 0; cursor:pointer; }
53
56
  .hist:hover { border-color:var(--accent); }
54
57
  .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
58
  .empty { color:var(--dim); font-size:13px; }
59
+ .viewer { margin-top:10px; border:1px solid var(--line); border-radius:10px; padding:12px; background:#0c0e12; }
60
+ .vbar { font-size:12px; color:var(--dim); margin-bottom:8px; }
61
+ .vbar a { color:var(--accent); text-decoration:none; }
62
+ .vimg { max-width:100%; border-radius:8px; }
63
+ .vframe { width:100%; height:420px; border:0; background:#fff; border-radius:8px; }
64
+ .viewer-pre { white-space:pre-wrap; font:13px/1.5 ui-monospace,Menlo,monospace; margin:0; color:var(--fg); overflow:auto; max-height:420px; }
65
+ .md { line-height:1.65; } .md h1,.md h2,.md h3,.md h4 { margin:.7em 0 .3em; line-height:1.3; }
66
+ .md code { background:#1a1d24; padding:1px 5px; border-radius:4px; font-size:.9em; }
67
+ .md pre.code { background:#1a1d24; padding:10px; border-radius:8px; overflow:auto; }
68
+ .md ul { margin:.3em 0 .3em 1.2em; } .md a { color:var(--accent); } .md p { margin:.5em 0; }
57
69
  </style>
58
70
  </head>
59
71
  <body>
@@ -61,6 +73,9 @@
61
73
  <header>
62
74
  <h1>🪄 xitto 許願台</h1>
63
75
  <span class="sub">說出你想完成的事,交給它去做、做完給你成品</span>
76
+ <span class="spacer"></span>
77
+ <select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
78
+ <button class="ghost" id="newspace" title="新專案">+</button>
64
79
  </header>
65
80
 
66
81
  <div class="ask">
@@ -105,12 +120,53 @@ setInterval(() => { if (liveTask && (liveTask.status==="running"||liveTask.statu
105
120
  const LABELS = { general:"通用", coding:"程式", "data-query":"查資料", notes:"筆記", "deep-research":"研究", devops:"維運" };
106
121
  $("#pack").innerHTML = PACKS.map(p=>`<option value="${p}" ${p==="general"?"selected":""}>${LABELS[p]||p}</option>`).join("");
107
122
 
123
+ // 專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設;不同空間的檔案與沉澱各自獨立)
124
+ let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
125
+ let curSpace = localStorage.getItem("xk_space")||"default";
126
+ function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option ${s===curSpace?"selected":""}>${esc(s)}</option>`).join(""); }
127
+ $("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; loadHistory(); };
128
+ $("#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(); };
129
+ renderSpaces();
130
+
131
+ // 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
132
+ function mdRender(src){
133
+ const lines=String(src).replace(/\r/g,"").split("\n"); const out=[]; let inCode=false,buf=[],inList=false;
134
+ 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>');
135
+ const closeL=()=>{ if(inList){out.push("</ul>");inList=false;} };
136
+ for(const ln of lines){
137
+ if(/^```/.test(ln)){ if(inCode){out.push("<pre class='code'>"+esc(buf.join("\n"))+"</pre>");buf=[];inCode=false;}else{closeL();inCode=true;} continue; }
138
+ if(inCode){ buf.push(ln); continue; }
139
+ const h=ln.match(/^(#{1,4})\s+(.*)/); if(h){ closeL(); out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`); continue; }
140
+ const li=ln.match(/^\s*(?:[-*]|\d+\.)\s+(.*)/); if(li){ if(!inList){out.push("<ul>");inList=true;} out.push("<li>"+inline(li[1])+"</li>"); continue; }
141
+ if(ln.trim()===""){ closeL(); continue; }
142
+ closeL(); out.push("<p>"+inline(ln)+"</p>");
143
+ }
144
+ closeL(); if(inCode)out.push("<pre class='code'>"+esc(buf.join("\n"))+"</pre>");
145
+ return out.join("");
146
+ }
147
+ const IMG=/\.(png|jpe?g|gif|webp|svg)$/i, MD=/\.(md|markdown)$/i, HTMLF=/\.html?$/i, JSONF=/\.json$/i;
148
+ const fileUrl=(id,path,extra="")=>"/v1/tasks/"+id+"/file?path="+encodeURIComponent(path)+"&token="+encodeURIComponent(TOKEN)+extra;
149
+ async function viewFile(id, encPath, name){
150
+ const path=decodeURIComponent(encPath); const v=$("#fview"); v.style.display="block";
151
+ const bar=`<div class="vbar">📄 ${esc(name)} · <a href="${fileUrl(id,path)}" target="_blank">開新分頁</a> · <a href="${fileUrl(id,path,'&download=1')}">下載</a></div>`;
152
+ if(IMG.test(name)){ v.innerHTML=bar+`<img class="vimg" src="${fileUrl(id,path)}">`; return; }
153
+ if(HTMLF.test(name)){ v.innerHTML=bar+`<iframe class="vframe" sandbox src="${fileUrl(id,path)}"></iframe>`; return; }
154
+ v.innerHTML=bar+`<div class="empty">載入中…</div>`;
155
+ const txt=await fetch(fileUrl(id,path)).then(r=>r.ok?r.text():null).catch(()=>null);
156
+ if(txt==null){ v.innerHTML=bar+`<div class="empty">(無法以文字呈現,請下載)</div>`; return; }
157
+ let body;
158
+ if(MD.test(name)) body=`<div class="md">${mdRender(txt)}</div>`;
159
+ 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>`; } }
160
+ else body=`<pre class="viewer-pre">${esc(txt)}</pre>`;
161
+ v.innerHTML=bar+body;
162
+ }
163
+
108
164
  let activeId = null, polling = null;
109
165
 
110
166
  $("#go").onclick = async () => {
111
167
  const goal = $("#goal").value.trim(); if (!goal) return;
112
168
  $("#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());
169
+ const r = await api("/v1/tasks", { method:"POST", body: JSON.stringify({ pack:$("#pack").value, mode:"goal", goal, workspace: curSpace }) }).then(r=>r.json());
114
170
  $("#go").disabled = false;
115
171
  if (r.error) { alert(r.error); return; }
116
172
  $("#goal").value = "";
@@ -137,7 +193,7 @@ async function poll() {
137
193
  function renderCurrent(t) {
138
194
  const a = t.result?.artifacts, files = a ? [...(a.created||[]).map(f=>[f,"new"]), ...(a.modified||[]).map(f=>[f,"mod"])] : [];
139
195
  $("#current").innerHTML = `<div class="card">
140
- <div class="goal">${esc(t.goal||"任務")}</div>
196
+ <div class="goal">${esc(t.goal||"任務")}<span class="wsbadge">📁 ${esc(t.workspace||"default")}</span></div>
141
197
  <span class="status ${statusClass(t.status)}">${statusText(t.status)}${t.rounds?` · ${t.rounds} 輪`:""}</span>
142
198
  ${CANCELLABLE.includes(t.status)?`<button class="cancel" onclick="cancelTask('${t.taskId}')">停止</button>`:""}
143
199
  ${t.status==="running"||t.status==="queued"?progressHtml(t):""}
@@ -146,8 +202,9 @@ function renderCurrent(t) {
146
202
  <input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
147
203
  ${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</div>`:""}
148
204
  ${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>
205
+ ${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>`:""}
206
+ ${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>`:""}
207
+ <div class="viewer" id="fview" style="display:none"></div>
151
208
  </div>`;
152
209
  if (t.status==="needs-input") {
153
210
  const inp = $("#ans"); inp.focus();
@@ -159,15 +216,9 @@ function renderCurrent(t) {
159
216
  }
160
217
  }
161
218
 
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
219
  async function loadHistory() {
169
220
  const r = await api("/v1/tasks").then(r=>r.json());
170
- const list = (r.tasks||[]).filter(t=>t.mode==="goal").reverse();
221
+ const list = (r.tasks||[]).filter(t=>t.mode==="goal" && (t.workspace||"default")===curSpace).reverse();
171
222
  $("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
172
223
  <div class="g">${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
173
224
  <div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
@@ -45,7 +45,7 @@ function loadContextFiles(cwd, names) {
45
45
  }
46
46
 
47
47
  // 交付物偵測:掃工作目錄前後快照,diff 出「產出/改動的檔案」(pack 無關,連 bash 寫的也抓得到)。
48
- const SKIP_SCAN = new Set(['.xitto-kernel', 'node_modules', '.git', '.swebench-repos', '.xitto-server']);
48
+ const SKIP_SCAN = new Set(['.xitto-kernel', 'node_modules', '.git', '.swebench-repos', '.xitto-server', 'tmp']);
49
49
  function scanWorkdir(dir, base = dir, acc = new Map(), depth = 0) {
50
50
  if (depth > 8 || acc.size > 5000) return acc;
51
51
  let entries; try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return acc; }
@@ -72,6 +72,9 @@ const DEFAULT_PLAYBOOK_GUIDE =
72
72
  const DEFAULT_EPISODE_GUIDE =
73
73
  '完成有價值的任務後,用 episode_record 記一筆情節(做了什麼+結果+tags);遇到相似任務時系統會自動召回最相關的幾筆供參考,也可主動用 episode_recall 查。';
74
74
 
75
+ const DEFAULT_OUTPUT_GUIDE =
76
+ '產出檔案時:最終成品放工作目錄根、用清楚好懂的檔名(如 report.md、budget.csv,別用 tmp_3.txt);中間/暫存檔(下載、草稿、解壓內容、爬到的原始資料)一律放 tmp/ 目錄——那是過程檔,不算成品也可能被清掉。';
77
+
75
78
  // 把 sandboxable 工具的命令在執行期包進 Seatbelt(macOS OS 級隔離)。
76
79
  // 非 macOS / 沙箱關閉 / 無 command → wrapWithSeatbelt 回 null,跑原命令(仍受第 5 格靜態策略保護)。
77
80
  function wrapSandboxable(tool, { cwd, getSandbox, getSandboxConfig }) {
@@ -221,6 +224,7 @@ export function createKernel(pack, config = {}) {
221
224
  pack.systemPrompt +
222
225
  loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
223
226
  '\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE + '\n' + DEFAULT_EPISODE_GUIDE +
227
+ '\n\n# 成品與暫存\n' + DEFAULT_OUTPUT_GUIDE +
224
228
  (memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
225
229
  (pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
226
230
  (askUserTool ? '\n\n# 詢問\n盡量自主完成目標。只在缺少關鍵資訊、無法合理推斷、或決策會明顯改變結果時,才用 ask_user 問使用者;能用合理預設就別問。' : '') +