xitto-kernel 0.1.0 → 0.3.0
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 +61 -0
- package/README.md +51 -9
- package/package.json +18 -4
- package/src/app/cli.js +71 -5
- package/src/app/index.js +1 -0
- package/src/app/main.js +50 -6
- package/src/app/md-render.js +95 -0
- package/src/app/scaffold.js +14 -6
- package/src/app/server.js +102 -0
- package/src/app/templates/package.json.tmpl +1 -1
- package/src/app/tui-run.js +198 -0
- package/src/app/tui.js +398 -0
- package/src/kernel/bg.js +70 -0
- package/src/kernel/goal-loop.js +34 -0
- package/src/kernel/index.js +51 -2
- package/src/kernel/todo.js +31 -0
- package/src/packs/coding/index.js +58 -18
- package/src/packs/data-query/index.js +35 -29
- package/src/packs/deep-research/index.js +41 -0
- package/src/packs/devops/index.js +30 -0
- package/src/packs/general/index.js +100 -0
- package/src/packs/shared/code-nav.js +0 -0
- package/src/packs/shared/fs-tools.js +82 -0
- package/src/packs/shared/web-tools.js +57 -0
package/src/app/tui.js
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// Ink(終端版 React)渲染層 — 對標 Claude Code:
|
|
2
|
+
// 1) 已完成訊息進 <Static>:打一次、不重繪、隨終端捲動。
|
|
3
|
+
// 2) 串流中的回覆:已完成的「段落」即時 render markdown 提交進 <Static>(見 appendLive),
|
|
4
|
+
// 動態區只保留「正在輸入的最後一段」並每幀重解析重繪——動態區恆小,避免回覆變長後
|
|
5
|
+
// 動態區高度 ≥ 終端列數而退回 Ink 全螢幕重繪慢路徑(閃爍根因)。
|
|
6
|
+
// 3) 自訂 Input:命令歷史(↑↓)、斜線/@補全(Tab)、多行(反斜線續行/貼上)、游標編輯。
|
|
7
|
+
// 4) 輸入框永遠在最底,上方依序為輸出 / spinner / 頁腳 / 狀態列。
|
|
8
|
+
import React, { useState, useEffect } from 'react';
|
|
9
|
+
import { render, Box, Text, Static, useInput } from 'ink';
|
|
10
|
+
import { md, lexBlocks, codeChunk } from './md-render.js';
|
|
11
|
+
|
|
12
|
+
const h = React.createElement;
|
|
13
|
+
const DOT = '\x1b[32m⏺\x1b[39m'; // 綠色實心圓,標記回覆/工具(對標 Claude Code)
|
|
14
|
+
// 左側溝槽:第一行加 head 前綴(為 '' 時與其餘行一樣縮排 2 空格,用於「延續區塊」),
|
|
15
|
+
// 讓整段成為一個視覺區塊。延續區塊不再重複 ● 標記。
|
|
16
|
+
export function gutter(text, head = DOT) {
|
|
17
|
+
const pad = head ? head + ' ' : ' ';
|
|
18
|
+
return text.split('\n').map((l, i) => (i === 0 ? pad + l : ' ' + l)).join('\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 串流增量提交(對標 Claude Code):用 markdown 的 block lexer 把 live 切成頂層區塊,
|
|
22
|
+
// 提交「除最後一塊外」的完整區塊進 <Static>,最後一塊(可能還沒打完)留在動態區。
|
|
23
|
+
// 例外:最後一塊是「未閉合且超長的程式碼框」時,逐行提交已完成的程式碼行(見 store.appendLive)。
|
|
24
|
+
|
|
25
|
+
// live 是否為一個「未閉合的 fenced code block」;是則回 {lang, body}(body 為開頭 fence 之後的程式碼)。
|
|
26
|
+
// 容忍開頭的區塊間空行(前一塊提交後 raw 常殘留前導 \n)。
|
|
27
|
+
function openCodeBlock(text) {
|
|
28
|
+
const t = text.replace(/^\n+/, '');
|
|
29
|
+
const m = t.match(/^```([^\n]*)\n/);
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
if ((t.match(/```/g) || []).length !== 1) return null; // 只有開頭那組 ``` = 未閉合
|
|
32
|
+
return { lang: m[1].trim(), body: t.slice(m[0].length) };
|
|
33
|
+
}
|
|
34
|
+
// 超長程式碼框逐行提交的行數門檻:接近一屏才啟動(小/中型 code 仍正常即時渲染、含標頭)。
|
|
35
|
+
function codeFlushLines() {
|
|
36
|
+
return Math.max(12, (process.stdout?.rows || 24) - 8);
|
|
37
|
+
}
|
|
38
|
+
const INV = '\x1b[7m';
|
|
39
|
+
const INVOFF = '\x1b[27m';
|
|
40
|
+
const GRAY = (s) => `\x1b[90m${s}\x1b[39m`;
|
|
41
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
42
|
+
function waitingVerb(sec) {
|
|
43
|
+
if (sec < 3) return '思考中';
|
|
44
|
+
if (sec < 10) return '處理中';
|
|
45
|
+
if (sec < 30) return '仍在處理';
|
|
46
|
+
return '長任務處理中';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 外部 store:plain object + listeners。main() 推狀態,元件 subscribe 後 re-render。
|
|
50
|
+
export function createStore(initial = {}) {
|
|
51
|
+
let state = {
|
|
52
|
+
transcript: [], live: '', liveStarted: false, liveCodeLang: null, thinking: '', tool: null, status: '',
|
|
53
|
+
tasks: '', // 任務面板:活動區就地更新(不進 Static,避免每步更新堆重複列表)
|
|
54
|
+
mode: 'idle', planMode: false, permission: null, placeholder: '',
|
|
55
|
+
busyAt: null, // 本輪開始時間戳(ms):思考動畫 + 耗時
|
|
56
|
+
modelLabel: '', cwdLabel: '', gitLabel: '', permLabel: '', sandboxLabel: '', // 狀態列:模型/目錄/git/權限模式/沙箱
|
|
57
|
+
ctx: null, // {used,total} 上下文用量
|
|
58
|
+
atStart: true, // 起始乾淨畫面:把輸入框推到終端底部,首次送出後關閉
|
|
59
|
+
suggestions: [], // 後續追問建議(ghost text + Tab 接受)
|
|
60
|
+
selection: null, // {title, options} 方向鍵選單(權限確認 / ask_user)
|
|
61
|
+
ask: null, // {prompt} 純文字問答輸入(ask_user 的「其他(自行輸入)」)
|
|
62
|
+
...initial,
|
|
63
|
+
};
|
|
64
|
+
const listeners = new Set();
|
|
65
|
+
let id = 1;
|
|
66
|
+
let liveStarted = false; // 本段文字是否已輸出過 ● 開頭(首塊帶 ●,段內後續塊為延續、不重複 ●)
|
|
67
|
+
let codeLang = null; // 非 null=正在「逐行提交」一個超長未閉合程式碼框;此時 state.live 只存程式碼本體(無 fence)
|
|
68
|
+
let scheduled = null;
|
|
69
|
+
const emitNow = () => { scheduled = null; for (const l of listeners) l(); };
|
|
70
|
+
const emitThrottled = () => { if (!scheduled) scheduled = setTimeout(emitNow, 40); };
|
|
71
|
+
|
|
72
|
+
// 提交一個已完成區塊進 <Static>:rendered 為已渲染好的 ANSI 字串。首塊帶 ●,之後為延續。
|
|
73
|
+
const commitBlock = (rendered) => {
|
|
74
|
+
if (!rendered || !rendered.trim()) return;
|
|
75
|
+
const block = '\n' + gutter(rendered, liveStarted ? '' : DOT);
|
|
76
|
+
liveStarted = true;
|
|
77
|
+
state = { ...state, liveStarted: true, transcript: [...state.transcript, { id: id++, text: block }] };
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
get: () => state,
|
|
82
|
+
subscribe(l) { listeners.add(l); return () => listeners.delete(l); },
|
|
83
|
+
set(p) { state = { ...state, ...p }; emitNow(); },
|
|
84
|
+
pushBlock(text) {
|
|
85
|
+
if (text == null || text === '') return;
|
|
86
|
+
state = { ...state, transcript: [...state.transcript, { id: id++, text }] };
|
|
87
|
+
emitNow();
|
|
88
|
+
},
|
|
89
|
+
appendLive(s) {
|
|
90
|
+
// --- 模式 A:正在逐行提交超長未閉合程式碼框(state.live 只存程式碼本體、無 fence)---
|
|
91
|
+
if (codeLang != null) {
|
|
92
|
+
let body = state.live + s;
|
|
93
|
+
const close = body.indexOf('```'); // 收尾 fence 到了?
|
|
94
|
+
if (close !== -1) {
|
|
95
|
+
const done = body.slice(0, close).replace(/\n$/, '');
|
|
96
|
+
if (done) commitBlock(codeChunk(done, codeLang, false));
|
|
97
|
+
codeLang = null;
|
|
98
|
+
// 收尾 fence 之後的剩餘文字(去掉 fence 本身與其後換行)回到一般模式,留待下次 delta / finalize 處理
|
|
99
|
+
const rest = body.slice(close + 3).replace(/^[^\n]*\n?/, '');
|
|
100
|
+
state = { ...state, live: rest, liveCodeLang: null };
|
|
101
|
+
emitThrottled();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const lines = body.split('\n');
|
|
105
|
+
if (lines.length > codeFlushLines()) {
|
|
106
|
+
commitBlock(codeChunk(lines.slice(0, -1).join('\n'), codeLang, false)); // 提交已完成行、留最後一行(可能未打完)
|
|
107
|
+
body = lines[lines.length - 1];
|
|
108
|
+
}
|
|
109
|
+
state = { ...state, live: body, liveCodeLang: codeLang };
|
|
110
|
+
emitThrottled();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- 模式 B:一般 markdown。用 block lexer 切塊,提交除最後一塊外的所有完整區塊 ---
|
|
115
|
+
let raw = state.live + s;
|
|
116
|
+
const tokens = lexBlocks(raw);
|
|
117
|
+
if (tokens.length >= 2) {
|
|
118
|
+
let committedRaw = '';
|
|
119
|
+
for (let i = 0; i < tokens.length - 1; i++) committedRaw += tokens[i].raw;
|
|
120
|
+
if (committedRaw.trim() && raw.startsWith(committedRaw)) {
|
|
121
|
+
commitBlock(md(committedRaw));
|
|
122
|
+
raw = raw.slice(committedRaw.length);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
// 最後一塊若是「未閉合且超長」的程式碼框 → 進入模式 A,立即提交首批已完成行(含標頭)
|
|
126
|
+
const open = openCodeBlock(raw);
|
|
127
|
+
if (open && open.body.split('\n').length > codeFlushLines()) {
|
|
128
|
+
codeLang = open.lang;
|
|
129
|
+
const lines = open.body.split('\n');
|
|
130
|
+
commitBlock(codeChunk(lines.slice(0, -1).join('\n'), codeLang, true)); // 首塊含 ┌─ lang 標頭
|
|
131
|
+
state = { ...state, live: lines[lines.length - 1], liveStarted, liveCodeLang: codeLang };
|
|
132
|
+
emitThrottled();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
state = { ...state, live: raw, liveStarted };
|
|
136
|
+
emitThrottled();
|
|
137
|
+
},
|
|
138
|
+
appendThinking(s) { state = { ...state, thinking: state.thinking + s }; emitThrottled(); },
|
|
139
|
+
finalizeLive() {
|
|
140
|
+
const live = state.live;
|
|
141
|
+
const started = liveStarted;
|
|
142
|
+
const cl = codeLang;
|
|
143
|
+
liveStarted = false; codeLang = null;
|
|
144
|
+
state = { ...state, live: '', liveStarted: false, liveCodeLang: null, thinking: '' };
|
|
145
|
+
if (live.trim()) {
|
|
146
|
+
const rendered = cl != null ? codeChunk(live, cl, false) : md(live);
|
|
147
|
+
state.transcript = [...state.transcript, { id: id++, text: '\n' + gutter(rendered, started ? '' : DOT) }];
|
|
148
|
+
}
|
|
149
|
+
emitNow();
|
|
150
|
+
},
|
|
151
|
+
setTool(tool) { state = { ...state, tool }; emitNow(); },
|
|
152
|
+
setStatus(status) { state = { ...state, status }; emitNow(); },
|
|
153
|
+
setMode(mode) { state = { ...state, mode }; emitNow(); },
|
|
154
|
+
setPlan(planMode) { state = { ...state, planMode }; emitNow(); },
|
|
155
|
+
setPlaceholder(placeholder) { state = { ...state, placeholder }; emitNow(); },
|
|
156
|
+
askSelect(title, options) { state = { ...state, mode: 'select', selection: { title, options } }; emitNow(); },
|
|
157
|
+
clearSelect() { state = { ...state, mode: 'busy', selection: null }; emitNow(); },
|
|
158
|
+
askInput(prompt) { state = { ...state, mode: 'ask', ask: { prompt } }; emitNow(); },
|
|
159
|
+
clearAsk() { state = { ...state, mode: 'busy', ask: null }; emitNow(); },
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 通用方向鍵選單(↑↓ / Enter 選、Esc 取消當前提示、Ctrl+C 中斷整輪、數字鍵快選)。
|
|
164
|
+
// onCancel:取消這一次選擇(如權限確認=拒絕該工具);onAbort:中斷整個回合(Ctrl+C)。
|
|
165
|
+
const optLabel = (o) => (typeof o === 'string' ? o : (o?.label ?? String(o)));
|
|
166
|
+
export function Select({ title, options, onSelect, onCancel, onAbort }) {
|
|
167
|
+
const [index, setIndex] = useState(0);
|
|
168
|
+
useInput((input, key) => {
|
|
169
|
+
if (key.ctrl && input === 'c') { (onAbort || onCancel)(); return; } // Ctrl+C → 中斷整輪
|
|
170
|
+
if (key.escape) { onCancel(); return; } // Esc → 僅取消這次(拒絕該工具)
|
|
171
|
+
if (key.upArrow) { setIndex((i) => (i - 1 + options.length) % options.length); return; }
|
|
172
|
+
if (key.downArrow) { setIndex((i) => (i + 1) % options.length); return; }
|
|
173
|
+
if (key.return) { onSelect(index); return; }
|
|
174
|
+
const n = parseInt(input, 10);
|
|
175
|
+
if (!Number.isNaN(n) && n >= 1 && n <= options.length) { onSelect(n - 1); return; }
|
|
176
|
+
});
|
|
177
|
+
return h(
|
|
178
|
+
Box,
|
|
179
|
+
{ flexDirection: 'column', borderStyle: 'round', borderColor: 'yellow', paddingX: 1, marginTop: 1 },
|
|
180
|
+
title ? h(Text, null, title) : null,
|
|
181
|
+
...options.map((o, i) => h(Text, { key: i, color: i === index ? 'cyan' : 'gray' }, `${i === index ? '❯' : ' '} ${i + 1}. ${optLabel(o)}`)),
|
|
182
|
+
h(Text, { color: 'gray' }, ' ↑↓ 選擇 · Enter 確認 · Esc 取消此項 · Ctrl+C 中斷整輪'),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 多行 value + 游標(游標處反白)渲染成字串;空值顯示 ghost/placeholder
|
|
187
|
+
function renderValue(value, cursor, hint) {
|
|
188
|
+
if (!value) return INV + ' ' + INVOFF + GRAY(hint || '');
|
|
189
|
+
const at = value.slice(cursor, cursor + 1) || ' ';
|
|
190
|
+
return value.slice(0, cursor) + INV + at + INVOFF + value.slice(cursor + 1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 貼上折疊占位符的「結尾」樣式(退格時用來判斷游標是否落在占位符末端)
|
|
194
|
+
const PASTE_TOKEN_END = /⟦貼上#\d+·\d+行⟧$/;
|
|
195
|
+
|
|
196
|
+
// 退格邏輯:若游標正好在貼上占位符的結尾,原子刪除整個占位符(連同它代表的內容會在送出時
|
|
197
|
+
// 一併消失);否則刪一個字元。避免退格只刪掉 ⟧ 破壞占位符→送出殘缺字面、貼上內容靜默遺失。
|
|
198
|
+
// 純函數,便於單測。回傳 { value, cursor }。
|
|
199
|
+
export function backspaceAt(value, cursor) {
|
|
200
|
+
if (cursor <= 0) return { value, cursor };
|
|
201
|
+
const m = value.slice(0, cursor).match(PASTE_TOKEN_END);
|
|
202
|
+
if (m) {
|
|
203
|
+
const start = cursor - m[0].length;
|
|
204
|
+
return { value: value.slice(0, start) + value.slice(cursor), cursor: start };
|
|
205
|
+
}
|
|
206
|
+
return { value: value.slice(0, cursor - 1) + value.slice(cursor), cursor: cursor - 1 };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 自訂輸入編輯器(取代 ink-text-input,以支援歷史/補全/多行)
|
|
210
|
+
function Input({ onSubmit, onCtrlC, onEscape, getHistory, complete, placeholder, promptPrefix, promptColor, suggestions }) {
|
|
211
|
+
const [value, setValue] = useState('');
|
|
212
|
+
const [cursor, setCursor] = useState(0);
|
|
213
|
+
const [menu, setMenu] = useState(null); // {items, index, start}
|
|
214
|
+
const [histIdx, setHistIdx] = useState(-1);
|
|
215
|
+
const [draft, setDraft] = useState('');
|
|
216
|
+
const [pastes, setPastes] = useState([]); // 多行貼上折疊:value 內放占位符,送出時展開
|
|
217
|
+
// ghost 建議:輸入框為空且有建議時,淡色顯示第一條,Tab 接受
|
|
218
|
+
const ghost = (!value && suggestions && suggestions.length) ? suggestions[0] : '';
|
|
219
|
+
const expandPastes = (v) => v.replace(/⟦貼上#(\d+)·\d+行⟧/g, (m, n) => pastes[Number(n) - 1] ?? m);
|
|
220
|
+
// 依目前輸入自動更新補全選單(邊打邊彈,對標 Claude Code)。
|
|
221
|
+
// 已是完整唯一匹配(如打完 /help)則不彈,避免接受後又跳出來。
|
|
222
|
+
const refresh = (v, cur) => {
|
|
223
|
+
const res = complete ? complete(v.slice(0, cur)) : null;
|
|
224
|
+
if (!res || !res.items.length) { setMenu(null); return; }
|
|
225
|
+
const token = v.slice(res.start, cur);
|
|
226
|
+
if (res.items.length === 1 && res.items[0] === token) { setMenu(null); return; }
|
|
227
|
+
setMenu({ items: res.items, index: 0, start: res.start });
|
|
228
|
+
};
|
|
229
|
+
const put = (v, c) => { const cc = c == null ? v.length : c; setValue(v); setCursor(cc); };
|
|
230
|
+
const edit = (v, c) => { put(v, c); refresh(v, c == null ? v.length : c); }; // 編輯內容並刷新補全
|
|
231
|
+
|
|
232
|
+
useInput((input, key) => {
|
|
233
|
+
if (key.ctrl && input === 'c') { onCtrlC(); return; }
|
|
234
|
+
if (key.escape) { if (menu) { setMenu(null); return; } onEscape(); return; }
|
|
235
|
+
|
|
236
|
+
if (menu) { // 補全選單導航(開啟時 ↑↓ 走選單、Tab/Enter 接受)
|
|
237
|
+
if (key.upArrow) { setMenu({ ...menu, index: (menu.index - 1 + menu.items.length) % menu.items.length }); return; }
|
|
238
|
+
if (key.downArrow) { setMenu({ ...menu, index: (menu.index + 1) % menu.items.length }); return; }
|
|
239
|
+
if (key.tab || key.return) { const pick = menu.items[menu.index]; edit(value.slice(0, menu.start) + (typeof pick === 'string' ? pick : pick.value)); return; }
|
|
240
|
+
// 其他鍵:先關選單,落到下面照常處理(並會在編輯後重新刷新)
|
|
241
|
+
setMenu(null);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (key.tab && ghost) { edit(ghost, ghost.length); return; } // Tab 接受 ghost 建議
|
|
245
|
+
if (key.tab) { refresh(value, cursor); return; } // 選單未開時 Tab 也可手動觸發
|
|
246
|
+
if (key.return) {
|
|
247
|
+
// Shift+Enter / Option(Alt)+Enter → 在游標處換行(多行輸入),不送出
|
|
248
|
+
if (key.shift || key.meta) { edit(value.slice(0, cursor) + '\n' + value.slice(cursor), cursor + 1); return; }
|
|
249
|
+
if (value.endsWith('\\')) { edit(value.slice(0, -1) + '\n'); return; } // 反斜線續行
|
|
250
|
+
const v = expandPastes(value); // 送出前把貼上占位符還原成原文
|
|
251
|
+
setValue(''); setCursor(0); setMenu(null); setHistIdx(-1); setDraft(''); setPastes([]);
|
|
252
|
+
onSubmit(v); return;
|
|
253
|
+
}
|
|
254
|
+
if (key.upArrow) {
|
|
255
|
+
const hist = getHistory(); if (!hist.length) return;
|
|
256
|
+
let i = histIdx; if (i === -1) { setDraft(value); i = 0; } else if (i < hist.length - 1) i++;
|
|
257
|
+
setHistIdx(i); put(hist[hist.length - 1 - i]); return;
|
|
258
|
+
}
|
|
259
|
+
if (key.downArrow) {
|
|
260
|
+
if (histIdx === -1) return;
|
|
261
|
+
const hist = getHistory(); const i = histIdx - 1;
|
|
262
|
+
if (i < 0) { setHistIdx(-1); put(draft); return; }
|
|
263
|
+
setHistIdx(i); put(hist[hist.length - 1 - i]); return;
|
|
264
|
+
}
|
|
265
|
+
// Alt/Option + ←/→:以「詞」為單位移動游標
|
|
266
|
+
if (key.meta && key.leftArrow) { let i = cursor; while (i > 0 && /\s/.test(value[i - 1])) i--; while (i > 0 && !/\s/.test(value[i - 1])) i--; setCursor(i); return; }
|
|
267
|
+
if (key.meta && key.rightArrow) { let i = cursor; while (i < value.length && /\s/.test(value[i])) i++; while (i < value.length && !/\s/.test(value[i])) i++; setCursor(i); return; }
|
|
268
|
+
if (key.leftArrow) { setCursor(Math.max(0, cursor - 1)); return; }
|
|
269
|
+
if (key.rightArrow) { setCursor(Math.min(value.length, cursor + 1)); return; }
|
|
270
|
+
if (key.ctrl && input === 'a') { setCursor(0); return; }
|
|
271
|
+
if (key.ctrl && input === 'e') { setCursor(value.length); return; }
|
|
272
|
+
if (key.ctrl && input === 'u') { edit(value.slice(cursor), 0); return; } // 刪到行首
|
|
273
|
+
if (key.ctrl && input === 'k') { edit(value.slice(0, cursor), cursor); return; } // 刪到行尾
|
|
274
|
+
if (key.backspace || key.delete) {
|
|
275
|
+
const r = backspaceAt(value, cursor); // 游標在貼上占位符末端→原子刪整個占位符
|
|
276
|
+
if (r.value !== value || r.cursor !== cursor) edit(r.value, r.cursor);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (input) {
|
|
280
|
+
// 多行貼上:折疊成占位符 ⟦貼上#n·N行⟧,不撐爆輸入框;送出時展開(對標 Claude Code)
|
|
281
|
+
if (input.includes('\n') && input.length > 1) {
|
|
282
|
+
const id = pastes.length + 1;
|
|
283
|
+
const token = `⟦貼上#${id}·${input.split('\n').length}行⟧`;
|
|
284
|
+
setPastes((p) => [...p, input]);
|
|
285
|
+
edit(value.slice(0, cursor) + token + value.slice(cursor), cursor + token.length);
|
|
286
|
+
} else {
|
|
287
|
+
edit(value.slice(0, cursor) + input + value.slice(cursor), cursor + input.length);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// 空輸入時,ghost 建議優先顯示(附 ⇥ 提示);否則顯示 placeholder
|
|
293
|
+
const hint = ghost ? `${ghost} \x1b[2m⇥ Tab\x1b[22m` : placeholder;
|
|
294
|
+
const lines = renderValue(value, cursor, hint).split('\n');
|
|
295
|
+
return h(
|
|
296
|
+
Box,
|
|
297
|
+
{ flexDirection: 'column' },
|
|
298
|
+
h(Box, null, h(Text, { color: promptColor }, promptPrefix), h(Text, null, lines[0])),
|
|
299
|
+
...lines.slice(1).map((ln, i) => h(Text, { key: 'l' + i }, ' ' + ln)),
|
|
300
|
+
menu
|
|
301
|
+
? h(Box, { flexDirection: 'column' },
|
|
302
|
+
...menu.items.slice(0, 8).map((it, i) => {
|
|
303
|
+
const val = typeof it === 'string' ? it : it.value;
|
|
304
|
+
const desc = typeof it === 'string' ? '' : (it.desc ? ' \x1b[90m' + it.desc + '\x1b[39m' : '');
|
|
305
|
+
return h(Text, { key: 'm' + i, color: i === menu.index ? 'cyan' : 'gray' }, (i === menu.index ? '❯ ' : ' ') + val + desc);
|
|
306
|
+
}))
|
|
307
|
+
: null,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function App({ store, handlers }) {
|
|
312
|
+
const [, force] = useState(0);
|
|
313
|
+
const [tick, setTick] = useState(0);
|
|
314
|
+
useEffect(() => store.subscribe(() => force((n) => n + 1)), [store]);
|
|
315
|
+
const s = store.get();
|
|
316
|
+
|
|
317
|
+
// spinner / 耗時計時:執行中每 120ms 重繪
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (s.mode !== 'busy') return undefined;
|
|
320
|
+
const t = setInterval(() => setTick((n) => n + 1), 120);
|
|
321
|
+
return () => clearInterval(t);
|
|
322
|
+
}, [s.mode]);
|
|
323
|
+
|
|
324
|
+
// 註:終端 resize 由 index.js 處理(全清螢幕 + 重新掛載),因為 Ink 的增量清行
|
|
325
|
+
// 會用「換行數」而非「實際終端列數」計算,視窗變窄時會誤算並殘留亂碼。
|
|
326
|
+
|
|
327
|
+
const elapsed = s.busyAt ? Math.floor((Date.now() - s.busyAt) / 1000) : 0;
|
|
328
|
+
const verb = s.live ? '輸出中' : (s.tool ? '執行中' : waitingVerb(elapsed)); // 已開始輸出/工具時改措辭
|
|
329
|
+
const waiting = s.mode === 'busy'
|
|
330
|
+
? `\x1b[35m${SPINNER[tick % SPINNER.length]} ${verb}… ${elapsed}s\x1b[39m ` +
|
|
331
|
+
GRAY('· 直接打字=引導,Esc/Ctrl+C 中斷')
|
|
332
|
+
: null;
|
|
333
|
+
|
|
334
|
+
const borderColor = s.mode === 'ask' ? 'cyan' : s.mode === 'busy' ? 'magenta' : s.planMode ? 'cyan' : 'gray';
|
|
335
|
+
const promptColor = s.mode === 'ask' ? 'cyan' : s.planMode ? 'cyan' : 'green';
|
|
336
|
+
|
|
337
|
+
// 狀態列:模型 · 目錄 · [計劃] · 上下文 N%
|
|
338
|
+
let ctxPart = '';
|
|
339
|
+
if (s.ctx && s.ctx.total) {
|
|
340
|
+
const pct = Math.min(100, Math.round((s.ctx.used / s.ctx.total) * 100));
|
|
341
|
+
const col = pct >= 90 ? '31' : pct >= 70 ? '33' : '90';
|
|
342
|
+
ctxPart = ` \x1b[90m·\x1b[39m \x1b[${col}m上下文 ${pct}%\x1b[39m`;
|
|
343
|
+
}
|
|
344
|
+
const planPart = s.planMode ? ' \x1b[90m·\x1b[39m \x1b[36m[計劃]\x1b[39m' : '';
|
|
345
|
+
const permPart = s.permLabel ? ' \x1b[90m·\x1b[39m \x1b[32m' + s.permLabel + '\x1b[39m' : '';
|
|
346
|
+
const sandboxPart = s.sandboxLabel ? ' \x1b[90m·\x1b[39m \x1b[33m' + s.sandboxLabel + '\x1b[39m' : '';
|
|
347
|
+
const gitPart = s.gitLabel ? ' \x1b[90m·\x1b[39m ' + GRAY(s.gitLabel) : '';
|
|
348
|
+
const statusBar = GRAY(`${s.modelLabel} ${s.cwdLabel}`) + gitPart + planPart + permPart + sandboxPart + ctxPart;
|
|
349
|
+
|
|
350
|
+
// 不再用 spacer 把輸入框頂到屏底。原本算高度撐滿整屏,會讓「活動區」逼近終端列數;
|
|
351
|
+
// 一旦 outputHeight >= rows,Ink 改走 clearTerminal 全屏重繪慢路徑(Windows conhost 上劇烈閃爍/跳動)。
|
|
352
|
+
// 改為自頂向下自然捲動:歷史進 <Static>(不計入活動區高度),活動區恆只剩 串流/狀態/輸入 幾行,
|
|
353
|
+
// Ink 永遠走 eraseLines 增量快路徑。輸入框在內容填滿一屏後自然落到底部(對標 Claude Code)。
|
|
354
|
+
return h(
|
|
355
|
+
Box,
|
|
356
|
+
{ flexDirection: 'column' },
|
|
357
|
+
h(Static, { items: s.transcript }, (item) => h(Box, { key: item.id, flexDirection: 'column' }, h(Text, null, item.text))),
|
|
358
|
+
s.thinking ? h(Text, { color: 'gray', wrap: 'wrap' }, s.thinking) : null,
|
|
359
|
+
s.live ? h(Box, { flexDirection: 'column' }, h(Text, null, '\n' + gutter(
|
|
360
|
+
s.liveCodeLang != null ? codeChunk(s.live, s.liveCodeLang, false) : md(s.live),
|
|
361
|
+
s.liveStarted ? '' : DOT))) : null,
|
|
362
|
+
s.tool ? h(Text, { color: 'yellow' }, `⏺ ${s.tool.name}`, s.tool.summary ? h(Text, { color: 'gray' }, `(${s.tool.summary})`) : null) : null,
|
|
363
|
+
waiting ? h(Text, null, waiting) : null,
|
|
364
|
+
s.ask ? h(Text, null, s.ask.prompt) : null,
|
|
365
|
+
s.status ? h(Text, null, ' ' + s.status) : null,
|
|
366
|
+
// 任務面板:就地更新的活動區元素(非 Static),每次 task_update 在原位重畫、不堆歷史
|
|
367
|
+
s.tasks ? h(Box, { flexDirection: 'column' }, h(Text, null, GRAY(' 任務')), h(Text, null, s.tasks)) : null,
|
|
368
|
+
statusBar.trim() ? h(Text, null, ' ' + statusBar) : null,
|
|
369
|
+
(s.mode === 'idle' && !s.selection) ? h(Text, null, GRAY(' ↵ 送出 · ⇧↵ 換行 · / 指令 · @ 檔案 · ↑ 歷史')) : null,
|
|
370
|
+
// 最底:選單模式渲染 <Select>(取代輸入框),否則渲染輸入框
|
|
371
|
+
s.selection
|
|
372
|
+
? h(Select, {
|
|
373
|
+
title: s.selection.title,
|
|
374
|
+
options: s.selection.options,
|
|
375
|
+
onSelect: handlers.onSelectChoice,
|
|
376
|
+
onCancel: handlers.onSelectCancel,
|
|
377
|
+
onAbort: handlers.onSelectAbort,
|
|
378
|
+
})
|
|
379
|
+
: h(Box, { borderStyle: 'round', borderColor, paddingX: 1, marginTop: 1 },
|
|
380
|
+
h(Input, {
|
|
381
|
+
onSubmit: handlers.onSubmit,
|
|
382
|
+
onCtrlC: handlers.onCtrlC,
|
|
383
|
+
onEscape: handlers.onEscape,
|
|
384
|
+
getHistory: handlers.getHistory,
|
|
385
|
+
// ask 模式(自行輸入答案)為純文字:關閉斜線/@補全與 ghost 建議,避免 `/`、`@` 誤彈選單
|
|
386
|
+
complete: s.mode === 'ask' ? undefined : handlers.complete,
|
|
387
|
+
placeholder: s.mode === 'ask' ? '輸入你的答案,Enter 送出 · Esc 取消' : s.placeholder,
|
|
388
|
+
promptPrefix: s.mode === 'ask' ? '› ' : (s.planMode ? '[計劃] › ' : '› '),
|
|
389
|
+
promptColor,
|
|
390
|
+
suggestions: s.mode === 'ask' ? [] : s.suggestions,
|
|
391
|
+
})),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// 掛載 TUI;回傳 Ink instance(含 unmount / waitUntilExit)
|
|
396
|
+
export function mountTui({ store, handlers }) {
|
|
397
|
+
return render(h(App, { store, handlers }), { exitOnCtrlC: false });
|
|
398
|
+
}
|
package/src/kernel/bg.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// 後台進程 — bash_bg / bash_output / bash_kill。對標 Claude Code 的 run_in_background + BashOutput + KillShell。
|
|
2
|
+
// 讓 agent 啟動 dev server / watch / build 而不阻塞對話。輸出緩衝在記憶體(上限裁切前段)。
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const OUTPUT_CAP = 256 * 1024;
|
|
6
|
+
const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
|
|
7
|
+
|
|
8
|
+
// 全域只註冊一次 process 退出清理(避免每個 pack 各註冊造成 listener 洩漏)
|
|
9
|
+
const cleanups = new Set();
|
|
10
|
+
let registered = false;
|
|
11
|
+
const ensureCleanup = () => { if (registered) return; registered = true; const run = () => { for (const c of cleanups) try { c(); } catch { /* 略 */ } }; process.once('exit', run); process.once('SIGTERM', run); };
|
|
12
|
+
|
|
13
|
+
export function createBackgroundTools(cwd) {
|
|
14
|
+
const procs = new Map();
|
|
15
|
+
let seq = 0;
|
|
16
|
+
const append = (proc, d) => {
|
|
17
|
+
proc.buf += d.toString();
|
|
18
|
+
if (proc.buf.length > OUTPUT_CAP) { const drop = proc.buf.length - OUTPUT_CAP; proc.buf = proc.buf.slice(drop); proc.readPos = Math.max(0, proc.readPos - drop); proc.truncated = true; }
|
|
19
|
+
};
|
|
20
|
+
const killAll = () => { for (const p of procs.values()) if (p.status === 'running') { try { p.child?.kill('SIGTERM'); } catch { /* 略 */ } } };
|
|
21
|
+
cleanups.add(killAll); ensureCleanup();
|
|
22
|
+
|
|
23
|
+
const bashBg = {
|
|
24
|
+
name: 'bash_bg', label: '後台執行', mutating: true, sandboxable: true,
|
|
25
|
+
description: '在後台啟動長時間/常駐命令(dev server、watch、build),立即回傳 id 不阻塞。之後 bash_output 讀新輸出、bash_kill 終止。一次性快命令用一般 bash。',
|
|
26
|
+
parameters: { type: 'object', properties: { command: { type: 'string' } }, required: ['command'] },
|
|
27
|
+
execute: async (_id, { command }) => {
|
|
28
|
+
const cmd = (command || '').trim();
|
|
29
|
+
if (!cmd) return txt({ error: 'command 不可為空' });
|
|
30
|
+
const id = 'bg' + (++seq);
|
|
31
|
+
const proc = { id, command: cmd, status: 'running', exitCode: null, buf: '', readPos: 0, truncated: false };
|
|
32
|
+
let child;
|
|
33
|
+
try { child = spawn(cmd, { shell: true, cwd, stdio: ['ignore', 'pipe', 'pipe'] }); }
|
|
34
|
+
catch (e) { proc.status = 'error'; proc.error = e.message; procs.set(id, proc); return txt({ error: e.message }); }
|
|
35
|
+
proc.child = child;
|
|
36
|
+
child.stdout?.on('data', (d) => append(proc, d));
|
|
37
|
+
child.stderr?.on('data', (d) => append(proc, d));
|
|
38
|
+
child.on('exit', (code) => { proc.status = 'exited'; proc.exitCode = code; });
|
|
39
|
+
child.on('error', (e) => { proc.status = 'error'; proc.error = e.message; });
|
|
40
|
+
procs.set(id, proc);
|
|
41
|
+
return txt({ id, status: 'running', hint: `bash_output("${id}") 讀輸出、bash_kill("${id}") 終止` });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const bashOutput = {
|
|
46
|
+
name: 'bash_output', label: '讀後台輸出', readOnly: true,
|
|
47
|
+
description: '讀取某後台進程(bash_bg 啟動)自上次以來的新輸出與狀態(running/exited/error)。',
|
|
48
|
+
parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
49
|
+
execute: async (_id, { id }) => {
|
|
50
|
+
const p = procs.get(id);
|
|
51
|
+
if (!p) return txt({ error: `找不到後台進程 ${id}` });
|
|
52
|
+
const out = p.buf.slice(p.readPos); p.readPos = p.buf.length;
|
|
53
|
+
return txt({ id, status: p.status, exitCode: p.exitCode, ...(p.error ? { error: p.error } : {}), ...(p.truncated ? { truncatedFront: true } : {}), output: out });
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const bashKill = {
|
|
58
|
+
name: 'bash_kill', label: '終止後台', readOnly: true,
|
|
59
|
+
description: '終止一個仍在運行的後台進程(bash_bg 啟動的)。',
|
|
60
|
+
parameters: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
|
|
61
|
+
execute: async (_id, { id }) => {
|
|
62
|
+
const p = procs.get(id);
|
|
63
|
+
if (!p) return txt({ error: `找不到後台進程 ${id}` });
|
|
64
|
+
if (p.status !== 'running') return txt({ id, status: p.status, note: '進程已結束' });
|
|
65
|
+
try { p.child?.kill('SIGTERM'); return txt({ id, killed: true }); } catch (e) { return txt({ id, error: e.message }); }
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return { tools: [bashBg, bashOutput, bashKill], killAll };
|
|
70
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// 目標驅動自主循環 — kernel 內建(領域無關)。給目標 → 反覆 runTurn + LLM 自我驗收,
|
|
2
|
+
// 直到達成 / 到上限 / 無進展。對標 xitto-code 的 /loop。checkGoal 用 LLM 判斷是否完成。
|
|
3
|
+
import { completeSimple } from '@mariozechner/pi-ai';
|
|
4
|
+
import { cacheRetentionFor } from './provider.js';
|
|
5
|
+
|
|
6
|
+
const JUDGE_SYS = '你是嚴格的驗收員。依「目標」與「對話進展」判斷目標是否已達成。' +
|
|
7
|
+
'只輸出 JSON:{"done": true|false, "remaining": "若未達成,還差什麼(一句)"}。不要任何多餘文字。';
|
|
8
|
+
|
|
9
|
+
const asText = (m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === 'text').map((c) => c.text).join(' ') : String(m?.content || ''));
|
|
10
|
+
|
|
11
|
+
export function normalizeFeedback(s) { return String(s || '').toLowerCase().replace(/\s+/g, ' ').trim(); }
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 用 LLM 判斷目標是否達成。回 { done, remaining, error? };任何失敗都保守回 done:false(續跑)。
|
|
15
|
+
*/
|
|
16
|
+
export async function checkGoal(goal, messages, model, apiKey, signal) {
|
|
17
|
+
if (!apiKey) return { done: false, remaining: '(無 API key)', error: true };
|
|
18
|
+
const recent = messages.slice(-8).map((m) => `${m.role}: ${asText(m).slice(0, 800)}`).join('\n').slice(0, 6000);
|
|
19
|
+
const ctx = {
|
|
20
|
+
systemPrompt: JUDGE_SYS,
|
|
21
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: `目標:\n${goal}\n\n對話進展:\n${recent}\n\n是否已達成?只輸出 JSON。` }], timestamp: Date.now() }],
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
const res = await completeSimple(model, ctx, { maxTokens: 220, apiKey, signal, cacheRetention: cacheRetentionFor(model) });
|
|
25
|
+
if (res.stopReason === 'error') return { done: false, remaining: '(驗收呼叫失敗)', error: true };
|
|
26
|
+
const t = res.content.filter((c) => c.type === 'text').map((c) => c.text).join('');
|
|
27
|
+
// 寬鬆解析:先試 JSON;失敗再用關鍵字判斷完成訊號(MiniMax 等 JSON 輸出不一定乾淨)
|
|
28
|
+
const m = t.match(/\{[\s\S]*\}/);
|
|
29
|
+
if (m) { try { const o = JSON.parse(m[0]); return { done: !!o.done, remaining: String(o.remaining || '') }; } catch { /* 落到下方 fallback */ } }
|
|
30
|
+
if (/"?done"?\s*[:=]\s*true|已達成|已完成|目標(已)?達成/i.test(t)) return { done: true, remaining: '' };
|
|
31
|
+
if (/"?done"?\s*[:=]\s*false|尚未|未達成|還(需|要|差)/i.test(t)) return { done: false, remaining: t.slice(0, 200) };
|
|
32
|
+
return { done: false, remaining: '(驗收輸出無法解析)', error: true };
|
|
33
|
+
} catch (e) { return { done: false, remaining: `(驗收例外:${e?.message || e})`, error: true }; }
|
|
34
|
+
}
|
package/src/kernel/index.js
CHANGED
|
@@ -10,10 +10,12 @@ import { composeGuards } from './guard-chain.js';
|
|
|
10
10
|
import { createPermissionStep } from './security/permission-step.js';
|
|
11
11
|
import { normalizeSandbox, wrapWithSeatbelt } from './security/sandbox.js';
|
|
12
12
|
import { createMemory } from './memory.js';
|
|
13
|
+
import { createTodo } from './todo.js';
|
|
13
14
|
import { createSpawnTool } from './subagent.js';
|
|
14
15
|
import { createSkills } from './skills.js';
|
|
15
16
|
import { loadHooks, runPreToolHooks, runPostToolHooks } from './hooks.js';
|
|
16
17
|
import { maybeCompact, resolveCompactionSettings } from './compaction.js';
|
|
18
|
+
import { checkGoal, normalizeFeedback } from './goal-loop.js';
|
|
17
19
|
import { newSessionId, saveSession, loadSession, listSessions, latestSession } from './session.js';
|
|
18
20
|
|
|
19
21
|
// 載入 pack.contextFiles:從 cwd 逐層往上找每個檔名,找到就讀入並注入 system prompt(領域規範)。
|
|
@@ -98,6 +100,7 @@ export function createKernel(pack, config = {}) {
|
|
|
98
100
|
// 每個 pack 在 cwd 下有獨立資料夾(記憶、session 分領域存放,互不混)
|
|
99
101
|
const dataDir = join(cwd, '.xitto-kernel', pack.name);
|
|
100
102
|
const memory = createMemory(join(dataDir, 'memory.md'));
|
|
103
|
+
const todo = createTodo();
|
|
101
104
|
const sessionsDir = join(dataDir, 'sessions');
|
|
102
105
|
const hooks = loadHooks(join(dataDir, 'settings.json')); // PreToolUse/PostToolUse
|
|
103
106
|
const skills = createSkills(join(dataDir, 'skills')); // 漸進揭露技能
|
|
@@ -112,6 +115,7 @@ export function createKernel(pack, config = {}) {
|
|
|
112
115
|
const baseTools = [
|
|
113
116
|
...pack.tools().map((t) => wrapUndo(wrapSandboxable(t, { cwd, getSandbox, getSandboxConfig }), { cwd, undoStack })),
|
|
114
117
|
...memory.tools,
|
|
118
|
+
todo.tool,
|
|
115
119
|
...(skills.tool ? [skills.tool] : []),
|
|
116
120
|
...(config.extraTools || []), // 外部注入(MCP 工具等):由 app 層先 async 載入再傳入
|
|
117
121
|
];
|
|
@@ -161,7 +165,7 @@ export function createKernel(pack, config = {}) {
|
|
|
161
165
|
services,
|
|
162
166
|
});
|
|
163
167
|
|
|
164
|
-
|
|
168
|
+
const api = {
|
|
165
169
|
pack,
|
|
166
170
|
registry,
|
|
167
171
|
mutatingTools,
|
|
@@ -170,6 +174,7 @@ export function createKernel(pack, config = {}) {
|
|
|
170
174
|
permissionPolicy: pack.permissionPolicy || {},
|
|
171
175
|
sandbox: { isOn: () => getSandbox(), config: () => getSandboxConfig() },
|
|
172
176
|
memory,
|
|
177
|
+
todo: { get: todo.get },
|
|
173
178
|
/** 撤銷上一次檔案改動(write/edit):還原內容,新建的檔則刪除。 */
|
|
174
179
|
undo: () => {
|
|
175
180
|
const snap = undoStack.pop();
|
|
@@ -285,7 +290,51 @@ export function createKernel(pack, config = {}) {
|
|
|
285
290
|
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
|
|
286
291
|
const text = (lastAssistant?.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('');
|
|
287
292
|
const aborted = lastAssistant?.stopReason === 'aborted';
|
|
288
|
-
return { text, messages, agent, aborted };
|
|
293
|
+
return { text, messages, agent, aborted, turnModified };
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* 目標驅動自主循環:反覆 runTurn + LLM 自我驗收,直到達成 / 到上限 / 連續無進展。
|
|
298
|
+
* @param {string} goal
|
|
299
|
+
* @param {{ maxRounds?: number, history?: object[], onRound?, onCheck?, onEvent?, onAgent?, signal? }} [opts]
|
|
300
|
+
* @returns {Promise<{ done: boolean, rounds: number, history: object[], stalled?: boolean, aborted?: boolean }>}
|
|
301
|
+
*/
|
|
302
|
+
runGoal: async (goal, opts = {}) => {
|
|
303
|
+
if (!config.model) throw new Error('runGoal 需要 config.model。');
|
|
304
|
+
const maxRounds = opts.maxRounds || 12;
|
|
305
|
+
const NO_PROGRESS_CAP = 3;
|
|
306
|
+
let history = opts.history || [];
|
|
307
|
+
let instruction = `目標:${goal}\n\n請著手完成這個目標;可自由使用工具(讀寫檔/跑命令/抓網頁/子 agent…)。完成後簡述你做了什麼、如何驗證。`;
|
|
308
|
+
let lastRemaining = null;
|
|
309
|
+
let noProgress = 0;
|
|
310
|
+
let verifyErrors = 0;
|
|
311
|
+
for (let round = 1; round <= maxRounds; round++) {
|
|
312
|
+
opts.onRound?.({ round, maxRounds });
|
|
313
|
+
const r = await api.runTurn(instruction, { history, onEvent: opts.onEvent, onAgent: opts.onAgent });
|
|
314
|
+
history = r.messages;
|
|
315
|
+
if (r.aborted) return { done: false, aborted: true, rounds: round, history };
|
|
316
|
+
let apiKey; try { apiKey = await config.getApiKey(config.model.provider); } catch { /* 略 */ }
|
|
317
|
+
const judge = config.checkGoal || checkGoal; // 可注入自訂驗收(測試 / app 客製)
|
|
318
|
+
const v = await judge(goal, history, config.model, apiKey, opts.signal);
|
|
319
|
+
opts.onCheck?.({ round, done: v.done, remaining: v.remaining });
|
|
320
|
+
if (v.done) return { done: true, rounds: round, history };
|
|
321
|
+
noProgress = r.turnModified ? 0 : noProgress + 1;
|
|
322
|
+
if (noProgress >= NO_PROGRESS_CAP) return { done: false, stalled: true, rounds: round, history };
|
|
323
|
+
// 驗收壞掉(網路/解析):remaining 是噪音,不拿來比對;連續壞 3 次就停(別空轉到上限)
|
|
324
|
+
if (v.error) {
|
|
325
|
+
verifyErrors += 1;
|
|
326
|
+
if (verifyErrors >= 3) return { done: false, verifyBroken: true, rounds: round, history };
|
|
327
|
+
instruction = `(驗收暫時無法判定)請繼續完成目標並自我檢查:${goal}`;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
verifyErrors = 0;
|
|
331
|
+
const rem = normalizeFeedback(v.remaining);
|
|
332
|
+
if (!r.turnModified && rem && rem === lastRemaining) return { done: false, stalled: true, rounds: round, history };
|
|
333
|
+
lastRemaining = rem;
|
|
334
|
+
instruction = `目標尚未達成。驗收回饋:${v.remaining}\n請繼續完成目標:${goal}`;
|
|
335
|
+
}
|
|
336
|
+
return { done: false, maxedOut: true, rounds: maxRounds, history };
|
|
289
337
|
},
|
|
290
338
|
};
|
|
339
|
+
return api;
|
|
291
340
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// 任務待辦清單 — kernel 內建(對標 Claude Code 的 TodoWrite)。多步任務時規劃 + 追蹤進度,
|
|
2
|
+
// 讓使用者看到 agent 在做什麼。傳入完整清單覆蓋(同 Claude Code 語意)。
|
|
3
|
+
const txt = (o) => ({ content: [{ type: 'text', text: typeof o === 'string' ? o : JSON.stringify(o) }] });
|
|
4
|
+
|
|
5
|
+
export function createTodo() {
|
|
6
|
+
let list = [];
|
|
7
|
+
const tool = {
|
|
8
|
+
name: 'todo_write', label: '待辦', readOnly: true,
|
|
9
|
+
description: '建立/更新任務待辦清單(傳入完整清單覆蓋)。3 步以上的任務建議用它規劃並隨進度更新狀態;'
|
|
10
|
+
+ '同時最多一個 in_progress。status:pending | in_progress | completed。',
|
|
11
|
+
parameters: {
|
|
12
|
+
type: 'object',
|
|
13
|
+
properties: {
|
|
14
|
+
todos: {
|
|
15
|
+
type: 'array',
|
|
16
|
+
items: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
properties: { content: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] } },
|
|
19
|
+
required: ['content', 'status'],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ['todos'],
|
|
24
|
+
},
|
|
25
|
+
execute: async (_id, { todos }) => {
|
|
26
|
+
list = Array.isArray(todos) ? todos.filter((t) => t && typeof t.content === 'string').map((t) => ({ content: t.content, status: t.status || 'pending' })) : [];
|
|
27
|
+
return txt({ ok: true, count: list.length, todos: list });
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return { tool, get: () => list };
|
|
31
|
+
}
|