xitto-kernel 0.8.5 → 0.9.3
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 +37 -0
- package/README.md +1 -3
- package/package.json +1 -1
- package/src/app/server.js +1 -1
- package/src/app/web/index.html +80 -80
- package/src/kernel/agent-loop.js +6 -0
- package/src/kernel/index.js +5 -1
- package/src/packs/coding/index.js +5 -2
- package/src/packs/general/index.js +6 -3
- package/src/packs/shared/fs-tools.js +6 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.3
|
|
4
|
+
|
|
5
|
+
- **許願台佈局優化(視覺層次 + 互動細節)**:
|
|
6
|
+
- **頂部列**:專案控制(下拉 + 選資料夾 + 新專案)群組成一個卡片靠右、加底部分隔線、置中對齊;標題與控制不再擠成一團;窄螢幕隱藏副標
|
|
7
|
+
- **左欄**:歷史成品與檔案兩段做成卡片區塊;**當前任務在歷史列高亮**(知道你正在看哪個);時間改友善格式(月/日 時:分);列項改輕量(hover/active),不再雙層卡片
|
|
8
|
+
- **許願框**:focus 高亮邊框 + 「⌘/Ctrl+Enter 送出」提示與快捷鍵
|
|
9
|
+
- **主區**:歡迎/空狀態改虛線框、置中,更像「等你下訂單」
|
|
10
|
+
- 純前端(CSS/HTML/小 JS);測試 192/192 + JS 語法 + 結構驗證
|
|
11
|
+
|
|
12
|
+
## 0.9.2
|
|
13
|
+
|
|
14
|
+
- **修:報告顯示完成但找不到真實檔案**(成品寫到工作區外)。從執行歷史查出:某任務 workspace=`/Users/…/Xiza`(本地就地),但 agent 把報告 `write` 到 `/tmp/…`、`/app/…`(絕對路徑,工作區外)→ 成品掃描只看工作區 → `artifacts:{created:[]}`,但 summary 說完成 → 使用者看到「有報告」卻找不到檔。兩道修法:
|
|
15
|
+
- **告知工作目錄**:system prompt 明確寫出 cwd +「請用相對路徑寫在此目錄內,不要寫到 /tmp、/app 等外面」(agent 原本不知道工作目錄在哪,只能亂猜)
|
|
16
|
+
- **寫檔限制在工作區內**:general/coding/fs-tools 的 `write`/`edit` confine 到 cwd,逃逸(絕對路徑外/`../`)直接擋下並回錯誤;讀檔不限制
|
|
17
|
+
- 4 個新測試(相對 OK/絕對逃逸擋/`..` 擋/`/app` 擋)+ 真實 model 端到端(report.md 落在工作區內、artifacts 正確)。測試 192/192
|
|
18
|
+
|
|
19
|
+
## 0.9.1
|
|
20
|
+
|
|
21
|
+
- **修:任務一直迴圈無法結束**。從持久化的執行歷史分析出:某任務(查 2026 世界盃淘汰賽賽程——資訊不存在)35 步 6 輪、web_search×11 + web_fetch×23 不停繞圈,因為查不到、驗收一直判未達成、agent 每輪都有動作所以「無進展」偵測不到 → 跑到上限/被手動取消。三道防護:
|
|
22
|
+
- **目標迴圈**:驗收回饋連續重複(agent 在繞圈、沒朝驗收要求收斂)→ 連 2 次相同就停(stalled),不再因「有動作」而空轉到上限
|
|
23
|
+
- **agent loop 硬上限**:單回合工具呼叫達 `maxSteps`(80) → 注入「別再用工具,用現有資訊作結」逼它收尾(修 `while(true)` 無上限的潛在無限迴圈)
|
|
24
|
+
- **server**:goal 任務 `maxRounds` 由 12 降到 8(許願台 fire-and-forget,不宜跑太久)
|
|
25
|
+
- 1 個新測試(不可達成目標在 ≤4 輪內停止,非跑到上限)。測試 190/190
|
|
26
|
+
|
|
27
|
+
## 0.9.0
|
|
28
|
+
|
|
29
|
+
- **許願台改單頁佈局(移除分頁,許願與工作台同頁呈現)**:原本「許願 | 工作台」要切分頁。改成一頁看全部:
|
|
30
|
+
- **頂部** = 許願輸入;**左欄** = 歷史成品 + 📂 檔案瀏覽器(各自獨立內捲,都到得了底);**主區** = 當前任務/進度/成品 + 檔案預覽(共用一個 #fview)
|
|
31
|
+
- 點任一檔(成品檔 或 工作區檔)都在**同一個主區預覽**;檔案瀏覽器逐層導航
|
|
32
|
+
- 任務完成自動刷新左欄檔案列;切換空間/新專案/選資料夾都同步刷新歷史+檔案
|
|
33
|
+
- 移除 tab 切換邏輯與多餘 DOM;交辦、看歷史、瀏覽檔案、預覽全在一頁,不再跳來跳去
|
|
34
|
+
- 純前端重構;測試 189/189 + 內嵌 JS 語法檢查 + 服務頁面結構驗證(.layout/.nav/.work/#fview、無 tabs、div 平衡)
|
|
35
|
+
|
|
36
|
+
## 0.8.6
|
|
37
|
+
|
|
38
|
+
- **修:工作台預覽開啟時目錄捲不動**。`.wbleft`(檔案列) 是 `position:sticky`,清單比視窗高時 top 被釘在 14px、底部就到不了(sticky 陷阱,預覽讓整列變高時浮現)。改成側欄/檔案列/預覽**各自獨立內捲**(`max-height:calc(100vh - 28px) + overflow:auto`),不再靠頁面捲動;窄螢幕(<=860px)回到單欄、取消內捲。測試 189/189。
|
|
39
|
+
|
|
3
40
|
## 0.8.5
|
|
4
41
|
|
|
5
42
|
- **許願台重啟後歷史還在(持久化)**:原本任務清單與對話 session 都是 in-memory,重啟全沒。改成落地:
|
package/README.md
CHANGED
|
@@ -110,9 +110,7 @@ npm run serve:local # = LOCAL=1 SANDBOX=off,token 預設
|
|
|
110
110
|
- **繼續/調整(迭代有脈絡)**:成品上有「↳ 繼續/調整這個成果」——打一句想改什麼/想深入什麼,送出一個**後續任務**,**接續這次的對話(sessionId)+ 同工作區**。agent 同時有「檔案 + 當時的討論與理由」,不只是檔案。預設每個許願是乾淨新對話(不暴脹),按「繼續」才接續那條線(像 ChatGPT 開新對話 vs 接著聊)。歷史以 `↳` 標出接續鏈
|
|
111
111
|
- **歷史成品**:過往交辦的清單(願望 + 狀態),不是聊天串
|
|
112
112
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
**工作台分頁(看見並管理你的工作區)**:許願台頂部有「許願 | 📂 工作台」分頁(同頁切換,不是另開頁面)。**許願**=交辦任務的主流程;**工作台**=**逐層瀏覽**當前專案的檔案(像檔案總管:只列當前目錄的子資料夾+檔案,點資料夾才進去,不一次遞迴攤平),看/下載/刪。單一任務用許願就夠;做專案的人切到工作台看「我累積了什麼、拿任意檔、清理」——讓持久工作空間從隱形變成可見可管理。刻意保持輕量(檔案清單,不是 IDE)。
|
|
113
|
+
**單頁佈局(無分頁,一眼看全部)**:頂部**許願輸入** + 左欄(**歷史成品** + **📂 檔案瀏覽器**,各自內捲)+ 主區(**當前任務/進度/成品/檔案預覽**共用)。不用切分頁——交辦任務、看歷史、瀏覽工作區檔案、預覽內容都在同一頁。檔案瀏覽器**逐層導航**(像檔案總管,不一次遞迴攤平),點任一檔(成品或工作區)都在主區預覽。容器 1180px,窄螢幕(≤860px)自動收成單欄。刻意保持輕量(不是 IDE)。
|
|
116
114
|
|
|
117
115
|
**持久工作空間(成品間的關係)**:每個成品是**獨立的對話**(不續接前一個,避免 context 暴脹),但**共用一個持久工作空間**(`.xitto-server/ws/<workspace>`,預設 `default`)——所以 ① **檔案留存**,後面的任務能接續前面的成果(「把我上次做的 plan.md 翻成英文」);② **五層沉澱跨成品累積**(偏好/技能/經驗/信任)——它**越用越懂你**,不再是每次都從零開始的陌生人。`workspace` 可在 POST 時指定(多使用者各自一個);網頁有「專案」下拉切換,每份成品卡標出 `📁 所屬空間`。
|
|
118
116
|
|
package/package.json
CHANGED
package/src/app/server.js
CHANGED
|
@@ -272,7 +272,7 @@ export function createServerApp({ model, getApiKey, token, baseDir = '.xitto-ser
|
|
|
272
272
|
const wrapped = (ev) => { if (ev.type === 'message_end' && ev.message?.usage) { usage.input += ev.message.usage.input || 0; usage.output += ev.message.usage.output || 0; } onEvent?.(ev); };
|
|
273
273
|
if (spec.mode === 'goal') {
|
|
274
274
|
// 結果導向:回傳交付物(做了什麼 + 產出的檔案 + 是否達成),對話只是過程
|
|
275
|
-
const o = await kernel.runOutcome(spec.goal || spec.input ||
|
|
275
|
+
const o = await kernel.runOutcome(spec.goal || spec.input || "", { maxRounds: 8, history: sess.history, onEvent: wrapped, onAgent, onRound: (i) => wrapped({ type: 'round', round: i.round, maxRounds: i.maxRounds }) });
|
|
276
276
|
sess.history = o.history || []; sessions.set(sessionId, sess); persistSession(sessionId, sess);
|
|
277
277
|
try { rmSync(join(workdir, 'tmp'), { recursive: true, force: true }); } catch { /* 清過程檔,失敗無妨 */ }
|
|
278
278
|
// 溯源:邏輯位置 workspace 永遠記;實體路徑只在本地模式給(託管不洩漏伺服器路徑)
|
package/src/app/web/index.html
CHANGED
|
@@ -9,18 +9,30 @@
|
|
|
9
9
|
* { box-sizing:border-box; }
|
|
10
10
|
body { margin:0; font:15px/1.6 -apple-system,"PingFang TC","Microsoft JhengHei",system-ui,sans-serif; background:var(--bg); color:var(--fg); }
|
|
11
11
|
.wrap { max-width:1180px; margin:0 auto; padding:24px 22px 80px; }
|
|
12
|
-
/*
|
|
13
|
-
.
|
|
14
|
-
.
|
|
15
|
-
.
|
|
16
|
-
.
|
|
17
|
-
.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
/* 單頁佈局:頂部許願 + 左欄(歷史+檔案) + 主區(當前任務/成品/預覽共用)。窄螢幕收單欄 */
|
|
13
|
+
.layout { display:grid; grid-template-columns:300px minmax(0,1fr); gap:24px; align-items:start; margin-top:18px; }
|
|
14
|
+
.nav { position:sticky; top:14px; max-height:calc(100vh - 28px); display:flex; flex-direction:column; gap:16px; }
|
|
15
|
+
.navsec { display:flex; flex-direction:column; min-height:0; background:var(--card); border:1px solid var(--line); border-radius:12px; padding:10px 12px; }
|
|
16
|
+
.navsec h3 { margin:0 0 8px; position:sticky; top:0; }
|
|
17
|
+
.hist-sec #history, .file-sec #wbfiles { overflow:auto; } /* 兩段各自內捲,都到得了底 */
|
|
18
|
+
.hist-sec { flex:0 1 auto; max-height:44vh; } .hist-sec #history { max-height:40vh; }
|
|
19
|
+
.file-sec { flex:1 1 auto; min-height:120px; } .file-sec #wbfiles { flex:1; }
|
|
20
|
+
.work { min-width:0; }
|
|
21
|
+
.welcome { padding:56px 24px; text-align:center; line-height:2; font-size:15px; border:1px dashed var(--line); border-radius:14px; }
|
|
22
|
+
@media (max-width:860px){ .layout{ grid-template-columns:1fr; } .nav{ position:static; max-height:none; } .hist-sec,.hist-sec #history,.file-sec #wbfiles{ max-height:none; } }
|
|
23
|
+
header { display:flex; align-items:center; gap:12px; padding-bottom:14px; border-bottom:1px solid var(--line); }
|
|
24
|
+
header h1 { font-size:20px; margin:0; white-space:nowrap; }
|
|
21
25
|
header .sub { color:var(--dim); font-size:13px; }
|
|
22
|
-
.
|
|
23
|
-
.
|
|
26
|
+
.topctl { display:flex; align-items:center; gap:8px; background:var(--card); border:1px solid var(--line); border-radius:10px; padding:5px 8px; }
|
|
27
|
+
.prjlabel { color:var(--dim); font-size:12px; }
|
|
28
|
+
.topctl select { padding:5px 8px; max-width:200px; }
|
|
29
|
+
.topctl button { padding:5px 10px; font-size:13px; font-weight:500; }
|
|
30
|
+
@media (max-width:640px){ header .sub { display:none; } }
|
|
31
|
+
.ask { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:14px; margin:18px 0; transition:border-color .15s; }
|
|
32
|
+
.ask:focus-within { border-color:var(--accent); }
|
|
33
|
+
.ask textarea { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:10px; padding:12px; font:inherit; resize:vertical; min-height:70px; outline:none; }
|
|
34
|
+
.ask textarea:focus { border-color:var(--accent); }
|
|
35
|
+
.askhint { color:var(--dim); font-size:12px; }
|
|
24
36
|
.row { display:flex; gap:10px; align-items:center; margin-top:10px; }
|
|
25
37
|
select, button { font:inherit; }
|
|
26
38
|
select { background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:8px; }
|
|
@@ -68,15 +80,14 @@
|
|
|
68
80
|
.qbox .q { color:var(--warn); margin-bottom:8px; }
|
|
69
81
|
.qbox input { width:100%; background:#0c0e12; color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:9px; font:inherit; }
|
|
70
82
|
h3 { color:var(--dim); font-size:13px; font-weight:600; text-transform:uppercase; letter-spacing:.05em; margin:28px 0 8px; }
|
|
71
|
-
.hist {
|
|
72
|
-
.hist:hover {
|
|
73
|
-
.hist
|
|
83
|
+
.hist { border:1px solid transparent; border-radius:8px; padding:8px 10px; margin:3px 0; cursor:pointer; }
|
|
84
|
+
.hist:hover { background:#0c0e12; }
|
|
85
|
+
.hist.active { background:#0c0e12; border-color:var(--accent); }
|
|
86
|
+
.hist .g { font-size:13.5px; line-height:1.4; } .hist .m { color:var(--dim); font-size:11px; margin-top:2px; }
|
|
74
87
|
.empty { color:var(--dim); font-size:13px; }
|
|
75
|
-
.
|
|
76
|
-
.
|
|
77
|
-
.
|
|
78
|
-
.wbrow { display:flex; align-items:center; gap:10px; padding:9px 12px; background:var(--card); border:1px solid var(--line); border-radius:8px; margin:6px 0; }
|
|
79
|
-
.wbname { flex:1; color:var(--accent); cursor:pointer; font-size:14px; }
|
|
88
|
+
.wbrow { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:8px; margin:1px 0; }
|
|
89
|
+
.wbrow:hover { background:#0c0e12; }
|
|
90
|
+
.wbname { flex:1; color:var(--accent); cursor:pointer; font-size:13.5px; }
|
|
80
91
|
.wbmeta { color:var(--dim); font-size:12px; }
|
|
81
92
|
.wbdel { cursor:pointer; opacity:.6; } .wbdel:hover { opacity:1; }
|
|
82
93
|
.followup { margin-top:14px; padding-top:12px; border-top:1px solid var(--line); }
|
|
@@ -105,11 +116,14 @@
|
|
|
105
116
|
<div class="wrap">
|
|
106
117
|
<header>
|
|
107
118
|
<h1>🪄 xitto 許願台</h1>
|
|
108
|
-
<span class="sub"
|
|
119
|
+
<span class="sub">說出你想完成的事,做完給你成品</span>
|
|
109
120
|
<span class="spacer"></span>
|
|
110
|
-
<
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
<div class="topctl">
|
|
122
|
+
<span class="prjlabel">專案</span>
|
|
123
|
+
<select id="space" title="專案/空間:不同專案的檔案與記憶各自獨立"></select>
|
|
124
|
+
<button class="ghost" id="browsebtn" title="瀏覽並選一個真實資料夾(本地模式)" style="display:none">📁 選</button>
|
|
125
|
+
<button class="ghost" id="newspace" title="新增一個專案/空間">+ 新專案</button>
|
|
126
|
+
</div>
|
|
113
127
|
</header>
|
|
114
128
|
|
|
115
129
|
<div id="fsmodal" class="modal" style="display:none">
|
|
@@ -126,38 +140,31 @@
|
|
|
126
140
|
</div>
|
|
127
141
|
</div>
|
|
128
142
|
|
|
129
|
-
<div class="
|
|
130
|
-
<
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
<div class="ask">
|
|
137
|
-
<textarea id="goal" placeholder="例如:把這個資料夾的 .md 檔整理成一份目錄 index.md;或:抓 example.com 的標題寫進 title.txt"></textarea>
|
|
138
|
-
<div class="row">
|
|
139
|
-
<select id="pack" title="領域"></select>
|
|
140
|
-
<span class="spacer"></span>
|
|
141
|
-
<button id="go">交辦 →</button>
|
|
142
|
-
</div>
|
|
143
|
-
</div>
|
|
144
|
-
<div id="current"></div>
|
|
143
|
+
<div class="ask">
|
|
144
|
+
<textarea id="goal" placeholder="說出你想完成的事⋯例如:把這個資料夾的 .md 整理成目錄 index.md;或抓 example.com 標題寫進 title.txt"></textarea>
|
|
145
|
+
<div class="row">
|
|
146
|
+
<select id="pack" title="領域"></select>
|
|
147
|
+
<span class="spacer"></span>
|
|
148
|
+
<span class="askhint">⌘ / Ctrl + Enter 送出</span>
|
|
149
|
+
<button id="go">交辦 →</button>
|
|
145
150
|
</div>
|
|
146
|
-
<aside class="side">
|
|
147
|
-
<h3>歷史成品</h3>
|
|
148
|
-
<div id="history"><div class="empty">還沒有任何任務。<br>左邊交辦一件事試試。</div></div>
|
|
149
|
-
</aside>
|
|
150
151
|
</div>
|
|
151
152
|
|
|
152
|
-
<div
|
|
153
|
-
<
|
|
154
|
-
<
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
153
|
+
<div class="layout">
|
|
154
|
+
<aside class="nav">
|
|
155
|
+
<section class="navsec hist-sec">
|
|
156
|
+
<h3>歷史成品</h3>
|
|
157
|
+
<div id="history"><div class="empty">還沒有任何任務。上面交辦一件事試試。</div></div>
|
|
158
|
+
</section>
|
|
159
|
+
<section class="navsec file-sec">
|
|
160
|
+
<h3>📂 檔案</h3>
|
|
161
|
+
<div id="wbfiles"></div>
|
|
162
|
+
</section>
|
|
163
|
+
</aside>
|
|
164
|
+
<main class="work">
|
|
165
|
+
<div id="current"><div class="empty welcome">👋 上面說出你想完成的事,交給它去做。<br>左邊看歷史成品與工作區檔案,點任一個在這裡展開。</div></div>
|
|
166
|
+
<div class="viewer" id="fview" style="display:none"></div>
|
|
167
|
+
</main>
|
|
161
168
|
</div>
|
|
162
169
|
</div>
|
|
163
170
|
|
|
@@ -194,7 +201,8 @@ let spaces = JSON.parse(localStorage.getItem("xk_spaces")||'["default"]');
|
|
|
194
201
|
let curSpace = localStorage.getItem("xk_space")||"default";
|
|
195
202
|
const spaceLabel=s=>s.startsWith("/")?("📁 "+(s.split("/").filter(Boolean).pop()||s)):s;
|
|
196
203
|
function renderSpaces(){ $("#space").innerHTML = spaces.map(s=>`<option value="${esc(s)}" ${s===curSpace?"selected":""}>${esc(spaceLabel(s))}</option>`).join(""); }
|
|
197
|
-
|
|
204
|
+
function refreshAll(){ $("#fview").style.display="none"; wbSub=""; loadHistory(); loadFiles(); }
|
|
205
|
+
$("#space").onchange = () => { curSpace=$("#space").value; localStorage.setItem("xk_space",curSpace); $("#current").innerHTML=""; refreshAll(); };
|
|
198
206
|
$("#newspace").onclick = () => {
|
|
199
207
|
const raw=(prompt(LOCAL?"新專案:輸入名稱,或貼上一個真實資料夾的絕對路徑(本地模式會就地改該資料夾的檔,像 Claude Code)":"新專案名稱(英數/底線/連字號):")||"").trim();
|
|
200
208
|
if(!raw) return;
|
|
@@ -202,7 +210,7 @@ $("#newspace").onclick = () => {
|
|
|
202
210
|
if(!n) return;
|
|
203
211
|
if(!spaces.includes(n)) spaces.push(n);
|
|
204
212
|
curSpace=n; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace);
|
|
205
|
-
renderSpaces(); $("#current").innerHTML="";
|
|
213
|
+
renderSpaces(); $("#current").innerHTML=""; refreshAll();
|
|
206
214
|
};
|
|
207
215
|
renderSpaces();
|
|
208
216
|
|
|
@@ -220,7 +228,7 @@ async function fsGo(p){
|
|
|
220
228
|
}
|
|
221
229
|
function fsHome(){ fsGo(window._fsHome||null); }
|
|
222
230
|
function closeFs(){ $("#fsmodal").style.display="none"; }
|
|
223
|
-
function chooseFs(){ if(!fsPath) return; const p=fsPath; if(!spaces.includes(p)) spaces.push(p); curSpace=p; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); closeFs(); $("#current").innerHTML="";
|
|
231
|
+
function chooseFs(){ if(!fsPath) return; const p=fsPath; if(!spaces.includes(p)) spaces.push(p); curSpace=p; localStorage.setItem("xk_spaces",JSON.stringify(spaces)); localStorage.setItem("xk_space",curSpace); renderSpaces(); closeFs(); $("#current").innerHTML=""; refreshAll(); }
|
|
224
232
|
|
|
225
233
|
// 極簡 markdown 渲染(零依賴、可離線;夠用於 agent 產的報告)
|
|
226
234
|
function mdRender(src){
|
|
@@ -274,33 +282,23 @@ async function renderFile(urlFn, encPath, name, sel){
|
|
|
274
282
|
v.innerHTML=bar+body;
|
|
275
283
|
}
|
|
276
284
|
function viewFile(id, encPath, name){ return renderFile(taskFileUrl(id), encPath, name, "#fview"); }
|
|
277
|
-
function wbView(encPath, name){
|
|
285
|
+
function wbView(encPath, name){ return renderFile(wsFileUrl(curSpace), encPath, name, "#fview"); }
|
|
278
286
|
|
|
279
|
-
//
|
|
287
|
+
// 左欄檔案瀏覽器:逐層瀏覽 / 看(→ 主區 #fview) / 刪
|
|
280
288
|
function fmtSize(n){ return n<1024?n+" B":n<1048576?(n/1024).toFixed(1)+" KB":(n/1048576).toFixed(1)+" MB"; }
|
|
281
289
|
let wbSub="";
|
|
282
|
-
function wbNav(s){ wbSub=decodeURIComponent(s);
|
|
283
|
-
function wbUp(){ wbSub=wbSub.includes("/")?wbSub.slice(0,wbSub.lastIndexOf("/")):"";
|
|
284
|
-
async function
|
|
285
|
-
$("#wbview").style.display="none"; { const h=$("#wbhint"); if(h) h.style.display=""; }
|
|
290
|
+
function wbNav(s){ wbSub=decodeURIComponent(s); loadFiles(); }
|
|
291
|
+
function wbUp(){ wbSub=wbSub.includes("/")?wbSub.slice(0,wbSub.lastIndexOf("/")):""; loadFiles(); }
|
|
292
|
+
async function loadFiles(){
|
|
286
293
|
const r=await api("/v1/workspaces/files?ws="+encodeURIComponent(curSpace)+"&sub="+encodeURIComponent(wbSub)).then(r=>r.json()).catch(()=>({dirs:[],files:[]}));
|
|
287
|
-
|
|
288
|
-
let html=`<div class="loc">📂 ${esc(root)}${wbSub?" / "+esc(wbSub):""}</div>`;
|
|
294
|
+
let html=wbSub?`<div class="loc">📂 ${esc(wbSub)}</div>`:"";
|
|
289
295
|
if(wbSub) html+=`<div class="wbrow up" onclick="wbUp()"><span class="wbname">⬆ 上一層</span></div>`;
|
|
290
296
|
html+=(r.dirs||[]).map(d=>`<div class="wbrow" onclick="wbNav('${encodeURIComponent((wbSub?wbSub+'/':'')+d)}')"><span class="wbname">📁 ${esc(d)}/</span></div>`).join("");
|
|
291
297
|
html+=(r.files||[]).map(f=>{const full=(wbSub?wbSub+'/':'')+f.name;return `<div class="wbrow"><span class="wbname" onclick="wbView('${encodeURIComponent(full)}','${esc(f.name)}')">📄 ${esc(f.name)}</span><span class="wbmeta">${fmtSize(f.size)}</span><span class="wbdel" title="刪除" onclick="wbDelete('${encodeURIComponent(full)}','${esc(f.name)}')">🗑</span></div>`;}).join("");
|
|
292
|
-
if(!(r.dirs||[]).length && !(r.files||[]).length) html+=`<div class="empty">(${wbSub?"
|
|
298
|
+
if(!(r.dirs||[]).length && !(r.files||[]).length) html+=`<div class="empty">(${wbSub?"空資料夾":"還沒有檔案"})</div>`;
|
|
293
299
|
$("#wbfiles").innerHTML=html;
|
|
294
300
|
}
|
|
295
|
-
async function wbDelete(encPath, name){ if(!confirm("刪除 "+name+"?此動作無法復原。")) return; await api("/v1/workspaces/file?ws="+encodeURIComponent(curSpace)+"&path="+encodeURIComponent(decodeURIComponent(encPath)),{method:"DELETE"});
|
|
296
|
-
function switchTab(name){
|
|
297
|
-
const wish=name==="wish";
|
|
298
|
-
$("#wishview").style.display=wish?"":"none";
|
|
299
|
-
$("#workbench").style.display=wish?"none":"";
|
|
300
|
-
$("#tab-wish").classList.toggle("active",wish);
|
|
301
|
-
$("#tab-wb").classList.toggle("active",!wish);
|
|
302
|
-
if(!wish){ wbSub=""; loadWorkbench(); }
|
|
303
|
-
}
|
|
301
|
+
async function wbDelete(encPath, name){ if(!confirm("刪除 "+name+"?此動作無法復原。")) return; await api("/v1/workspaces/file?ws="+encodeURIComponent(curSpace)+"&path="+encodeURIComponent(decodeURIComponent(encPath)),{method:"DELETE"}); loadFiles(); }
|
|
304
302
|
|
|
305
303
|
let activeId = null, polling = null;
|
|
306
304
|
|
|
@@ -311,9 +309,11 @@ $("#go").onclick = async () => {
|
|
|
311
309
|
$("#go").disabled = false;
|
|
312
310
|
if (r.error) { alert(r.error); return; }
|
|
313
311
|
$("#goal").value = "";
|
|
312
|
+
$("#fview").style.display="none"; expandedLog=false;
|
|
314
313
|
activeId = r.taskId;
|
|
315
|
-
poll();
|
|
314
|
+
poll(); loadHistory();
|
|
316
315
|
};
|
|
316
|
+
$("#goal").addEventListener("keydown", e=>{ if((e.metaKey||e.ctrlKey) && e.key==="Enter"){ e.preventDefault(); $("#go").click(); } });
|
|
317
317
|
|
|
318
318
|
function statusClass(s){ return s==="running"?"running":s==="needs-input"?"needs":s==="done"?"done":(s==="error"||s==="cancelled"||s==="interrupted")?"error":""; }
|
|
319
319
|
function statusText(s){ return ({queued:"排隊中",running:"進行中…","needs-input":"需要你回答",done:"已完成",error:"失敗",cancelled:"已中斷",interrupted:"已中斷(重啟)"})[s]||s; }
|
|
@@ -326,7 +326,7 @@ async function poll() {
|
|
|
326
326
|
const t = await api("/v1/tasks/"+activeId).then(r=>r.json());
|
|
327
327
|
liveTask = t;
|
|
328
328
|
renderCurrent(t);
|
|
329
|
-
if (t.status==="done" || t.status==="error") { loadHistory(); return; }
|
|
329
|
+
if (t.status==="done" || t.status==="error") { loadHistory(); loadFiles(); return; }
|
|
330
330
|
if (t.status==="needs-input") return; // 等使用者回答
|
|
331
331
|
polling = setTimeout(poll, 1200);
|
|
332
332
|
}
|
|
@@ -348,7 +348,6 @@ function renderCurrent(t) {
|
|
|
348
348
|
${t.status==="error"?`<div class="summary">⚠ ${esc(t.error)}</div>`:""}
|
|
349
349
|
${files.length?`<div class="files">📦 成品:${files.map(([f,k])=>`<span class="file ${k==="mod"?"mod":""}" onclick="viewFile('${t.taskId}','${encodeURIComponent(f)}','${esc(f)}')">${k==="mod"?"~":"+"} ${esc(f)}</span>`).join("")}</div>`:""}
|
|
350
350
|
${t.result&&t.result.workspaceDir?`<div class="loc">📂 檔案位置:<code title="點擊複製" onclick="navigator.clipboard&&navigator.clipboard.writeText('${esc(t.result.workspaceDir)}')">${esc(t.result.workspaceDir)}</code></div>`:""}
|
|
351
|
-
<div class="viewer" id="fview" style="display:none"></div>
|
|
352
351
|
${t.status==="done"?`<div class="followup">
|
|
353
352
|
<button class="ghost" onclick="toggleFollowup()">↳ 繼續/調整這個成果</button>
|
|
354
353
|
<div id="fubox" style="display:none;margin-top:8px">
|
|
@@ -379,13 +378,14 @@ async function submitFollowup(sessionId, pack, workspace){
|
|
|
379
378
|
async function loadHistory() {
|
|
380
379
|
const r = await api("/v1/tasks").then(r=>r.json());
|
|
381
380
|
const list = (r.tasks||[]).filter(t=>t.mode==="goal" && (t.workspace||"default")===curSpace).reverse();
|
|
382
|
-
$("#history").innerHTML = list.length ? list.map(t=>`<div class="hist" onclick="openTask('${t.taskId}')">
|
|
381
|
+
$("#history").innerHTML = list.length ? list.map(t=>`<div class="hist${t.taskId===activeId?' active':''}" onclick="openTask('${t.taskId}')">
|
|
383
382
|
<div class="g">${t.continued?'<span class="cont" title="接續前一個任務">↳</span> ':''}${esc(t.goal||t.taskId)} <span class="status ${statusClass(t.status)}">${statusText(t.status)}</span></div>
|
|
384
|
-
<div class="m">${esc(t.createdAt)}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
|
|
383
|
+
<div class="m">${esc(fmtTime(t.createdAt))}</div></div>`).join("") : `<div class="empty">還沒有任何任務。</div>`;
|
|
385
384
|
}
|
|
386
|
-
|
|
385
|
+
function fmtTime(s){ try{ const d=new Date(s); return d.toLocaleString(undefined,{month:"numeric",day:"numeric",hour:"2-digit",minute:"2-digit"}); }catch{ return s; } }
|
|
386
|
+
async function openTask(id){ activeId=id; const t=await api("/v1/tasks/"+id).then(r=>r.json()); liveTask=t; $("#fview").style.display="none"; renderCurrent(t); loadHistory(); if(t.status==="running"||t.status==="queued"){poll();} window.scrollTo({top:0,behavior:"smooth"}); }
|
|
387
387
|
|
|
388
|
-
loadHistory();
|
|
388
|
+
loadHistory(); loadFiles();
|
|
389
389
|
</script>
|
|
390
390
|
</body>
|
|
391
391
|
</html>
|
package/src/kernel/agent-loop.js
CHANGED
|
@@ -50,6 +50,7 @@ export class Agent {
|
|
|
50
50
|
};
|
|
51
51
|
this.listeners = new Set();
|
|
52
52
|
this.streamFn = options.streamFn; // 必傳(xitto 都注入;缺則無法串流)
|
|
53
|
+
this.maxSteps = options.maxSteps || 80; // 單回合硬上限:防 agent 無限呼叫工具繞圈
|
|
53
54
|
this.getApiKey = options.getApiKey;
|
|
54
55
|
this.beforeToolCall = options.beforeToolCall;
|
|
55
56
|
this.afterToolCall = options.afterToolCall;
|
|
@@ -135,6 +136,7 @@ export class Agent {
|
|
|
135
136
|
const newMessages = [];
|
|
136
137
|
let pending = initialMessages.slice();
|
|
137
138
|
let firstTurn = true;
|
|
139
|
+
let steps = 0;
|
|
138
140
|
while (true) {
|
|
139
141
|
let hasMoreToolCalls = true;
|
|
140
142
|
while (hasMoreToolCalls || pending.length) {
|
|
@@ -149,6 +151,10 @@ export class Agent {
|
|
|
149
151
|
// abort 守衛:迴圈邊界若已中止,立即走 handleRunFailure(aborted)。
|
|
150
152
|
// 真實 provider 由 fetch signal 中止串流;此守衛確保 fake/工具觸發的 abort 也確定性收尾。
|
|
151
153
|
if (signal.aborted) throw new Error('Aborted');
|
|
154
|
+
// 硬上限:單回合工具呼叫太多 → 注入一句「別再用工具,直接用現有資訊作結」,逼它收尾
|
|
155
|
+
if (steps === this.maxSteps - 1) this._state.messages.push({ role: 'user', content: [{ type: 'text', text: `[系統] 已達工具呼叫上限(${this.maxSteps} 步)。請停止使用任何工具,直接根據目前已知資訊給出結論或說明卡在哪、缺什麼,然後結束。` }] });
|
|
156
|
+
if (steps >= this.maxSteps) { await this.emit({ type: 'agent_end', messages: newMessages }); return; }
|
|
157
|
+
steps++;
|
|
152
158
|
const message = await this.streamAssistant(signal);
|
|
153
159
|
newMessages.push(message);
|
|
154
160
|
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
|
package/src/kernel/index.js
CHANGED
|
@@ -236,6 +236,7 @@ export function createKernel(pack, config = {}) {
|
|
|
236
236
|
pack.systemPrompt +
|
|
237
237
|
loadContextFiles(cwd, pack.contextFiles) + // 注入領域規範檔(CLAUDE.md 等)
|
|
238
238
|
'\n\n# 記憶與專案手冊\n' + (pack.memoryGuide || DEFAULT_MEMORY_GUIDE) + '\n' + DEFAULT_PLAYBOOK_GUIDE + '\n' + DEFAULT_EPISODE_GUIDE +
|
|
239
|
+
'\n\n# 工作目錄\n你的工作目錄是:' + cwd + '\n所有檔案請用相對路徑寫在這個目錄內(如 report.md、data/x.csv)。除非使用者明確要求,絕對不要寫到此目錄之外(例如 /tmp、/app、/workspace、系統根目錄)——寫在外面使用者拿不到成品。' +
|
|
239
240
|
'\n\n# 成品與暫存\n' + DEFAULT_OUTPUT_GUIDE +
|
|
240
241
|
(memText ? `\n\n# 已記住的事實(跨 session)\n${memText}` : '') +
|
|
241
242
|
(pbText ? `\n\n# 專案手冊(這個專案怎麼做事,跨 session 累積)\n${pbText}` : '') +
|
|
@@ -438,6 +439,7 @@ export function createKernel(pack, config = {}) {
|
|
|
438
439
|
let lastRemaining = null;
|
|
439
440
|
let noProgress = 0;
|
|
440
441
|
let verifyErrors = 0;
|
|
442
|
+
let sameFeedback = 0;
|
|
441
443
|
for (let round = 1; round <= maxRounds; round++) {
|
|
442
444
|
opts.onRound?.({ round, maxRounds });
|
|
443
445
|
const r = await api.runTurn(instruction, { history, onEvent: opts.onEvent, onAgent: opts.onAgent });
|
|
@@ -459,7 +461,9 @@ export function createKernel(pack, config = {}) {
|
|
|
459
461
|
}
|
|
460
462
|
verifyErrors = 0;
|
|
461
463
|
const rem = normalizeFeedback(v.remaining);
|
|
462
|
-
|
|
464
|
+
// 驗收回饋重複 = agent 在繞圈(即使一直有動作也沒朝驗收要求收斂,如查不到的資訊一直換來源)→ 連 2 次相同就停,別空轉到上限
|
|
465
|
+
if (rem && rem === lastRemaining) { if (++sameFeedback >= 2) return { done: false, stalled: true, rounds: round, history }; }
|
|
466
|
+
else sameFeedback = 0;
|
|
463
467
|
lastRemaining = rem;
|
|
464
468
|
instruction = `目標尚未達成。驗收回饋:${v.remaining}\n請繼續完成目標:${goal}`;
|
|
465
469
|
}
|
|
@@ -30,6 +30,9 @@ const SYSTEM_PROMPT = [
|
|
|
30
30
|
export function createCodingPack({ cwd = process.cwd() } = {}) {
|
|
31
31
|
const readFiles = new Set(); // 已 read 過的絕對路徑(read 工具寫入、read-before-edit 守衛讀取)
|
|
32
32
|
const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
|
|
33
|
+
// 寫檔限制在工作目錄內:逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
|
|
34
|
+
const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
|
|
35
|
+
const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md)', path });
|
|
33
36
|
const bg = createBackgroundTools(cwd); // bash_bg / bash_output / bash_kill
|
|
34
37
|
|
|
35
38
|
const readTool = {
|
|
@@ -64,7 +67,7 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
|
|
|
64
67
|
name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
|
|
65
68
|
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
66
69
|
execute: async (_id, { path, content }) => {
|
|
67
|
-
const p =
|
|
70
|
+
const p = within(path); if (!p) return escapeErr(path);
|
|
68
71
|
writeFileSync(p, content ?? '', 'utf8');
|
|
69
72
|
readFiles.add(p); // 寫過即視為已知內容
|
|
70
73
|
return txt({ written: path, bytes: Buffer.byteLength(content ?? '') });
|
|
@@ -76,7 +79,7 @@ export function createCodingPack({ cwd = process.cwd() } = {}) {
|
|
|
76
79
|
description: '把檔案中的 oldText 換成 newText。oldText 必須唯一(出現多次會失敗,請加上下文;或設 replaceAll:true 全部取代)。',
|
|
77
80
|
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
|
|
78
81
|
execute: async (_id, { path, oldText, newText, replaceAll }) => {
|
|
79
|
-
const p =
|
|
82
|
+
const p = within(path); if (!p) return escapeErr(path);
|
|
80
83
|
if (!existsSync(p)) return txt({ error: '檔案不存在', path });
|
|
81
84
|
const before = readFileSync(p, 'utf8');
|
|
82
85
|
const occurrences = before.split(oldText).length - 1;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// general pack — 通用自主 agent。廣的 system prompt + 檔案/shell/web 工具。
|
|
2
2
|
// 搭配 kernel 的 runGoal(目標循環)+ 子 agent + MCP,即為「給目標、自己做到完成」的通用 agent。
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
-
import { isAbsolute, join, extname } from 'node:path';
|
|
4
|
+
import { isAbsolute, join, extname, relative } from 'node:path';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { createGrepTool, createGlobTool } from '../shared/code-nav.js';
|
|
7
7
|
import { createWebFetchTool, createWebSearchTool, createHttpTool } from '../shared/web-tools.js';
|
|
@@ -24,6 +24,9 @@ const SYSTEM_PROMPT = [
|
|
|
24
24
|
export function createGeneralPack({ cwd = process.cwd() } = {}) {
|
|
25
25
|
const readFiles = new Set();
|
|
26
26
|
const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
|
|
27
|
+
// 寫檔限制在工作目錄內:回解析後路徑,逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
|
|
28
|
+
const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
|
|
29
|
+
const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md),不要寫到工作區之外', path });
|
|
27
30
|
|
|
28
31
|
const read = {
|
|
29
32
|
name: 'read', label: '讀檔', description: '讀取檔案內容', readOnly: true,
|
|
@@ -38,12 +41,12 @@ export function createGeneralPack({ cwd = process.cwd() } = {}) {
|
|
|
38
41
|
const write = {
|
|
39
42
|
name: 'write', label: '寫檔', description: '建立或覆寫檔案', mutating: true,
|
|
40
43
|
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
41
|
-
execute: async (_id, { path, content }) => { const p =
|
|
44
|
+
execute: async (_id, { path, content }) => { const p = within(path); if (!p) return escapeErr(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
|
|
42
45
|
};
|
|
43
46
|
const edit = {
|
|
44
47
|
name: 'edit', label: '編輯', description: '把檔案中的 oldText 換成 newText', mutating: true,
|
|
45
48
|
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' } }, required: ['path', 'oldText', 'newText'] },
|
|
46
|
-
execute: async (_id, { path, oldText, newText }) => { const p =
|
|
49
|
+
execute: async (_id, { path, oldText, newText }) => { const p = within(path); if (!p) return escapeErr(path); if (!existsSync(p)) return txt({ error: '檔案不存在', path }); const b = readFileSync(p, 'utf8'); if (!b.includes(oldText)) return txt({ error: 'oldText 未找到', path }); writeFileSync(p, b.replace(oldText, newText), 'utf8'); return txt({ edited: path }); },
|
|
47
50
|
};
|
|
48
51
|
const bash = {
|
|
49
52
|
name: 'bash', label: 'bash', description: '執行 shell 命令(可選 timeout 秒數,預設 120)', mutating: true, sandboxable: true,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// 共用檔案/shell 工具(read/ls/write/edit/bash)+ read-before-edit 守衛。
|
|
2
2
|
// 多個 pack(coding/devops…)共用;read 附行號、edit 唯一性檢查、bash 用 spawnSync 捕捉 stderr。
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
4
|
-
import { isAbsolute, join } from 'node:path';
|
|
4
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
|
|
7
7
|
const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s : JSON.stringify(s) }] });
|
|
@@ -13,6 +13,9 @@ const txt = (s) => ({ content: [{ type: 'text', text: typeof s === 'string' ? s
|
|
|
13
13
|
export function createFsTools(cwd) {
|
|
14
14
|
const readFiles = new Set();
|
|
15
15
|
const abs = (p) => (isAbsolute(p) ? p : join(cwd, p));
|
|
16
|
+
// 寫檔限制在工作目錄內:逃逸 cwd(如 /tmp、/app)回 null。讀檔不限制。
|
|
17
|
+
const within = (p) => { const full = abs(p); const r = relative(cwd, full); return (r === '' || (!r.startsWith('..') && !isAbsolute(r))) ? full : null; };
|
|
18
|
+
const escapeErr = (path) => txt({ error: `只能寫在工作目錄內:${cwd}`, hint: '請用相對路徑(如 report.md)', path });
|
|
16
19
|
|
|
17
20
|
const read = {
|
|
18
21
|
name: 'read', label: '讀檔', readOnly: true,
|
|
@@ -38,14 +41,14 @@ export function createFsTools(cwd) {
|
|
|
38
41
|
const write = {
|
|
39
42
|
name: 'write', label: '寫檔', mutating: true, description: '建立或覆寫檔案',
|
|
40
43
|
parameters: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
41
|
-
execute: async (_id, { path, content }) => { const p =
|
|
44
|
+
execute: async (_id, { path, content }) => { const p = within(path); if (!p) return escapeErr(path); writeFileSync(p, content ?? '', 'utf8'); readFiles.add(p); return txt({ written: path, bytes: Buffer.byteLength(content ?? '') }); },
|
|
42
45
|
};
|
|
43
46
|
const edit = {
|
|
44
47
|
name: 'edit', label: '編輯', mutating: true,
|
|
45
48
|
description: '把 oldText 換成 newText。oldText 須唯一(多次出現需 replaceAll)。',
|
|
46
49
|
parameters: { type: 'object', properties: { path: { type: 'string' }, oldText: { type: 'string' }, newText: { type: 'string' }, replaceAll: { type: 'boolean' } }, required: ['path', 'oldText', 'newText'] },
|
|
47
50
|
execute: async (_id, { path, oldText, newText, replaceAll }) => {
|
|
48
|
-
const p =
|
|
51
|
+
const p = within(path); if (!p) return escapeErr(path);
|
|
49
52
|
if (!existsSync(p)) return txt({ error: '檔案不存在', path });
|
|
50
53
|
const before = readFileSync(p, 'utf8');
|
|
51
54
|
const n = before.split(oldText).length - 1;
|