xitto-kernel 0.9.3 → 0.9.5

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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.5
4
+
5
+ - **領域自動判斷(auto-routing)**:非技術使用者不必懂「該選哪個 pack」——預設「🪄 自動判斷領域」,系統依願望文字自動挑最適合的領域,並顯示「已自動用『研究』領域」+ 可在下拉覆蓋。
6
+ - **LLM 為主**(一次輕量 `completeSimple` 分流呼叫,maxTokens 12)+ **關鍵字 heuristic 備援**(LLM 不可用/逾時/回垃圾/拋錯都不炸);**任何不確定一律 general**(最通用,涵蓋八成)。
7
+ - 資源型領域(data-query 需 DB、notes 需筆記庫)只在明確訊號才選,避免誤分流到跑不起來的領域;分流有 6s 逾時,不拖慢交辦。
8
+ - 後端:`classifyPack` / `heuristicPack`(可注入 `complete` 測試);`POST /v1/tasks` 與 `/v1/run`、`/v1/stream` 支援 `pack:"auto"`,回應帶 `pack`+`routed`;任務 view 帶 `auto`。
9
+ - 許願台:領域下拉預設「自動」,交辦後顯示判定結果,任務卡有領域徽章(🪄自動/🧭指定)。
10
+ - 測試 +6(202/202):heuristic 各領域、LLM 採用/別名/落備援/拋錯不炸/不打 LLM 的捷徑。
11
+
12
+ ## 0.9.4
13
+
14
+ - **執行中可中途補充(steering)**:任務跑到一半,使用者可隨時插話調整方向/補需求,不必取消重來。
15
+ - 排隊注入,**不中斷正在跑的工具**——下一個邊界才生效:
16
+ - agent 串流中 → 即時排進 agent 的 steeringQueue(turn 邊界 drain)
17
+ - 回合之間(goal loop 的驗收空檔,agent 已收尾)→ 緩衝到 task,kernel 下一輪用 `drainSteer` 折進指令
18
+ - 兩路互斥,不重複套用
19
+ - 後端:`createTaskStore.steer(id,text)` + `POST /v1/tasks/:id/steer`;kernel `runGoal` 新增 `opts.drainSteer` 鉤子
20
+ - 許願台:進行中顯示補充輸入框(Enter 送出)+「✋ 已收到,會在下一步納入」回饋;輸入內容/游標在每 1.2s 輪詢重繪間保留,不洗掉打到一半的字
21
+ - 測試 +4(196/196):串流即時路徑、回合間緩衝/drain-once、非進行中擋下、drainSteer 折進指令
22
+
3
23
  ## 0.9.3
4
24
 
5
25
  - **許願台佈局優化(視覺層次 + 互動細節)**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.9.3",
3
+ "version": "0.9.5",
4
4
  "type": "module",
5
5
  "description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
6
6
  "keywords": [
package/src/app/server.js CHANGED
@@ -7,7 +7,9 @@ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync, readdirSync
7
7
  import { join, dirname, isAbsolute, relative, basename, resolve } from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
  import { homedir } from 'node:os';
10
+ import { completeSimple } from '@mariozechner/pi-ai';
10
11
  import { createKernel } from '../kernel/index.js';
12
+ import { cacheRetentionFor } from '../kernel/provider.js';
11
13
  import { loadModel } from './providers.js';
12
14
  import { createCodingPack } from '../packs/coding/index.js';
13
15
  import { createDataQueryPack } from '../packs/data-query/index.js';
@@ -24,6 +26,49 @@ const PACKS = {
24
26
  const lastText = (history) => ([...(history || [])].reverse().find((m) => m.role === 'assistant')?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
25
27
  const newId = (p = 's') => p + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
26
28
 
29
+ // 任務自動分流:非技術使用者不必懂「領域」,依願望文字自動挑最適合的 pack。
30
+ // LLM 為主、關鍵字 heuristic 為備援/逾時保險;任何不確定一律回 general(最通用,涵蓋八成需求)。
31
+ // 資源型 pack(data-query 需 DB、notes 需筆記庫)只在明確訊號才選,避免誤分流到跑不起來的領域。
32
+ const ROUTE_GUIDE =
33
+ 'general:通用(預設)。上網查資料、讀寫檔案、跑小腳本、串 API 的一般任務。不確定就選這個。\n' +
34
+ 'coding:改既有程式專案/repo——修 bug、跑測試、git。\n' +
35
+ 'deep-research:一個主題查多個來源、查證後寫成研究報告。\n' +
36
+ 'data-query:對 SQLite 資料庫下 SQL 撈數據——僅當明確提到資料庫/SQL/.db 才選。\n' +
37
+ 'notes:管理筆記知識庫——僅當明確提到筆記才選。\n' +
38
+ 'devops:伺服器維運/部署/docker/CI/常駐服務。';
39
+
40
+ // 關鍵字快速判斷(LLM 不可用/逾時時的備援;命中強訊號才回領域,否則 null→general)。
41
+ export function heuristicPack(goal) {
42
+ const g = String(goal || '').toLowerCase();
43
+ if (/(sqlite|資料庫|database|\.db\b|撈數據|查詢資料表|\bsql\b|select\s+\*)/.test(g)) return 'data-query';
44
+ if (/(部署|deploy|docker|kubernetes|k8s|nginx|ci\/cd|systemd|伺服器維運)/.test(g)) return 'devops';
45
+ if (/(筆記本?|\bnotes?\b)/.test(g)) return 'notes';
46
+ if (/(研究報告|深度研究|多來源|文獻|綜述|市場調查|競品分析|deep\s*research)/.test(g)) return 'deep-research';
47
+ if (/(修\s*bug|debug|重構|refactor|單元測試|unit\s*test|程式碼|codebase|\brepo\b|git\s*commit|pull\s*request|\.(js|ts|jsx|tsx|py|go|rs|java|cpp?|rb|php)\b)/.test(g)) return 'coding';
48
+ return null;
49
+ }
50
+
51
+ // 回傳最適合的 pack 名(一定是 PACKS 內的合法 key)。complete 可注入(測試用),預設用 pi-ai completeSimple。
52
+ export async function classifyPack(goal, { model, getApiKey, complete = completeSimple } = {}) {
53
+ const fallback = heuristicPack(goal) || 'general';
54
+ if (!model || !getApiKey || !String(goal || '').trim()) return fallback;
55
+ const ac = new AbortController();
56
+ const timer = setTimeout(() => ac.abort(), 6000); // 分流不該拖慢交辦:逾時就用 heuristic
57
+ try {
58
+ const apiKey = await getApiKey(model.provider);
59
+ if (!apiKey) return fallback;
60
+ const ctx = {
61
+ systemPrompt: '你是任務分流器。把使用者的需求分到最適合的「領域」,只輸出一個領域代號(general/coding/deep-research/data-query/notes/devops)其中之一,不要解釋、不要標點。\n領域說明:\n' + ROUTE_GUIDE,
62
+ messages: [{ role: 'user', content: [{ type: 'text', text: `需求:${String(goal).slice(0, 600)}\n\n領域代號是?` }], timestamp: Date.now() }],
63
+ };
64
+ const res = await complete(model, ctx, { maxTokens: 12, apiKey, signal: ac.signal, cacheRetention: cacheRetentionFor(model) });
65
+ const t = (res?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('').toLowerCase();
66
+ const hit = Object.keys(PACKS).find((p) => t.includes(p)) || (t.includes('research') ? 'deep-research' : null);
67
+ return hit || fallback;
68
+ } catch { return fallback; }
69
+ finally { clearTimeout(timer); }
70
+ }
71
+
27
72
  // 交付檔案的 content-type(讓圖片能顯示、md/html 能渲染、其餘可下載)。
28
73
  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' };
29
74
  export function contentTypeFor(name) { const ext = (String(name).split('.').pop() || '').toLowerCase(); return MIME[ext] || 'application/octet-stream'; }
@@ -120,7 +165,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
120
165
  }
121
166
  }
122
167
 
123
- 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, continued: !!t.spec.sessionId, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
168
+ const view = (t) => ({ taskId: t.id, status: t.status, pack: t.spec.pack || 'general', auto: !!t.spec.auto, 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, continued: !!t.spec.sessionId, createdAt: t.createdAt, startedAt: t.startedAt, finishedAt: t.finishedAt, error: t.error, pending: t.pending || null, progress: t.progress || null });
124
169
 
125
170
  const emit = (t, ev) => {
126
171
  t.events.push(ev);
@@ -139,6 +184,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
139
184
  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); }
140
185
  else if (ev.type === 'round') { p.round = ev.round; if (ev.maxRounds) p.maxRounds = ev.maxRounds; p.thinking = ''; t._textbuf = ''; }
141
186
  else if (ev.type === 'phase') p.phase = ev.phase;
187
+ else if (ev.type === 'steered') { (p.steers ||= []).push(ev.text); if (p.steers.length > 8) p.steers.shift(); } // 使用者中途補充(給 UI 回饋「已收到」)
142
188
  else if (ev.type === 'needs_input') p.phase = 'needs-input';
143
189
  else if (ev.type === 'answered') p.phase = 'acting';
144
190
  else if (ev.type === 'end') p.phase = ev.status;
@@ -160,7 +206,7 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
160
206
  t.status = 'running'; t.startedAt = new Date().toISOString();
161
207
  emit(t, { type: 'status', status: 'running' });
162
208
  Promise.resolve()
163
- .then(() => runJob(t.spec, (ev) => emit(t, ev), makeAsk(t), (agent) => { t._agent = agent; }))
209
+ .then(() => runJob(t.spec, (ev) => emit(t, ev), makeAsk(t), (agent) => { t._agent = agent; }, () => { const b = t.steerBuf || []; t.steerBuf = []; return b; }))
164
210
  .then((result) => { t.status = 'done'; t.result = result; })
165
211
  .catch((e) => { t.status = 'error'; t.error = e.message || String(e); })
166
212
  .finally(() => {
@@ -214,6 +260,19 @@ export function createTaskStore({ runJob, concurrency = 2, onFinish, maxEvents =
214
260
  resolve(String(text ?? ''));
215
261
  return true;
216
262
  },
263
+ // 中途補充(steering):任務進行中,使用者插話。agent 正在串流 → 即時排進 steeringQueue(下個 turn 邊界生效,不中斷當前工具);
264
+ // 回合之間(goal loop 的 checkGoal 空檔,agent 已收尾)→ 緩衝到 task,由 kernel 下一輪 drainSteer 折進指令。兩路互斥,不重複套用。
265
+ steer(id, text) {
266
+ const t = tasks.get(id);
267
+ if (!t || t.status !== 'running') return false;
268
+ const msg = String(text ?? '').trim();
269
+ if (!msg) return false;
270
+ const live = !!(t._agent && t._agent.state && t._agent.state.isStreaming);
271
+ if (live) { try { t._agent.steer({ role: 'user', content: [{ type: 'text', text: msg }] }); } catch { (t.steerBuf ||= []).push(msg); } }
272
+ else (t.steerBuf ||= []).push(msg);
273
+ emit(t, { type: 'steered', text: msg, queued: !live });
274
+ return true;
275
+ },
217
276
  stats: () => ({ active, queued: queue.length, total: tasks.size }),
218
277
  };
219
278
  }
@@ -257,7 +316,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
257
316
 
258
317
  // 共用:跑一輪/一目標,回傳 { sessionId, text, usage, rounds, done };onEvent 收原始 kernel 事件;
259
318
  // ask(可選)= 澄清通道,讓 agent 在背景任務中暫停問使用者。
260
- async function runKernel(spec, onEvent, ask, onAgent) {
319
+ async function runKernel(spec, onEvent, ask, onAgent, drainSteer) {
261
320
  const make = PACKS[spec.pack || 'general'];
262
321
  if (!make) throw new Error(`未知 pack「${spec.pack}」,可用:${Object.keys(PACKS).join(', ')}`);
263
322
  // 持久工作空間(B 模型):workdir 綁 workspace(非 sessionId)→ 檔案留存 + 五層沉澱跨成品累積。
@@ -272,7 +331,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
272
331
  const wrapped = (ev) => { if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; } onEvent?.(ev); };
273
332
  if (spec.mode === 'goal') {
274
333
  // 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
275
- const o = await kernel.runOutcome(spec.goal || spec.input || "", { maxRounds: 8, history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
334
+ const o = await kernel.runOutcome(spec.goal || spec.input || "", { maxRounds: 8, history: sess.history, onEvent: wrapped, onAgent, drainSteer, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
276
335
  sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
277
336
  try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
278
337
  // 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
@@ -295,7 +354,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
295
354
  const tasks = createTaskStore({
296
355
  concurrency,
297
356
  persistDir: join(baseDir, 'tasks'),
298
- runJob: (spec, emit, ask, onAgent) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent),
357
+ runJob: (spec, emit, ask, onAgent, drainSteer) => runKernel(spec, (ev) => { const m = mapEvent(ev); if (m) emit(m); }, ask, onAgent, drainSteer),
299
358
  onFinish: (task) => { log({ task: task.id, pack: task.spec.pack, mode: task.spec.mode || 'turn', status: task.status, ms: task.startedAt ? Date.parse(task.finishedAt) - Date.parse(task.startedAt) : 0 }); fireWebhook(task); },
300
359
  });
301
360
 
@@ -316,6 +375,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
316
375
  // 同步:跑完才回(JSON 或 SSE 串流)
317
376
  if (req.method === 'POST' && (path === '/v1/run' || path === '/v1/stream')) {
318
377
  const body = await readBody(req);
378
+ if (!body.pack || body.pack === 'auto') body.pack = await classifyPack(body.goal || body.input || '', { model, getApiKey }); // 自動分流
319
379
  const streaming = path === '/v1/stream';
320
380
  if (streaming) sseHead(res);
321
381
  const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
@@ -334,12 +394,15 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
334
394
  // 背景任務:立刻回 taskId,後台跑,完成發 webhook
335
395
  if (req.method === 'POST' && path === '/v1/tasks') {
336
396
  const body = await readBody(req);
337
- if (!PACKS[body.pack || 'general']) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
397
+ // 自動分流:pack 省略或為 'auto' 依願望文字挑領域(非技術使用者不必懂領域)。
398
+ let pack = body.pack; let routed = false;
399
+ if (!pack || pack === 'auto') { pack = await classifyPack(body.goal || body.input || '', { model, getApiKey }); routed = true; }
400
+ if (!PACKS[pack]) return json(res, 400, { error: `未知 pack「${body.pack}」,可用:${Object.keys(PACKS).join(', ')}` });
338
401
  if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
339
402
  if (local && body.workspace && isAbsolute(body.workspace) && !existsSync(body.workspace)) return json(res, 400, { error: `資料夾不存在:${body.workspace}` });
340
- 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 });
341
- log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
342
- return json(res, 202, { taskId: t.id, status: t.status, ...tasks.stats() });
403
+ const t = tasks.enqueue({ pack, mode: body.mode, input: body.input, goal: body.goal, sessionId: body.sessionId, webhook: body.webhook, workspace: body.workspace, auto: routed });
404
+ log({ task: t.id, action: 'enqueue', pack, routed, mode: body.mode || 'turn' });
405
+ return json(res, 202, { taskId: t.id, status: t.status, pack, routed, ...tasks.stats() });
343
406
  }
344
407
  if (req.method === 'GET' && path === '/v1/tasks') return json(res, 200, { tasks: tasks.list(), ...tasks.stats() });
345
408
 
@@ -359,6 +422,19 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
359
422
  return json(res, 200, { ok: true, taskId: mAns[1], status: 'running' });
360
423
  }
361
424
 
425
+ // 中途補充(steering):任務進行中,使用者插話調整方向/補需求。排隊注入,不中斷當前工作,下個邊界生效。
426
+ const mSteer = path.match(/^\/v1\/tasks\/([^/]+)\/steer$/);
427
+ if (req.method === 'POST' && mSteer) {
428
+ const body = await readBody(req);
429
+ const t = tasks.get(mSteer[1]);
430
+ if (!t) return json(res, 404, { error: 'task not found' });
431
+ if (t.status !== 'running') return json(res, 409, { error: '只有進行中的任務可以補充', status: t.status });
432
+ const ok = tasks.steer(mSteer[1], body.text);
433
+ if (!ok) return json(res, 400, { error: '補充內容為空或無法送出' });
434
+ log({ task: mSteer[1], action: 'steer' });
435
+ return json(res, 200, { ok: true, taskId: mSteer[1] });
436
+ }
437
+
362
438
  // 中斷任務(取消鈕):控制權在使用者手上,降低「啟動了控制不了的東西」的焦慮
363
439
  const mCancel = path.match(/^\/v1\/tasks\/([^/]+)\/cancel$/);
364
440
  if (req.method === 'POST' && mCancel) {
@@ -436,7 +512,7 @@ export function startServer() {
436
512
  console.log(`🪄 許願台:http://localhost:${port}/ (瀏覽器打開即用——說出目標、交付成品)`);
437
513
  console.log(`xitto-kernel server · model ${model.id} · 沙箱 ${sandbox ? '開' : '關'} · 背景並發 ${concurrency}${local ? ' · 本地模式(顯示檔案位置)' : ''}`);
438
514
  console.log(`token: ${token === 'dev-token' ? 'dev-token(請設 XITTO_SERVER_TOKEN)' : '(已設定)'}`);
439
- console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
515
+ console.log('API:POST /v1/run · /v1/stream · /v1/tasks · /v1/tasks/:id/{answer,steer,cancel}|GET /v1/tasks[/:id[/events|/file]] · /health');
440
516
  });
441
517
  return server;
442
518
  }
@@ -79,6 +79,10 @@
79
79
  .qbox { margin-top:12px; padding:12px; background:#1d1c12; border:1px solid var(--warn); border-radius:10px; }
80
80
  .qbox .q { color:var(--warn); margin-bottom:8px; }
81
81
  .qbox input { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font:inherit; }
82
+ .steerbox { margin-top:12px; padding-top:10px; border-top:1px dashed var(--line); }
83
+ .steernote { font-size:12.5px; color:var(--ok); margin-bottom:6px; }
84
+ #steerin { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px 11px; font:inherit; outline:none; }
85
+ #steerin:focus { border-color:var(--accent); }
82
86
  h3 { color:var(--dim); font-size:13px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin:28px 0 8px; }
83
87
  .hist { border:1px solid transparent; border-radius:8px; padding:8px 10px; margin:3px 0; cursor:pointer; }
84
88
  .hist:hover { background:#0c0e12; }
@@ -148,6 +152,7 @@
148
152
  <span class="askhint">⌘ / Ctrl + Enter 送出</span>
149
153
  <button id="go">交辦 →</button>
150
154
  </div>
155
+ <div id="routehint" class="askhint" style="margin-top:8px;display:none"></div>
151
156
  </div>
152
157
 
153
158
  <div class="layout">
@@ -194,7 +199,9 @@ setInterval(() => { if (liveTask && (liveTask.status==="running"||liveTask.statu
194
199
 
195
200
  // 領域選單(非技術使用者預設「通用」)
196
201
  const LABELS = { general:"通用", coding:"程式", "data-query":"查資料", notes:"筆記", "deep-research":"研究", devops:"維運" };
197
- $("#pack").innerHTML = PACKS.map(p=>`<option value="${p}" ${p==="general"?"selected":""}>${LABELS[p]||p}</option>`).join("");
202
+ const packLabel = (p) => LABELS[p] || p;
203
+ // 預設「自動判斷」:使用者不必懂領域,系統依願望文字挑;要手動指定可在此覆蓋
204
+ $("#pack").innerHTML = `<option value="auto" selected>🪄 自動判斷領域</option>` + PACKS.map(p=>`<option value="${p}">${packLabel(p)}</option>`).join("");
198
205
 
199
206
  // 專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設;不同空間的檔案與沉澱各自獨立)
200
207
  let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
@@ -304,10 +311,16 @@ let activeId = null, polling = null;
304
311
 
305
312
  $("#go").onclick = async () => {
306
313
  const goal = $("#goal").value.trim(); if (!goal) return;
314
+ const picked = $("#pack").value;
307
315
  $("#go").disabled = true;
308
- const r = await api("/v1/tasks", { method:"POST", body: JSON.stringify({ pack:$("#pack").value, mode:"goal", goal, workspace: curSpace }) }).then(r=>r.json());
316
+ if (picked==="auto") { const rh=$("#routehint"); rh.style.display=""; rh.textContent="🪄 判斷適合的領域…"; }
317
+ const r = await api("/v1/tasks", { method:"POST", body: JSON.stringify({ pack:picked, mode:"goal", goal, workspace: curSpace }) }).then(r=>r.json());
309
318
  $("#go").disabled = false;
310
- if (r.error) { alert(r.error); return; }
319
+ if (r.error) { alert(r.error); $("#routehint").style.display="none"; return; }
320
+ // 自動分流回饋:告訴使用者這次用了哪個領域,要改可在上面下拉覆蓋後重交辦
321
+ const rh=$("#routehint");
322
+ if (r.routed && r.pack) { rh.style.display=""; rh.innerHTML=`🪄 已自動用「<b>${esc(packLabel(r.pack))}</b>」領域 · 不對的話可在上方下拉改選領域再交辦`; }
323
+ else rh.style.display="none";
311
324
  $("#goal").value = "";
312
325
  $("#fview").style.display="none"; expandedLog=false;
313
326
  activeId = r.taskId;
@@ -332,11 +345,13 @@ async function poll() {
332
345
  }
333
346
 
334
347
  function renderCurrent(t) {
348
+ // 保住 steer 輸入框的內容與游標:輪詢每 1.2s 重繪整張卡,別把使用者正打到一半的補充洗掉
349
+ const ps=(()=>{const el=document.getElementById("steerin");return el?{v:el.value,f:document.activeElement===el,s:el.selectionStart}:null;})();
335
350
  const a = t.result?.artifacts, files = a ? [...(a.created||[]).map(f=>[f,"new"]), ...(a.modified||[]).map(f=>[f,"mod"])] : [];
336
351
  const p = t.progress||{}, nLog=(p.log||[]).length;
337
352
  const logSec = nLog ? `<div class="logtoggle" onclick="toggleLog()">${expandedLog?"▾ 收合過程":"▸ 展開過程("+nLog+" 步)"}</div>${expandedLog?`<div class="loglist">${logHtml(p)}</div>`:""}` : "";
338
353
  $("#current").innerHTML = `<div class="card">
339
- <div class="goal">${esc(t.goal||"任務")}<span class="wsbadge">📁 ${esc(t.workspace||"default")}</span></div>
354
+ <div class="goal">${esc(t.goal||"任務")}<span class="wsbadge">📁 ${esc(t.workspace||"default")}</span>${t.pack?`<span class="wsbadge" title="${t.auto?"自動判斷的領域":"指定的領域"}">${t.auto?"🪄":"🧭"} ${esc(packLabel(t.pack))}</span>`:""}</div>
340
355
  <span class="status ${statusClass(t.status)}">${statusText(t.status)}${t.rounds?` · ${t.rounds} 輪`:""}</span>
341
356
  ${CANCELLABLE.includes(t.status)?`<button class="cancel" onclick="cancelTask('${t.taskId}')">停止</button>`:""}
342
357
  ${t.status==="running"||t.status==="queued"?progressHtml(t):""}
@@ -344,6 +359,10 @@ function renderCurrent(t) {
344
359
  ${logSec}
345
360
  ${t.status==="needs-input"?`<div class="qbox"><div class="q">❓ ${esc(t.pending?.question)}</div>
346
361
  <input id="ans" placeholder="輸入你的回答,按 Enter 送出"></div>`:""}
362
+ ${t.status==="running"?`<div class="steerbox">
363
+ ${(p.steers||[]).slice(-3).map(s=>`<div class="steernote">✋ 已收到補充,會在下一步納入:${esc(s)}</div>`).join("")}
364
+ <input id="steerin" placeholder="想補充或調整方向?打字按 Enter——會在下一步納入,不中斷目前工作">
365
+ </div>`:""}
347
366
  ${t.status==="done"?`<div class="summary">${esc(t.result?.text||"")}</div>`:""}
348
367
  ${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
349
368
  ${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>`:""}
@@ -364,6 +383,17 @@ function renderCurrent(t) {
364
383
  poll();
365
384
  }};
366
385
  }
386
+ if (t.status==="running") {
387
+ const si = $("#steerin");
388
+ if (si) {
389
+ if (ps) { si.value = ps.v; if (ps.f) { si.focus(); try { si.setSelectionRange(ps.s, ps.s); } catch {} } } // 還原打到一半的內容/游標
390
+ si.onkeydown = async (e) => { if (e.key==="Enter" && si.value.trim()) {
391
+ const txt = si.value.trim(); si.value = ""; si.disabled = true;
392
+ await api("/v1/tasks/"+t.taskId+"/steer", { method:"POST", body: JSON.stringify({ text: txt }) });
393
+ si.disabled = false; si.focus();
394
+ }};
395
+ }
396
+ }
367
397
  }
368
398
 
369
399
  // 繼續/調整:送出接續上一個任務對話(sessionId)+ 同工作區的後續任務
@@ -442,6 +442,11 @@ export function createKernel(pack, config = {}) {
442
442
  let sameFeedback = 0;
443
443
  for (let round = 1; round <= maxRounds; round++) {
444
444
  opts.onRound?.({ round, maxRounds });
445
+ // 使用者中途補充(steering):把上一輪之間累積的補充折進這一輪指令(回合內的即時補充走 agent.steer)。
446
+ if (opts.drainSteer) {
447
+ const extra = opts.drainSteer();
448
+ if (extra && extra.length) instruction += '\n\n[使用者中途補充,請納入考量並據此調整]\n' + extra.map((s) => `- ${s}`).join('\n');
449
+ }
445
450
  const r = await api.runTurn(instruction, { history, onEvent: opts.onEvent, onAgent: opts.onAgent });
446
451
  history = r.messages;
447
452
  if (r.aborted) return { done: false, aborted: true, rounds: round, history };