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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
把底座的「能力」與「體驗」補到接近 Claude Code,並擴充領域 pack 與評測。
|
|
6
|
+
|
|
7
|
+
### 新增
|
|
8
|
+
|
|
9
|
+
- **完整 Ink TUI**(`--tui`):持久底部狀態列(model/cwd/git/權限/sandbox/plan/ctx)、`Static` 捲動轉錄、即時串流重繪、Esc 中斷、markdown/程式碼語法高亮、Select 式權限詢問;非真實終端自動退回 readline CLI
|
|
10
|
+
- **新領域 pack**:`deep-research`(多源檢索→深讀→綜整)、`devops`
|
|
11
|
+
- **agent loop 內建化**:把 xitto-code 的 agent loop 移植進 `runTurn`(自帶、用 pi-ai streamFn)
|
|
12
|
+
- **真實 sandbox 接入守衛鏈第 5 格**:macOS Seatbelt(sandbox-exec)OS 層隔離
|
|
13
|
+
- **server app PoC**:把 kernel 包成 HTTP 服務(`/v1/run`、`/v1/stream` SSE、bearer 驗證、per-session workdir)
|
|
14
|
+
- **評測框架**:scorers(answerMatch/stateCheck/toolCalled/allOf)+ SWE-bench mini 與真實 SWE-bench Verified adapter;各 pack eval 套件
|
|
15
|
+
- 套件匯出補上 `./packs/general`、`./packs/deep-research`、`./packs/devops`
|
|
16
|
+
|
|
17
|
+
### 驗證
|
|
18
|
+
|
|
19
|
+
- 真實 SWE-bench Verified:coding pack(MiniMax)可重現解出 flask-5014、requests-1142 等
|
|
20
|
+
- 測試 116/116 通過(含 Ink TUI 煙霧測試)
|
|
21
|
+
|
|
22
|
+
## 0.2.0
|
|
23
|
+
|
|
24
|
+
第一個功能完整版 —— 從 0.1.0(基礎 kernel + CLI + 腳手架)補齊近乎 xitto-code 等價的能力,
|
|
25
|
+
並加入通用自主 agent。
|
|
26
|
+
|
|
27
|
+
### 新增
|
|
28
|
+
|
|
29
|
+
- **記憶 + session resume**:`memory_save`/`memory_list` 工具自動注入;`--resume [id]`、`/sessions`、`/resume`、`/memory`
|
|
30
|
+
- **互動權限確認**:mutating/危險工具執行前彈確認(y/a/n);`always` 記住;`--yes`/`/auto` 自動核准(危險仍把關)
|
|
31
|
+
- **計劃模式 + 撤銷**:`/plan`(只規劃、擋 mutating)、`/undo`(還原上次 write/edit)
|
|
32
|
+
- **git 能力**(coding pack):`git_status` / `git_diff` / `git_log` / `git_commit`
|
|
33
|
+
- **子 agent**:`spawn_agent` 派唯讀子 agent 做聚焦調查
|
|
34
|
+
- **hooks**:PreToolUse / PostToolUse(`.xitto-kernel/<pack>/settings.json`)
|
|
35
|
+
- **skills**:漸進揭露(`.xitto-kernel/<pack>/skills/*.md` + `skill` 工具)
|
|
36
|
+
- **MCP**:連 MCP server(stdio),工具以 `mcp__<server>__<tool>` 注入
|
|
37
|
+
- **回合內上下文壓縮**:逼近視窗時摘要較舊對話、保留最近
|
|
38
|
+
- **輕量串流 markdown 渲染** + edit/write 彩色 diff(CLI)
|
|
39
|
+
- **通用自主 agent**:`general` pack(檔案/shell/`web_fetch`/`web_search`)+ **goal loop**
|
|
40
|
+
(`runGoal` / `--goal "..."` / `/goal`:給目標、反覆做到完成、LLM 自我驗收)
|
|
41
|
+
- **code agent 工具升級(達 Claude Code 等級)**:
|
|
42
|
+
- `grep`(正則搜內容、`path:line`、glob 過濾)、`glob`(`**` 遞迴找檔)
|
|
43
|
+
- `read` 附行號 + `offset`/`limit`;`edit` 唯一性檢查 + `replaceAll`(避免改錯位置)
|
|
44
|
+
- `bash` timeout 參數;`bash_bg` / `bash_output` / `bash_kill`(後台 dev server/watch)
|
|
45
|
+
- `web_fetch`(coding pack 也能查線上文件)
|
|
46
|
+
- **TodoWrite**(`todo_write`):多步任務規劃/追蹤,CLI 即時清單渲染(☐/◐/☑)
|
|
47
|
+
|
|
48
|
+
### 變更
|
|
49
|
+
|
|
50
|
+
- `new-agent` 產出的專案預設依賴 `^<version>`(正式版本),`--local` 用 `file:` 開發
|
|
51
|
+
- pack.verify / pack.contextFiles slot 接通 runtime
|
|
52
|
+
|
|
53
|
+
### 修正
|
|
54
|
+
|
|
55
|
+
- test script 改 `node --test`(Node 20 不支援 `--test` glob)
|
|
56
|
+
- goal loop 驗收健壯性:寬鬆 JSON 解析 + 連續失敗停止
|
|
57
|
+
|
|
58
|
+
## 0.1.0
|
|
59
|
+
|
|
60
|
+
首發:kernel(pack 系統 / 工具 metadata / 守衛鏈 / agent loop / 真實 sandbox(Seatbelt))、
|
|
61
|
+
互動 CLI、腳手架(`new-agent` 產出獨立專案)、三個範例 pack(coding / data-query / notes)。
|
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# xitto-kernel
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/xitto-kernel)
|
|
3
4
|
[](https://github.com/ishoplus/xitto-kernel/actions/workflows/ci.yml)
|
|
4
5
|
[](./LICENSE)
|
|
5
6
|
[](https://nodejs.org)
|
|
@@ -27,13 +28,11 @@ xitto-code 經掃描後,約 **8 成已是領域無關的 kernel**;真正跟
|
|
|
27
28
|
- `~/.xitto-code/providers.json` —— LLM provider 設定(與 xitto-code 共用,內含 API key)。
|
|
28
29
|
沒有的話,複製一份填入 key 即可(格式見 xitto-code 的 `providers.example.json`)。
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
**安裝**(已發佈 npm)
|
|
31
32
|
```bash
|
|
32
|
-
|
|
33
|
-
npm install
|
|
34
|
-
npm link # 之後任何目錄都能用 xitto-kernel 命令
|
|
33
|
+
npm install -g xitto-kernel # 全域命令 xitto-kernel
|
|
35
34
|
```
|
|
36
|
-
>
|
|
35
|
+
> 開發本倉庫:`cd xitto-kernel && npm install && npm link`。
|
|
37
36
|
|
|
38
37
|
**跑內建 pack(互動 CLI)**
|
|
39
38
|
```bash
|
|
@@ -43,7 +42,29 @@ xitto-kernel --pack data-query
|
|
|
43
42
|
xitto-kernel --sandbox # 啟動就開 Seatbelt 沙箱
|
|
44
43
|
```
|
|
45
44
|
|
|
46
|
-
**CLI 內操作**:直接打需求(模型會自己呼叫工具);指令 `/help` `/sandbox
|
|
45
|
+
**CLI 內操作**:直接打需求(模型會自己呼叫工具);指令 `/help` `/goal <目標>` `/sandbox` `/plan` `/undo` `/tools` `/memory` `/sessions` `/resume` `/exit`;`Ctrl+C` 中斷該輪、閒置時再按一次離開。
|
|
46
|
+
|
|
47
|
+
**通用自主 agent(給目標、自己做到完成)**
|
|
48
|
+
```bash
|
|
49
|
+
xitto-kernel --pack general --yes --goal "抓取 example.com 摘要成繁中寫進 summary.txt"
|
|
50
|
+
```
|
|
51
|
+
`general` pack(檔案/shell/web_fetch)+ kernel 的 **goal loop**(反覆 runTurn + LLM 自我驗收,直到達成/無進展/上限)。互動模式用 `/goal <目標>`。
|
|
52
|
+
|
|
53
|
+
## 當成服務跑(不只 CLI)
|
|
54
|
+
|
|
55
|
+
kernel 是 UI 無關的,CLI 只是其中一個 app。`src/app/server.js` 是把它包成 **HTTP 服務**的 PoC
|
|
56
|
+
(零依賴 `node:http`)—— 證明「個人工具 → 可服務化底座」:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
XITTO_SERVER_TOKEN=secret npm run serve # http://localhost:8787
|
|
60
|
+
curl -s localhost:8787/health
|
|
61
|
+
curl -s -XPOST localhost:8787/v1/run -H "Authorization: Bearer secret" \
|
|
62
|
+
-H content-type:application/json -d '{"pack":"general","sessionId":"s1","input":"..."}'
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
特性:bearer token 認證、**per-session 隔離工作目錄 + 歷史**(多輪記得上文)、沙箱(Seatbelt)、
|
|
66
|
+
結構化 JSON 日誌(審計/觀測)、6 個 pack 可選、JSON 或 SSE(`/v1/stream`)串流。
|
|
67
|
+
「個人 vs 生產」是 **app 層**的事 —— 同一個 kernel,CLI 與 server 是兩個 app。
|
|
47
68
|
|
|
48
69
|
## 做你自己的領域 agent(不固化)
|
|
49
70
|
|
|
@@ -93,9 +114,12 @@ xitto-kernel/
|
|
|
93
114
|
│ │ ├── templates/ ✅ 獨立專案樣板(package.json/index.js/pack.js…)
|
|
94
115
|
│ │ └── providers.js ✅ providers.json 載入(provider 設定屬 app,非 kernel)
|
|
95
116
|
│ └── packs/
|
|
96
|
-
│ ├── coding/ ✅ 參考 pack(read/ls/write/edit/bash
|
|
117
|
+
│ ├── coding/ ✅ 參考 pack(read/ls/write/edit/bash/git)
|
|
97
118
|
│ ├── data-query/ ✅ 第二領域(證明正交)
|
|
98
|
-
│
|
|
119
|
+
│ ├── notes/ ✅ 第三領域(知識庫)
|
|
120
|
+
│ ├── general/ ✅ 通用自主 agent(檔案/shell/web/http + goal loop)
|
|
121
|
+
│ ├── deep-research/ ✅ 深度研究(多來源搜尋→查證→有引用結論)
|
|
122
|
+
│ └── devops/ ✅ 維運/SRE(shell + bash_bg + 設定 + 日誌 + 健康檢查)
|
|
99
123
|
├── bin/xitto-kernel.js ✅ CLI 進入點(run / new-agent)
|
|
100
124
|
├── test/ ✅ 41 測試全綠(runTurn + Seatbelt 隔離 + 腳手架 + …)
|
|
101
125
|
└── examples/
|
|
@@ -127,10 +151,28 @@ xitto-kernel/
|
|
|
127
151
|
**git 能力**(coding pack)、**spawn_agent 子 agent**、**PreToolUse/PostToolUse hooks**、
|
|
128
152
|
**skills 漸進揭露**、**MCP 工具接入**、互動 CLI、腳手架(`new-agent` 產出獨立專案)。75 測試全綠。
|
|
129
153
|
|
|
130
|
-
|
|
154
|
+
**已發佈 npm**:`npm install -g xitto-kernel`;`new-agent` 產出的專案預設依賴 `^0.1.0`(`--local` 用 file: 開發)。
|
|
155
|
+
**可選後續**:Ink 全功能 TUI 可作為另一個 app(目前 CLI 已有輕量串流 markdown + 彩色 diff)。
|
|
131
156
|
|
|
132
157
|
**設計取向**:沿用 Node ESM + pi-ai provider 抽象;不重寫 xitto-code(kernel 是抽象,xitto-code 仍可獨立存在)。
|
|
133
158
|
|
|
159
|
+
## 評估(能力可量化)
|
|
160
|
+
|
|
161
|
+
每個 pack 配一個 EvalSuite(`eval/`,共用 `eval/framework.js`,不進 npm 包)。
|
|
162
|
+
範式:**新領域 agent = 新 pack(會什麼)+ 新 EvalSuite(怎麼打分)**。
|
|
163
|
+
|
|
164
|
+
| Suite | 對標 | 評分方式 | 跑法 | 參考結果* |
|
|
165
|
+
|------|------|------|------|------|
|
|
166
|
+
| coding | SWE-bench Verified | 隱藏測試 fail→pass(Docker)| `eval/swebench-generate.js` + 官方 harness | 3/8 resolved(真實子集)|
|
|
167
|
+
| coding(迷你)| SWE-bench 風格 | 隱藏測試(免 Docker)| `npm run eval` | 4/4 |
|
|
168
|
+
| general | GAIA 風格 | 答案比對 / 狀態檢查 | `node eval/general-run.js` | 4/4 |
|
|
169
|
+
| data-query | Spider/BIRD 風格 | 真實 SQLite + 答案比對 | `node eval/data-query-run.js` | 4/4 |
|
|
170
|
+
| deep-research | GAIA/研究 | 事實正確 + 真的查證(allOf)| `node eval/deep-research-run.js` | 3/3 |
|
|
171
|
+
| devops | Terminal-Bench 風格 | 狀態檢查(系統/檔案達標)| `node eval/devops-run.js` | 4/4 |
|
|
172
|
+
| 工具呼叫 | BFCL 風格 | 軌跡檢查(呼叫對工具/參數)| `node eval/tool-calling-run.js` | 6/6 |
|
|
173
|
+
|
|
174
|
+
\* 用 MiniMax-M2.7 跑的參考數字(小樣本);換模型/擴樣本見 `eval/README.md`。scorer 型:`answerMatch` / `stateCheck` / `toolCalled`。
|
|
175
|
+
|
|
134
176
|
## 貢獻
|
|
135
177
|
|
|
136
178
|
見 [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.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "領域無關的 agent 底座(kernel + 可插拔 DomainPack),從 xitto-code 抽象而來",
|
|
6
6
|
"keywords": [
|
|
@@ -34,11 +34,14 @@
|
|
|
34
34
|
"bin",
|
|
35
35
|
"docs",
|
|
36
36
|
"README.md",
|
|
37
|
-
"LICENSE"
|
|
37
|
+
"LICENSE",
|
|
38
|
+
"CHANGELOG.md"
|
|
38
39
|
],
|
|
39
40
|
"scripts": {
|
|
40
41
|
"test": "node --test",
|
|
41
42
|
"demo": "node examples/demo.js",
|
|
43
|
+
"eval": "node eval/run.js",
|
|
44
|
+
"serve": "node src/app/server.js",
|
|
42
45
|
"start": "node bin/xitto-kernel.js"
|
|
43
46
|
},
|
|
44
47
|
"exports": {
|
|
@@ -46,10 +49,21 @@
|
|
|
46
49
|
"./app": "./src/app/index.js",
|
|
47
50
|
"./packs/coding": "./src/packs/coding/index.js",
|
|
48
51
|
"./packs/data-query": "./src/packs/data-query/index.js",
|
|
49
|
-
"./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"
|
|
50
56
|
},
|
|
51
57
|
"dependencies": {
|
|
52
58
|
"@mariozechner/pi-ai": "^0.70.6",
|
|
53
|
-
"@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"
|
|
54
68
|
}
|
|
55
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,14 +108,24 @@ 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();
|
|
121
|
+
if (ev.toolName === 'todo_write' && Array.isArray(ev.args?.todos)) {
|
|
122
|
+
out(c.cyan('☑ 待辦更新\n'));
|
|
123
|
+
for (const t of ev.args.todos) {
|
|
124
|
+
const mark = t.status === 'completed' ? c.green('☑') : t.status === 'in_progress' ? c.yellow('◐') : c.gray('☐');
|
|
125
|
+
out(` ${mark} ${t.status === 'completed' ? c.gray(t.content) : t.content}\n`);
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
99
129
|
out(c.yellow('⚙ ' + ev.toolName) + c.gray('(' + summarize(ev.args) + ')\n'));
|
|
100
130
|
diffPreview(ev.toolName, ev.args);
|
|
101
131
|
break;
|
|
@@ -130,6 +160,7 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
130
160
|
' /sandbox [on|off] 切換沙箱(macOS=Seatbelt 真隔離)',
|
|
131
161
|
' /auto [on|off] 自動核准 mutating 工具(危險命令仍把關)',
|
|
132
162
|
' /plan [on|off] 計劃模式(只規劃、擋下實際改動)',
|
|
163
|
+
' /goal <目標> 目標驅動自主循環(反覆做到完成)',
|
|
133
164
|
' /undo 撤銷上一次檔案改動(write/edit)',
|
|
134
165
|
' /tools 列出此 pack 的工具',
|
|
135
166
|
' /memory 顯示跨 session 記憶',
|
|
@@ -193,7 +224,14 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
193
224
|
}
|
|
194
225
|
};
|
|
195
226
|
|
|
196
|
-
|
|
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 });
|
|
197
235
|
let closed = false;
|
|
198
236
|
const cleanup = () => { try { rl.close(); } catch { /* 略 */ } try { onExit?.(); } catch { /* 略 */ } };
|
|
199
237
|
const finish = () => { cleanup(); process.exit(0); };
|
|
@@ -201,13 +239,13 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
201
239
|
|
|
202
240
|
// Ctrl+C:執行中→中斷該輪;閒置→離開
|
|
203
241
|
process.on('SIGINT', () => {
|
|
204
|
-
if (currentAgent) { currentAgent.abort(); out(c.yellow('\n⏹ 已中斷本輪\n')); }
|
|
242
|
+
if (currentAgent) { stopSpin(); currentAgent.abort(); out(c.yellow('\n⏹ 已中斷本輪\n')); }
|
|
205
243
|
else { out(c.gray('\n再見。\n')); cleanup(); process.exit(0); }
|
|
206
244
|
});
|
|
207
245
|
|
|
208
246
|
// 橫幅
|
|
209
247
|
out('\n' + c.cyan('✻ ') + c.bold('xitto-kernel') + c.gray(` · ${pack.name} pack · ${model.id}`) + '\n');
|
|
210
|
-
out(c.gray(` 沙箱 ${sandboxOn ? '開' : '關'}${seatbeltAvailable() ? '(Seatbelt 可用)' : ''} · /help
|
|
248
|
+
out(c.gray(` 沙箱 ${sandboxOn ? '開' : '關'}${seatbeltAvailable() ? '(Seatbelt 可用)' : ''} · /help · Tab 補全 · ↑↓ 歷史 · Ctrl+C 中斷/離開`) + '\n');
|
|
211
249
|
if (resumedNote) out(c.gray(' ' + resumedNote) + '\n');
|
|
212
250
|
out('\n');
|
|
213
251
|
|
|
@@ -215,21 +253,49 @@ export function runCli({ pack, model, getApiKey, sandbox = false, resume = null,
|
|
|
215
253
|
if (closed) return finish();
|
|
216
254
|
let q;
|
|
217
255
|
try { q = rl.question.bind(rl); } catch { return finish(); }
|
|
218
|
-
q(c.blue('› '), async (raw) => {
|
|
256
|
+
q(modeTag() + c.blue('› '), async (raw) => {
|
|
219
257
|
const input = (raw || '').trim();
|
|
220
258
|
if (!input) return loop();
|
|
259
|
+
// /goal <目標>:目標驅動自主循環(在此 await,避免與下一個提示交錯)
|
|
260
|
+
if (input.startsWith('/goal ') || input === '/goal') {
|
|
261
|
+
const goal = input.slice(5).trim();
|
|
262
|
+
if (!goal) { out(c.gray('用法 /goal <目標>\n')); return loop(); }
|
|
263
|
+
try {
|
|
264
|
+
out(c.cyan('🎯 目標:') + goal + '\n');
|
|
265
|
+
turnUsage.input = 0; turnUsage.output = 0;
|
|
266
|
+
const t0 = Date.now();
|
|
267
|
+
const r = await kernel.runGoal(goal, {
|
|
268
|
+
history,
|
|
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`)); },
|
|
271
|
+
onEvent, onAgent: (a) => { currentAgent = a; },
|
|
272
|
+
});
|
|
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');
|
|
275
|
+
const why = r.stalled ? '無進展' : r.aborted ? '中斷' : r.verifyBroken ? '驗收持續失敗' : '到上限';
|
|
276
|
+
out('\n' + (r.done ? c.green(`✅ 目標達成(${r.rounds} 輪)`) : c.yellow(`⚠ 未達成(${why},${r.rounds} 輪)`)) + '\n');
|
|
277
|
+
} catch (err) { endStream(); out(c.red('錯誤:' + err.message) + '\n'); }
|
|
278
|
+
finally { currentAgent = null; }
|
|
279
|
+
out('\n'); return loop();
|
|
280
|
+
}
|
|
221
281
|
if (handleSlash(input)) return loop();
|
|
222
282
|
try {
|
|
223
283
|
const text = planMode
|
|
224
284
|
? `[計劃模式:只制定計劃,列出你打算做的步驟與會改動的檔案,不要實際寫檔或執行命令]\n\n${input}`
|
|
225
285
|
: input;
|
|
286
|
+
turnUsage.input = 0; turnUsage.output = 0;
|
|
287
|
+
const t0 = Date.now();
|
|
288
|
+
startSpin();
|
|
226
289
|
const r = await kernel.runTurn(text, {
|
|
227
290
|
history, onEvent, onAgent: (a) => { currentAgent = a; },
|
|
228
291
|
});
|
|
292
|
+
stopSpin();
|
|
229
293
|
endStream();
|
|
230
294
|
history = r.messages;
|
|
231
295
|
persist(); // 每輪結束自動存檔(可 /resume 續接)
|
|
296
|
+
out(c.gray(`↳ ${((Date.now() - t0) / 1000).toFixed(1)}s · ${turnUsage.input + turnUsage.output} tokens`) + '\n');
|
|
232
297
|
} catch (err) {
|
|
298
|
+
stopSpin();
|
|
233
299
|
endStream();
|
|
234
300
|
out(c.red('錯誤:' + err.message) + '\n');
|
|
235
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/main.js
CHANGED
|
@@ -3,29 +3,38 @@
|
|
|
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 { createKernel } from '../kernel/index.js';
|
|
7
9
|
import { loadMcpTools } from '../kernel/mcp.js';
|
|
8
10
|
import { createCodingPack } from '../packs/coding/index.js';
|
|
9
11
|
import { createDataQueryPack } from '../packs/data-query/index.js';
|
|
10
12
|
import { createNotesPack } from '../packs/notes/index.js';
|
|
13
|
+
import { createGeneralPack } from '../packs/general/index.js';
|
|
14
|
+
import { createDeepResearchPack } from '../packs/deep-research/index.js';
|
|
15
|
+
import { createDevopsPack } from '../packs/devops/index.js';
|
|
11
16
|
|
|
12
17
|
const e = (n) => (s) => `\x1b[${n}m${s}\x1b[0m`;
|
|
13
|
-
const green = e(32); const gray = e(90); const red = e(31);
|
|
18
|
+
const green = e(32); const gray = e(90); const red = e(31); const cyan = e(36); const yellow = e(33);
|
|
14
19
|
|
|
15
20
|
const PACKS = {
|
|
16
21
|
coding: createCodingPack,
|
|
17
22
|
'data-query': createDataQueryPack,
|
|
18
23
|
notes: createNotesPack,
|
|
24
|
+
general: createGeneralPack,
|
|
25
|
+
'deep-research': createDeepResearchPack,
|
|
26
|
+
devops: createDevopsPack,
|
|
19
27
|
};
|
|
20
28
|
|
|
21
29
|
export async function main(argv = process.argv.slice(2)) {
|
|
22
30
|
// 子指令:new-agent <name> —— 產出獨立 agent 專案(不碰 kernel)
|
|
23
31
|
if (argv[0] === 'new-agent') {
|
|
24
|
-
const name = argv
|
|
32
|
+
const name = argv.find((a, i) => i >= 1 && !a.startsWith('--'));
|
|
33
|
+
const local = argv.includes('--local');
|
|
25
34
|
try {
|
|
26
|
-
const { target, files } = newAgent(name);
|
|
35
|
+
const { target, files, dep } = newAgent(name, { local });
|
|
27
36
|
console.log(green(`✓ 已建立獨立 agent 專案:${target}`));
|
|
28
|
-
console.log(gray(` ${files.join(' ')}`));
|
|
37
|
+
console.log(gray(` ${files.join(' ')} · 依賴 xitto-kernel@${dep}`));
|
|
29
38
|
console.log('\n下一步:');
|
|
30
39
|
console.log(gray(` cd ${name} && npm install && npm start`));
|
|
31
40
|
console.log(gray(' (改 pack.js 換成你的領域;npm update xitto-kernel 升級底座,不固化)'));
|
|
@@ -47,6 +56,34 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
47
56
|
const cwd = process.cwd();
|
|
48
57
|
const mcp = await loadMcpTools(join(cwd, '.xitto-kernel', opts.pack, 'mcp.json'), (m) => console.log(gray(` [MCP] ${m}`)));
|
|
49
58
|
|
|
59
|
+
// --goal "...":headless 自主循環(給目標、自己做到完成)後退出,不進互動 CLI
|
|
60
|
+
if (opts.goal) {
|
|
61
|
+
const kernel = createKernel(make({ cwd }), {
|
|
62
|
+
model, getApiKey, extraTools: mcp.tools,
|
|
63
|
+
sandbox: { enabled: opts.sandbox }, getSandbox: () => opts.sandbox,
|
|
64
|
+
confirm: opts.yes ? (async () => 'yes') : undefined, // headless:--yes 才自動核准 mutating
|
|
65
|
+
});
|
|
66
|
+
console.log(cyan('🎯 目標:') + opts.goal + gray(` · ${opts.pack} pack · ${model.id}`));
|
|
67
|
+
const res = await kernel.runGoal(opts.goal, {
|
|
68
|
+
onRound: ({ round, maxRounds }) => console.log(yellow(`\n🔁 第 ${round}/${maxRounds} 輪`)),
|
|
69
|
+
onCheck: ({ done, remaining }) => console.log(done ? green(' ✓ 驗收:已達成') : gray(` ↻ 驗收:${remaining}`)),
|
|
70
|
+
onEvent: (ev) => {
|
|
71
|
+
if (ev.type === 'tool_execution_start') console.log(yellow(` ⚙ ${ev.toolName}`) + gray('(' + JSON.stringify(ev.args).slice(0, 80) + ')'));
|
|
72
|
+
if (ev.type === 'tool_execution_end') console.log(ev.isError ? red(' ⎿ ✗') : gray(' ⎿ ✓'));
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
const why = res.stalled ? '無進展停止' : res.aborted ? '中斷' : res.verifyBroken ? '驗收持續失敗' : '到上限';
|
|
76
|
+
console.log('\n' + (res.done ? green(`✅ 目標達成(${res.rounds} 輪)`) : yellow(`⚠ 未達成(${why},${res.rounds} 輪)`)));
|
|
77
|
+
try { await mcp.close(); } catch { /* 略 */ }
|
|
78
|
+
process.exit(res.done ? 0 : 1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (opts.tui && process.stdin.isTTY) {
|
|
82
|
+
runTui({ pack: make({ cwd }), model, getApiKey, sandbox: opts.sandbox, resume: opts.resume });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (opts.tui) console.error(gray('(--tui 需要真實終端,退回一般 CLI)'));
|
|
86
|
+
|
|
50
87
|
runCli({
|
|
51
88
|
pack: make({ cwd }), model, getApiKey,
|
|
52
89
|
sandbox: opts.sandbox, resume: opts.resume, auto: opts.yes,
|
|
@@ -55,7 +92,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
55
92
|
}
|
|
56
93
|
|
|
57
94
|
function parse(argv) {
|
|
58
|
-
const o = { pack: 'coding', model: undefined, sandbox: false, help: false, resume: null, yes: false };
|
|
95
|
+
const o = { pack: 'coding', model: undefined, sandbox: false, help: false, resume: null, yes: false, goal: null };
|
|
59
96
|
for (let i = 0; i < argv.length; i++) {
|
|
60
97
|
const a = argv[i];
|
|
61
98
|
if (a === '--help' || a === '-h') o.help = true;
|
|
@@ -63,6 +100,8 @@ function parse(argv) {
|
|
|
63
100
|
else if (a === '--model') o.model = argv[++i];
|
|
64
101
|
else if (a === '--sandbox') o.sandbox = true;
|
|
65
102
|
else if (a === '--yes' || a === '-y') o.yes = true;
|
|
103
|
+
else if (a === '--tui') o.tui = true;
|
|
104
|
+
else if (a === '--goal') o.goal = argv[++i];
|
|
66
105
|
else if (a === '--resume') { const nxt = argv[i + 1]; if (nxt && !nxt.startsWith('--')) { o.resume = nxt; i++; } else o.resume = true; }
|
|
67
106
|
}
|
|
68
107
|
return o;
|
|
@@ -74,11 +113,16 @@ function printHelp() {
|
|
|
74
113
|
'',
|
|
75
114
|
'用法:',
|
|
76
115
|
' xitto-kernel [--pack <name>] [--model <id>] [--sandbox] [--resume [id]] [--yes] 互動跑內建 pack',
|
|
116
|
+
' xitto-kernel --pack general --goal "..." [--yes] 目標驅動自主循環(headless)',
|
|
77
117
|
' xitto-kernel new-agent <name> 產出依賴 kernel 的獨立 agent 專案',
|
|
78
118
|
'',
|
|
79
|
-
' --pack <name> 選擇內建 DomainPack(coding | data-query | notes;預設 coding)',
|
|
119
|
+
' --pack <name> 選擇內建 DomainPack(coding | data-query | notes | general | deep-research | devops;預設 coding)',
|
|
120
|
+
' --goal "..." 給目標,agent 自主反覆做到完成(建議搭配 --pack general)',
|
|
80
121
|
' --model <id> 指定 model(預設用 providers.json 的 defaultModel)',
|
|
81
122
|
' --sandbox 啟動即開啟沙箱(macOS=Seatbelt 真隔離)',
|
|
123
|
+
' --tui 完整 Ink TUI(持久狀態列、串流轉錄、Esc 中斷;需真實終端)',
|
|
124
|
+
' --resume [id] 接續上次 session(不給 id 接最近一次)',
|
|
125
|
+
' --yes, -y 自動核准 mutating 工具(headless / 自主循環常用)',
|
|
82
126
|
' --help 顯示說明',
|
|
83
127
|
'',
|
|
84
128
|
'需要 ~/.xitto-code/providers.json(與 xitto-code 共用)。',
|
|
@@ -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/scaffold.js
CHANGED
|
@@ -17,24 +17,32 @@ const FILES = [
|
|
|
17
17
|
['gitignore.tmpl', '.gitignore'],
|
|
18
18
|
];
|
|
19
19
|
|
|
20
|
+
const kernelVersion = () => {
|
|
21
|
+
try { return JSON.parse(readFileSync(join(KERNEL_ROOT, 'package.json'), 'utf8')).version || '0.1.0'; }
|
|
22
|
+
catch { return '0.1.0'; }
|
|
23
|
+
};
|
|
24
|
+
|
|
20
25
|
/**
|
|
21
26
|
* 產生一個獨立 agent 專案。
|
|
22
27
|
* @param {string} name agent 名(字母/數字/連字號)
|
|
23
|
-
* @param {{ dir?: string, kernelPath?: string }} [opts]
|
|
24
|
-
* dir:產出根目錄(預設 cwd);
|
|
25
|
-
* @returns {{ target: string, files: string[] }}
|
|
28
|
+
* @param {{ dir?: string, local?: boolean, kernelPath?: string }} [opts]
|
|
29
|
+
* dir:產出根目錄(預設 cwd);local:用 file: 依賴本機 kernel(開發用,預設用 npm 正式版本)
|
|
30
|
+
* @returns {{ target: string, files: string[], dep: string }}
|
|
26
31
|
*/
|
|
27
|
-
export function newAgent(name, { dir = process.cwd(), kernelPath = KERNEL_ROOT } = {}) {
|
|
32
|
+
export function newAgent(name, { dir = process.cwd(), local = false, kernelPath = KERNEL_ROOT } = {}) {
|
|
28
33
|
if (!name || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) {
|
|
29
34
|
throw new Error(`agent 名稱不合法:「${name}」(只能用字母/數字/連字號,且不以連字號開頭)`);
|
|
30
35
|
}
|
|
31
36
|
const target = join(dir, name);
|
|
32
37
|
if (existsSync(target)) throw new Error(`目錄已存在:${target}`);
|
|
33
38
|
|
|
39
|
+
// 依賴:預設用 npm 正式版本(^x.y.z);--local 用 file: 指向本機 kernel(開發測試用)
|
|
40
|
+
const dep = local ? `file:${kernelPath}` : `^${kernelVersion()}`;
|
|
41
|
+
|
|
34
42
|
mkdirSync(target, { recursive: true });
|
|
35
|
-
const subst = (s) => s.replaceAll('__NAME__', name).replaceAll('
|
|
43
|
+
const subst = (s) => s.replaceAll('__NAME__', name).replaceAll('__KERNEL_DEP__', dep);
|
|
36
44
|
for (const [tmpl, outName] of FILES) {
|
|
37
45
|
writeFileSync(join(target, outName), subst(readFileSync(join(TEMPLATES, tmpl), 'utf8')));
|
|
38
46
|
}
|
|
39
|
-
return { target, files: FILES.map(([, o]) => o) };
|
|
47
|
+
return { target, files: FILES.map(([, o]) => o), dep };
|
|
40
48
|
}
|