xitto-kernel 0.2.0 → 0.3.2
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 +38 -0
- package/README.md +48 -7
- package/package.json +16 -3
- package/src/app/cli.js +46 -8
- package/src/app/index.js +1 -0
- package/src/app/init.js +134 -0
- package/src/app/main.js +42 -3
- package/src/app/md-render.js +95 -0
- package/src/app/providers.js +1 -1
- package/src/app/server.js +102 -0
- package/src/app/tui-run.js +198 -0
- package/src/app/tui.js +398 -0
- package/src/packs/coding/index.js +11 -71
- 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 +38 -38
- 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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2
|
|
4
|
+
|
|
5
|
+
- **沒設定就啟動 → 直接進導引**:偵測到沒有 providers.json 且在真實終端時,
|
|
6
|
+
不再只印提示,而是直接帶進 `init` 設定流程,完成後接續啟動該 pack(非 TTY 仍只給提示)。
|
|
7
|
+
|
|
8
|
+
## 0.3.1
|
|
9
|
+
|
|
10
|
+
首次使用導引 —— 不再假設使用者已有 xitto-code。
|
|
11
|
+
|
|
12
|
+
### 新增
|
|
13
|
+
|
|
14
|
+
- **`xitto-kernel init`**:互動式設定導引,產生 `~/.xitto-code/providers.json`
|
|
15
|
+
- 內建 provider 範本(MiniMax / Anthropic / OpenAI / DeepSeek / 自訂)
|
|
16
|
+
- 引導選 provider → 填 model → 處理 API key(環境變數參照 `${NAME}` 不落地,或內嵌)
|
|
17
|
+
- 既有設定不覆寫;`--force` 合併新 provider
|
|
18
|
+
- pipe-safe 逐行讀取(可 `echo answers | xitto-kernel init` 腳本化)
|
|
19
|
+
- **沒設定就啟動**:改丟明確提示,引導跑 `xitto-kernel init`(不再叫人去找 xitto-code 的範例檔)
|
|
20
|
+
- README 快速開始改為 安裝 → `init` → 啟動 三步
|
|
21
|
+
|
|
22
|
+
## 0.3.0
|
|
23
|
+
|
|
24
|
+
把底座的「能力」與「體驗」補到接近 Claude Code,並擴充領域 pack 與評測。
|
|
25
|
+
|
|
26
|
+
### 新增
|
|
27
|
+
|
|
28
|
+
- **完整 Ink TUI**(`--tui`):持久底部狀態列(model/cwd/git/權限/sandbox/plan/ctx)、`Static` 捲動轉錄、即時串流重繪、Esc 中斷、markdown/程式碼語法高亮、Select 式權限詢問;非真實終端自動退回 readline CLI
|
|
29
|
+
- **新領域 pack**:`deep-research`(多源檢索→深讀→綜整)、`devops`
|
|
30
|
+
- **agent loop 內建化**:把 xitto-code 的 agent loop 移植進 `runTurn`(自帶、用 pi-ai streamFn)
|
|
31
|
+
- **真實 sandbox 接入守衛鏈第 5 格**:macOS Seatbelt(sandbox-exec)OS 層隔離
|
|
32
|
+
- **server app PoC**:把 kernel 包成 HTTP 服務(`/v1/run`、`/v1/stream` SSE、bearer 驗證、per-session workdir)
|
|
33
|
+
- **評測框架**:scorers(answerMatch/stateCheck/toolCalled/allOf)+ SWE-bench mini 與真實 SWE-bench Verified adapter;各 pack eval 套件
|
|
34
|
+
- 套件匯出補上 `./packs/general`、`./packs/deep-research`、`./packs/devops`
|
|
35
|
+
|
|
36
|
+
### 驗證
|
|
37
|
+
|
|
38
|
+
- 真實 SWE-bench Verified:coding pack(MiniMax)可重現解出 flask-5014、requests-1142 等
|
|
39
|
+
- 測試 116/116 通過(含 Ink TUI 煙霧測試)
|
|
40
|
+
|
|
3
41
|
## 0.2.0
|
|
4
42
|
|
|
5
43
|
第一個功能完整版 —— 從 0.1.0(基礎 kernel + CLI + 腳手架)補齊近乎 xitto-code 等價的能力,
|
package/README.md
CHANGED
|
@@ -23,20 +23,26 @@ xitto-code 經掃描後,約 **8 成已是領域無關的 kernel**;真正跟
|
|
|
23
23
|
|
|
24
24
|
## 快速開始
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
- Node.js ≥ 20
|
|
28
|
-
- `~/.xitto-code/providers.json` —— LLM provider 設定(與 xitto-code 共用,內含 API key)。
|
|
29
|
-
沒有的話,複製一份填入 key 即可(格式見 xitto-code 的 `providers.example.json`)。
|
|
26
|
+
**前置需求**:Node.js ≥ 20
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
**1. 安裝**(已發佈 npm)
|
|
32
29
|
```bash
|
|
33
30
|
npm install -g xitto-kernel # 全域命令 xitto-kernel
|
|
34
31
|
```
|
|
35
32
|
> 開發本倉庫:`cd xitto-kernel && npm install && npm link`。
|
|
36
33
|
|
|
37
|
-
|
|
34
|
+
**2. 首次設定**(互動導引,產生 `~/.xitto-code/providers.json`)
|
|
35
|
+
```bash
|
|
36
|
+
xitto-kernel init
|
|
37
|
+
```
|
|
38
|
+
引導你選 provider(MiniMax / Anthropic / OpenAI / DeepSeek / 自訂)→ 填 model →
|
|
39
|
+
設定 API key(建議用環境變數參照 `${NAME}`,金鑰不落地)。已是 xitto-code 使用者可直接共用既有設定、跳過此步。
|
|
40
|
+
(沒設定就啟動會提示你跑 `init`;既有設定不會被覆寫,`--force` 才會合併新 provider。)
|
|
41
|
+
|
|
42
|
+
**3. 跑內建 pack(互動 CLI)**
|
|
38
43
|
```bash
|
|
39
44
|
xitto-kernel # coding agent(讀寫檔案、跑命令)
|
|
45
|
+
xitto-kernel --tui # 完整 Ink TUI(持久狀態列、串流、Esc 中斷;需真實終端)
|
|
40
46
|
xitto-kernel --pack notes # 筆記 / 知識庫 agent
|
|
41
47
|
xitto-kernel --pack data-query
|
|
42
48
|
xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
|
|
@@ -50,6 +56,22 @@ xitto-kernel --pack general --yes --goal "抓取 example.com 摘要成繁中寫
|
|
|
50
56
|
```
|
|
51
57
|
`general` pack(檔案/shell/web_fetch)+ kernel 的 **goal loop**(反覆 runTurn + LLM 自我驗收,直到達成/無進展/上限)。互動模式用 `/goal <目標>`。
|
|
52
58
|
|
|
59
|
+
## 當成服務跑(不只 CLI)
|
|
60
|
+
|
|
61
|
+
kernel 是 UI 無關的,CLI 只是其中一個 app。`src/app/server.js` 是把它包成 **HTTP 服務**的 PoC
|
|
62
|
+
(零依賴 `node:http`)—— 證明「個人工具 → 可服務化底座」:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
XITTO_SERVER_TOKEN=secret npm run serve # http://localhost:8787
|
|
66
|
+
curl -s localhost:8787/health
|
|
67
|
+
curl -s -XPOST localhost:8787/v1/run -H "Authorization: Bearer secret" \
|
|
68
|
+
-H content-type:application/json -d '{"pack":"general","sessionId":"s1","input":"..."}'
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
特性:bearer token 認證、**per-session 隔離工作目錄 + 歷史**(多輪記得上文)、沙箱(Seatbelt)、
|
|
72
|
+
結構化 JSON 日誌(審計/觀測)、6 個 pack 可選、JSON 或 SSE(`/v1/stream`)串流。
|
|
73
|
+
「個人 vs 生產」是 **app 層**的事 —— 同一個 kernel,CLI 與 server 是兩個 app。
|
|
74
|
+
|
|
53
75
|
## 做你自己的領域 agent(不固化)
|
|
54
76
|
|
|
55
77
|
kernel 是**被依賴的套件**,不是被 clone 的範本。你的 agent 是獨立小專案:
|
|
@@ -101,7 +123,9 @@ xitto-kernel/
|
|
|
101
123
|
│ ├── coding/ ✅ 參考 pack(read/ls/write/edit/bash/git)
|
|
102
124
|
│ ├── data-query/ ✅ 第二領域(證明正交)
|
|
103
125
|
│ ├── notes/ ✅ 第三領域(知識庫)
|
|
104
|
-
│
|
|
126
|
+
│ ├── general/ ✅ 通用自主 agent(檔案/shell/web/http + goal loop)
|
|
127
|
+
│ ├── deep-research/ ✅ 深度研究(多來源搜尋→查證→有引用結論)
|
|
128
|
+
│ └── devops/ ✅ 維運/SRE(shell + bash_bg + 設定 + 日誌 + 健康檢查)
|
|
105
129
|
├── bin/xitto-kernel.js ✅ CLI 進入點(run / new-agent)
|
|
106
130
|
├── test/ ✅ 41 測試全綠(runTurn + Seatbelt 隔離 + 腳手架 + …)
|
|
107
131
|
└── examples/
|
|
@@ -138,6 +162,23 @@ xitto-kernel/
|
|
|
138
162
|
|
|
139
163
|
**設計取向**:沿用 Node ESM + pi-ai provider 抽象;不重寫 xitto-code(kernel 是抽象,xitto-code 仍可獨立存在)。
|
|
140
164
|
|
|
165
|
+
## 評估(能力可量化)
|
|
166
|
+
|
|
167
|
+
每個 pack 配一個 EvalSuite(`eval/`,共用 `eval/framework.js`,不進 npm 包)。
|
|
168
|
+
範式:**新領域 agent = 新 pack(會什麼)+ 新 EvalSuite(怎麼打分)**。
|
|
169
|
+
|
|
170
|
+
| Suite | 對標 | 評分方式 | 跑法 | 參考結果* |
|
|
171
|
+
|------|------|------|------|------|
|
|
172
|
+
| coding | SWE-bench Verified | 隱藏測試 fail→pass(Docker)| `eval/swebench-generate.js` + 官方 harness | 3/8 resolved(真實子集)|
|
|
173
|
+
| coding(迷你)| SWE-bench 風格 | 隱藏測試(免 Docker)| `npm run eval` | 4/4 |
|
|
174
|
+
| general | GAIA 風格 | 答案比對 / 狀態檢查 | `node eval/general-run.js` | 4/4 |
|
|
175
|
+
| data-query | Spider/BIRD 風格 | 真實 SQLite + 答案比對 | `node eval/data-query-run.js` | 4/4 |
|
|
176
|
+
| deep-research | GAIA/研究 | 事實正確 + 真的查證(allOf)| `node eval/deep-research-run.js` | 3/3 |
|
|
177
|
+
| devops | Terminal-Bench 風格 | 狀態檢查(系統/檔案達標)| `node eval/devops-run.js` | 4/4 |
|
|
178
|
+
| 工具呼叫 | BFCL 風格 | 軌跡檢查(呼叫對工具/參數)| `node eval/tool-calling-run.js` | 6/6 |
|
|
179
|
+
|
|
180
|
+
\* 用 MiniMax-M2.7 跑的參考數字(小樣本);換模型/擴樣本見 `eval/README.md`。scorer 型:`answerMatch` / `stateCheck` / `toolCalled`。
|
|
181
|
+
|
|
141
182
|
## 貢獻
|
|
142
183
|
|
|
143
184
|
見 [CONTRIBUTING.md](CONTRIBUTING.md)。核心原則:kernel 必須領域無關(安全行為靠工具 metadata,不寫死領域名單);新領域 = 新增一個 pack,kernel 零改動。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "xitto-kernel",
|
|
3
|
-
"version": "0.2
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
|
|
6
6
|
"keywords": [
|
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
"scripts": {
|
|
41
41
|
"test": "node --test",
|
|
42
42
|
"demo": "node examples/demo.js",
|
|
43
|
+
"eval": "node eval/run.js",
|
|
44
|
+
"serve": "node src/app/server.js",
|
|
43
45
|
"start": "node bin/xitto-kernel.js"
|
|
44
46
|
},
|
|
45
47
|
"exports": {
|
|
@@ -47,10 +49,21 @@
|
|
|
47
49
|
"./app": "./src/app/index.js",
|
|
48
50
|
"./packs/coding": "./src/packs/coding/index.js",
|
|
49
51
|
"./packs/data-query": "./src/packs/data-query/index.js",
|
|
50
|
-
"./packs/notes": "./src/packs/notes/index.js"
|
|
52
|
+
"./packs/notes": "./src/packs/notes/index.js",
|
|
53
|
+
"./packs/general": "./src/packs/general/index.js",
|
|
54
|
+
"./packs/deep-research": "./src/packs/deep-research/index.js",
|
|
55
|
+
"./packs/devops": "./src/packs/devops/index.js"
|
|
51
56
|
},
|
|
52
57
|
"dependencies": {
|
|
53
58
|
"@mariozechner/pi-ai": "^0.70.6",
|
|
54
|
-
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
59
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
60
|
+
"cli-highlight": "^2.1.11",
|
|
61
|
+
"ink": "^5.2.1",
|
|
62
|
+
"marked": "^12.0.2",
|
|
63
|
+
"marked-terminal": "^7.3.0",
|
|
64
|
+
"react": "^18.3.1"
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"ink-testing-library": "^4.0.0"
|
|
55
68
|
}
|
|
56
69
|
}
|
package/src/app/cli.js
CHANGED
|
@@ -41,6 +41,26 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
41
41
|
let history = [];
|
|
42
42
|
let currentAgent = null;
|
|
43
43
|
let streaming = false;
|
|
44
|
+
const turnUsage = { input: 0, output: 0 }; // 本輪 token 累計(顯示頁腳)
|
|
45
|
+
|
|
46
|
+
// 等待 LLM 時的 spinner(首個可見輸出出現即停)
|
|
47
|
+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
48
|
+
let spinTimer = null;
|
|
49
|
+
const startSpin = () => {
|
|
50
|
+
if (!process.stdout.isTTY) return; // 非互動(管線)不顯示 spinner,避免 \r 雜訊
|
|
51
|
+
let i = 0; const t0 = Date.now();
|
|
52
|
+
spinTimer = setInterval(() => { process.stdout.write(`\r\x1b[2m${FRAMES[i++ % FRAMES.length]} 思考中… ${Math.round((Date.now() - t0) / 1000)}s\x1b[0m\x1b[K`); }, 100);
|
|
53
|
+
};
|
|
54
|
+
const stopSpin = () => { if (spinTimer) { clearInterval(spinTimer); spinTimer = null; process.stdout.write('\r\x1b[K'); } };
|
|
55
|
+
|
|
56
|
+
// 目前模式標記(提示列前綴,讓使用者隨時看到狀態)
|
|
57
|
+
const modeTag = () => {
|
|
58
|
+
const t = [];
|
|
59
|
+
if (planMode) t.push('plan');
|
|
60
|
+
if (sandboxOn) t.push('🔒');
|
|
61
|
+
if (autoApprove) t.push('⚡');
|
|
62
|
+
return t.length ? c.gray('[' + t.join(' ') + '] ') : '';
|
|
63
|
+
};
|
|
44
64
|
|
|
45
65
|
// 互動權限確認:守衛鏈第 5 格對 mutating/危險工具呼叫此函數。autoApprove → 一律放行。
|
|
46
66
|
// 回 'yes'(允許一次)/ 'always'(此工具全部)/ 'no'(拒絕)。
|
|
@@ -88,13 +108,15 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
88
108
|
};
|
|
89
109
|
|
|
90
110
|
const onEvent = (ev) => {
|
|
111
|
+
if (ev.type === 'message_end' && ev.message?.usage) { turnUsage.input += ev.message.usage.input || 0; turnUsage.output += ev.message.usage.output || 0; }
|
|
91
112
|
switch (ev.type) {
|
|
92
113
|
case 'message_update': {
|
|
93
114
|
const a = ev.assistantMessageEvent;
|
|
94
|
-
if (a?.type === 'text_delta' && a.delta) { md.push(a.delta); streaming = true; }
|
|
115
|
+
if (a?.type === 'text_delta' && a.delta) { stopSpin(); md.push(a.delta); streaming = true; }
|
|
95
116
|
break;
|
|
96
117
|
}
|
|
97
118
|
case 'tool_execution_start':
|
|
119
|
+
stopSpin();
|
|
98
120
|
endStream();
|
|
99
121
|
if (ev.toolName === 'todo_write' && Array.isArray(ev.args?.todos)) {
|
|
100
122
|
out(c.cyan('☑ 待辦更新\n'));
|
|
@@ -202,7 +224,14 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
202
224
|
}
|
|
203
225
|
};
|
|
204
226
|
|
|
205
|
-
|
|
227
|
+
// 斜線指令 tab 補全
|
|
228
|
+
const SLASH = ['/help', '/goal ', '/sandbox', '/auto', '/plan', '/undo', '/tools', '/memory', '/sessions', '/resume', '/clear', '/exit'];
|
|
229
|
+
const completer = (line) => {
|
|
230
|
+
if (!line.startsWith('/')) return [[], line];
|
|
231
|
+
const hits = SLASH.filter((s) => s.startsWith(line));
|
|
232
|
+
return [hits.length ? hits : SLASH, line];
|
|
233
|
+
};
|
|
234
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: c.blue('› '), completer, historySize: 200 });
|
|
206
235
|
let closed = false;
|
|
207
236
|
const cleanup = () => { try { rl.close(); } catch { /* 略 */ } try { onExit?.(); } catch { /* 略 */ } };
|
|
208
237
|
const finish = () => { cleanup(); process.exit(0); };
|
|
@@ -210,13 +239,13 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
210
239
|
|
|
211
240
|
// Ctrl+C:執行中→中斷該輪;閒置→離開
|
|
212
241
|
process.on('SIGINT', () => {
|
|
213
|
-
if (currentAgent) { currentAgent.abort(); out(c.yellow('\n⏹ 已中斷本輪\n')); }
|
|
242
|
+
if (currentAgent) { stopSpin(); currentAgent.abort(); out(c.yellow('\n⏹ 已中斷本輪\n')); }
|
|
214
243
|
else { out(c.gray('\n再見。\n')); cleanup(); process.exit(0); }
|
|
215
244
|
});
|
|
216
245
|
|
|
217
246
|
// 橫幅
|
|
218
247
|
out('\n' + c.cyan('✻ ') + c.bold('xitto-kernel') + c.gray(` · ${pack.name} pack · ${model.id}`) + '\n');
|
|
219
|
-
out(c.gray(` 沙箱 ${sandboxOn ? '開' : '關'}${seatbeltAvailable() ? '(Seatbelt 可用)' : ''} · /help
|
|
248
|
+
out(c.gray(` 沙箱 ${sandboxOn ? '開' : '關'}${seatbeltAvailable() ? '(Seatbelt 可用)' : ''} · /help · Tab 補全 · ↑↓ 歷史 · Ctrl+C 中斷/離開`) + '\n');
|
|
220
249
|
if (resumedNote) out(c.gray(' ' + resumedNote) + '\n');
|
|
221
250
|
out('\n');
|
|
222
251
|
|
|
@@ -224,7 +253,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
224
253
|
if (closed) return finish();
|
|
225
254
|
let q;
|
|
226
255
|
try { q = rl.question.bind(rl); } catch { return finish(); }
|
|
227
|
-
q(c.blue('› '), async (raw) => {
|
|
256
|
+
q(modeTag() + c.blue('› '), async (raw) => {
|
|
228
257
|
const input = (raw || '').trim();
|
|
229
258
|
if (!input) return loop();
|
|
230
259
|
// /goal <目標>:目標驅動自主循環(在此 await,避免與下一個提示交錯)
|
|
@@ -233,13 +262,16 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
233
262
|
if (!goal) { out(c.gray('用法 /goal <目標>\n')); return loop(); }
|
|
234
263
|
try {
|
|
235
264
|
out(c.cyan('🎯 目標:') + goal + '\n');
|
|
265
|
+
turnUsage.input = 0; turnUsage.output = 0;
|
|
266
|
+
const t0 = Date.now();
|
|
236
267
|
const r = await kernel.runGoal(goal, {
|
|
237
268
|
history,
|
|
238
|
-
onRound: ({ round, maxRounds }) => out(c.yellow(`\n🔁 第 ${round}/${maxRounds}
|
|
239
|
-
onCheck: ({ done, remaining }) => out(done ? c.green(' ✓ 驗收:已達成\n') : c.gray(` ↻ ${remaining}\n`)),
|
|
269
|
+
onRound: ({ round, maxRounds }) => { stopSpin(); out(c.yellow(`\n🔁 第 ${round}/${maxRounds} 輪 `)); startSpin(); },
|
|
270
|
+
onCheck: ({ done, remaining }) => { stopSpin(); out(done ? c.green(' ✓ 驗收:已達成\n') : c.gray(` ↻ ${remaining}\n`)); },
|
|
240
271
|
onEvent, onAgent: (a) => { currentAgent = a; },
|
|
241
272
|
});
|
|
242
|
-
endStream(); history = r.history; persist();
|
|
273
|
+
stopSpin(); endStream(); history = r.history; persist();
|
|
274
|
+
out(c.gray(`↳ ${((Date.now() - t0) / 1000).toFixed(1)}s · ${turnUsage.input + turnUsage.output} tokens · ${r.rounds} 輪`) + '\n');
|
|
243
275
|
const why = r.stalled ? '無進展' : r.aborted ? '中斷' : r.verifyBroken ? '驗收持續失敗' : '到上限';
|
|
244
276
|
out('\n' + (r.done ? c.green(`✅ 目標達成(${r.rounds} 輪)`) : c.yellow(`⚠ 未達成(${why},${r.rounds} 輪)`)) + '\n');
|
|
245
277
|
} catch (err) { endStream(); out(c.red('錯誤:' + err.message) + '\n'); }
|
|
@@ -251,13 +283,19 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
251
283
|
const text = planMode
|
|
252
284
|
? `[計劃模式:只制定計劃,列出你打算做的步驟與會改動的檔案,不要實際寫檔或執行命令]\n\n${input}`
|
|
253
285
|
: input;
|
|
286
|
+
turnUsage.input = 0; turnUsage.output = 0;
|
|
287
|
+
const t0 = Date.now();
|
|
288
|
+
startSpin();
|
|
254
289
|
const r = await kernel.runTurn(text, {
|
|
255
290
|
history, onEvent, onAgent: (a) => { currentAgent = a; },
|
|
256
291
|
});
|
|
292
|
+
stopSpin();
|
|
257
293
|
endStream();
|
|
258
294
|
history = r.messages;
|
|
259
295
|
persist(); // 每輪結束自動存檔(可 /resume 續接)
|
|
296
|
+
out(c.gray(`↳ ${((Date.now() - t0) / 1000).toFixed(1)}s · ${turnUsage.input + turnUsage.output} tokens`) + '\n');
|
|
260
297
|
} catch (err) {
|
|
298
|
+
stopSpin();
|
|
261
299
|
endStream();
|
|
262
300
|
out(c.red('錯誤:' + err.message) + '\n');
|
|
263
301
|
} finally {
|
package/src/app/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// app 子路徑公開 API(xitto-kernel/app):外部 agent 專案 import 這些來啟動,不需改 kernel。
|
|
2
2
|
export { runCli } from './cli.js';
|
|
3
|
+
export { createServerApp, startServer } from './server.js';
|
|
3
4
|
export { loadModel, loadProvidersConfig, buildModel } from './providers.js';
|
|
4
5
|
export { newAgent } from './scaffold.js';
|
|
5
6
|
export { main } from './main.js';
|
package/src/app/init.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// 首次啟動導引:互動式產生 ~/.xitto-code/providers.json。
|
|
2
|
+
// 內建常見 provider 範本(MiniMax / Anthropic / OpenAI / DeepSeek / 自訂),
|
|
3
|
+
// 引導選 provider → 填 model → 處理 API key(環境變數或內嵌),寫檔不覆寫既有設定。
|
|
4
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
|
|
9
|
+
const e = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
|
|
10
|
+
const green = e(32); const gray = e(90); const cyan = e(36); const yellow = e(33); const bold = e(1); const red = e(31);
|
|
11
|
+
|
|
12
|
+
// provider 範本(沿用實測可用的格式;baseUrl/api/model 皆可在引導中改)
|
|
13
|
+
const PRESETS = {
|
|
14
|
+
minimax: { label: 'MiniMax(M2.7,anthropic 相容)', api: 'anthropic-messages', baseUrl: 'https://api.minimaxi.com/anthropic', env: 'MINIMAX_API_KEY', model: { id: 'MiniMax-M2.7', name: 'MiniMax M2.7', contextWindow: 1000192, maxTokens: 131072 } },
|
|
15
|
+
anthropic: { label: 'Anthropic Claude', api: 'anthropic-messages', baseUrl: 'https://api.anthropic.com', env: 'ANTHROPIC_API_KEY', model: { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', contextWindow: 200000, maxTokens: 64000 } },
|
|
16
|
+
openai: { label: 'OpenAI', api: 'openai-completions', baseUrl: 'https://api.openai.com/v1', env: 'OPENAI_API_KEY', model: { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000, maxTokens: 16384 } },
|
|
17
|
+
deepseek: { label: 'DeepSeek', api: 'openai-completions', baseUrl: 'https://api.deepseek.com', env: 'DEEPSEEK_API_KEY', model: { id: 'deepseek-chat', name: 'DeepSeek Chat', contextWindow: 64000, maxTokens: 8192 } },
|
|
18
|
+
custom: { label: '自訂(手動填全部)', api: 'openai-completions', baseUrl: '', env: 'LLM_API_KEY', model: { id: '', name: '', contextWindow: 32000, maxTokens: 4096 } },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const PATH = () => process.env.XITTO_CODE_CONFIG || join(homedir(), '.xitto-code', 'providers.json');
|
|
22
|
+
|
|
23
|
+
export { PRESETS };
|
|
24
|
+
|
|
25
|
+
// 純函數:把答案組成 providers.json 結構(合併既有 providers)。互動殼與測試共用。
|
|
26
|
+
export function buildConfig(a, existing) {
|
|
27
|
+
const cfg = existing && existing.providers ? existing : { defaultModel: a.modelId, providers: {} };
|
|
28
|
+
cfg.providers = cfg.providers || {};
|
|
29
|
+
cfg.providers[a.providerName] = {
|
|
30
|
+
baseUrl: a.baseUrl, apiKey: a.apiKey, api: a.api,
|
|
31
|
+
models: [{ id: a.modelId, name: a.modelName || a.modelId, contextWindow: a.contextWindow, maxTokens: a.maxTokens }],
|
|
32
|
+
};
|
|
33
|
+
cfg.defaultModel = a.modelId;
|
|
34
|
+
return cfg;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// pipe-safe 逐行讀取:把 'line' 事件排入佇列,即使管線一次送進整批輸入也能依序消費
|
|
38
|
+
// (readline/promises 的 question() 在非 TTY 管線下會丟失已緩衝的行)。
|
|
39
|
+
function makeAsker() {
|
|
40
|
+
const rl = createInterface({ input: process.stdin });
|
|
41
|
+
const queue = []; const waiters = []; let closed = false;
|
|
42
|
+
rl.on('line', (l) => { const w = waiters.shift(); if (w) w(l); else queue.push(l); });
|
|
43
|
+
rl.on('close', () => { closed = true; while (waiters.length) waiters.shift()(null); });
|
|
44
|
+
const nextLine = () => new Promise((res) => { if (queue.length) res(queue.shift()); else if (closed) res(null); else waiters.push(res); });
|
|
45
|
+
return {
|
|
46
|
+
close: () => rl.close(),
|
|
47
|
+
ask: async (q, def) => {
|
|
48
|
+
process.stdout.write(gray(q + (def ? ` [${def}]` : '') + ' '));
|
|
49
|
+
const line = await nextLine();
|
|
50
|
+
return ((line == null ? '' : line).trim()) || def || '';
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function runInit(argv = []) {
|
|
56
|
+
const force = argv.includes('--force');
|
|
57
|
+
const path = PATH();
|
|
58
|
+
const io = makeAsker();
|
|
59
|
+
const rl = { close: io.close };
|
|
60
|
+
const ask = io.ask;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
console.log('\n' + bold('🚀 xitto-kernel 首次設定') + gray(' —— 建立 LLM provider 設定'));
|
|
64
|
+
console.log(gray(`設定檔:${path}\n`));
|
|
65
|
+
|
|
66
|
+
// 既有檔保護
|
|
67
|
+
if (existsSync(path) && !force) {
|
|
68
|
+
console.log(yellow(`已存在設定檔。`) + gray(' 用 `xitto-kernel init --force` 覆寫,或直接編輯該檔。'));
|
|
69
|
+
let cfg; try { cfg = JSON.parse(readFileSync(path, 'utf8')); } catch { /* 壞檔忽略 */ }
|
|
70
|
+
if (cfg) {
|
|
71
|
+
const names = Object.keys(cfg.providers || {});
|
|
72
|
+
console.log(gray(` 目前 provider:${names.join(', ') || '(無)'} 預設 model:${cfg.defaultModel || '(未設)'}`));
|
|
73
|
+
}
|
|
74
|
+
rl.close();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 1) 選 provider
|
|
79
|
+
const keys = Object.keys(PRESETS);
|
|
80
|
+
console.log(bold('1) 選 LLM provider:'));
|
|
81
|
+
keys.forEach((k, i) => console.log(` ${cyan(String(i + 1))}. ${PRESETS[k].label}`));
|
|
82
|
+
const pick = await ask('輸入編號', '1');
|
|
83
|
+
const presetKey = keys[(parseInt(pick, 10) || 1) - 1] || 'minimax';
|
|
84
|
+
const preset = PRESETS[presetKey];
|
|
85
|
+
console.log(green(` → ${preset.label}\n`));
|
|
86
|
+
|
|
87
|
+
// 2) 連線 / model
|
|
88
|
+
console.log(bold('2) 連線與 model:'));
|
|
89
|
+
const providerName = await ask('provider 名稱(providers.json 的鍵)', presetKey);
|
|
90
|
+
const baseUrl = await ask('baseUrl', preset.baseUrl);
|
|
91
|
+
const api = await ask('api 型別(anthropic-messages | openai-completions)', preset.api);
|
|
92
|
+
const modelId = await ask('model id', preset.model.id);
|
|
93
|
+
const modelName = await ask('model 顯示名', preset.model.name || modelId);
|
|
94
|
+
const contextWindow = parseInt(await ask('contextWindow', String(preset.model.contextWindow)), 10) || preset.model.contextWindow;
|
|
95
|
+
const maxTokens = parseInt(await ask('maxTokens', String(preset.model.maxTokens)), 10) || preset.model.maxTokens;
|
|
96
|
+
if (!modelId) { console.log(red('\nmodel id 不可空,已取消。')); rl.close(); return; }
|
|
97
|
+
|
|
98
|
+
// 3) API key:環境變數(建議)或內嵌
|
|
99
|
+
console.log('\n' + bold('3) API key:'));
|
|
100
|
+
console.log(gray(' a) 用環境變數參照(建議,金鑰不落地在設定檔)'));
|
|
101
|
+
console.log(gray(' b) 現在貼上金鑰(直接存進設定檔,檔案在你家目錄)'));
|
|
102
|
+
const mode = (await ask('選 a 或 b', 'a')).toLowerCase();
|
|
103
|
+
let apiKey; let envHint = '';
|
|
104
|
+
if (mode === 'b') {
|
|
105
|
+
const k = await ask('貼上 API key', '');
|
|
106
|
+
apiKey = k;
|
|
107
|
+
if (!k) console.log(yellow(' (未填,稍後請手動補上 apiKey)'));
|
|
108
|
+
} else {
|
|
109
|
+
const envName = await ask('環境變數名', preset.env);
|
|
110
|
+
apiKey = '${' + envName + '}';
|
|
111
|
+
envHint = envName;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 組設定(保留既有 providers,--force 時合併)
|
|
115
|
+
let existing; if (existsSync(path)) { try { existing = JSON.parse(readFileSync(path, 'utf8')); } catch { /* 壞檔重建 */ } }
|
|
116
|
+
const cfg = buildConfig({ providerName, baseUrl, api, modelId, modelName, contextWindow, maxTokens, apiKey }, existing);
|
|
117
|
+
|
|
118
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
119
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
120
|
+
|
|
121
|
+
console.log('\n' + green('✓ 已寫入 ') + path);
|
|
122
|
+
if (envHint) {
|
|
123
|
+
console.log('\n' + bold('下一步:設定環境變數(金鑰)'));
|
|
124
|
+
console.log(gray(` export ${envHint}="你的金鑰" # 加進 ~/.zshrc 永久生效`));
|
|
125
|
+
}
|
|
126
|
+
console.log('\n' + bold('啟動:'));
|
|
127
|
+
console.log(green(' xitto-kernel') + gray(' # coding pack,互動對話'));
|
|
128
|
+
console.log(green(' xitto-kernel --tui') + gray(' # 完整 Ink TUI(真實終端)'));
|
|
129
|
+
console.log(green(' xitto-kernel --pack general') + gray(' # 通用 agent'));
|
|
130
|
+
console.log('');
|
|
131
|
+
} finally {
|
|
132
|
+
rl.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/app/main.js
CHANGED
|
@@ -3,13 +3,17 @@
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { loadModel } from './providers.js';
|
|
5
5
|
import { runCli } from './cli.js';
|
|
6
|
+
import { runTui } from './tui-run.js';
|
|
6
7
|
import { newAgent } from './scaffold.js';
|
|
8
|
+
import { runInit } from './init.js';
|
|
7
9
|
import { createKernel } from '../kernel/index.js';
|
|
8
10
|
import { loadMcpTools } from '../kernel/mcp.js';
|
|
9
11
|
import { createCodingPack } from '../packs/coding/index.js';
|
|
10
12
|
import { createDataQueryPack } from '../packs/data-query/index.js';
|
|
11
13
|
import { createNotesPack } from '../packs/notes/index.js';
|
|
12
14
|
import { createGeneralPack } from '../packs/general/index.js';
|
|
15
|
+
import { createDeepResearchPack } from '../packs/deep-research/index.js';
|
|
16
|
+
import { createDevopsPack } from '../packs/devops/index.js';
|
|
13
17
|
|
|
14
18
|
const e = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
|
|
15
19
|
const green = e(32); const gray = e(90); const red = e(31); const cyan = e(36); const yellow = e(33);
|
|
@@ -19,9 +23,14 @@ const PACKS = {
|
|
|
19
23
|
'data-query': createDataQueryPack,
|
|
20
24
|
notes: createNotesPack,
|
|
21
25
|
general: createGeneralPack,
|
|
26
|
+
'deep-research': createDeepResearchPack,
|
|
27
|
+
devops: createDevopsPack,
|
|
22
28
|
};
|
|
23
29
|
|
|
24
30
|
export async function main(argv = process.argv.slice(2)) {
|
|
31
|
+
// 子指令:init —— 首次設定導引,產生 providers.json
|
|
32
|
+
if (argv[0] === 'init') { await runInit(argv.slice(1)); return; }
|
|
33
|
+
|
|
25
34
|
// 子指令:new-agent <name> —— 產出獨立 agent 專案(不碰 kernel)
|
|
26
35
|
if (argv[0] === 'new-agent') {
|
|
27
36
|
const name = argv.find((a, i) => i >= 1 && !a.startsWith('--'));
|
|
@@ -45,7 +54,26 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
45
54
|
|
|
46
55
|
let model, getApiKey;
|
|
47
56
|
try { ({ model, getApiKey } = loadModel(opts.model)); }
|
|
48
|
-
catch (err) {
|
|
57
|
+
catch (err) {
|
|
58
|
+
// 沒設定 + 真實終端:直接帶進設定導引,完成後續跑;非 TTY 才只給提示
|
|
59
|
+
if (err.noConfig && process.stdin.isTTY) {
|
|
60
|
+
console.log(cyan('首次使用,沒找到 providers.json —— 進入設定導引。') + gray('(按 Ctrl+C 取消)'));
|
|
61
|
+
await runInit([]);
|
|
62
|
+
try { ({ model, getApiKey } = loadModel(opts.model)); }
|
|
63
|
+
catch (err2) {
|
|
64
|
+
console.error(red(err2.message));
|
|
65
|
+
console.error(gray(err2.noConfig ? '(未完成設定,已取消)' : '(設定好像缺東西,可編輯該檔或重跑 `xitto-kernel init`)'));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
console.error(red(err.message));
|
|
70
|
+
if (err.noConfig) {
|
|
71
|
+
console.error('\n' + cyan('首次使用?') + ' 跑一次設定導引:');
|
|
72
|
+
console.error(green(' xitto-kernel init') + gray(' # 選 provider、填 model、設定 API key'));
|
|
73
|
+
}
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
49
77
|
|
|
50
78
|
// MCP:啟動時連 .xitto-kernel/<pack>/mcp.json 的 server,工具以 extraTools 注入
|
|
51
79
|
const cwd = process.cwd();
|
|
@@ -73,6 +101,12 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
73
101
|
process.exit(res.done ? 0 : 1);
|
|
74
102
|
}
|
|
75
103
|
|
|
104
|
+
if (opts.tui && process.stdin.isTTY) {
|
|
105
|
+
runTui({ pack: make({ cwd }), model, getApiKey, sandbox: opts.sandbox, resume: opts.resume });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (opts.tui) console.error(gray('(--tui 需要真實終端,退回一般 CLI)'));
|
|
109
|
+
|
|
76
110
|
runCli({
|
|
77
111
|
pack: make({ cwd }), model, getApiKey,
|
|
78
112
|
sandbox: opts.sandbox, resume: opts.resume, auto: opts.yes,
|
|
@@ -89,6 +123,7 @@ function parse(argv) {
|
|
|
89
123
|
else if (a === '--model') o.model = argv[++i];
|
|
90
124
|
else if (a === '--sandbox') o.sandbox = true;
|
|
91
125
|
else if (a === '--yes' || a === '-y') o.yes = true;
|
|
126
|
+
else if (a === '--tui') o.tui = true;
|
|
92
127
|
else if (a === '--goal') o.goal = argv[++i];
|
|
93
128
|
else if (a === '--resume') { const nxt = argv[i + 1]; if (nxt && !nxt.startsWith('--')) { o.resume = nxt; i++; } else o.resume = true; }
|
|
94
129
|
}
|
|
@@ -100,17 +135,21 @@ function printHelp() {
|
|
|
100
135
|
'xitto-kernel — 領域無關 agent 底座',
|
|
101
136
|
'',
|
|
102
137
|
'用法:',
|
|
138
|
+
' xitto-kernel init 首次設定導引(產生 providers.json)',
|
|
103
139
|
' xitto-kernel [--pack <name>] [--model <id>] [--sandbox] [--resume [id]] [--yes] 互動跑內建 pack',
|
|
104
140
|
' xitto-kernel --pack general --goal "..." [--yes] 目標驅動自主循環(headless)',
|
|
105
141
|
' xitto-kernel new-agent <name> 產出依賴 kernel 的獨立 agent 專案',
|
|
106
142
|
'',
|
|
107
|
-
' --pack <name> 選擇內建 DomainPack(coding | data-query | notes | general;預設 coding)',
|
|
143
|
+
' --pack <name> 選擇內建 DomainPack(coding | data-query | notes | general | deep-research | devops;預設 coding)',
|
|
108
144
|
' --goal "..." 給目標,agent 自主反覆做到完成(建議搭配 --pack general)',
|
|
109
145
|
' --model <id> 指定 model(預設用 providers.json 的 defaultModel)',
|
|
110
146
|
' --sandbox 啟動即開啟沙箱(macOS=Seatbelt 真隔離)',
|
|
147
|
+
' --tui 完整 Ink TUI(持久狀態列、串流轉錄、Esc 中斷;需真實終端)',
|
|
148
|
+
' --resume [id] 接續上次 session(不給 id 接最近一次)',
|
|
149
|
+
' --yes, -y 自動核准 mutating 工具(headless / 自主循環常用)',
|
|
111
150
|
' --help 顯示說明',
|
|
112
151
|
'',
|
|
113
|
-
'
|
|
152
|
+
'首次使用先跑 `xitto-kernel init` 建立 ~/.xitto-code/providers.json(已是 xitto-code 使用者可直接共用)。',
|
|
114
153
|
'new-agent 產出的是獨立專案,import xitto-kernel 而非修改它——升級不固化。',
|
|
115
154
|
].join('\n'));
|
|
116
155
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Markdown → 終端 ANSI 渲染(對標 Claude Code 的回覆排版)
|
|
2
|
+
// 用 marked 解析、marked-terminal 轉 ANSI(標題、粗體、列表、表格、程式碼區塊語法高亮)。
|
|
3
|
+
// Claude Code 的做法:每來一個 token 就把「目前累積的整段 markdown」重新解析重繪,
|
|
4
|
+
// 半截的 **粗體 / 未閉合的 ``` 會在下一幀補齊後自動修正。本檔提供純函式 md(),
|
|
5
|
+
// 重繪由 Ink 的 re-render 負責(見 tui.js)。
|
|
6
|
+
import { Marked } from 'marked';
|
|
7
|
+
import { markedTerminal } from 'marked-terminal';
|
|
8
|
+
import { highlight as hl } from 'cli-highlight';
|
|
9
|
+
|
|
10
|
+
// 終端寬度(隨視窗;最小 40 避免表格擠壞)
|
|
11
|
+
function termWidth() {
|
|
12
|
+
const w = process.stdout?.columns || 80;
|
|
13
|
+
return Math.max(40, Math.min(w, 120));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// ANSI 樣式小工具(marked-terminal 的樣式選項接受 (str)=>str 函式)
|
|
17
|
+
const a = (open, close) => (s) => `\x1b[${open}m${s}\x1b[${close}m`;
|
|
18
|
+
const bold = a(1, 22);
|
|
19
|
+
const cyan = a(36, 39);
|
|
20
|
+
const yellow = a(33, 39);
|
|
21
|
+
const gray = a(90, 39);
|
|
22
|
+
|
|
23
|
+
// 程式碼區塊本體:語法高亮 + 左側豎線邊框(+ 可選語言標籤)。不含前後空行,方便外部逐塊拼接。
|
|
24
|
+
// withHeader=false 用於「同一個 code block 的延續塊」(串流逐行提交時避免重複 ┌─ lang 標頭)。
|
|
25
|
+
export function codeChunk(text, lang, withHeader = true) {
|
|
26
|
+
let body;
|
|
27
|
+
try { body = hl(text, { language: lang || 'plaintext', ignoreIllegals: true }); }
|
|
28
|
+
catch { body = text; }
|
|
29
|
+
const bar = gray('│') + ' ';
|
|
30
|
+
const label = (withHeader && lang) ? gray(`┌─ ${lang}`) + '\n' : '';
|
|
31
|
+
return label + body.replace(/\n$/, '').split('\n').map((l) => bar + l).join('\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 程式碼區塊(marked renderer 用):前後補空行與內文隔開
|
|
35
|
+
function codeBlock(text, lang) {
|
|
36
|
+
return '\n' + codeChunk(text, lang, true) + '\n';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 依寬度快取 Marked 實例:終端 resize 後寬度改變 → 換一個實例,markdown 自動以新寬度重排。
|
|
40
|
+
// 上限保護:resize 反覆拖動會堆積不同寬度的實例,超過上限即淘汰最舊的(LRU 近似)。
|
|
41
|
+
const cache = new Map();
|
|
42
|
+
const MAX_CACHE = 32;
|
|
43
|
+
function renderer(width) {
|
|
44
|
+
let m = cache.get(width);
|
|
45
|
+
if (!m) {
|
|
46
|
+
m = new Marked();
|
|
47
|
+
m.use(
|
|
48
|
+
markedTerminal({
|
|
49
|
+
width,
|
|
50
|
+
reflowText: true,
|
|
51
|
+
tab: 2,
|
|
52
|
+
showSectionPrefix: false, // 不顯示標題的 # 記號(對標 Claude Code)
|
|
53
|
+
firstHeading: (s) => bold(cyan(s)), // 一級標題:粗體青
|
|
54
|
+
heading: (s) => bold(s), // 其餘標題:粗體
|
|
55
|
+
strong: (s) => bold(s),
|
|
56
|
+
codespan: (s) => yellow(s),
|
|
57
|
+
blockquote: (s) => gray(s),
|
|
58
|
+
listitem: (s) => s,
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
// 覆寫 code 渲染:加左側邊框(marked-terminal 內建無邊框)
|
|
62
|
+
m.use({
|
|
63
|
+
renderer: {
|
|
64
|
+
code(token, infostring) {
|
|
65
|
+
const text = typeof token === 'object' ? token.text : token;
|
|
66
|
+
const lang = (typeof token === 'object' ? token.lang : infostring) || '';
|
|
67
|
+
return codeBlock(text, lang);
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
if (cache.size >= MAX_CACHE) cache.delete(cache.keys().next().value); // 淘汰最舊
|
|
72
|
+
cache.set(width, m);
|
|
73
|
+
}
|
|
74
|
+
return m;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 用 marked 的 block lexer 把 markdown 切成頂層區塊 token(含 .raw,串接即原文)。
|
|
78
|
+
// 串流增量提交用:提交「除最後一塊外」的完整區塊,最後一塊(可能還沒打完)留在動態區。
|
|
79
|
+
const lexMarked = new Marked();
|
|
80
|
+
export function lexBlocks(text) {
|
|
81
|
+
try { return lexMarked.lexer(text); }
|
|
82
|
+
catch { return [{ type: 'paragraph', raw: text }]; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 把 markdown 字串渲染成帶 ANSI 的終端字串。容錯:解析失敗就回原文。
|
|
86
|
+
export function md(text) {
|
|
87
|
+
if (!text) return '';
|
|
88
|
+
try {
|
|
89
|
+
const out = renderer(termWidth()).parse(text);
|
|
90
|
+
// 無序列表符號 * → •(marked-terminal 的 BULLET_POINT 寫死為 '* ')
|
|
91
|
+
return (typeof out === 'string' ? out : String(out)).replace(/\n+$/, '').replace(/^(\s*)\* /gm, '$1• ');
|
|
92
|
+
} catch {
|
|
93
|
+
return text;
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/app/providers.js
CHANGED
|
@@ -7,7 +7,7 @@ import { join } from 'node:path';
|
|
|
7
7
|
const DEFAULT_PATH = () => process.env.XITTO_CODE_CONFIG || join(homedir(), '.xitto-code', 'providers.json');
|
|
8
8
|
|
|
9
9
|
export function loadProvidersConfig(path = DEFAULT_PATH()) {
|
|
10
|
-
if (!existsSync(path))
|
|
10
|
+
if (!existsSync(path)) { const err = new Error(`找不到 providers.json:${path}`); err.noConfig = true; throw err; }
|
|
11
11
|
return { ...JSON.parse(readFileSync(path, 'utf8')), path };
|
|
12
12
|
}
|
|
13
13
|
|