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.
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/agent.js +748 -0
- package/dist/attachments.js +158 -0
- package/dist/config.js +87 -0
- package/dist/context.js +498 -0
- package/dist/detect.js +474 -0
- package/dist/events.js +24 -0
- package/dist/history.js +9 -0
- package/dist/hosts.js +107 -0
- package/dist/index.js +391 -0
- package/dist/logo.js +48 -0
- package/dist/netscan.js +159 -0
- package/dist/network.js +193 -0
- package/dist/plan.js +102 -0
- package/dist/prompt.js +84 -0
- package/dist/providers/lmstudio.js +347 -0
- package/dist/providers/ollama.js +269 -0
- package/dist/providers/scheduler.js +57 -0
- package/dist/providers/transport.js +86 -0
- package/dist/providers/types.js +62 -0
- package/dist/sandbox.js +207 -0
- package/dist/session.js +639 -0
- package/dist/tools/check.js +193 -0
- package/dist/tools/fs-tools.js +431 -0
- package/dist/tools/index.js +260 -0
- package/dist/tools/search-worker.js +34 -0
- package/dist/tools/shell.js +186 -0
- package/dist/tools/tasks.js +147 -0
- package/dist/tools/web-search.js +155 -0
- package/dist/tui/editor.js +134 -0
- package/dist/tui/keys.js +145 -0
- package/dist/tui/tui.js +723 -0
- package/dist/ui.js +226 -0
- package/dist/util.js +91 -0
- package/dist/verification.js +71 -0
- package/dist/web/channel.js +260 -0
- package/dist/web/client.js +1010 -0
- package/dist/web/hub.js +952 -0
- package/dist/web/page.js +87 -0
- package/dist/web/store.js +199 -0
- package/dist/web/styles.js +333 -0
- package/dist/web/terminal.js +190 -0
- package/package.json +49 -0
package/dist/context.js
ADDED
|
@@ -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
|
+
}
|