smolcoder-plus 1.0.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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -0
  3. package/dist/agent.js +748 -0
  4. package/dist/attachments.js +158 -0
  5. package/dist/config.js +87 -0
  6. package/dist/context.js +498 -0
  7. package/dist/detect.js +474 -0
  8. package/dist/events.js +24 -0
  9. package/dist/history.js +9 -0
  10. package/dist/hosts.js +107 -0
  11. package/dist/index.js +391 -0
  12. package/dist/logo.js +48 -0
  13. package/dist/netscan.js +159 -0
  14. package/dist/network.js +193 -0
  15. package/dist/plan.js +102 -0
  16. package/dist/prompt.js +84 -0
  17. package/dist/providers/lmstudio.js +347 -0
  18. package/dist/providers/ollama.js +269 -0
  19. package/dist/providers/scheduler.js +57 -0
  20. package/dist/providers/transport.js +86 -0
  21. package/dist/providers/types.js +62 -0
  22. package/dist/sandbox.js +207 -0
  23. package/dist/session.js +639 -0
  24. package/dist/tools/check.js +193 -0
  25. package/dist/tools/fs-tools.js +431 -0
  26. package/dist/tools/index.js +260 -0
  27. package/dist/tools/search-worker.js +34 -0
  28. package/dist/tools/shell.js +186 -0
  29. package/dist/tools/tasks.js +147 -0
  30. package/dist/tools/web-search.js +155 -0
  31. package/dist/tui/editor.js +134 -0
  32. package/dist/tui/keys.js +145 -0
  33. package/dist/tui/tui.js +723 -0
  34. package/dist/ui.js +226 -0
  35. package/dist/util.js +91 -0
  36. package/dist/verification.js +71 -0
  37. package/dist/web/channel.js +260 -0
  38. package/dist/web/client.js +1010 -0
  39. package/dist/web/hub.js +952 -0
  40. package/dist/web/page.js +87 -0
  41. package/dist/web/store.js +199 -0
  42. package/dist/web/styles.js +333 -0
  43. package/dist/web/terminal.js +190 -0
  44. package/package.json +49 -0
package/dist/agent.js ADDED
@@ -0,0 +1,748 @@
1
+ "use strict";
2
+ // The agent loop: one tool call at a time, tool results fed back, until the
3
+ // model answers in plain text. Parallel tool calls are not requested; if a
4
+ // model emits several anyway, they simply run sequentially. Malformed calls
5
+ // come back as coaching errors so the model can retry instead of derailing.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.Agent = void 0;
8
+ exports.isAutoApproved = isAutoApproved;
9
+ exports.describeStats = describeStats;
10
+ const attachments_1 = require("./attachments");
11
+ const index_1 = require("./tools/index");
12
+ const util_1 = require("./util");
13
+ const sandbox_1 = require("./sandbox");
14
+ const transport_1 = require("./providers/transport");
15
+ const util_2 = require("./util");
16
+ const crypto_1 = require("crypto");
17
+ const shell_1 = require("./tools/shell");
18
+ const verification_1 = require("./verification");
19
+ const TRANSIENT_ERROR = /fetch failed|econn|socket|network|timed?.?out|429|50[0-4]|stream ended|malformed JSON|stream error/i;
20
+ const CONTEXT_ERROR = /context.{0,50}(exceed|overflow|full|length)|too (many|long).{0,30}tokens|prompt.{0,30}(too long|exceed)/i;
21
+ /** Shell metacharacters that let one "allowed program" smuggle in others.
22
+ * Auto-approval via always-allow only applies to commands without them. */
23
+ const SHELL_META = /[;&|`$<>(){}\n\r\\]/;
24
+ /** Exported for tests: does the always-allow set cover this exact command?
25
+ * First-token match alone is bypassable (`npm -v; evil`) because commands run
26
+ * under a real shell — so chained/piped/substituted commands always re-prompt. */
27
+ function isAutoApproved(command, allowed) {
28
+ const trimmed = command.trim();
29
+ let program = trimmed.split(/\s+/)[0] ?? "";
30
+ if (process.platform === "win32")
31
+ program = program.toLowerCase();
32
+ return allowed.has(program) && !SHELL_META.test(trimmed);
33
+ }
34
+ class Agent {
35
+ provider;
36
+ mode;
37
+ systemPrompt;
38
+ toolCtx;
39
+ ctxMgr;
40
+ bus;
41
+ ui;
42
+ interactive;
43
+ maxSteps;
44
+ callerVerification;
45
+ messages = [];
46
+ tools;
47
+ alwaysAllowed = new Set();
48
+ originalRequest = "";
49
+ currentRequest = "";
50
+ planNudged = false;
51
+ abort = null;
52
+ /** Speed/size figures for the last completed turn (for the turn-end label
53
+ * and headless stats). */
54
+ lastTurnStats = null;
55
+ outcome = "idle";
56
+ lastError = null;
57
+ verificationResult = null;
58
+ verification;
59
+ progressFailure = "";
60
+ sameVerificationFailures = 0;
61
+ canRefreshVerification = false;
62
+ constructor(provider, mode, systemPrompt, toolCtx, ctxMgr, bus, ui, interactive,
63
+ /** Tool-call budget per user turn. Headless runs get a much larger one. */
64
+ maxSteps = 30, callerVerification) {
65
+ this.provider = provider;
66
+ this.mode = mode;
67
+ this.systemPrompt = systemPrompt;
68
+ this.toolCtx = toolCtx;
69
+ this.ctxMgr = ctxMgr;
70
+ this.bus = bus;
71
+ this.ui = ui;
72
+ this.interactive = interactive;
73
+ this.maxSteps = maxSteps;
74
+ this.callerVerification = callerVerification;
75
+ this.verification = callerVerification;
76
+ if (callerVerification && (!callerVerification.command.trim() || (callerVerification.maxAttempts !== undefined && (!Number.isSafeInteger(callerVerification.maxAttempts) || callerVerification.maxAttempts < 1))))
77
+ throw new Error("Verification needs a command and a positive attempt limit.");
78
+ this.messages = [{ role: "system", content: systemPrompt }];
79
+ this.tools = (0, index_1.buildToolSpecs)(mode);
80
+ this.ctxMgr.setReplayThinking(provider.replaysThinking !== false);
81
+ }
82
+ setMode(mode, systemPrompt) {
83
+ this.ctxMgr.cancelBackground(true);
84
+ this.ctxMgr.resetAnchor();
85
+ this.mode = mode;
86
+ this.tools = (0, index_1.buildToolSpecs)(mode);
87
+ this.messages[0] = { role: "system", content: systemPrompt };
88
+ }
89
+ setProvider(provider) {
90
+ this.ctxMgr.cancelBackground(true);
91
+ this.provider = provider;
92
+ this.ctxMgr.setReplayThinking(provider.replaysThinking !== false);
93
+ }
94
+ resetTranscript() {
95
+ this.ctxMgr.cancelBackground(true);
96
+ this.messages = [this.messages[0]];
97
+ this.originalRequest = "";
98
+ this.currentRequest = "";
99
+ this.planNudged = false;
100
+ this.outcome = "idle";
101
+ this.lastError = null;
102
+ this.verificationResult = null;
103
+ this.verification = this.callerVerification;
104
+ this.progressFailure = "";
105
+ this.sameVerificationFailures = 0;
106
+ // Session facts feed the compaction state note — stale ones from a
107
+ // cleared conversation would assert work the new task never did.
108
+ this.toolCtx.filesTouched.clear();
109
+ this.toolCtx.commandsRun.length = 0;
110
+ this.ctxMgr.resetAnchor();
111
+ }
112
+ /** Resume a saved session: the transcript (without its system message) and
113
+ * the two requests the compaction note is built around. */
114
+ restoreTranscript(messages, originalRequest, currentRequest) {
115
+ this.ctxMgr.cancelBackground(true);
116
+ this.messages = [this.messages[0], ...messages];
117
+ this.originalRequest = originalRequest;
118
+ this.currentRequest = currentRequest;
119
+ this.planNudged = false;
120
+ this.ctxMgr.resetAnchor();
121
+ this.repairTranscript("[Tool execution was interrupted by a restart. Its outcome is unknown. Inspect files or command state before retrying; do not assume it failed or rerun it blindly.]");
122
+ }
123
+ cancel() {
124
+ this.abort?.abort();
125
+ this.ctxMgr.cancelBackground(true);
126
+ }
127
+ contextPercent() {
128
+ return this.ctxMgr.fillPercent(this.messages, this.tools);
129
+ }
130
+ contextTokens() {
131
+ return this.ctxMgr.estimatePrompt(this.messages, this.tools);
132
+ }
133
+ contextBudget() { return this.ctxMgr.budget(this.messages, this.tools); }
134
+ compactState() {
135
+ const failure = this.verificationResult && !this.verificationResult.passed
136
+ ? `\nLast acceptance failure (actual command output; resolve before completion):\n${(0, util_2.truncateMiddle)(this.verificationResult.output, 1800)}` : "";
137
+ const progress = this.progressFailure ? `\nLast project check failure (may predate subsequent edits):\n${(0, util_2.truncateMiddle)(this.progressFailure, 1800)}` : "";
138
+ return { originalRequest: this.originalRequest, currentRequest: this.currentRequest, verificationLine: this.verificationInstruction() + failure + progress, filesTouched: this.toolCtx.filesTouched, commandsRun: this.toolCtx.commandsRun, planLine: this.toolCtx.plan.compactLine() };
139
+ }
140
+ async checkProgress(signal) {
141
+ const command = (0, verification_1.projectVerification)(this.toolCtx.workspace);
142
+ if (!command)
143
+ return false;
144
+ this.ui.status("· checking implementation progress");
145
+ this.ui.toolCall("verification", { command });
146
+ this.ctxMgr.prepareBackground(this.messages, this.tools, this.provider, this.compactState());
147
+ const output = await (0, shell_1.runCommand)(command, this.toolCtx.workspace, signal);
148
+ if (signal.aborted)
149
+ throw abortError();
150
+ const passed = !output.startsWith("Error") && /\[exit code 0 in [^\]]+\]\s*$/.test(output);
151
+ this.progressFailure = passed ? "" : output;
152
+ this.ui.toolResult(output);
153
+ await this.bus.emit("post_progress_check", { command, passed, output });
154
+ this.messages.push({ role: "user", content: passed
155
+ ? `[Project checks passed: ${command}. Continue the remaining work in the original request.]`
156
+ : `[Progress checks failed. Fix the first concrete failure before further investigation. Continue the same task.\nCommand: ${command}\n${(0, util_2.truncateMiddle)(output, this.ctxMgr.toolResultCharLimit())}]` });
157
+ return true;
158
+ }
159
+ verificationInstruction() {
160
+ return this.verification ? `\n${this.verification.source === "project" ? `Project checks (${this.verification.command})` : "Caller-owned acceptance checks"} must pass before completion. The harness runs them automatically and returns failures for repair. Use project files and returned failures to fix the application. Do not weaken or bypass acceptance checks.` : "";
161
+ }
162
+ discoverVerification(wroteThisTurn) {
163
+ if ((!this.verification || this.verification.source === "project") && wroteThisTurn && this.mode !== "ro") {
164
+ const command = (0, verification_1.projectVerification)(this.toolCtx.workspace);
165
+ if (command) {
166
+ // Include scripts added during repairs, while retaining checks already
167
+ // required earlier in this turn (deleting one must not bypass it).
168
+ const commands = new Set([...(this.verification?.command.split(" && ") ?? []), ...command.split(" && ")]);
169
+ this.verification = { command: [...commands].join(" && "), source: "project" };
170
+ }
171
+ }
172
+ }
173
+ async verify(signal) {
174
+ if (this.mode === "ro")
175
+ throw new Error("Acceptance commands are unavailable in read-only mode.");
176
+ const check = this.verification;
177
+ const attempts = (this.verificationResult?.attempts ?? 0) + 1;
178
+ if (attempts > (check.maxAttempts ?? 6))
179
+ throw new Error("Acceptance attempt limit reached before the agent finished. The task is incomplete.");
180
+ this.ui.status(`· checking acceptance (${attempts}/${check.maxAttempts ?? 6})`);
181
+ this.ui.toolCall("verification", { command: check.command });
182
+ const output = await (0, shell_1.runCommand)(check.command, this.toolCtx.workspace, signal);
183
+ if (signal.aborted)
184
+ throw abortError();
185
+ const passed = !output.startsWith("Error") && /\[exit code 0 in [^\]]+\]\s*$/.test(output);
186
+ this.sameVerificationFailures = passed ? 0
187
+ : this.verificationResult && !this.verificationResult.passed && (0, verification_1.failureSignature)(this.verificationResult.output) === (0, verification_1.failureSignature)(output)
188
+ ? this.sameVerificationFailures + 1 : 1;
189
+ this.verificationResult = { attempts, passed, output };
190
+ this.ui.toolResult(output);
191
+ await this.bus.emit("post_verify", this.verificationResult);
192
+ if (passed) {
193
+ this.ui.status("· acceptance checks passed");
194
+ return true;
195
+ }
196
+ if (attempts >= (check.maxAttempts ?? 6))
197
+ throw new Error(`Acceptance checks still fail after ${attempts} attempts. The task is incomplete.\n${(0, util_2.truncateMiddle)(output, 1600)}`);
198
+ this.ui.status("· acceptance failed — continuing repairs automatically");
199
+ this.messages.push({ role: "user", content: `[Acceptance failed; the task is not complete. Repair the first failing behavior. The harness will rerun acceptance automatically. Do not skip tests or report success.${check.source === "project" ? `\nCommand: ${check.command}` : ""}\n${(0, util_2.truncateMiddle)(output, this.ctxMgr.toolResultCharLimit())}]` });
200
+ if (this.sameVerificationFailures === 2 && this.canRefreshVerification) {
201
+ this.ui.status("· same check failed again — refreshing working context");
202
+ // Repeating an unchanged hypothesis in a larger transcript is not
203
+ // progress. Keep the task, plan/checkpoint and actual failure, but drop
204
+ // old model-written narratives before a facts-only handover. Current
205
+ // files remain untouched and can be reread; no additional inference.
206
+ this.ctxMgr.cancelBackground(true);
207
+ this.messages = this.messages.filter(message => !message.compactNote);
208
+ this.ctxMgr.resetAnchor();
209
+ await this.compactNow(true, true);
210
+ }
211
+ return false;
212
+ }
213
+ async compactNow(force = true, deterministic = false) {
214
+ this.ui.startSpinner("organizing context");
215
+ try {
216
+ await this.ctxMgr.foreground();
217
+ await this.bus.emit("pre_compact");
218
+ const { messages, report } = await this.ctxMgr.manage(this.messages, this.tools, this.provider, this.compactState(), { force, deterministic, signal: this.abort?.signal });
219
+ this.messages = messages;
220
+ await this.bus.emit("post_compact", report);
221
+ await this.bus.emit("context_update");
222
+ }
223
+ finally {
224
+ this.ui.stopSpinner();
225
+ }
226
+ }
227
+ async runTurn(userInput, attachments = []) {
228
+ await this.ctxMgr.foreground();
229
+ this.ctxMgr.cancelBackground(true);
230
+ this.outcome = "running";
231
+ this.ctxMgr.resetAnchor(); // prior-turn reasoning no longer travels on the wire
232
+ this.lastError = null;
233
+ this.verificationResult = null;
234
+ this.verification = this.callerVerification;
235
+ this.progressFailure = "";
236
+ this.sameVerificationFailures = 0;
237
+ // Earlier user decisions may exist only in a previous turn's summary.
238
+ // Only a fresh conversation can safely discard every old narrative.
239
+ this.canRefreshVerification = !this.originalRequest && this.messages.length === 1;
240
+ const rendered = (0, attachments_1.renderAttachmentsForModel)(attachments, this.provider.vision !== false);
241
+ const request = userInput || (attachments.length ? `See the attached ${attachments.length === 1 ? "file" : "files"}.` : "");
242
+ // Compaction keeps the request text, so the file names ride along with it.
243
+ const requestNote = attachments.length ? `${request} [attached: ${attachments.map((a) => a.name).join(", ")}]` : request;
244
+ if (!this.originalRequest)
245
+ this.originalRequest = requestNote;
246
+ this.currentRequest = requestNote; // the task compaction must never lose
247
+ this.messages.push({
248
+ role: "user",
249
+ content: request + (rendered.text ? "\n\n" + rendered.text : "") + this.verificationInstruction(),
250
+ ...(rendered.images.length ? { images: rendered.images } : {}),
251
+ });
252
+ this.abort = new AbortController();
253
+ const signal = this.abort.signal;
254
+ const t0 = Date.now();
255
+ let completed = false;
256
+ let steps = 0;
257
+ let nudges = 0;
258
+ let toolCallsThisTurn = 0;
259
+ let lastVerifiedToolCalls = -1;
260
+ const runAcceptance = async () => {
261
+ if (signal.aborted)
262
+ throw abortError();
263
+ // A summary after a successful check does not need to run it twice.
264
+ if (this.verificationResult?.passed && lastVerifiedToolCalls === toolCallsThisTurn)
265
+ return true;
266
+ const passed = await this.verify(signal);
267
+ lastVerifiedToolCalls = toolCallsThisTurn;
268
+ return passed;
269
+ };
270
+ let sincePlanUpdate = 0;
271
+ let repeatKey = "";
272
+ let repeats = 0;
273
+ let failedCalls = 0;
274
+ const repeatedReads = new Map();
275
+ let readsSinceAction = 0;
276
+ let actionOnlyCalls = 0;
277
+ let reasoningExhaustions = 0;
278
+ let wroteThisTurn = false;
279
+ let lastProgressCheck = 0;
280
+ const stats = {
281
+ modelCalls: 0,
282
+ toolCalls: 0,
283
+ generatedTokens: 0,
284
+ genSeconds: 0,
285
+ thinkingChars: 0,
286
+ promptTokensLast: 0,
287
+ durationMs: 0,
288
+ };
289
+ this.lastTurnStats = stats;
290
+ try {
291
+ await this.refreshLoadedWindow();
292
+ agentLoop: while (steps++ < this.maxSteps) {
293
+ // Context management before every request.
294
+ await this.bus.emit("pre_request");
295
+ if (this.ctxMgr.needsAttention(this.messages, this.tools)) {
296
+ await this.compactNow(false);
297
+ }
298
+ await this.ctxMgr.foreground();
299
+ this.ctxMgr.assertFits(this.messages, this.tools);
300
+ this.ui.startSpinner("thinking");
301
+ let result;
302
+ try {
303
+ result = await this.chatWithRetry(signal, actionOnlyCalls > 0);
304
+ if (actionOnlyCalls > 0)
305
+ actionOnlyCalls--;
306
+ }
307
+ finally {
308
+ this.ui.stopSpinner();
309
+ }
310
+ this.ctxMgr.calibrate(result.promptTokens, this.messages, this.tools);
311
+ this.messages.push({
312
+ role: "assistant",
313
+ content: result.content,
314
+ toolCalls: result.toolCalls.length ? result.toolCalls : undefined,
315
+ thinking: result.thinking,
316
+ });
317
+ // Anchor AFTER the push: lastPromptTokens+lastCompletionTokens then
318
+ // cover exactly the first `messages.length` messages — recording
319
+ // before the push double-counted the reply in every estimate.
320
+ this.ctxMgr.recordUsage(result.promptTokens, result.completionTokens, this.messages.length);
321
+ if (stats.modelCalls === 0)
322
+ await this.refreshLoadedWindow();
323
+ await this.bus.emit("context_update");
324
+ stats.modelCalls++;
325
+ if (result.generatedTokens) {
326
+ stats.generatedTokens += result.generatedTokens;
327
+ if (result.genTokPerSec)
328
+ stats.genSeconds += result.generatedTokens / result.genTokPerSec;
329
+ }
330
+ if (result.promptTokens)
331
+ stats.promptTokensLast = result.promptTokens;
332
+ if (result.thinking)
333
+ stats.thinkingChars += result.thinking.length;
334
+ if (result.content)
335
+ this.ui.println(); // end the streamed line
336
+ if (result.toolCalls.length === 0) {
337
+ // A reply cut off by the output cap, or an empty reply, is not a
338
+ // finished turn — that is how local-model sessions die silently.
339
+ // Nudge the model back on track (bounded).
340
+ if (result.truncated && nudges < 3) {
341
+ nudges++;
342
+ this.ui.status("· reply hit the output limit — asking the model to continue");
343
+ // Reasoning models can burn the ENTIRE budget thinking, arriving
344
+ // with no visible output at all — "continue where you left off"
345
+ // would just restart the same doomed think. Target that case.
346
+ // With thinking off, an empty truncated reply is almost always a
347
+ // tool call whose arguments (a whole file) overflowed the cap — it
348
+ // was never parsed, so nothing was saved and "continue" cannot
349
+ // work. Name the cap and coach the split explicitly.
350
+ const noContent = !result.content.trim();
351
+ const burnedByThinking = noContent && !!result.thinking?.trim();
352
+ if (burnedByThinking) {
353
+ reasoningExhaustions++;
354
+ actionOnlyCalls = reasoningExhaustions === 1 ? 1 : Math.min(8, 2 ** Math.min(reasoningExhaustions, 3));
355
+ this.ui.status(`· reasoning exhausted the reply budget — ${actionOnlyCalls} response${actionOnlyCalls === 1 ? "" : "s"} with thinking off, then restore the selected effort`);
356
+ }
357
+ const nudgeText = burnedByThinking
358
+ ? `[Your reasoning used the entire output limit (${this.provider.maxOutputTokens} tokens) and produced no answer. Do not re-derive everything — reply now with your next tool call or a brief answer.]`
359
+ : noContent
360
+ ? `[${this.truncatedCallHint()}]`
361
+ : `[Your reply was cut off by the output length limit of ${this.provider.maxOutputTokens} tokens. Continue where you left off. If a file was too large for one write_file call, split the content into separate files — writing the same path again replaces it completely.]`;
362
+ this.messages.push({ role: "user", content: nudgeText });
363
+ continue;
364
+ }
365
+ if (!result.content.trim() && nudges < 2) {
366
+ nudges++;
367
+ this.ui.status("· empty reply — nudging the model");
368
+ this.messages.push({
369
+ role: "user",
370
+ content: "[Your reply was empty. If the task is finished, summarize what you did. Otherwise make the next tool call now.]",
371
+ });
372
+ continue;
373
+ }
374
+ // The model wants to stop but its own plan still has open steps —
375
+ // the classic local-model quit-halfway. One bounded push back.
376
+ // One nudge PER PLAN STATE, not per turn: an abandoned plan must not
377
+ // drag every later unrelated question back to stale work. The flag
378
+ // re-arms only when the plan actually changes (set/done/add).
379
+ const plan = this.toolCtx.plan;
380
+ if (plan.exists && plan.currentIndex >= 0 && toolCallsThisTurn > 0 && !this.planNudged) {
381
+ this.planNudged = true;
382
+ this.ui.status("· plan has unfinished steps — nudging the model to continue");
383
+ this.messages.push({
384
+ role: "user",
385
+ content: `[Your plan still has unfinished steps: ${plan.pendingSummary()}. Continue with the next step now — or if a step no longer applies, mark it done with the plan tool and explain why.]`,
386
+ });
387
+ continue;
388
+ }
389
+ if (result.truncated || !result.content.trim())
390
+ throw new Error("The model repeatedly returned an empty or cut-off reply. Progress is kept. Try /effort off, switch models with /models, or say continue.");
391
+ this.discoverVerification(wroteThisTurn);
392
+ if (this.verification && !(await runAcceptance())) {
393
+ repeatedReads.clear();
394
+ repeats = 0;
395
+ failedCalls = 0;
396
+ readsSinceAction = 0;
397
+ lastProgressCheck = toolCallsThisTurn;
398
+ continue;
399
+ }
400
+ completed = true;
401
+ this.outcome = "completed";
402
+ return; // plain answer — turn over
403
+ }
404
+ nudges = 0;
405
+ for (const call of result.toolCalls) {
406
+ if (signal.aborted)
407
+ throw abortError();
408
+ this.ui.toolCall(call.name, call.parseError ? { __raw: (call.rawArgs ?? "").slice(0, 80) } : call.args);
409
+ let output;
410
+ let observedOutput;
411
+ if (call.parseError) {
412
+ // LM Studio streams the partial arguments of a cut-off call, so
413
+ // the overflow surfaces here as unparseable JSON.
414
+ output = result.truncated
415
+ ? `Error: ${this.truncatedCallHint()}`
416
+ : `Error: your tool call arguments could not be parsed (${call.parseError}). Send the arguments as a single JSON object, e.g. {"path": "src/app.js"}.`;
417
+ }
418
+ else if (result.truncated) {
419
+ output = `Error: ${this.truncatedCallHint()}`;
420
+ }
421
+ else if (!this.tools.some((t) => t.name === call.name)) {
422
+ // HARD mode enforcement. The schemas sent to the model are only
423
+ // advisory — a hallucinated or injected write_file/run_command in
424
+ // read-only mode must be rejected here, at execution time.
425
+ output = `Error: the tool "${call.name}" is not available in ${index_1.MODE_LABELS[this.mode]} mode. Available tools: ${this.tools.map((t) => t.name).join(", ")}.`;
426
+ }
427
+ else {
428
+ this.toolCtx.resultCharLimit = this.ctxMgr.toolResultCharLimit();
429
+ if (call.name === "run_command") {
430
+ let end = this.messages.length - 1;
431
+ while (this.messages[end]?.role === "tool")
432
+ end--;
433
+ this.ctxMgr.prepareBackground(this.messages.slice(0, end), this.tools, this.provider, this.compactState());
434
+ }
435
+ output = await this.gateAndExecute(call.name, call.args, signal);
436
+ observedOutput = output; // fingerprint real evidence before coaching/reminders
437
+ toolCallsThisTurn++;
438
+ stats.toolCalls++;
439
+ // Tier-0 context hygiene: a full overwrite makes every earlier
440
+ // read of that file wrong. Stub them out right away.
441
+ if ((call.name === "write_file" || call.name === "edit_file") && !output.startsWith("Error") && typeof call.args?.path === "string") {
442
+ wroteThisTurn = true;
443
+ this.ctxMgr.evictStaleReads(this.messages, call.args.path);
444
+ repeatedReads.clear();
445
+ readsSinceAction = 0;
446
+ }
447
+ // Keep the plan honest: small models forget to mark steps done
448
+ // mid-flow, leaving the checklist stale for minutes. A periodic
449
+ // one-line reminder riding on a tool result fixes it cheaply.
450
+ const plan = this.toolCtx.plan;
451
+ if (call.name === "plan") {
452
+ sincePlanUpdate = 0;
453
+ if (!output.startsWith("Error") && ["set", "done", "add"].includes(String(call.args?.action)))
454
+ this.planNudged = false;
455
+ }
456
+ else if (plan.exists && plan.currentIndex >= 0 && ++sincePlanUpdate >= 4) {
457
+ sincePlanUpdate = 0;
458
+ const cur = plan.steps[plan.currentIndex];
459
+ output += `\n[Reminder: the plan still shows step ${plan.currentIndex + 1} "${cur.text}" as current. If you have finished steps, mark each with plan {"action": "done"} now.]`;
460
+ }
461
+ }
462
+ const key = JSON.stringify([call.name, call.args, observedOutput ?? output]);
463
+ repeats = key === repeatKey ? repeats + 1 : 1;
464
+ repeatKey = key;
465
+ failedCalls = output.startsWith("Error") ? failedCalls + 1 : 0;
466
+ if (repeats === 3 || failedCalls === 3)
467
+ output += "\n[Repeated attempts are not making progress. Inspect the error or relevant file and change your approach before trying again.]";
468
+ let readRepeats = 0;
469
+ let readKey = "";
470
+ if (["read_file", "search", "list_files"].includes(call.name) && !output.startsWith("Error")) {
471
+ if (++readsSinceAction % 12 === 0 && this.mode !== "ro")
472
+ output += "\n[Investigation checkpoint: record the exact APIs, unresolved error and next small edit with plan checkpoint. Then make and verify that edit before inspecting more modules.]";
473
+ // Match both the request and file/output contents: rereading an
474
+ // edited file or a different line range is legitimate progress.
475
+ readKey = (0, crypto_1.createHash)("sha256").update(call.name + JSON.stringify(call.args) + (observedOutput ?? output)).digest("hex");
476
+ const previous = repeatedReads.get(readKey);
477
+ // Refetching evidence that WE removed is legitimate. Count only
478
+ // repeated observations the model can still see in its context.
479
+ const retained = previous?.receipt && !previous.receipt.evicted && this.messages.includes(previous.receipt);
480
+ readRepeats = retained ? previous.count + 1 : 1;
481
+ if (!retained)
482
+ repeats = 1; // the generic consecutive-call guard must agree
483
+ repeatedReads.set(readKey, { count: readRepeats });
484
+ if (readRepeats >= 3)
485
+ output += "\n[This unchanged result has already been read repeatedly. Do not restart the same reads after compaction. Use a different small line range or narrow search if needed, then implement the next step.]";
486
+ }
487
+ const outputCap = this.ctxMgr.toolResultCharLimit();
488
+ // read_file already paginates on complete lines. Never middle-cut
489
+ // that page while its trailer claims a contiguous line range.
490
+ if (output.length > outputCap && call.name !== "read_file")
491
+ output = (0, util_2.truncateMiddle)(output, outputCap) + "\n[Output capped for this context window. Read a smaller line range or narrow the search.]";
492
+ // Plan changes render as the visual checklist instead of a ✓ line.
493
+ if (call.name === "plan" &&
494
+ !output.startsWith("Error") &&
495
+ ["set", "done", "add"].includes(String(call.args?.action ?? (typeof call.args?.steps === "string" ? "set" : undefined)))) {
496
+ this.ui.planUpdated(this.toolCtx.plan);
497
+ }
498
+ else {
499
+ this.ui.toolResult(output);
500
+ }
501
+ this.messages.push({
502
+ role: "tool",
503
+ content: output,
504
+ toolCallId: call.id,
505
+ toolName: call.name,
506
+ });
507
+ if (readKey)
508
+ repeatedReads.get(readKey).receipt = this.messages[this.messages.length - 1];
509
+ await this.bus.emit("post_tool", { name: call.name, args: call.args });
510
+ await this.bus.emit("context_update");
511
+ // A cancel during tool execution ends the turn now, with the
512
+ // (cancelled) result already recorded so the transcript stays valid.
513
+ if (signal.aborted)
514
+ throw abortError();
515
+ // Individual rereads after eviction are legitimate, but a long
516
+ // investigation with no edit must still return to executable evidence.
517
+ // Preserve this counter across compaction; only an edit or check
518
+ // resets it. Read-only investigations never execute commands.
519
+ const needsEvidence = readRepeats >= 5 || repeats >= 6 || failedCalls >= 6 ||
520
+ (wroteThisTurn && readsSinceAction >= 24 && this.mode !== "ro");
521
+ if (needsEvidence)
522
+ this.discoverVerification(wroteThisTurn);
523
+ if (this.verification && needsEvidence) {
524
+ this.repairTranscript("[Not executed: the harness switched to executable checks after repeated attempts.]");
525
+ this.ctxMgr.resetAnchor();
526
+ // During implementation, known failing project checks are already
527
+ // the actionable evidence. Tool-recovery checks must not spend the
528
+ // caller's final acceptance budget before acceptance has started.
529
+ if (!this.verificationResult && this.progressFailure && await this.checkProgress(signal)) {
530
+ repeatedReads.clear();
531
+ repeats = 0;
532
+ failedCalls = 0;
533
+ readsSinceAction = 0;
534
+ lastProgressCheck = toolCallsThisTurn;
535
+ continue agentLoop;
536
+ }
537
+ if (!(await runAcceptance())) {
538
+ repeatedReads.clear();
539
+ repeats = 0;
540
+ failedCalls = 0;
541
+ readsSinceAction = 0;
542
+ lastProgressCheck = toolCallsThisTurn;
543
+ continue agentLoop;
544
+ }
545
+ // Passing acceptance does not authorize dropping the remaining
546
+ // task: ask for a final requirements review before completion.
547
+ this.messages.push({ role: "user", content: "[Acceptance passed. Review the original request, finish any remaining work, and summarize the verified result.]" });
548
+ repeatedReads.clear();
549
+ repeats = 0;
550
+ failedCalls = 0;
551
+ readsSinceAction = 0;
552
+ lastProgressCheck = toolCallsThisTurn;
553
+ continue agentLoop;
554
+ }
555
+ if (readRepeats >= 5)
556
+ throw new Error("Paused after repeatedly reading the same unchanged data without an edit. Progress is kept. Ask for a specific next change, read a different range, or use a larger context window.");
557
+ if (repeats >= 6 || failedCalls >= 6)
558
+ throw new Error("Paused after repeated tool attempts made no progress. Progress is kept; inspect the error, change the request or model, then continue.");
559
+ }
560
+ // A model can cycle through different reads and small API edits forever
561
+ // without triggering an identical-call guard. Periodic executable
562
+ // feedback grounds that investigation before a proposed completion.
563
+ if (wroteThisTurn && this.mode !== "ro" && toolCallsThisTurn - lastProgressCheck >= 24) {
564
+ lastProgressCheck = toolCallsThisTurn;
565
+ this.discoverVerification(wroteThisTurn);
566
+ if (this.verification && this.verificationResult) {
567
+ // Once acceptance has found a real failure, keep checking THAT
568
+ // behavior. Passing a weaker build check cannot resolve it.
569
+ if (await runAcceptance())
570
+ this.messages.push({ role: "user", content: "[Acceptance checks passed. Finish your response with the verified result.]" });
571
+ readsSinceAction = 0;
572
+ repeatedReads.clear();
573
+ repeats = 0;
574
+ failedCalls = 0;
575
+ }
576
+ else if (await this.checkProgress(signal)) {
577
+ readsSinceAction = 0;
578
+ repeatedReads.clear();
579
+ repeats = 0;
580
+ failedCalls = 0;
581
+ }
582
+ }
583
+ }
584
+ throw new Error(`Paused after ${this.maxSteps} model steps. Progress is kept. Say "continue" to keep going.`);
585
+ }
586
+ catch (err) {
587
+ if (err?.name === "AbortError" || signal.aborted) {
588
+ this.ui.println();
589
+ this.ui.status("· cancelled");
590
+ this.outcome = "cancelled";
591
+ this.sanitizeAfterCancel();
592
+ return;
593
+ }
594
+ this.outcome = "error";
595
+ this.lastError = String(err?.message ?? err);
596
+ this.repairTranscript("[Tool did not run because the turn stopped after an error. Inspect the preceding error before continuing.]");
597
+ throw err;
598
+ }
599
+ finally {
600
+ await this.ctxMgr.foreground();
601
+ this.abort = null;
602
+ stats.durationMs = Date.now() - t0;
603
+ if (completed) {
604
+ this.ui.turnEnd(`${(0, util_1.fmtDuration)(stats.durationMs)}${describeStats(stats)}`);
605
+ }
606
+ }
607
+ }
608
+ /** Coaching for a tool call that overflowed the output cap. Exported via
609
+ * the class for tests. */
610
+ truncatedCallHint() {
611
+ const cap = this.provider.maxOutputTokens;
612
+ const part = Math.max(300, Math.floor(cap * 0.5));
613
+ return (`Your tool call was cut off by the output limit of ${cap} tokens, so it was NOT executed and nothing was saved. ` +
614
+ `Send smaller calls: write the file in parts of at most ~${part} tokens — write_file with the first part, ` +
615
+ `then edit_file to append each next part (old_text = the last line you wrote, new_text = that line followed by the next part) — ` +
616
+ `or split the code across several smaller files.`);
617
+ }
618
+ /** One model call, with bounded retries on transient backend failures
619
+ * (Ollama/LM Studio hiccups, dropped sockets, 5xx). The transcript is
620
+ * unchanged between attempts, so a retry is always safe. */
621
+ async chatWithRetry(signal, actionOnly = false) {
622
+ let lastErr;
623
+ let recoveredContext = false;
624
+ for (let attempt = 1; attempt <= 3; attempt++) {
625
+ let streamed = false;
626
+ try {
627
+ return await this.provider.chat(this.messages, this.tools, {
628
+ signal,
629
+ ...(actionOnly ? { effortOverride: "off" } : {}),
630
+ maxTokens: Math.min(this.provider.maxOutputTokens, this.contextBudget().reserve),
631
+ onToken: (t) => { streamed = true; this.ui.token(t); },
632
+ onThinking: (t) => { streamed = true; this.ui.thinking(t); },
633
+ });
634
+ }
635
+ catch (err) {
636
+ if (err?.name === "AbortError" || signal.aborted)
637
+ throw err;
638
+ lastErr = err;
639
+ if (streamed)
640
+ this.ui.resetResponse?.();
641
+ if (!recoveredContext && CONTEXT_ERROR.test(String(err?.message ?? err))) {
642
+ recoveredContext = true;
643
+ this.ui.status("· backend context limit — reducing history and retrying");
644
+ await this.compactNow(true, true);
645
+ this.ctxMgr.assertFits(this.messages, this.tools);
646
+ continue;
647
+ }
648
+ if (attempt === 3 || !TRANSIENT_ERROR.test(String(err?.message ?? err)))
649
+ throw err;
650
+ this.ui.warn(`· backend error (${String(err?.message ?? err).slice(0, 80)}) — retrying in ${attempt * 3}s`);
651
+ await (0, transport_1.abortableDelay)(attempt * 3000, signal);
652
+ }
653
+ }
654
+ throw lastErr;
655
+ }
656
+ async refreshLoadedWindow() {
657
+ const actual = await this.provider.loadedContextWindow?.();
658
+ if (actual && Number.isSafeInteger(actual) && actual < this.contextBudget().window) {
659
+ this.ctxMgr.setWindow(actual, Math.min(this.provider.maxOutputTokens, Math.floor(actual / 4)));
660
+ this.ui.status(`· loaded model context changed to ${actual.toLocaleString()} tokens; budget adjusted`);
661
+ await this.bus.emit("context_update");
662
+ }
663
+ }
664
+ async gateAndExecute(name, args, signal) {
665
+ const command = (0, index_1.commandOf)(name, args);
666
+ // Gate everywhere except bypass (defense-in-depth: in ro mode exec tools are
667
+ // already rejected before this point by the tool-existence check). Edit
668
+ // mode runs commands that stay inside the workspace without asking and
669
+ // only prompts for ones that reach outside it.
670
+ if (command !== null && this.mode !== "bypass") {
671
+ const reason = (0, sandbox_1.commandEscapesWorkspace)(command, this.toolCtx.workspace);
672
+ if (reason !== null && !isAutoApproved(command, this.alwaysAllowed)) {
673
+ if (!this.interactive) {
674
+ return `Error: this command ${reason}, which needs user approval, and this session is non-interactive. Keep every path inside the workspace (relative paths, a scratch folder in the workspace instead of /tmp), or the user can rerun smol with --mode bypass, or run this themselves: ${command}`;
675
+ }
676
+ const answer = await this.ui.confirmCommand(command, reason);
677
+ if (answer === "no") {
678
+ return "The user declined to run this command. Continue without it, or ask the user what to do instead.";
679
+ }
680
+ if (answer === "always") {
681
+ let program = command.trim().split(/\s+/)[0] ?? "";
682
+ if (process.platform === "win32")
683
+ program = program.toLowerCase();
684
+ if (program)
685
+ this.alwaysAllowed.add(program);
686
+ }
687
+ }
688
+ }
689
+ if (signal?.aborted)
690
+ throw signal.reason;
691
+ if (!this.tools.some((t) => t.name === name))
692
+ return `Error: ${name} is no longer available in ${index_1.MODE_LABELS[this.mode]} mode.`;
693
+ return (0, index_1.executeTool)(name, args, this.toolCtx, signal);
694
+ }
695
+ repairTranscript(reason) {
696
+ const repaired = [];
697
+ for (let i = 0; i < this.messages.length; i++) {
698
+ const m = this.messages[i];
699
+ if (m.role === "tool")
700
+ continue; // consumed with its assistant, or orphaned
701
+ repaired.push(m);
702
+ if (!m.toolCalls?.length)
703
+ continue;
704
+ const results = new Map();
705
+ while (this.messages[i + 1]?.role === "tool") {
706
+ const t = this.messages[++i];
707
+ results.set(t.toolCallId, t);
708
+ }
709
+ for (const call of m.toolCalls)
710
+ repaired.push(results.get(call.id) ?? { role: "tool", toolCallId: call.id, toolName: call.name, content: reason });
711
+ }
712
+ this.messages = repaired;
713
+ }
714
+ /**
715
+ * After a cancel, the most recent assistant tool-call message may have some
716
+ * calls unanswered — strict backends reject that shape on the next request.
717
+ * A cancel mid-way through a MULTI-call batch buries that assistant message
718
+ * behind the already-pushed tool results, so walk back past them.
719
+ */
720
+ sanitizeAfterCancel() {
721
+ this.repairTranscript("[cancelled by the user before this tool ran]");
722
+ }
723
+ statusLine() {
724
+ const pct = this.contextPercent();
725
+ const tasks = this.toolCtx.taskManager.runningSummary();
726
+ const taskPart = tasks.length ? ` · ${tasks.length} bg task${tasks.length > 1 ? "s" : ""}` : "";
727
+ return util_1.c.gray(`ctx ${pct}% of ${this.provider.contextWindow.toLocaleString()} · ${this.provider.label} · ${this.mode}${taskPart}`);
728
+ }
729
+ }
730
+ exports.Agent = Agent;
731
+ /** " · 12 tools · 4.1k tok @ 118 tok/s" — the speed readout local-model users
732
+ * actually want to compare backends with. */
733
+ function describeStats(s) {
734
+ const parts = [];
735
+ if (s.toolCalls)
736
+ parts.push(`${s.toolCalls} tool${s.toolCalls === 1 ? "" : "s"}`);
737
+ if (s.generatedTokens) {
738
+ const k = s.generatedTokens >= 1000 ? `${(s.generatedTokens / 1000).toFixed(1)}k` : String(s.generatedTokens);
739
+ const rate = s.genSeconds > 0 ? ` @ ${Math.round(s.generatedTokens / s.genSeconds)} tok/s` : "";
740
+ parts.push(`${k} tok${rate}`);
741
+ }
742
+ return parts.length ? " · " + parts.join(" · ") : "";
743
+ }
744
+ function abortError() {
745
+ const e = new Error("aborted");
746
+ e.name = "AbortError";
747
+ return e;
748
+ }