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
@@ -0,0 +1,498 @@
1
+ "use strict";
2
+ // Context budget management — the part local models actually live or die by.
3
+ //
4
+ // Fill gauge: every backend reports real prompt token usage per response; we
5
+ // anchor on that and only estimate the delta of new messages (chars-based). No
6
+ // homegrown tokenizer, works for any GGUF.
7
+ //
8
+ // Tiered compaction, cheap lever first:
9
+ // Tier 0 (free, continuous): stale-read eviction — the moment a file is
10
+ // overwritten, every earlier read of it is dead weight AND misleading.
11
+ // Tier 1 (free): evict old tool-result bodies and old reasoning traces —
12
+ // files can be re-read, so this is nearly lossless and usually recovers
13
+ // most of the window.
14
+ // Tier 2 (one model call): rebuild the transcript around a state note. The
15
+ // harness assembles the factual part deterministically (plan, files touched,
16
+ // commands run) and the model writes a structured progress summary with
17
+ // thinking OFF — local models summarize well, they just must not be allowed
18
+ // to reason for a minute about it.
19
+ //
20
+ // Guard rails learned the hard way:
21
+ // - compaction notes are FLAGGED so a later compaction strips them instead
22
+ // of stacking note-on-note (which made compaction stop shrinking anything)
23
+ // - the note always carries the CURRENT turn's request, not only the
24
+ // session's first one
25
+ // - when the irreducible floor (system prompt + tools + protected tail)
26
+ // alone exceeds the threshold, we stop trying instead of thrashing a
27
+ // futile summarizer call before every request
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.ContextManager = void 0;
30
+ exports.renderForDigest = renderForDigest;
31
+ const types_1 = require("./providers/types");
32
+ const util_1 = require("./util");
33
+ const transport_1 = require("./providers/transport");
34
+ const history_1 = require("./history");
35
+ const attachments_1 = require("./attachments");
36
+ const MSG_OVERHEAD_TOKENS = 8;
37
+ const EVICT_KEEP_RECENT = 6; // never evict tool results in the last N messages
38
+ const EVICT_STUB = "[old output removed to save space — run the tool again if you need it]";
39
+ const STALE_READ_STUB = "[this read is out of date — the file was rewritten afterwards. Call read_file again if you need its current content.]";
40
+ const STALE_READ_MIN_CHARS = 1500; // small reads are cheaper to keep than to re-prefill around
41
+ /** Tool-call args for a tool-result message (the call lives on the preceding
42
+ * assistant message). */
43
+ function callFor(messages, toolMsgIndex) {
44
+ const id = messages[toolMsgIndex].toolCallId;
45
+ if (!id)
46
+ return null;
47
+ for (let i = toolMsgIndex - 1; i >= 0; i--) {
48
+ const m = messages[i];
49
+ if (m.role !== "assistant" || !m.toolCalls)
50
+ continue;
51
+ const tc = m.toolCalls.find((t) => t.id === id);
52
+ if (tc)
53
+ return { name: tc.name, args: tc.args };
54
+ }
55
+ return null;
56
+ }
57
+ class ContextManager {
58
+ window;
59
+ reserve;
60
+ lastPromptTokens = 0;
61
+ lastCompletionTokens = 0;
62
+ anchorIndex = 0; // messages.length at the time usage was reported
63
+ floorWarned = false;
64
+ calibration = 1;
65
+ replaysThinking = true;
66
+ background = null;
67
+ prepared = null;
68
+ preparedAt = 0;
69
+ constructor(window, reserve) {
70
+ this.window = window;
71
+ this.reserve = reserve;
72
+ }
73
+ /** Model switches mid-session change the window we budget against. */
74
+ setWindow(window, reserve) {
75
+ this.cancelBackground(true);
76
+ this.window = window;
77
+ this.calibration = 1;
78
+ if (reserve !== undefined)
79
+ this.reserve = reserve;
80
+ this.resetAnchor();
81
+ }
82
+ /** Invariant: lastPromptTokens + lastCompletionTokens cover exactly the
83
+ * first `anchorIndex` messages of the transcript at record time. */
84
+ recordUsage(promptTokens, completionTokens, messageCount) {
85
+ if (typeof promptTokens === "number" && promptTokens > 0) {
86
+ this.lastPromptTokens = promptTokens;
87
+ this.lastCompletionTokens = completionTokens ?? 0;
88
+ this.anchorIndex = messageCount;
89
+ }
90
+ }
91
+ /** Drop the usage anchor (transcript replaced/cleared behind it). */
92
+ resetAnchor() {
93
+ this.lastPromptTokens = 0;
94
+ this.lastCompletionTokens = 0;
95
+ this.anchorIndex = 0;
96
+ this.floorWarned = false;
97
+ }
98
+ estimateMessages(messages) {
99
+ // Reasoning traces before the current user turn are not sent to the
100
+ // backend (see providers), so they must not count either.
101
+ const thinkingFrom = (0, types_1.lastUserIndex)(messages);
102
+ let total = 0;
103
+ for (let i = 0; i < messages.length; i++) {
104
+ const m = messages[i];
105
+ total += (0, util_1.estimateTokens)(m.content ?? "") + MSG_OVERHEAD_TOKENS;
106
+ if (m.images?.length)
107
+ total += m.images.length * attachments_1.IMAGE_TOKENS;
108
+ if (this.replaysThinking && m.thinking && i > thinkingFrom)
109
+ total += (0, util_1.estimateTokens)(m.thinking);
110
+ if (m.toolCalls) {
111
+ for (const tc of m.toolCalls) {
112
+ total += (0, util_1.estimateTokens)(tc.name + JSON.stringify(tc.args)) + MSG_OVERHEAD_TOKENS;
113
+ }
114
+ }
115
+ }
116
+ return total;
117
+ }
118
+ estimateTools(tools) {
119
+ return (0, util_1.estimateTokens)(JSON.stringify(tools));
120
+ }
121
+ setReplayThinking(value) { this.replaysThinking = value; this.resetAnchor(); }
122
+ /** Best estimate of the next request's prompt size in tokens. */
123
+ estimatePrompt(messages, tools) {
124
+ const charBased = Math.ceil((this.estimateMessages(messages) + this.estimateTools(tools)) * this.calibration);
125
+ if (this.lastPromptTokens > 0 && this.anchorIndex <= messages.length) {
126
+ const newMsgs = messages.slice(this.anchorIndex);
127
+ const anchored = this.lastPromptTokens + this.lastCompletionTokens + this.estimateMessages(newMsgs);
128
+ return Math.max(charBased, anchored);
129
+ }
130
+ return charBased;
131
+ }
132
+ usableWindow() {
133
+ return Math.max(0, this.window - this.reserve - Math.min(256, Math.floor(this.window * 0.05)));
134
+ }
135
+ /** Leave room for several related reads, their calls, and the next edit.
136
+ * This is a character cap, deliberately much smaller than input tokens. */
137
+ toolResultCharLimit() {
138
+ return Math.min(10000, Math.max(600, Math.floor(this.usableWindow() * 0.4)));
139
+ }
140
+ /** Learn conservative tokenizer overhead without adding a tokenizer dependency. */
141
+ calibrate(promptTokens, input, tools) {
142
+ if (!promptTokens || !Number.isFinite(promptTokens))
143
+ return;
144
+ const estimate = this.estimateMessages(input) + this.estimateTools(tools);
145
+ if (estimate > 0)
146
+ this.calibration = Math.max(this.calibration, Math.min(3, promptTokens / estimate));
147
+ }
148
+ budget(messages, tools) {
149
+ const prompt = this.estimatePrompt(messages, tools);
150
+ return { prompt, window: this.window, reserve: this.reserve, available: Math.max(0, this.usableWindow() - prompt), source: this.lastPromptTokens > 0 ? "measured + estimate" : "estimate" };
151
+ }
152
+ assertFits(messages, tools) {
153
+ const size = this.estimatePrompt(messages, tools);
154
+ if (size > this.usableWindow()) {
155
+ throw new Error(`Context budget exceeded: about ${size} input tokens, ${this.usableWindow()} available after reserving the reply. Shorten the request or AGENTS.md, use /models for a larger loaded window, or restart with --ctx. The request was not sent.`);
156
+ }
157
+ }
158
+ /** Only run while a command is using the CPU/shell. Never queue behind coding. */
159
+ prepareBackground(messages, tools, provider, state, delayMs = 750) {
160
+ if (this.background || this.prepared || messages.length < Math.max(10, this.preparedAt + 6) || this.estimatePrompt(messages, tools) < this.usableWindow() * 0.6)
161
+ return;
162
+ this.preparedAt = messages.length;
163
+ const snapshot = JSON.parse(JSON.stringify(messages));
164
+ const source = JSON.stringify(snapshot);
165
+ const controller = new AbortController();
166
+ const frozen = { ...state, filesTouched: new Set(state.filesTouched), commandsRun: [...state.commandsRun] };
167
+ // Most shell checks finish faster than a local-model prefill. Give them
168
+ // time to finish before occupying the GPU with a summary we'd immediately
169
+ // cancel. Long installs/builds still overlap with useful compaction.
170
+ const work = (0, transport_1.abortableDelay)(delayMs, controller.signal).then(() => this.compact(snapshot, provider, frozen, { signal: controller.signal, background: true })).then((compacted) => {
171
+ if (!controller.signal.aborted && this.estimatePrompt(compacted, tools) < this.estimatePrompt(snapshot, tools)) {
172
+ this.prepared = { source, count: snapshot.length, messages: compacted };
173
+ }
174
+ }).catch(() => { }).finally(() => { if (this.background?.controller === controller)
175
+ this.background = null; });
176
+ this.background = { controller, work };
177
+ }
178
+ cancelBackground(discard = false) {
179
+ this.background?.controller.abort();
180
+ if (discard) {
181
+ this.prepared = null;
182
+ this.preparedAt = 0;
183
+ }
184
+ }
185
+ async foreground() {
186
+ const job = this.background;
187
+ this.cancelBackground();
188
+ if (job)
189
+ await job.work;
190
+ }
191
+ fillPercent(messages, tools) {
192
+ return Math.min(100, Math.round((this.estimatePrompt(messages, tools) / this.window) * 100));
193
+ }
194
+ needsAttention(messages, tools) {
195
+ if (this.estimatePrompt(messages, tools) <= 0.8 * this.usableWindow()) {
196
+ this.floorWarned = false; // healthy again — re-arm the floor warning
197
+ return false;
198
+ }
199
+ // Once we've established the transcript cannot shrink further, stop
200
+ // triggering a futile compaction before every request.
201
+ return !this.floorWarned || this.estimatePrompt(messages, tools) > this.usableWindow();
202
+ }
203
+ /**
204
+ * Tier 0: a file was just completely rewritten — every earlier read_file
205
+ * result for that path is now wrong. Replace the big ones with a stub so
206
+ * they neither cost context nor mislead the next edit. Returns how many
207
+ * results were stubbed.
208
+ */
209
+ evictStaleReads(messages, filePath) {
210
+ const norm = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
211
+ const target = norm(filePath);
212
+ let n = 0;
213
+ // Skip the most recent message: it is the write's own result.
214
+ for (let i = 1; i < messages.length - 1; i++) {
215
+ const m = messages[i];
216
+ if (m.role !== "tool" || m.evicted || m.content.length < STALE_READ_MIN_CHARS)
217
+ continue;
218
+ const call = callFor(messages, i);
219
+ if (!call || call.name !== "read_file")
220
+ continue;
221
+ if (norm(String(call.args?.path ?? "")) !== target)
222
+ continue;
223
+ m.content = STALE_READ_STUB;
224
+ m.evicted = true;
225
+ n++;
226
+ }
227
+ if (n) {
228
+ // The transcript shrank behind the usage anchor.
229
+ this.anchorIndex = 0;
230
+ this.lastPromptTokens = 0;
231
+ }
232
+ return n;
233
+ }
234
+ /**
235
+ * Bring the transcript back under budget. Mutates and/or replaces `messages`;
236
+ * returns the (possibly new) array plus a report for the UI.
237
+ */
238
+ async manage(messages, tools, provider, state, opts = {}) {
239
+ const before = this.estimatePrompt(messages, tools);
240
+ if (!opts.force && before <= 0.8 * this.usableWindow()) {
241
+ return { messages, report: { action: "none", before, after: before } };
242
+ }
243
+ if (this.prepared) {
244
+ const ready = this.prepared;
245
+ this.prepared = null;
246
+ if (JSON.stringify(messages.slice(0, ready.count)) === ready.source) {
247
+ const candidate = [...ready.messages, ...messages.slice(ready.count)];
248
+ const after = Math.ceil((this.estimateMessages(candidate) + this.estimateTools(tools)) * this.calibration);
249
+ if (after < before && after <= this.usableWindow() * 0.8) {
250
+ this.resetAnchor();
251
+ return { messages: candidate, report: { action: "compacted", before, after } };
252
+ }
253
+ }
254
+ }
255
+ // Older reasoning is cheaper to drop than fresh source code. Preserve the
256
+ // newest assistant group's reasoning; tool calls/results remain intact.
257
+ let thinkingFrom = messages.length;
258
+ for (let i = messages.length - 1; i >= 1; i--) {
259
+ if (messages[i].role === "assistant") {
260
+ thinkingFrom = i;
261
+ break;
262
+ }
263
+ }
264
+ for (let i = 1; i < thinkingFrom; i++) {
265
+ if (messages[i].role === "assistant" && messages[i].thinking) {
266
+ messages[i].thinking = undefined;
267
+ this.resetAnchor();
268
+ }
269
+ }
270
+ // Completed writes are already on disk. Old request bodies can be much
271
+ // larger than tool results, and used to force a summary after every file.
272
+ // Replace completed calls with marked historical data, never synthetic
273
+ // executable arguments or assistant answers: small models imitate both.
274
+ // Keep the newest group intact and never remove an unexecuted/failed call.
275
+ let latestGroup = messages.length;
276
+ for (let i = messages.length - 1; i >= 1; i--) {
277
+ if (messages[i].role === "assistant" && messages[i].toolCalls?.length) {
278
+ latestGroup = i;
279
+ break;
280
+ }
281
+ }
282
+ for (let i = latestGroup - 1; i >= 1; i--) {
283
+ const message = messages[i];
284
+ if (!message.toolCalls)
285
+ continue;
286
+ const results = [];
287
+ for (let j = i + 1; j < messages.length && messages[j].role === "tool"; j++)
288
+ results.push(messages[j]);
289
+ const removed = new Set();
290
+ const receipts = [];
291
+ for (const call of message.toolCalls) {
292
+ const receipt = results.find((m) => m.toolCallId === call.id);
293
+ if (!receipt || !/^(Created|Overwrote|Edited) /.test(receipt.content))
294
+ continue;
295
+ const keys = call.name === "write_file" ? ["content"] : call.name === "edit_file" ? ["old_text", "new_text"] : [];
296
+ if (!keys.some((key) => typeof call.args[key] === "string" &&
297
+ (call.args[key].length > 1200 || (0, history_1.isHistoryPlaceholder)(call.args[key]))))
298
+ continue;
299
+ removed.add(call.id);
300
+ receipts.push(`${call.name}: ${(0, util_1.truncateEnd)(receipt.content, 600)}`);
301
+ }
302
+ if (!removed.size)
303
+ continue;
304
+ const remainingCalls = message.toolCalls.filter((call) => !removed.has(call.id));
305
+ const retained = remainingCalls.length
306
+ ? [{ ...message, toolCalls: remainingCalls }, ...results.filter((result) => !removed.has(result.toolCallId))]
307
+ : message.content ? [{ role: "assistant", content: message.content }] : [];
308
+ messages.splice(i, results.length + 1, ...retained, {
309
+ role: "user", historyNote: true,
310
+ content: `[Harness history record — earlier tool executions, not a new request. Applied code omitted; read_file returns current source. Future changes require actual tool calls.]\n${receipts.join("\n")}`,
311
+ });
312
+ this.resetAnchor();
313
+ }
314
+ // Tier 1b: evict old tool-result bodies, oldest first.
315
+ const evictBoundary = Math.max(1, messages.length - EVICT_KEEP_RECENT);
316
+ for (let i = 1; i < evictBoundary; i++) {
317
+ const m = messages[i];
318
+ if (m.role === "tool" && !m.evicted && m.content.length > 200) {
319
+ m.content = EVICT_STUB;
320
+ m.evicted = true;
321
+ // invalidate the usage anchor — the transcript shrank behind it
322
+ this.anchorIndex = 0;
323
+ this.lastPromptTokens = 0;
324
+ if (this.estimatePrompt(messages, tools) <= 0.6 * this.usableWindow())
325
+ break;
326
+ }
327
+ }
328
+ let after = this.estimatePrompt(messages, tools);
329
+ if (!opts.force && after <= 0.8 * this.usableWindow()) {
330
+ return { messages, report: { action: "evicted", before, after } };
331
+ }
332
+ // Tier 2: full compaction around a state note.
333
+ const compacted = await this.compact(messages, provider, state, opts);
334
+ this.anchorIndex = 0;
335
+ this.lastPromptTokens = 0;
336
+ after = this.estimatePrompt(compacted, tools);
337
+ // Evict whole assistant/tool groups when the protected tail itself is too
338
+ // large. Never leave orphan tool results or silently trim the live request.
339
+ // 80% is a soft trigger, not permission to erase the data just requested.
340
+ // Keep the latest complete result if it fits the hard input budget. Losing
341
+ // it here causes read -> compact -> reread loops on small windows.
342
+ while (after > this.usableWindow() && compacted.length > 2) {
343
+ let end = 3;
344
+ if (compacted[2].role === "assistant" && compacted[2].toolCalls?.length) {
345
+ while (end < compacted.length && compacted[end].role === "tool")
346
+ end++;
347
+ }
348
+ compacted.splice(2, end - 2);
349
+ after = this.estimatePrompt(compacted, tools);
350
+ }
351
+ if (after > 0.8 * this.usableWindow()) {
352
+ // Irreducible floor: the window simply cannot hold what must stay
353
+ // (system prompt + AGENTS.md + tool schemas + the working tail).
354
+ // Stop repeated futile summaries; assertFits still guards every request.
355
+ this.floorWarned = true;
356
+ return { messages: compacted, report: { action: "floor", before, after } };
357
+ }
358
+ return { messages: compacted, report: { action: "compacted", before, after } };
359
+ }
360
+ async compact(allMessages, provider, state, opts = {}) {
361
+ const system = allMessages[0];
362
+ // Strip prior compaction notes — their content is regenerated fresh below.
363
+ // Without this, notes accrete (each new note keeps the old one in its
364
+ // tail) and compaction stops shrinking the transcript at all.
365
+ const messages = [system, ...allMessages.slice(1).filter((m) => !m.compactNote)];
366
+ // Deterministic part of the state note — the harness knows these facts.
367
+ // The plan goes first: it is the model's map of the task.
368
+ const facts = [];
369
+ if (state.planLine)
370
+ facts.push(state.planLine);
371
+ if (state.filesTouched.size) {
372
+ facts.push(`Files created/modified so far: ${[...state.filesTouched].slice(-30).join(", ")}`);
373
+ }
374
+ if (state.commandsRun.length) {
375
+ // Keep recent outcomes, not entire inline scripts or the oldest command
376
+ // swallowing the facts budget. Middle truncation preserves the exit code.
377
+ facts.push(`Recent commands: ${state.commandsRun.slice(-6).map((s) => (0, util_1.truncateMiddle)(s.replace(/\s+/g, " "), 180)).join("; ")}`);
378
+ }
379
+ // Model-written progress summary — structured, thinking off, short cap.
380
+ // Skipped entirely on very small windows: the summarize call itself must
381
+ // fit, and on Ollama an oversized prompt is silently front-truncated
382
+ // (losing the instructions), so facts-only is the safe degradation. If the
383
+ // call fails, facts alone carry the note.
384
+ let narrative = "";
385
+ const digestBudgetChars = Math.min(60000, Math.max(0, (this.usableWindow() / this.calibration - 1500) * 3));
386
+ if (!opts.deterministic && digestBudgetChars >= 3000) {
387
+ try {
388
+ // Prior notes contain decisions that may exist nowhere else now.
389
+ const previous = allMessages.filter((m) => m.compactNote).map((m) => m.content.split("Hand-over notes:\n")[1] ?? m.content).join("\n");
390
+ const transcript = renderForDigest(messages.slice(1), Math.max(0, digestBudgetChars - Math.min(previous.length, 2400)));
391
+ const res = await provider.chat([
392
+ {
393
+ role: "system",
394
+ content: "You write hand-over notes for a coding agent whose conversation is about to be cleared. Be concrete and factual; never invent. Reply with only the notes.",
395
+ },
396
+ {
397
+ role: "user",
398
+ content: `Write hand-over notes for this session under exactly these headings:\n` +
399
+ `In progress: what was being worked on when the log ends, and its current state.\n` +
400
+ `Next: the next concrete step.\n` +
401
+ `Notes: key decisions, gotchas, exact names/APIs/values the agent must not forget, and any unresolved errors.\n` +
402
+ `Keep it under 180 words. Preserve exact module exports, function signatures and required argument shapes. Do not repeat the goal, plan or file list: the harness adds those separately. Never treat a failed check as completed work.\n\n` +
403
+ (state.planLine ? `Current plan:\n${(0, util_1.truncateEnd)(state.planLine, 1600)}\n\n` : "") +
404
+ (previous ? `Previous hand-over (retain still-relevant decisions):\n${(0, util_1.truncateEnd)(previous, 2400)}\n\n` : "") +
405
+ `Session log (oldest first, long outputs shortened):\n${transcript}`,
406
+ },
407
+ ], [], { effortOverride: "off", maxTokens: Math.min(700, this.reserve), signal: opts.signal, timeoutMs: 45_000, background: opts.background });
408
+ if (!res.truncated)
409
+ narrative = (0, util_1.truncateEnd)(res.content.trim(), Math.min(2800, Math.max(600, Math.floor(this.usableWindow() * 0.3))));
410
+ }
411
+ catch (err) {
412
+ if (opts.signal?.aborted)
413
+ throw opts.signal.reason;
414
+ if (opts.background)
415
+ throw err;
416
+ narrative = "";
417
+ }
418
+ }
419
+ if (!narrative) {
420
+ narrative = (0, util_1.truncateEnd)(allMessages.filter((m) => m.compactNote).map((m) => m.content.split("Hand-over notes:\n")[1] ?? "").join("\n"), Math.min(2800, Math.max(600, Math.floor(this.usableWindow() * 0.3))));
421
+ }
422
+ // Keep a clean tail. Preferred cut: the most recent plain user message.
423
+ // Mid-turn there often is none nearby — then keep the last COMPLETE
424
+ // assistant-toolcall + tool-results group instead of dropping everything,
425
+ // so the model retains the material it just fetched for its next action.
426
+ let keepFrom = messages.length;
427
+ for (let i = messages.length - 1; i >= Math.max(1, messages.length - 8); i--) {
428
+ if (messages[i].role === "user" && !messages[i].compactNote && !messages[i].historyNote) {
429
+ keepFrom = i;
430
+ break;
431
+ }
432
+ }
433
+ if (keepFrom === messages.length) {
434
+ for (let i = messages.length - 1; i >= 1; i--) {
435
+ const m = messages[i];
436
+ if (m.role === "assistant" && m.toolCalls?.length) {
437
+ const allAnswered = m.toolCalls.every((tc) => messages.slice(i + 1).some((t) => t.role === "tool" && t.toolCallId === tc.id));
438
+ if (allAnswered)
439
+ keepFrom = i;
440
+ break;
441
+ }
442
+ if (m.role === "assistant") {
443
+ keepFrom = i;
444
+ break;
445
+ }
446
+ }
447
+ }
448
+ const tail = keepFrom < messages.length ? messages.slice(keepFrom) : [];
449
+ // The tail's own reasoning is history now; the model does not need to
450
+ // re-read its old thoughts, and Ollama would replay them.
451
+ for (const m of tail)
452
+ if (m.role === "assistant")
453
+ m.thinking = undefined;
454
+ const requestLines = state.currentRequest && state.currentRequest !== state.originalRequest
455
+ ? `Original request: ${state.originalRequest}\nCurrent request (what you are working on NOW): ${state.currentRequest}\n`
456
+ : `Original request: ${state.originalRequest}\n`;
457
+ const note = `[The conversation so far was compacted to save context. Continue the task from these notes — do not start over, and do not redo finished steps.]\n` +
458
+ requestLines +
459
+ (state.verificationLine ? state.verificationLine + "\n" : "") +
460
+ (facts.length ? (0, util_1.truncateEnd)(facts.join("\n"), 2400) + "\n" : "") +
461
+ (narrative ? `\n[Model-written summary; file contents and tool results take precedence.]\nHand-over notes:\n${narrative}` : "");
462
+ return [system, { role: "user", content: note, compactNote: true }, ...tail];
463
+ }
464
+ }
465
+ exports.ContextManager = ContextManager;
466
+ /** Transcript rendering for the summarizer: recent messages get more room
467
+ * than old ones (the end of the log is where the live state is), tool
468
+ * results are shortened, reasoning traces are dropped entirely. Exported for
469
+ * tests. */
470
+ function renderForDigest(messages, budgetChars) {
471
+ const n = messages.length;
472
+ const lines = [];
473
+ for (let i = 0; i < n; i++) {
474
+ const m = messages[i];
475
+ const recent = i >= n - 12;
476
+ const cap = m.role === "tool" ? (recent ? 700 : 200) : recent ? 1500 : 400;
477
+ const tools = m.toolCalls
478
+ ?.map((t) => {
479
+ const a = { ...t.args };
480
+ if (typeof a.content === "string")
481
+ a.content = `<${a.content.length} chars>`;
482
+ if (typeof a.new_text === "string")
483
+ a.new_text = (0, util_1.truncateEnd)(a.new_text, 120);
484
+ if (typeof a.old_text === "string")
485
+ a.old_text = (0, util_1.truncateEnd)(a.old_text, 80);
486
+ return `${t.name}(${(0, util_1.truncateEnd)(JSON.stringify(a), 200)})`;
487
+ })
488
+ .join(", ");
489
+ const body = (0, util_1.truncateEnd)(m.content ?? "", cap);
490
+ lines.push(`${m.role.toUpperCase()}: ${body}${tools ? ` [called: ${tools}]` : ""}`);
491
+ }
492
+ let out = lines.join("\n");
493
+ if (out.length > budgetChars) {
494
+ // Keep the END of the log — that is where the current state lives.
495
+ out = "[earlier log omitted]\n" + out.slice(out.length - budgetChars);
496
+ }
497
+ return out;
498
+ }