xitto-kernel 0.9.4 → 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,14 @@
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
+
3
12
  ## 0.9.4
4
13
 
5
14
  - **執行中可中途補充(steering)**:任務跑到一半,使用者可隨時插話調整方向/補需求,不必取消重來。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xitto-kernel",
3
- "version": "0.9.4",
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);
@@ -330,6 +375,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
330
375
  // 同步:跑完才回(JSON 或 SSE 串流)
331
376
  if (req.method === 'POST' && (path === '/v1/run' || path === '/v1/stream')) {
332
377
  const body = await readBody(req);
378
+ if (!body.pack || body.pack === 'auto') body.pack = await classifyPack(body.goal || body.input || '', { model, getApiKey }); // 自動分流
333
379
  const streaming = path === '/v1/stream';
334
380
  if (streaming) sseHead(res);
335
381
  const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
@@ -348,12 +394,15 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
348
394
  // 背景任務:立刻回 taskId,後台跑,完成發 webhook
349
395
  if (req.method === 'POST' && path === '/v1/tasks') {
350
396
  const body = await readBody(req);
351
- 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(', ')}` });
352
401
  if (body.webhook && !/^https?:\/\//.test(body.webhook)) return json(res, 400, { error: 'webhook 需為 http(s) URL' });
353
402
  if (local && body.workspace && isAbsolute(body.workspace) && !existsSync(body.workspace)) return json(res, 400, { error: `資料夾不存在:${body.workspace}` });
354
- 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 });
355
- log({ task: t.id, action: 'enqueue', pack: body.pack || 'general', mode: body.mode || 'turn' });
356
- 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() });
357
406
  }
358
407
  if (req.method === 'GET' && path === '/v1/tasks') return json(res, 200, { tasks: tasks.list(), ...tasks.stats() });
359
408
 
@@ -152,6 +152,7 @@
152
152
  <span class="askhint">⌘ / Ctrl + Enter 送出</span>
153
153
  <button id="go">交辦 →</button>
154
154
  </div>
155
+ <div id="routehint" class="askhint" style="margin-top:8px;display:none"></div>
155
156
  </div>
156
157
 
157
158
  <div class="layout">
@@ -198,7 +199,9 @@ setInterval(() => { if (liveTask && (liveTask.status==="running"||liveTask.statu
198
199
 
199
200
  // 領域選單(非技術使用者預設「通用」)
200
201
  const LABELS = { general:"通用", coding:"程式", "data-query":"查資料", notes:"筆記", "deep-research":"研究", devops:"維運" };
201
- $("#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("");
202
205
 
203
206
  // 專案/空間(對應 Claude Code 的「目錄」,但可選+命名+有預設;不同空間的檔案與沉澱各自獨立)
204
207
  let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
@@ -308,10 +311,16 @@ let activeId = null, polling = null;
308
311
 
309
312
  $("#go").onclick = async () => {
310
313
  const goal = $("#goal").value.trim(); if (!goal) return;
314
+ const picked = $("#pack").value;
311
315
  $("#go").disabled = true;
312
- 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());
313
318
  $("#go").disabled = false;
314
- 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";
315
324
  $("#goal").value = "";
316
325
  $("#fview").style.display="none"; expandedLog=false;
317
326
  activeId = r.taskId;
@@ -342,7 +351,7 @@ function renderCurrent(t) {
342
351
  const p = t.progress||{}, nLog=(p.log||[]).length;
343
352
  const logSec = nLog ? `<div class="logtoggle" onclick="toggleLog()">${expandedLog?"▾ 收合過程":"▸ 展開過程("+nLog+" 步)"}</div>${expandedLog?`<div class="loglist">${logHtml(p)}</div>`:""}` : "";
344
353
  $("#current").innerHTML = `<div class="card">
345
- <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>
346
355
  <span class="status ${statusClass(t.status)}">${statusText(t.status)}${t.rounds?` · ${t.rounds} 輪`:""}</span>
347
356
  ${CANCELLABLE.includes(t.status)?`<button class="cancel" onclick="cancelTask('${t.taskId}')">停止</button>`:""}
348
357
  ${t.status==="running"||t.status==="queued"?progressHtml(t):""}