opencode-clm 0.1.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.
@@ -0,0 +1,339 @@
1
+ /**
2
+ * Conversion between OpenCode's `{ info, parts }` messages (as delivered to
3
+ * `experimental.chat.messages.transform`) and the flat message list the core
4
+ * (context-document.ts, projection.ts, observation.ts, overflow.ts) works on.
5
+ *
6
+ * Takes the place of pi-clm's identity cast between AgentMessage and LiveContextMessage
7
+ * (pi-clm src/index.ts, MIT, Copyright 2026 Emanuel Casco): Pi already stores pi-shaped
8
+ * messages, OpenCode does not.
9
+ *
10
+ * Shape. OpenCode keeps a tool call and its result in one `tool` part of an assistant
11
+ * message. The core expects an assistant message carrying `toolCall` blocks followed by one
12
+ * `toolResult` message per call, so `flatten` splits every assistant message that way and
13
+ * each tool result becomes its own mirror block. Every flat message carries `ocMessageID`,
14
+ * the OpenCode message it came from (projection checkpoints store these as `sourceIds`).
15
+ *
16
+ * Fidelity. `flatten` follows OpenCode 1.18.34 `MessageV2.toModelMessagesEffect`:
17
+ * - a message without parts is skipped;
18
+ * - a failed assistant message (`info.error`) is skipped unless the error is
19
+ * `MessageAbortedError` and some part is neither `step-start` nor `reasoning`;
20
+ * - user text parts that are `ignored` or empty are skipped; `text/plain` and directory file
21
+ * parts are skipped (OpenCode inlines their content as synthetic text parts); a
22
+ * `compaction` part reads "What did we do so far?" and a `subtask` part "The following
23
+ * tool was executed by the user" (OpenCode's own strings);
24
+ * - a completed tool sends its output, or "[Old tool result content cleared]" once
25
+ * OpenCode's prune set `time.compacted`; an error sends its error text, or the partial
26
+ * output of an interrupted call; a pending or running call sends
27
+ * "[Tool execution was interrupted]".
28
+ * Flattening never depends on settings: the projection digest is computed from it, and a
29
+ * settings toggle must not discard an accepted revision. Reasoning is always flattened;
30
+ * `withoutReasoning` hides it from the mirror view only.
31
+ *
32
+ * Known gap: an assistant message whose only sendable parts are empty text parts has no
33
+ * flat form and is left out of the request. OpenCode sends it as an empty assistant turn.
34
+ *
35
+ * Identity rule for `unflatten`: a block the model did not touch maps back to the current
36
+ * raw OpenCode object (looked up by `ocMessageID` and call id), so untouched messages reach
37
+ * the provider exactly as OpenCode would send them. Only edited user and assistant bodies,
38
+ * tool results flagged `edited`, `withheld` or `capped`, and notes are synthesized. One
39
+ * exception: an untouched result that OpenCode's prune cleared while the flat text kept the
40
+ * full output (inside an accepted revision) is sent with the flat text.
41
+ */
42
+ import { createHash } from "node:crypto";
43
+
44
+ import { NOTE_TYPE, renderMessage } from "./context-document.ts";
45
+ import type { LiveContextMessage } from "./types.ts";
46
+
47
+ // Loose structural types: assignable from @opencode-ai/sdk `Message` / `Part`, which this
48
+ // module does not import at runtime (the plugin package is an optional peer dependency).
49
+ export type OcPart = { id: string; type: string; [key: string]: any };
50
+ export type OcInfo = { id: string; sessionID: string; role: "user" | "assistant"; [key: string]: any };
51
+ export interface OcMessage {
52
+ info: OcInfo;
53
+ parts: OcPart[];
54
+ }
55
+
56
+ /** OpenCode's own placeholder texts (packages/opencode/src/session/message-v2.ts). */
57
+ export const INTERRUPTED_TOOL_TEXT = "[Tool execution was interrupted]";
58
+ export const CLEARED_TOOL_TEXT = "[Old tool result content cleared]";
59
+ export const COMPACTION_USER_TEXT = "What did we do so far?";
60
+ export const SUBTASK_USER_TEXT = "The following tool was executed by the user";
61
+
62
+ /** Placeholder for an assistant message whose only content is hidden reasoning. */
63
+ export const REASONING_HIDDEN_TEXT = "[reasoning not shown in the mirror]";
64
+
65
+ /** The text OpenCode sends as the result of one tool part, and whether it is an error. */
66
+ export function toolPartOutput(part: OcPart): { text: string; isError: boolean } {
67
+ const state = part.state ?? {};
68
+ switch (state.status) {
69
+ case "completed":
70
+ return { text: state.time?.compacted ? CLEARED_TOOL_TEXT : String(state.output ?? ""), isError: false };
71
+ case "error": {
72
+ const partial = state.metadata?.interrupted === true ? state.metadata.output : undefined;
73
+ if (typeof partial === "string") return { text: partial, isError: false };
74
+ return { text: String(state.error ?? ""), isError: true };
75
+ }
76
+ default:
77
+ return { text: INTERRUPTED_TOOL_TEXT, isError: true };
78
+ }
79
+ }
80
+
81
+ /** OpenCode drops failed assistant messages, except aborted ones with real content. */
82
+ export function sentToModel(message: OcMessage): boolean {
83
+ if (message.parts.length === 0) return false;
84
+ const error = message.info.role === "assistant" ? message.info.error : undefined;
85
+ if (!error) return true;
86
+ return error.name === "MessageAbortedError" &&
87
+ message.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning");
88
+ }
89
+
90
+ function fileBlock(mime: string, filename: unknown): Record<string, unknown> {
91
+ if (mime.startsWith("image/")) return { type: "image", mimeType: mime };
92
+ return { type: "text", text: `[file ${mime}: ${typeof filename === "string" && filename ? filename : "file"}]` };
93
+ }
94
+
95
+ function userContent(message: OcMessage): Record<string, unknown>[] {
96
+ const content: Record<string, unknown>[] = [];
97
+ for (const part of message.parts) {
98
+ if (part.type === "text" && !part.ignored && part.text !== "") content.push({ type: "text", text: String(part.text ?? "") });
99
+ else if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") {
100
+ content.push(fileBlock(String(part.mime ?? "application/octet-stream"), part.filename));
101
+ } else if (part.type === "compaction") content.push({ type: "text", text: COMPACTION_USER_TEXT });
102
+ else if (part.type === "subtask") content.push({ type: "text", text: SUBTASK_USER_TEXT });
103
+ }
104
+ return content;
105
+ }
106
+
107
+ function toolResultContent(part: OcPart, text: string): Record<string, unknown>[] {
108
+ const content: Record<string, unknown>[] = [{ type: "text", text }];
109
+ const state = part.state ?? {};
110
+ if (state.status === "completed" && !state.time?.compacted && Array.isArray(state.attachments)) {
111
+ for (const attachment of state.attachments) {
112
+ if (attachment && typeof attachment.mime === "string") content.push(fileBlock(attachment.mime, attachment.filename));
113
+ }
114
+ }
115
+ return content;
116
+ }
117
+
118
+ /**
119
+ * OpenCode messages → flat messages, each tagged with `ocMessageID`. Deterministic and
120
+ * independent of settings, so the same stored history always digests the same way.
121
+ */
122
+ export function flatten(messages: readonly OcMessage[]): LiveContextMessage[] {
123
+ const out: LiveContextMessage[] = [];
124
+ for (const message of messages) {
125
+ if (!sentToModel(message)) continue;
126
+ const id = message.info.id;
127
+ const timestamp = Number(message.info.time?.created ?? 0);
128
+ if (message.info.role === "user") {
129
+ const content = userContent(message);
130
+ if (content.length > 0) out.push({ role: "user", content, ocMessageID: id, timestamp });
131
+ continue;
132
+ }
133
+ const content: Record<string, unknown>[] = [];
134
+ const results: LiveContextMessage[] = [];
135
+ for (const part of message.parts) {
136
+ if (part.type === "text" && part.text) content.push({ type: "text", text: String(part.text) });
137
+ else if (part.type === "reasoning" && String(part.text ?? "").trim()) {
138
+ content.push({ type: "thinking", thinking: String(part.text) });
139
+ } else if (part.type === "tool") {
140
+ content.push({ type: "toolCall", id: part.callID, name: part.tool, arguments: part.state?.input ?? {} });
141
+ const output = toolPartOutput(part);
142
+ results.push({
143
+ role: "toolResult",
144
+ toolCallId: part.callID,
145
+ toolName: part.tool,
146
+ content: toolResultContent(part, output.text),
147
+ isError: output.isError,
148
+ ocMessageID: id,
149
+ timestamp,
150
+ });
151
+ }
152
+ }
153
+ if (content.length === 0) continue;
154
+ out.push({ role: "assistant", content, ocMessageID: id, timestamp });
155
+ out.push(...results);
156
+ }
157
+ return out;
158
+ }
159
+
160
+ const hiddenReasoning = new WeakMap<LiveContextMessage, LiveContextMessage>();
161
+
162
+ /**
163
+ * Mirror view without reasoning (`reasoning: false`). Assistant messages lose their
164
+ * `thinking` blocks; one with nothing else keeps a short placeholder, so its block is not
165
+ * empty (an empty body deletes the block). One-to-one, so projection indexes still hold.
166
+ * Unflatten maps untouched assistant blocks back to the raw message, reasoning included.
167
+ */
168
+ export function withoutReasoning(messages: LiveContextMessage[]): LiveContextMessage[] {
169
+ let changed = false;
170
+ const output = messages.map((message) => {
171
+ if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
172
+ const cached = hiddenReasoning.get(message);
173
+ if (cached) {
174
+ changed ||= cached !== message;
175
+ return cached;
176
+ }
177
+ const content = message.content.filter((part) => !(part && typeof part === "object" && (part as { type?: unknown }).type === "thinking"));
178
+ let next = message;
179
+ if (content.length !== message.content.length) {
180
+ next = { ...message, content: content.length > 0 ? content : [{ type: "text", text: REASONING_HIDDEN_TEXT }] };
181
+ changed = true;
182
+ }
183
+ hiddenReasoning.set(message, next);
184
+ return next;
185
+ });
186
+ return changed ? output : messages;
187
+ }
188
+
189
+ function textOf(message: LiveContextMessage): string {
190
+ const content = message.content;
191
+ if (typeof content === "string") return content;
192
+ if (!Array.isArray(content)) return "";
193
+ return content
194
+ .filter((part): part is { type: "text"; text: string } => Boolean(part) && (part as { type?: string }).type === "text" && typeof (part as { text?: unknown }).text === "string")
195
+ .map((part) => part.text)
196
+ .join("\n");
197
+ }
198
+
199
+ function shortHash(value: string): string {
200
+ return createHash("sha256").update(value).digest("hex").slice(0, 20);
201
+ }
202
+
203
+ export interface UnflattenContext {
204
+ sessionID: string;
205
+ /** Current raw messages by id. */
206
+ rawById: Map<string, OcMessage>;
207
+ /** Info whose agent and model synthetic user messages copy (the newest user message). */
208
+ template?: OcInfo;
209
+ }
210
+
211
+ /** A synthetic user message carrying text: notes, notices, continuity, orphaned results. */
212
+ export function noteMessage(text: string, seed: string, context: UnflattenContext, created = Date.now()): OcMessage {
213
+ const hash = shortHash(`${seed}\n${text}`);
214
+ const messageID = `msg_clm${hash}`;
215
+ return {
216
+ info: {
217
+ id: messageID,
218
+ sessionID: context.sessionID,
219
+ role: "user",
220
+ time: { created },
221
+ agent: context.template?.agent ?? "build",
222
+ model: context.template?.model ?? { providerID: "", modelID: "" },
223
+ },
224
+ parts: [{ id: `prt_clm${hash}`, sessionID: context.sessionID, messageID, type: "text", text, synthetic: true }],
225
+ };
226
+ }
227
+
228
+ function textPart(message: OcMessage, text: string, kind: string): OcPart {
229
+ return {
230
+ id: `prt_clm${shortHash(`${message.info.id}:${kind}:${text}`)}`,
231
+ sessionID: message.info.sessionID,
232
+ messageID: message.info.id,
233
+ type: "text",
234
+ text,
235
+ synthetic: true,
236
+ };
237
+ }
238
+
239
+ /**
240
+ * A tool part with its output replaced: the call keeps its pairing, only the result text
241
+ * changes. A capped result keeps its attachments; an edited or withheld one drops them.
242
+ */
243
+ function withOutput(part: OcPart, result: LiveContextMessage, output = textOf(result)): OcPart {
244
+ const state = part.state ?? {};
245
+ const start = Number(state.time?.start ?? 0);
246
+ const keepAttachments = result.edited !== true && result.withheld !== true;
247
+ return {
248
+ ...part,
249
+ state: {
250
+ status: "completed",
251
+ input: state.input ?? {},
252
+ output,
253
+ title: typeof state.title === "string" ? state.title : "",
254
+ metadata: state.metadata ?? {},
255
+ time: { start, end: Number(state.time?.end ?? start) },
256
+ ...(keepAttachments && Array.isArray(state.attachments) ? { attachments: state.attachments } : {}),
257
+ },
258
+ };
259
+ }
260
+
261
+ function rewritten(result: LiveContextMessage): boolean {
262
+ return result.edited === true || result.withheld === true || result.capped === true;
263
+ }
264
+
265
+ /** The tool output text of a flat result: its first block (attachments follow it). */
266
+ function resultOutput(result: LiveContextMessage): string | undefined {
267
+ const first = Array.isArray(result.content) ? (result.content[0] as { type?: unknown; text?: unknown } | undefined) : undefined;
268
+ return first?.type === "text" && typeof first.text === "string" ? first.text : undefined;
269
+ }
270
+
271
+ /**
272
+ * The tool part to send for an untouched result. A part OpenCode's prune cleared after a
273
+ * revision stored its full text is sent with the flat text, so the request matches the
274
+ * mirror and the estimate. Other changes to a stored output pass through as OpenCode sends them.
275
+ */
276
+ function restored(part: OcPart, result: LiveContextMessage): OcPart {
277
+ if (part.state?.status !== "completed" || !part.state.time?.compacted) return part;
278
+ const output = resultOutput(result);
279
+ if (output === undefined || output === toolPartOutput(part).text) return part;
280
+ return withOutput(part, result, output);
281
+ }
282
+
283
+ /**
284
+ * Flat messages → OpenCode messages. Expects legal tool groups (applyContextDocument
285
+ * repairs them): a kept assistant is followed by the results of all its calls. Anything
286
+ * that cannot be mapped back to a raw message becomes a synthetic user note.
287
+ */
288
+ export function unflatten(messages: readonly LiveContextMessage[], context: UnflattenContext): OcMessage[] {
289
+ const out: OcMessage[] = [];
290
+ for (let index = 0; index < messages.length; index++) {
291
+ const message = messages[index]!;
292
+ const id = typeof message.ocMessageID === "string" ? message.ocMessageID : undefined;
293
+ const raw = id ? context.rawById.get(id) : undefined;
294
+ if (raw && raw.info.role === "user" && message.role === "user") {
295
+ out.push(message.edited ? { info: raw.info, parts: [textPart(raw, textOf(message), "user")] } : raw);
296
+ continue;
297
+ }
298
+ if (raw && raw.info.role === "assistant" && message.role === "assistant") {
299
+ const results = new Map<string, LiveContextMessage>();
300
+ while (
301
+ index + 1 < messages.length &&
302
+ messages[index + 1]!.role === "toolResult" &&
303
+ messages[index + 1]!.ocMessageID === id
304
+ ) {
305
+ const result = messages[++index]!;
306
+ results.set(String(result.toolCallId), result);
307
+ }
308
+ if (message.edited) {
309
+ out.push({ info: raw.info, parts: [textPart(raw, textOf(message), "assistant")] });
310
+ for (const result of results.values()) {
311
+ out.push(noteMessage(renderMessage(result), `orphan:${id}:${String(result.toolCallId)}`, context));
312
+ }
313
+ continue;
314
+ }
315
+ const parts = raw.parts.flatMap((part) => {
316
+ if (part.type !== "tool") return [part];
317
+ const result = results.get(String(part.callID));
318
+ if (!result) return [];
319
+ return [rewritten(result) ? withOutput(part, result) : restored(part, result)];
320
+ });
321
+ const untouched = parts.length === raw.parts.length && parts.every((part, at) => part === raw.parts[at]);
322
+ out.push(untouched ? raw : { info: raw.info, parts });
323
+ continue;
324
+ }
325
+ // Notes the model wrote, bodies whose role changed, results whose call was removed,
326
+ // and anything whose raw source is gone: plain synthetic user text.
327
+ const text = message.role === "custom" && message.customType === NOTE_TYPE ? textOf(message) : renderMessage(message);
328
+ if (text.trim()) {
329
+ const created = typeof message.timestamp === "number" && message.timestamp > 0 ? message.timestamp : Date.now();
330
+ out.push(noteMessage(text, `${index}:${id ?? ""}`, context, created));
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+
336
+ /** Replace the contents of OpenCode's message array in place (the hook's output object). */
337
+ export function replaceInPlace(target: OcMessage[], next: readonly OcMessage[]): void {
338
+ target.splice(0, target.length, ...next);
339
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Overflow guard: keep the next request under the budget when the model has not.
3
+ *
4
+ * Adapted from pi-clm src/overflow.ts (MIT, Copyright 2026 Emanuel Casco).
5
+ *
6
+ * Reminders arrive at request boundaries, but one assistant turn with many parallel tool
7
+ * calls can add tens of thousands of tokens at once. When the estimated request exceeds
8
+ * the guard limit, tool results are *withheld* from the effective context, oldest first —
9
+ * each replaced by a short note giving the tool, call id, size, and a file holding the
10
+ * full text — until the estimate fits or nothing withholdable is left.
11
+ *
12
+ * Properties:
13
+ * - transcript-only: OpenCode's stored parts are untouched and no tool is ever re-run;
14
+ * - oldest first, tool results only; user, assistant and note blocks are never touched.
15
+ * Oldest first matters: the result the model just asked for (often a re-read of a
16
+ * withheld file) must stay visible, otherwise withholding turns into a loop;
17
+ * - deterministic and cached per source message, so repeated transform calls agree;
18
+ * - a note keeps the `toolResult` role and `toolCallId`, so tool-call pairing stays legal,
19
+ * and carries `withheld: true` so the unflatten step writes it into the tool part;
20
+ * - the note is an ordinary block in the mirror: the model can shorten, delete, or
21
+ * replace it, and can re-read the saved file.
22
+ *
23
+ * OpenCode differences from pi-clm: the limit has no `window − 4096` ceiling (that one
24
+ * models pi-ai's `max_tokens` clamp). The caller must therefore pass the model's output
25
+ * limit to `resolveBudget` (budget.ts), which subtracts it from the window; otherwise the
26
+ * guard limit can exceed the room OpenCode leaves for input. The saved file holds the
27
+ * uncapped output when the observation cap cut it.
28
+ */
29
+
30
+ import { createHash } from "node:crypto";
31
+ import { chmodSync, closeSync, constants, fchmodSync, mkdirSync, openSync, writeSync } from "node:fs";
32
+ import { join } from "node:path";
33
+ import { uncappedSource } from "./observation.ts";
34
+ import type { LiveContextMessage } from "./types.ts";
35
+
36
+ export interface OverflowGuardPolicy {
37
+ mode: "withhold" | "off";
38
+ }
39
+
40
+ export const DEFAULT_OVERFLOW_GUARD: OverflowGuardPolicy = { mode: "withhold" };
41
+
42
+ /** Prefix of every withheld note; a result that starts with it is never withheld again. */
43
+ export const WITHHELD_NOTE_PREFIX = "[clm overflow guard]";
44
+
45
+ export function resolveOverflowGuard(overrides: Partial<OverflowGuardPolicy> | undefined): OverflowGuardPolicy {
46
+ return { ...DEFAULT_OVERFLOW_GUARD, ...(overrides ?? {}) };
47
+ }
48
+
49
+ /** The limit the guard enforces: the budget minus the generation reserve (at least 1). */
50
+ export function overflowGuardLimit(budget: number, reserve: number): number {
51
+ return Math.max(1, budget - reserve);
52
+ }
53
+
54
+ export interface WithheldRecord {
55
+ message: LiveContextMessage;
56
+ toolCallId: string;
57
+ toolName: string;
58
+ tokens: number;
59
+ file?: string;
60
+ }
61
+
62
+ export interface OverflowGuardResult {
63
+ messages: LiveContextMessage[];
64
+ withheld: WithheldRecord[];
65
+ /** Estimated tokens of the returned messages plus `fixedTokens` (same estimator as the caller). */
66
+ estimated: number;
67
+ }
68
+
69
+ interface TextPart {
70
+ type: "text";
71
+ text: string;
72
+ }
73
+
74
+ function toolResultText(message: LiveContextMessage): string {
75
+ const content = message.content;
76
+ if (typeof content === "string") return content;
77
+ if (!Array.isArray(content)) return "";
78
+ return content
79
+ .filter((part): part is TextPart => Boolean(part) && typeof part === "object" && (part as TextPart).type === "text" && typeof (part as TextPart).text === "string")
80
+ .map((part) => part.text)
81
+ .join("\n");
82
+ }
83
+
84
+ /** Short by design: notes accumulate, so each costs as little context as possible. */
85
+ export function withheldNoteText(record: Omit<WithheldRecord, "message">): string {
86
+ const where = record.file ? ` Full text: ${record.file}` : " Full text remains in the session history.";
87
+ return `${WITHHELD_NOTE_PREFIX} ${record.toolName}#${record.toolCallId} (~${record.tokens.toLocaleString("en-US")} tok) withheld over budget.${where}`;
88
+ }
89
+
90
+ const withheldCache = new WeakMap<LiveContextMessage, LiveContextMessage>();
91
+ const savedFiles = new WeakMap<LiveContextMessage, string>();
92
+
93
+ export interface ApplyOverflowGuardOptions {
94
+ limit: number;
95
+ /** Estimated tokens of everything outside `messages` (system prompt, notices). */
96
+ fixedTokens: number;
97
+ estimate: (messages: LiveContextMessage[]) => number;
98
+ /** Directory for saved outputs (`<mirror dir>/withheld`); omitted → nothing is written. */
99
+ saveDirectory?: string;
100
+ /** Only messages at or after this index are candidates (the raw suffix after the last accepted edit); default 0. */
101
+ protectBefore?: number;
102
+ }
103
+
104
+ const MAX_FILE_NAME = 120;
105
+
106
+ function safeName(value: string): string {
107
+ return value.replace(/[^A-Za-z0-9_-]/g, "_");
108
+ }
109
+
110
+ /**
111
+ * `<ocMessageID>-<callID>.txt`, sanitized. Call ids repeat across messages, so the
112
+ * message id is part of the name. Names over 120 characters are cut and suffixed with a
113
+ * short hash of the source text.
114
+ */
115
+ export function withheldFileName(source: LiveContextMessage, index: number, text: string): string {
116
+ const base = `${safeName(String(source.ocMessageID ?? "msg"))}-${safeName(String(source.toolCallId ?? `idx${index}`))}`;
117
+ if (base.length + 4 <= MAX_FILE_NAME) return `${base}.txt`;
118
+ const hash = createHash("sha256").update(text).digest("hex").slice(0, 12);
119
+ return `${base.slice(0, MAX_FILE_NAME - 4 - hash.length - 1)}-${hash}.txt`;
120
+ }
121
+
122
+ function saveFullText(source: LiveContextMessage, index: number, directory: string): string | undefined {
123
+ let fd: number | undefined;
124
+ try {
125
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
126
+ chmodSync(directory, 0o700); // an existing directory keeps its old mode otherwise
127
+ const text = toolResultText(uncappedSource(source));
128
+ const file = join(directory, withheldFileName(source, index, text));
129
+ // O_NOFOLLOW: never write through a symlink planted at the target path.
130
+ fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
131
+ fchmodSync(fd, 0o600); // an existing file keeps its old mode otherwise
132
+ writeSync(fd, text);
133
+ return file;
134
+ } catch {
135
+ return undefined;
136
+ } finally {
137
+ if (fd !== undefined) closeSync(fd);
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Withhold tool results, oldest first, until `fixedTokens + estimate(messages) <= limit`.
143
+ * Returns the same array when nothing needed to change.
144
+ */
145
+ export function applyOverflowGuard(messages: LiveContextMessage[], options: ApplyOverflowGuardOptions): OverflowGuardResult {
146
+ let current = messages;
147
+ let estimated = options.fixedTokens + options.estimate(current);
148
+ const withheld: WithheldRecord[] = [];
149
+ if (estimated <= options.limit) return { messages, withheld, estimated };
150
+
151
+ const candidates: number[] = [];
152
+ for (let index = Math.max(0, options.protectBefore ?? 0); index < messages.length; index++) {
153
+ const message = messages[index]!;
154
+ if (message.role !== "toolResult") continue;
155
+ if (withheldCache.get(message) === message) continue; // already a note
156
+ if (toolResultText(message).startsWith(WITHHELD_NOTE_PREFIX)) continue;
157
+ candidates.push(index);
158
+ }
159
+ if (candidates.length === 0) return { messages, withheld, estimated };
160
+
161
+ const output = [...messages];
162
+ for (const index of candidates) {
163
+ const source = output[index]!;
164
+ const tokens = options.estimate([source]);
165
+ let file = savedFiles.get(source);
166
+ if (!file && options.saveDirectory) {
167
+ file = saveFullText(source, index, options.saveDirectory);
168
+ if (file) savedFiles.set(source, file);
169
+ }
170
+ const record: WithheldRecord = {
171
+ message: source,
172
+ toolCallId: String(source.toolCallId ?? "unknown"),
173
+ toolName: String(source.toolName ?? "tool"),
174
+ tokens,
175
+ file,
176
+ };
177
+ let note = withheldCache.get(source);
178
+ if (!note) {
179
+ // A note replaces the whole result, so the cap flag no longer applies.
180
+ const { capped: _capped, ...rest } = source;
181
+ note = {
182
+ ...rest,
183
+ content: [{ type: "text", text: withheldNoteText(record) }],
184
+ withheld: true,
185
+ };
186
+ withheldCache.set(source, note);
187
+ withheldCache.set(note, note);
188
+ }
189
+ output[index] = note;
190
+ withheld.push(record);
191
+ current = output;
192
+ estimated = options.fixedTokens + options.estimate(current);
193
+ if (estimated <= options.limit) break;
194
+ }
195
+ return { messages: current, withheld, estimated };
196
+ }
197
+
198
+ export function overflowNoticeText(result: OverflowGuardResult, limit: number): string {
199
+ const count = result.withheld.length;
200
+ const files = result.withheld.filter((record) => record.file).length;
201
+ const list = result.withheld
202
+ .map((record) => `${record.toolName}#${record.toolCallId} (~${record.tokens.toLocaleString("en-US")} tok)`)
203
+ .join(", ");
204
+ const fits = result.estimated <= limit;
205
+ return (
206
+ `[CLM BUDGET] Overflow guard: the request would have exceeded the limit of ${limit.toLocaleString("en-US")} tokens, so ${count} tool result${count === 1 ? "" : "s"} ` +
207
+ `${count === 1 ? "was" : "were"} withheld from your context and replaced by notes${files > 0 ? " with file paths" : ""}: ${list}. ` +
208
+ `Estimated request is now ${result.estimated.toLocaleString("en-US")} tokens${fits ? "" : " and still over the limit; edit your context now"}. ` +
209
+ "Nothing was re-run; the full outputs are in the files named in the notes and in the session history. " +
210
+ "Free space by editing the context mirror, then re-read only the parts you need, e.g. with sed -n or grep."
211
+ );
212
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,60 @@
1
+ // Gate design after the CLM paper's harness (fit / shrink / none); text written for this package.
2
+
3
+ /**
4
+ * Edit gate: decides whether a mirror edit that grows the context is applied.
5
+ *
6
+ * - "fit": growth is accepted while the result stays within the limit (budget − reserve).
7
+ * Without a positive finite limit there is nothing to measure against, and
8
+ * fit accepts every edit, like "none".
9
+ * - "shrink": growth is rejected; an edit with after <= before is accepted.
10
+ * - "none": no size check (pi-clm's CLM mode).
11
+ *
12
+ * `before`, `after` and `limit` must share one unit: that of the estimator that produced them.
13
+ */
14
+ export type EditGate = "fit" | "shrink" | "none";
15
+
16
+ const GATES: readonly EditGate[] = ["fit", "shrink", "none"];
17
+
18
+ /** Rule text quoted in a shrink refusal. */
19
+ export const shrinkLimitText = "gate shrink: limit = size before the edit";
20
+
21
+ /** Rule text quoted in a fit refusal. */
22
+ export function fitLimitText(limit: number, unit: "characters" | "tokens" = "tokens"): string {
23
+ return `gate fit: limit ${limit} ${unit}`;
24
+ }
25
+
26
+ /** Reads a gate name, case-insensitive. Booleans are refused: `false` could mean shrink or none. */
27
+ export function toEditGate(value: unknown): EditGate {
28
+ const name = typeof value === "string" ? value.trim().toLowerCase() : undefined;
29
+ const gate = GATES.find((candidate) => candidate === name);
30
+ if (gate) return gate;
31
+ throw new Error(`unknown edit gate ${JSON.stringify(value)}; use "fit", "shrink" or "none"`);
32
+ }
33
+
34
+ export interface GateInput {
35
+ gate: EditGate;
36
+ before: number;
37
+ after: number;
38
+ /** Same unit as `before` and `after`. */
39
+ limit?: number;
40
+ /** Unit named in the rejection text. */
41
+ unit?: "characters" | "tokens";
42
+ }
43
+
44
+ export function gateEdit(input: GateInput): { accepted: boolean; reason?: string } {
45
+ if (input.gate === "none" || input.after <= input.before) return { accepted: true };
46
+ const unit = input.unit ?? "tokens";
47
+ let rule = shrinkLimitText;
48
+ if (input.gate === "fit") {
49
+ const limit = input.limit;
50
+ if (limit === undefined || !Number.isFinite(limit) || limit <= 0) return { accepted: true };
51
+ if (input.after <= limit) return { accepted: true };
52
+ rule = fitLimitText(limit, unit);
53
+ }
54
+ return {
55
+ accepted: false,
56
+ reason:
57
+ `Edit refused (${rule}). Size ${input.before} -> ${input.after} ${unit} (estimated). ` +
58
+ "The previous context is still in effect.",
59
+ };
60
+ }