s-app-scoder-agent 0.6.0 → 0.7.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/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -468
- package/dist/agent.js +825 -175
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +121 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +498 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +182 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { claimsCompletion } from "./context.js";
|
|
2
|
+
/** Marker do system prompt quy ước: lượt chat thuần, không cần build. */
|
|
3
|
+
export const CHAT_DONE_MARKER = "<<CHAT_DONE>>";
|
|
4
|
+
export const DEFAULT_GUARDRAIL_OPTIONS = {
|
|
5
|
+
textOnlyStreakLimit: 2,
|
|
6
|
+
doomLoopRepeats: 2,
|
|
7
|
+
wrapUpStepsBeforeEnd: 2,
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Wrap-up prefill: gần hết step budget → ép model dừng mở việc mới, chạy build
|
|
11
|
+
* và tổng kết. Nếu không, model thường đốt nốt các bước cuối để khám phá và
|
|
12
|
+
* run thất bại mà không có summary.
|
|
13
|
+
*/
|
|
14
|
+
export function wrapUpRule(opts) {
|
|
15
|
+
return {
|
|
16
|
+
name: "wrap-up",
|
|
17
|
+
description: `inject reminder ${opts.wrapUpStepsBeforeEnd} steps before budget end`,
|
|
18
|
+
evaluate(ctx) {
|
|
19
|
+
const t = ctx;
|
|
20
|
+
if (t.wrapUpInjected)
|
|
21
|
+
return null;
|
|
22
|
+
if (ctx.maxSteps - ctx.step !== opts.wrapUpStepsBeforeEnd)
|
|
23
|
+
return null;
|
|
24
|
+
return {
|
|
25
|
+
action: {
|
|
26
|
+
kind: "inject",
|
|
27
|
+
reminder: "<system-reminder>Step budget nearly exhausted (2 steps left). Stop starting new work: finish the current change, run the build command, and reply with a final summary. If the build cannot pass in the remaining steps, report exactly what remains in your final message.</system-reminder>",
|
|
28
|
+
},
|
|
29
|
+
reason: `${opts.wrapUpStepsBeforeEnd} steps left in budget`,
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Lượt chat thuần (marker CHAT_DONE) → kết thúc, không cần build. */
|
|
35
|
+
const chatDoneRule = {
|
|
36
|
+
name: "chat-done",
|
|
37
|
+
description: "assistant emitted the chat-done marker — end the conversational turn",
|
|
38
|
+
evaluate(ctx) {
|
|
39
|
+
if (!ctx.chatDone)
|
|
40
|
+
return null;
|
|
41
|
+
// Phòng vệ: làm sạch marker dù loop đã làm trước — summary không bao giờ
|
|
42
|
+
// lộ marker nội bộ ra UI.
|
|
43
|
+
const clean = ctx.text.split(CHAT_DONE_MARKER)[0].trim();
|
|
44
|
+
return {
|
|
45
|
+
action: {
|
|
46
|
+
kind: "finish",
|
|
47
|
+
success: true,
|
|
48
|
+
summary: clean || "Conversation turn complete.",
|
|
49
|
+
chatOnly: true,
|
|
50
|
+
},
|
|
51
|
+
reason: "chat-done marker present",
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Completion claim: model tuyên bố xong việc → không tin lời nói, ép build xác
|
|
57
|
+
* minh. Trường hợp không sửa file nào (chỉ trả lời câu hỏi) thì bỏ qua build.
|
|
58
|
+
*/
|
|
59
|
+
const completionClaimRule = {
|
|
60
|
+
name: "completion-claim",
|
|
61
|
+
description: "assistant claims completion — force a build verification",
|
|
62
|
+
evaluate(ctx) {
|
|
63
|
+
if (ctx.hasToolCalls)
|
|
64
|
+
return null;
|
|
65
|
+
if (!claimsCompletion(ctx.text))
|
|
66
|
+
return null;
|
|
67
|
+
if (ctx.step >= ctx.maxSteps)
|
|
68
|
+
return null;
|
|
69
|
+
if (!ctx.filesModified) {
|
|
70
|
+
return {
|
|
71
|
+
action: { kind: "finish", success: true, summary: ctx.text },
|
|
72
|
+
reason: "claims completion but no files were modified — skip build",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
action: {
|
|
77
|
+
kind: "verify-build",
|
|
78
|
+
successSummary: ctx.text,
|
|
79
|
+
// Loop sẽ ghép thêm "\nOutput:\n<build output>\nFix and continue."
|
|
80
|
+
failReminder: "Build still FAILED after your claimed completion.",
|
|
81
|
+
},
|
|
82
|
+
reason: "claims completion with modified files — verifying build",
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Text-only streak breaker: model chỉ nói không làm (không gọi tool, không sửa
|
|
88
|
+
* file) đủ N bước liên tiếp → dừng run thay vì đốt step budget bằng vòng lặp
|
|
89
|
+
* "nudge" vô sản (failure mode "chào" nổi tiếng).
|
|
90
|
+
*/
|
|
91
|
+
export function textOnlyStreakRule(opts) {
|
|
92
|
+
return {
|
|
93
|
+
name: "text-only-streak",
|
|
94
|
+
description: `end the run after ${opts.textOnlyStreakLimit} consecutive tool-less steps with no file changes`,
|
|
95
|
+
evaluate(ctx) {
|
|
96
|
+
const t = ctx;
|
|
97
|
+
if (ctx.hasToolCalls)
|
|
98
|
+
return null;
|
|
99
|
+
if (t.textOnlyStreak < opts.textOnlyStreakLimit)
|
|
100
|
+
return null;
|
|
101
|
+
if (ctx.filesModified)
|
|
102
|
+
return null;
|
|
103
|
+
return {
|
|
104
|
+
action: { kind: "finish", success: true, summary: ctx.text || "(agent replied without acting)" },
|
|
105
|
+
reason: `${t.textOnlyStreak} consecutive text-only steps with no file changes`,
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Error-loop breaker (H-12): cùng tool + cùng error lặp lại 3 lần ⇒ inject
|
|
112
|
+
* "change approach" — doom-loop mở rộng bằng signature bao gồm error hash.
|
|
113
|
+
*/
|
|
114
|
+
export function errorLoopRule(_opts) {
|
|
115
|
+
return {
|
|
116
|
+
name: "error-loop",
|
|
117
|
+
description: "same tool + same error 3x in a row — force a strategy change",
|
|
118
|
+
evaluate(ctx) {
|
|
119
|
+
const t = ctx;
|
|
120
|
+
if (t.toolErrorRepeat < 2)
|
|
121
|
+
return null; // 3rd occurrence = repeat 2
|
|
122
|
+
return {
|
|
123
|
+
action: {
|
|
124
|
+
kind: "inject",
|
|
125
|
+
reminder: "<system-reminder>The same tool call has failed with the same error 3 times. Stop retrying: the approach itself is wrong. Diagnose the root cause, try a fundamentally different method, or report the blocker in your final message.</system-reminder>",
|
|
126
|
+
},
|
|
127
|
+
reason: `same tool+error repeated ${t.toolErrorRepeat + 1}x`,
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Token-no-progress (H-12): outputTokens < 20 trong 3 step liên tiếp ⇒ finish
|
|
134
|
+
* (model degenerate — tránh treo run không tiến triển).
|
|
135
|
+
*/
|
|
136
|
+
export function tokenNoProgressRule(opts) {
|
|
137
|
+
return {
|
|
138
|
+
name: "token-no-progress",
|
|
139
|
+
description: "output tokens < 20 for 3 consecutive steps — end a degenerate run",
|
|
140
|
+
evaluate(ctx) {
|
|
141
|
+
const t = ctx;
|
|
142
|
+
void opts;
|
|
143
|
+
if (ctx.outputTokensThisStep === undefined)
|
|
144
|
+
return null;
|
|
145
|
+
if (ctx.outputTokensThisStep >= 20)
|
|
146
|
+
return null;
|
|
147
|
+
if (t.lowTokenStreak < 3)
|
|
148
|
+
return null;
|
|
149
|
+
return {
|
|
150
|
+
action: {
|
|
151
|
+
kind: "finish",
|
|
152
|
+
success: false,
|
|
153
|
+
summary: "Run stopped: 3 consecutive near-empty LLM responses (no progress).",
|
|
154
|
+
},
|
|
155
|
+
reason: `output tokens ${ctx.outputTokensThisStep} < 20 for ${t.lowTokenStreak} steps`,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Nudge: trả lời text thường (không claim, không hết streak) → nhắc tiếp tục làm việc. */
|
|
161
|
+
const continueNudgeRule = { name: "continue-nudge",
|
|
162
|
+
description: "tool-less step that neither claims completion nor ends the run — nudge the agent to act",
|
|
163
|
+
evaluate(ctx) {
|
|
164
|
+
if (ctx.hasToolCalls)
|
|
165
|
+
return null;
|
|
166
|
+
if (ctx.chatDone)
|
|
167
|
+
return null;
|
|
168
|
+
if (claimsCompletion(ctx.text))
|
|
169
|
+
return null; // completion-claim rule phụ trách
|
|
170
|
+
return {
|
|
171
|
+
action: {
|
|
172
|
+
kind: "inject",
|
|
173
|
+
reminder: "Continue working on the task. Use tools. Build must pass before you finish.",
|
|
174
|
+
},
|
|
175
|
+
reason: "plain text answer without tool calls",
|
|
176
|
+
};
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Doom-loop breaker: chuỗi tool call giống hệt nhau lặp lại quá ngưỡng →
|
|
181
|
+
* can thiệp bằng lời nhắc đổi chiến lược (model hiếm khi tự thoát khỏi loop;
|
|
182
|
+
* run không người trông sẽ đốt sạch step budget).
|
|
183
|
+
*/
|
|
184
|
+
export function doomLoopRule(opts) {
|
|
185
|
+
return {
|
|
186
|
+
name: "doom-loop",
|
|
187
|
+
description: `intervene after ${opts.doomLoopRepeats + 1} identical consecutive tool-call batches`,
|
|
188
|
+
evaluate(ctx) {
|
|
189
|
+
const t = ctx;
|
|
190
|
+
if (!ctx.hasToolCalls)
|
|
191
|
+
return null;
|
|
192
|
+
if (t.sigRepeat < opts.doomLoopRepeats)
|
|
193
|
+
return null;
|
|
194
|
+
return {
|
|
195
|
+
action: {
|
|
196
|
+
kind: "inject",
|
|
197
|
+
reminder: "<system-reminder>You have made the identical tool call 3 times in a row. This is a loop: read the last output carefully, change your approach, or report the blocker in your final message. Do NOT repeat the same call again.</system-reminder>",
|
|
198
|
+
},
|
|
199
|
+
reason: `identical tool-call batch repeated ${t.sigRepeat + 1}x`,
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Context management: luôn cắt gọn lịch sử sau mỗi bước. */
|
|
205
|
+
const historyTrimRule = {
|
|
206
|
+
name: "history-trim",
|
|
207
|
+
description: "trim message history after every step (token budget)",
|
|
208
|
+
evaluate() {
|
|
209
|
+
return { action: { kind: "trim" }, reason: "routine context management" };
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
/**
|
|
213
|
+
* G6: token budget per run — tổng (input+output) vượt cap → kết thúc run
|
|
214
|
+
* (pause thay vì đốt tiền). Đọc usage từ ctx (loop truyền usageTotals).
|
|
215
|
+
*/
|
|
216
|
+
export function tokenBudgetRule(capTokens) {
|
|
217
|
+
return {
|
|
218
|
+
name: "token-budget",
|
|
219
|
+
description: `end the run when total session tokens exceed ${capTokens}`,
|
|
220
|
+
evaluate(ctx) {
|
|
221
|
+
const total = (ctx.totalTokensIn ?? 0) + (ctx.totalTokensOut ?? 0);
|
|
222
|
+
if (total < capTokens)
|
|
223
|
+
return null;
|
|
224
|
+
return {
|
|
225
|
+
action: {
|
|
226
|
+
kind: "finish",
|
|
227
|
+
success: false,
|
|
228
|
+
summary: `Token budget exhausted: ${total} tokens used (cap ${capTokens}). ` +
|
|
229
|
+
"Report current progress in the summary.",
|
|
230
|
+
},
|
|
231
|
+
reason: `token budget ${total}/${capTokens}`,
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Build stop condition: bước này có tool call chạy ĐÚNG build command →
|
|
238
|
+
* thành công là điều kiện kết thúc run (dùng lại kết quả tool, không build
|
|
239
|
+
* lần 2); thất bại thì tự xác minh lại rồi nudge fix.
|
|
240
|
+
*/
|
|
241
|
+
const buildStopRule = {
|
|
242
|
+
name: "build-stop",
|
|
243
|
+
description: "a tool call ran the exact build command — success ends the run, failure re-verifies",
|
|
244
|
+
evaluate(ctx) {
|
|
245
|
+
if (!ctx.hasToolCalls || !ctx.buildCallPresent)
|
|
246
|
+
return null;
|
|
247
|
+
const summary = ctx.text || "Build passed.";
|
|
248
|
+
if (ctx.buildPassedThisStep) {
|
|
249
|
+
return {
|
|
250
|
+
action: { kind: "finish", success: true, summary },
|
|
251
|
+
reason: "build command succeeded this step",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
action: {
|
|
256
|
+
kind: "verify-build",
|
|
257
|
+
successSummary: summary,
|
|
258
|
+
// Loop sẽ ghép thêm output build thật vào sau.
|
|
259
|
+
failReminder: "Build FAILED (stop condition not met).",
|
|
260
|
+
},
|
|
261
|
+
reason: "build command ran and failed — re-verifying independently",
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
/** Bộ rule mặc định, theo thứ tự ưu tiên (finish đầu tiên thắng). */
|
|
266
|
+
export function defaultGuardrailRules(opts = DEFAULT_GUARDRAIL_OPTIONS) {
|
|
267
|
+
return [
|
|
268
|
+
wrapUpRule(opts),
|
|
269
|
+
chatDoneRule,
|
|
270
|
+
// G6: budget rule đứng sớm — vượt cap là finish ngay, trước mọi rule khác
|
|
271
|
+
// (trừ wrap-up, vốn vô hại khi budget đã cạn).
|
|
272
|
+
...(opts.tokenBudget ? [tokenBudgetRule(opts.tokenBudget)] : []),
|
|
273
|
+
completionClaimRule,
|
|
274
|
+
textOnlyStreakRule(opts),
|
|
275
|
+
continueNudgeRule,
|
|
276
|
+
doomLoopRule(opts),
|
|
277
|
+
errorLoopRule(opts),
|
|
278
|
+
tokenNoProgressRule(opts),
|
|
279
|
+
buildStopRule,
|
|
280
|
+
historyTrimRule,
|
|
281
|
+
];
|
|
282
|
+
}
|
|
283
|
+
// ── Engine ──────────────────────────────────────────────────────────────────
|
|
284
|
+
/**
|
|
285
|
+
* Engine chạy toàn bộ rule mỗi bước và thu thập các decision đã kích hoạt.
|
|
286
|
+
* runLoop áp dụng: finish đầu tiên thắng; inject/trim gom lại áp một lần.
|
|
287
|
+
* G1: toàn bộ tracker state (textOnlyStreak, sigRepeat, wrapUpInjected,
|
|
288
|
+
* toolError*, lowTokenStreak) sống ở đây — runLoop chỉ emit sự kiện thô.
|
|
289
|
+
*/
|
|
290
|
+
export class GuardrailEngine {
|
|
291
|
+
onDecision;
|
|
292
|
+
rules;
|
|
293
|
+
/** G1: per-run tracker — reset() khi bắt đầu run mới. */
|
|
294
|
+
tracker = {
|
|
295
|
+
textOnlyStreak: 0,
|
|
296
|
+
lastCallSig: "",
|
|
297
|
+
sigRepeat: 0,
|
|
298
|
+
wrapUpInjected: false,
|
|
299
|
+
toolErrorSig: "",
|
|
300
|
+
toolErrorRepeat: 0,
|
|
301
|
+
lowTokenStreak: 0,
|
|
302
|
+
};
|
|
303
|
+
constructor(rules, onDecision) {
|
|
304
|
+
this.onDecision = onDecision;
|
|
305
|
+
this.rules = rules ?? defaultGuardrailRules();
|
|
306
|
+
}
|
|
307
|
+
/** Reset tracker — gọi ở đầu mỗi run (per-run state, không rò rỉ giữa các run). */
|
|
308
|
+
reset() {
|
|
309
|
+
this.tracker = {
|
|
310
|
+
textOnlyStreak: 0,
|
|
311
|
+
lastCallSig: "",
|
|
312
|
+
sigRepeat: 0,
|
|
313
|
+
wrapUpInjected: false,
|
|
314
|
+
toolErrorSig: "",
|
|
315
|
+
toolErrorRepeat: 0,
|
|
316
|
+
lowTokenStreak: 0,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
/** Steer batch được deliver — reset text-only allowance một lần cho cả batch. */
|
|
320
|
+
notifySteered() {
|
|
321
|
+
this.tracker.textOnlyStreak = 0;
|
|
322
|
+
}
|
|
323
|
+
/** Wrap-up reminder vừa được apply — chỉ inject một lần mỗi run. */
|
|
324
|
+
markWrapUpInjected() {
|
|
325
|
+
this.tracker.wrapUpInjected = true;
|
|
326
|
+
}
|
|
327
|
+
/** Đăng ký thêm rule (chạy sau các rule mặc định nếu thêm sau khi tạo). */
|
|
328
|
+
register(rule) {
|
|
329
|
+
this.rules.push(rule);
|
|
330
|
+
}
|
|
331
|
+
listRules() {
|
|
332
|
+
return this.rules.map((r) => ({ name: r.name, description: r.description }));
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Chạy tất cả rule; trả về các decision ĐÃ kích hoạt (theo thứ tự ưu tiên).
|
|
336
|
+
* Tracker được cập nhật từ ctx thô TRƯỚC khi rule chạy (rule thấy giá trị
|
|
337
|
+
* của bước này — đúng semantics cũ của runLoop).
|
|
338
|
+
*/
|
|
339
|
+
evaluate(ctx) {
|
|
340
|
+
this.updateTrackers(ctx);
|
|
341
|
+
// Rules nhìn thấy tracker qua ctx mở rộng (internal — không thuộc API public).
|
|
342
|
+
const full = { ...ctx, ...this.tracker };
|
|
343
|
+
// G6: rule có thể async — chạy tuần tự (thứ tự ưu tiên giữ nguyên).
|
|
344
|
+
return (async () => {
|
|
345
|
+
const out = [];
|
|
346
|
+
for (const rule of this.rules) {
|
|
347
|
+
let r;
|
|
348
|
+
try {
|
|
349
|
+
r = await rule.evaluate(full);
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
// Một rule lỗi không được phép làm gãy vòng lặp agent.
|
|
353
|
+
r = { action: { kind: "none" }, reason: `rule threw: ${err.message}` };
|
|
354
|
+
}
|
|
355
|
+
if (!r)
|
|
356
|
+
continue;
|
|
357
|
+
const d = { rule: rule.name, action: r.action, reason: r.reason };
|
|
358
|
+
out.push(d);
|
|
359
|
+
try {
|
|
360
|
+
this.onDecision?.(d);
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
/* logging không được phép giết loop */
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return out;
|
|
367
|
+
})();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* G1: cập nhật tracker từ sự kiện thô của bước này (thay cho state cục bộ
|
|
371
|
+
* của runLoop cũ). Semantics giữ nguyên 100% so với code inline trước đây.
|
|
372
|
+
*/
|
|
373
|
+
updateTrackers(ctx) {
|
|
374
|
+
const t = this.tracker;
|
|
375
|
+
if (ctx.hasToolCalls) {
|
|
376
|
+
t.textOnlyStreak = 0;
|
|
377
|
+
// Doom-loop: chữ ký chuỗi tool call so với bước trước.
|
|
378
|
+
const callSig = (ctx.message?.tool_calls ?? [])
|
|
379
|
+
.map((tc) => `${tc.name}:${tc.arguments}`)
|
|
380
|
+
.sort()
|
|
381
|
+
.join("|");
|
|
382
|
+
t.sigRepeat = callSig === t.lastCallSig ? t.sigRepeat + 1 : 0;
|
|
383
|
+
t.lastCallSig = callSig;
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
t.textOnlyStreak++;
|
|
387
|
+
}
|
|
388
|
+
// Error-loop (H-12): signature tool+error từ kết quả bước này.
|
|
389
|
+
const errSig = (ctx.message?.tool_calls ?? [])
|
|
390
|
+
.map((tc) => {
|
|
391
|
+
const r = ctx.toolResults?.get(tc.id);
|
|
392
|
+
return r && !r.ok ? `${tc.name}:${r.output.slice(0, 80)}` : null;
|
|
393
|
+
})
|
|
394
|
+
.filter(Boolean)
|
|
395
|
+
.sort()
|
|
396
|
+
.join("|");
|
|
397
|
+
t.toolErrorRepeat = errSig && errSig === t.toolErrorSig ? t.toolErrorRepeat + 1 : 0;
|
|
398
|
+
if (errSig)
|
|
399
|
+
t.toolErrorSig = errSig;
|
|
400
|
+
// Token-no-progress (H-12).
|
|
401
|
+
const outTok = ctx.outputTokensThisStep;
|
|
402
|
+
t.lowTokenStreak = outTok !== undefined && outTok < 20 ? t.lowTokenStreak + 1 : 0;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/** Engine với bộ rule mặc định + ngưỡng tùy chọn. */
|
|
406
|
+
export function createGuardrailEngine(opts = {}, onDecision) {
|
|
407
|
+
const merged = { ...DEFAULT_GUARDRAIL_OPTIONS, ...opts };
|
|
408
|
+
return new GuardrailEngine(defaultGuardrailRules(merged), onDecision);
|
|
409
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,56 +8,65 @@ import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
|
|
|
8
8
|
import { JsonlLogger } from "./logger.js";
|
|
9
9
|
import { collectUsage, formatCostReport } from "./cost.js";
|
|
10
10
|
import { getVersion as getPackageVersion } from "./version.js";
|
|
11
|
-
import {
|
|
11
|
+
import { dispatchCommand } from "./tui/command-registry.js";
|
|
12
12
|
import * as ui from "./ui.js";
|
|
13
13
|
// Single source of truth: read version from package.json (see src/version.ts).
|
|
14
14
|
const VERSION = getPackageVersion();
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
scoder
|
|
25
|
-
scoder
|
|
26
|
-
|
|
27
|
-
scoder
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
--
|
|
35
|
-
|
|
36
|
-
--
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
--
|
|
44
|
-
|
|
45
|
-
--
|
|
46
|
-
--
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
15
|
+
// Safety net: a stray promise rejection (e.g. a provider socket aborting after
|
|
16
|
+
// its consumer already moved on) must log — never hard-crash the agent.
|
|
17
|
+
process.on("unhandledRejection", (reason) => {
|
|
18
|
+
const msg = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
|
|
19
|
+
ui.warn(`background error (ignored): ${msg.slice(0, 200)}`);
|
|
20
|
+
});
|
|
21
|
+
const HELP = `scoder — autonomous coding agent CLI
|
|
22
|
+
|
|
23
|
+
USAGE
|
|
24
|
+
scoder [options] "<task>" Run a task autonomously until the build passes
|
|
25
|
+
scoder Start interactive session (chat + tools)
|
|
26
|
+
scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
|
|
27
|
+
scoder init First-run wizard: writes .scoderrc.json + tests the connection
|
|
28
|
+
scoder list List saved sessions for this directory
|
|
29
|
+
scoder models [provider] [--refresh]
|
|
30
|
+
Model catalog (context window, pricing) —
|
|
31
|
+
synced from models.dev, cached 24h
|
|
32
|
+
scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
|
|
33
|
+
scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
|
|
34
|
+
scoder cost [--session <id>] [--last <n>]
|
|
35
|
+
Token usage & estimated cost from JSONL logs
|
|
36
|
+
scoder serve [--port <n>] [--host <addr>]
|
|
37
|
+
Run as an HTTP server: POST tasks, stream
|
|
38
|
+
progress via Server-Sent Events (see below)
|
|
39
|
+
scoder --version | --help
|
|
40
|
+
|
|
41
|
+
OPTIONS
|
|
42
|
+
-m, --model <name> LLM model (env SCODER_MODEL / OPENAI_MODEL, default gpt-4o-mini)
|
|
43
|
+
--base-url <url> OpenAI-compatible API base URL (env SCODER_BASE_URL / OPENAI_BASE_URL)
|
|
44
|
+
-k, --api-key <key> API key (env SCODER_API_KEY / OPENAI_API_KEY)
|
|
45
|
+
--provider <name> openai | anthropic (env SCODER_PROVIDER; auto-detected from model/baseUrl)
|
|
46
|
+
-B, --build <cmd> Build command = stop condition (auto-detected if omitted)
|
|
47
|
+
-d, --dir <path> Working directory (default: current directory)
|
|
48
|
+
-s, --max-steps <n> Max autonomous loop iterations (default 40)
|
|
49
|
+
-y, --yes Non-interactive mode (auto-approves commands)
|
|
50
|
+
--permission <mode> auto | ask | readonly (env SCODER_PERMISSION, default auto)
|
|
51
|
+
--no-save Disable session auto-save to .scoder/sessions/
|
|
52
|
+
--log-file Write a JSONL event log to .scoder/logs/<session>.jsonl
|
|
53
|
+
(or set SCODER_LOG_FILE=1 / =/path/to/log.jsonl)
|
|
54
|
+
--session <id> (cost) Aggregate usage of one specific session
|
|
55
|
+
--last <n> (cost) Aggregate usage of the n most recent sessions
|
|
56
|
+
-h, --help Show this help
|
|
57
|
+
-v, --version Show version
|
|
58
|
+
|
|
59
|
+
CONFIG FILE
|
|
60
|
+
.scoderrc.json in the working directory (see .scoderrc.example.json):
|
|
61
|
+
{ "model": "...", "baseUrl": "...", "buildCmd": "...", "maxSteps": 40,
|
|
62
|
+
"permissions": { "mode": "auto", "allowCommands": ["npm "], "denyCommands": ["curl "] } }
|
|
63
|
+
|
|
64
|
+
EXAMPLES
|
|
65
|
+
scoder "add a divide function to src/math.ts with tests"
|
|
66
|
+
scoder --build "make test" "fix the failing test in utils"
|
|
67
|
+
scoder --permission ask "npm install express and wire up a route"
|
|
68
|
+
scoder -m claude-sonnet-4-5 "refactor the parser module" # auto-detects Anthropic
|
|
69
|
+
scoder resume last "now add unit tests"
|
|
61
70
|
`;
|
|
62
71
|
function parseArgs(argv) {
|
|
63
72
|
const args = { task: null, yes: false, help: false, version: false };
|
|
@@ -181,6 +190,13 @@ function parseArgs(argv) {
|
|
|
181
190
|
args.subcommand = "tui";
|
|
182
191
|
args.task = rest.length > 1 ? rest.slice(1).join(" ") : null;
|
|
183
192
|
}
|
|
193
|
+
else if (rest[0] === "models") {
|
|
194
|
+
// scoder models [provider] [--refresh] — models.dev catalog (I-02)
|
|
195
|
+
args.subcommand = "models";
|
|
196
|
+
args.modelsProvider = rest[1] && !rest[1].startsWith("--") ? rest[1] : undefined;
|
|
197
|
+
args.modelsRefresh = rest.includes("--refresh");
|
|
198
|
+
args.task = null;
|
|
199
|
+
}
|
|
184
200
|
else if (rest[0] === "init") {
|
|
185
201
|
args.subcommand = "init";
|
|
186
202
|
args.task = null;
|
|
@@ -227,9 +243,19 @@ export async function runInit() {
|
|
|
227
243
|
}
|
|
228
244
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
229
245
|
const ask = (q, def) => new Promise((res) => rl.question(`${q}${def ? ` [${def}]` : ""}: `, (a) => res(a.trim() || def)));
|
|
230
|
-
|
|
246
|
+
// I-03: probe local engines first — suggest when found (no auto-switch when
|
|
247
|
+
// a cloud key exists; user always decides).
|
|
248
|
+
ui.info("probing local engines (ollama :11434, lmstudio :1234, vllm :8000)…");
|
|
249
|
+
const { discoverLocalEngines } = await import("./provider-registry.js");
|
|
250
|
+
const locals = await discoverLocalEngines();
|
|
251
|
+
for (const e of locals) {
|
|
252
|
+
ui.info(` found ${e.provider.id} at ${e.provider.baseUrl} — ${e.models.length} model(s), e.g. ${e.models[0]}`);
|
|
253
|
+
}
|
|
254
|
+
const suggestBase = locals[0]?.provider.baseUrl ?? "https://api.openai.com/v1";
|
|
255
|
+
const suggestModel = locals[0]?.models[0] ?? "gpt-4o-mini";
|
|
256
|
+
const baseUrl = await ask("API base URL (OpenAI-compatible)", suggestBase);
|
|
231
257
|
const apiKey = await ask("API key (SCODER_API_KEY/OPENAI_API_KEY also work at runtime)", "");
|
|
232
|
-
const model = await ask("Model",
|
|
258
|
+
const model = await ask("Model", suggestModel);
|
|
233
259
|
const buildCmd = await ask("Build command (empty = auto-detect)", "");
|
|
234
260
|
const permission = (await ask("Permission mode (auto | ask | readonly)", "auto")).toLowerCase();
|
|
235
261
|
rl.close();
|
|
@@ -290,35 +316,35 @@ async function repl(cfg) {
|
|
|
290
316
|
continue;
|
|
291
317
|
if (input === "exit" || input === "quit")
|
|
292
318
|
break;
|
|
293
|
-
// Slash commands —
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
console.log(formatSlashHelp("scoder"));
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
if (slash.cmd === "/clear") {
|
|
319
|
+
// Slash commands — registry dùng chung với TUI (T1). Command riêng của
|
|
320
|
+
// TUI (/review /undo /theme /diff turn-scope) do TUI tự xử lý trước.
|
|
321
|
+
const host = {
|
|
322
|
+
agent: () => agent,
|
|
323
|
+
config: cfg,
|
|
324
|
+
replaceAgent: (next) => {
|
|
303
325
|
agent.abort();
|
|
304
|
-
agent = new Agent(cfg);
|
|
326
|
+
agent = next ?? new Agent(cfg);
|
|
305
327
|
first = true;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
328
|
+
},
|
|
329
|
+
resumeAndRun: async (s) => {
|
|
330
|
+
agent.abort();
|
|
331
|
+
agent = new Agent(cfg);
|
|
332
|
+
first = false;
|
|
333
|
+
await agent.resume(s);
|
|
334
|
+
return { success: true, steps: 0, buildCmd: cfg.buildCmd, summary: "resumed" };
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
const { handled, outcome } = await dispatchCommand(input, host, (text, level) => {
|
|
338
|
+
if (level === "error")
|
|
339
|
+
ui.error(text);
|
|
340
|
+
else
|
|
341
|
+
console.log(text);
|
|
342
|
+
});
|
|
343
|
+
if (handled) {
|
|
344
|
+
if (outcome?.quit)
|
|
345
|
+
break;
|
|
346
|
+
if (outcome?.notice)
|
|
347
|
+
ui.info(outcome.notice);
|
|
322
348
|
continue;
|
|
323
349
|
}
|
|
324
350
|
try {
|
|
@@ -433,6 +459,25 @@ async function main() {
|
|
|
433
459
|
console.log(formatCostReport(sessionCosts));
|
|
434
460
|
return;
|
|
435
461
|
}
|
|
462
|
+
if (args.subcommand === "models") {
|
|
463
|
+
const { loadCatalog, cacheAgeHours } = await import("./model-catalog.js");
|
|
464
|
+
const filter = args.modelsProvider ?? null;
|
|
465
|
+
const { models, source } = await loadCatalog(args.modelsRefresh === true);
|
|
466
|
+
const age = cacheAgeHours();
|
|
467
|
+
const ageStr = source === "fallback" ? "offline fallback snapshot" : age < 0 ? "?" : `${age.toFixed(1)}h ago`;
|
|
468
|
+
console.log(`models catalog — source: ${source} (updated ${ageStr}), ${Object.keys(models).length} entries\n`);
|
|
469
|
+
const rows = Object.values(models)
|
|
470
|
+
.filter((m) => !filter || m.provider === filter || m.id.includes(filter))
|
|
471
|
+
.sort((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id))
|
|
472
|
+
.slice(0, 60);
|
|
473
|
+
for (const m of rows) {
|
|
474
|
+
const flags = [m.supportCache ? "cache" : null, m.supportReasoning ? "reasoning" : null].filter(Boolean).join(",");
|
|
475
|
+
console.log(` ${m.provider}/${m.id} ctx=${(m.contextWindow / 1000).toFixed(0)}k${m.pricing ? ` $${m.pricing.input}/$${m.pricing.output} per 1M` : ""}${flags ? ` [${flags}]` : ""}`);
|
|
476
|
+
}
|
|
477
|
+
if (rows.length === 0)
|
|
478
|
+
console.log(` (no models match "${filter}")`);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
436
481
|
if (args.subcommand === "tui") {
|
|
437
482
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
438
483
|
ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");
|