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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +35 -0
- package/NOTICE +41 -0
- package/README.md +294 -0
- package/index.ts +332 -0
- package/package.json +28 -0
- package/skills/clm-context/SKILL.md +166 -0
- package/src/budget.ts +304 -0
- package/src/clm.ts +725 -0
- package/src/compact.ts +73 -0
- package/src/context-document.ts +659 -0
- package/src/continuity.ts +722 -0
- package/src/mirror-guard.ts +95 -0
- package/src/mirror-store.ts +154 -0
- package/src/observation.ts +155 -0
- package/src/opencode.ts +339 -0
- package/src/overflow.ts +212 -0
- package/src/policy.ts +60 -0
- package/src/presentation.ts +127 -0
- package/src/projection.ts +146 -0
- package/src/settings.ts +189 -0
- package/src/state.ts +241 -0
- package/src/steering.ts +54 -0
- package/src/types.ts +102 -0
- package/steering/house-brief.md +43 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// Adapted from pi-clm src/context-document.ts (MIT, Copyright 2026 Emanuel Casco).
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
import { gateEdit } from "./policy.ts";
|
|
5
|
+
import type {
|
|
6
|
+
ApplyDocumentOptions,
|
|
7
|
+
ApplyDocumentResult,
|
|
8
|
+
ContextDocumentSnapshot,
|
|
9
|
+
ContextEditSourceKind,
|
|
10
|
+
ContextEditTrace,
|
|
11
|
+
DocumentBlock,
|
|
12
|
+
LiveContextMessage,
|
|
13
|
+
} from "./types.ts";
|
|
14
|
+
|
|
15
|
+
const DOCUMENT_VERSION = 1;
|
|
16
|
+
const EDIT_HINT = "# Edit bodies or delete editable CTX_TURN blocks. Keep metadata/header lines intact.";
|
|
17
|
+
/** customType of messages the plugin authors: model-inserted blocks and edited bodies that changed role. */
|
|
18
|
+
export const NOTE_TYPE = "clm-note";
|
|
19
|
+
// Metadata is valid only as the first line. An example copied into a message body must
|
|
20
|
+
// never be mistaken for the active document envelope.
|
|
21
|
+
const META_RE =
|
|
22
|
+
/^\[\[LIVE_CONTEXT version=(\d+) revision=(\d+) document=([a-f0-9]{64}) baseline=([a-f0-9]{64})\]\](?:\r?\n|$)/;
|
|
23
|
+
|
|
24
|
+
interface ParsedBlock {
|
|
25
|
+
index: number;
|
|
26
|
+
id: string;
|
|
27
|
+
role: string;
|
|
28
|
+
protected: boolean;
|
|
29
|
+
body: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ParsedDocument {
|
|
33
|
+
version?: number;
|
|
34
|
+
revision?: number;
|
|
35
|
+
documentId?: string;
|
|
36
|
+
baselineDigest?: string;
|
|
37
|
+
blocks: ParsedBlock[];
|
|
38
|
+
preamble: string;
|
|
39
|
+
duplicateIds: string[];
|
|
40
|
+
malformedCurrentHeaders: string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function normalizeJson(value: unknown): unknown {
|
|
44
|
+
if (Array.isArray(value)) return value.map(normalizeJson);
|
|
45
|
+
if (value && typeof value === "object") {
|
|
46
|
+
return Object.fromEntries(
|
|
47
|
+
Object.entries(value as Record<string, unknown>)
|
|
48
|
+
.filter(([, item]) => item !== undefined)
|
|
49
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
50
|
+
.map(([key, item]) => [key, normalizeJson(item)]),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function canonicalMessage(message: LiveContextMessage): string {
|
|
57
|
+
return JSON.stringify(normalizeJson(message));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function digestMessages(messages: LiveContextMessage[]): string {
|
|
61
|
+
const hash = createHash("sha256");
|
|
62
|
+
for (const message of messages) {
|
|
63
|
+
hash.update(canonicalMessage(message));
|
|
64
|
+
hash.update("\n");
|
|
65
|
+
}
|
|
66
|
+
return hash.digest("hex");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function textFromContent(content: unknown): string {
|
|
70
|
+
if (typeof content === "string") return content;
|
|
71
|
+
if (!Array.isArray(content)) return "";
|
|
72
|
+
|
|
73
|
+
return content
|
|
74
|
+
.map((part) => {
|
|
75
|
+
if (typeof part === "string") return part;
|
|
76
|
+
if (!part || typeof part !== "object") return String(part ?? "");
|
|
77
|
+
const block = part as Record<string, unknown>;
|
|
78
|
+
if (block.type === "text" && typeof block.text === "string") return block.text;
|
|
79
|
+
if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
80
|
+
return `[thinking]\n${block.thinking}`;
|
|
81
|
+
}
|
|
82
|
+
if (block.type === "toolCall") {
|
|
83
|
+
const name = typeof block.name === "string" ? block.name : "tool";
|
|
84
|
+
return `[tool call: ${name}]\n${JSON.stringify(normalizeJson(block.arguments ?? {}))}`;
|
|
85
|
+
}
|
|
86
|
+
if (block.type === "image") {
|
|
87
|
+
const mimeType =
|
|
88
|
+
typeof block.mimeType === "string"
|
|
89
|
+
? block.mimeType
|
|
90
|
+
: typeof (block.source as Record<string, unknown> | undefined)?.mediaType === "string"
|
|
91
|
+
? String((block.source as Record<string, unknown>).mediaType)
|
|
92
|
+
: "unknown";
|
|
93
|
+
return `[image: ${mimeType}; data omitted]`;
|
|
94
|
+
}
|
|
95
|
+
return JSON.stringify(normalizeJson(block));
|
|
96
|
+
})
|
|
97
|
+
.filter(Boolean)
|
|
98
|
+
.join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function renderMessage(message: LiveContextMessage): string {
|
|
102
|
+
const parts: string[] = [];
|
|
103
|
+
let content = textFromContent(message.content);
|
|
104
|
+
const contextRole = (message.details as { contextRole?: unknown } | undefined)?.contextRole;
|
|
105
|
+
if (message.role === "custom" && message.customType === NOTE_TYPE && typeof contextRole === "string") {
|
|
106
|
+
const prefix = `[context role=${contextRole}]\n`;
|
|
107
|
+
if (content.startsWith(prefix)) content = content.slice(prefix.length);
|
|
108
|
+
}
|
|
109
|
+
if (content) parts.push(content);
|
|
110
|
+
|
|
111
|
+
if (message.role === "bashExecution") {
|
|
112
|
+
if (typeof message.command === "string") parts.push(`[command]\n${message.command}`);
|
|
113
|
+
if (typeof message.output === "string" && message.output) parts.push(`[output]\n${message.output}`);
|
|
114
|
+
}
|
|
115
|
+
if (message.role === "branchSummary" || message.role === "compactionSummary") {
|
|
116
|
+
if (typeof message.summary === "string") parts.push(message.summary);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Legacy/OpenAI-shaped messages are accepted by the pure core as well.
|
|
120
|
+
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
121
|
+
for (const toolCall of toolCalls) {
|
|
122
|
+
if (!toolCall || typeof toolCall !== "object") continue;
|
|
123
|
+
const fn = (toolCall as Record<string, unknown>).function as Record<string, unknown> | undefined;
|
|
124
|
+
if (!fn) continue;
|
|
125
|
+
parts.push(`[tool call: ${String(fn.name ?? "tool")}]\n${String(fn.arguments ?? "")}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return parts.join("\n\n").trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function estimateContextCharacters(messages: LiveContextMessage[]): number {
|
|
132
|
+
return messages.reduce((total, message) => total + renderMessage(message).length, 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function blockId(message: LiveContextMessage, index: number): string {
|
|
136
|
+
const digest = createHash("sha256").update(canonicalMessage(message)).digest("hex").slice(0, 12);
|
|
137
|
+
return `${index + 1}-${digest}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function documentId(baselineDigest: string, revision: number): string {
|
|
141
|
+
return createHash("sha256")
|
|
142
|
+
.update(`opencode-clm:${DOCUMENT_VERSION}:${revision}:${baselineDigest}`)
|
|
143
|
+
.digest("hex");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Structural lines inside a body are escaped when the document nonce is stable across
|
|
148
|
+
* renders (CLM mode). With a per-render nonce this was unnecessary: a header the model
|
|
149
|
+
* printed on one call could never match the nonce of the next render. With a per-revision
|
|
150
|
+
* nonce, a `head -5 LIVE_CONTEXT.md` tool result would contain live headers, so they are
|
|
151
|
+
* neutralised with a leading backslash at render time and restored when a body is edited.
|
|
152
|
+
*/
|
|
153
|
+
const STRUCTURAL_LINE_RE = /^(\\*)(\[\[(?:CTX_TURN|LIVE_CONTEXT) )/gm;
|
|
154
|
+
|
|
155
|
+
export function escapeStructuralLines(body: string): string {
|
|
156
|
+
return body.replace(STRUCTURAL_LINE_RE, (_match, slashes: string, start: string) => `\\${slashes}${start}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function unescapeStructuralLines(body: string): string {
|
|
160
|
+
return body.replace(STRUCTURAL_LINE_RE, (_match, slashes: string, start: string) =>
|
|
161
|
+
slashes.length > 0 ? `${slashes.slice(1)}${start}` : `${slashes}${start}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function escapeRegExp(value: string): string {
|
|
166
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface RenderContextDocumentOptions {
|
|
170
|
+
revision?: number;
|
|
171
|
+
protectedIndexes?: Set<number>;
|
|
172
|
+
/**
|
|
173
|
+
* When set, the document nonce is derived from this seed and the revision instead of
|
|
174
|
+
* the rendered baseline, so it stays constant between accepted edits even as new
|
|
175
|
+
* messages arrive. Header ids a model reads on one call remain valid on the next.
|
|
176
|
+
* Bodies are then escaped so live headers quoted inside tool output stay inert.
|
|
177
|
+
*/
|
|
178
|
+
documentSeed?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function renderContextDocument(
|
|
182
|
+
messages: LiveContextMessage[],
|
|
183
|
+
options: RenderContextDocumentOptions = {},
|
|
184
|
+
): ContextDocumentSnapshot {
|
|
185
|
+
const revision = options.revision ?? 0;
|
|
186
|
+
const protectedIndexes = options.protectedIndexes ?? new Set<number>();
|
|
187
|
+
const baselineDigest = digestMessages(messages);
|
|
188
|
+
const stable = options.documentSeed !== undefined;
|
|
189
|
+
const currentDocumentId = documentId(stable ? `seed:${options.documentSeed}` : baselineDigest, revision);
|
|
190
|
+
const blocks: DocumentBlock[] = messages.map((source, index) => {
|
|
191
|
+
const id = blockId(source, index);
|
|
192
|
+
const authoredRole = (source.details as { contextRole?: unknown } | undefined)?.contextRole;
|
|
193
|
+
const role = source.role === "custom" && source.customType === NOTE_TYPE &&
|
|
194
|
+
typeof authoredRole === "string" && /^[A-Za-z][A-Za-z0-9_-]*$/.test(authoredRole)
|
|
195
|
+
? authoredRole : source.role;
|
|
196
|
+
const protectedBlock = protectedIndexes.has(index);
|
|
197
|
+
return {
|
|
198
|
+
index,
|
|
199
|
+
id,
|
|
200
|
+
role,
|
|
201
|
+
protected: protectedBlock,
|
|
202
|
+
header: `[[CTX_TURN document=${currentDocumentId} index=${index + 1} role=${role} id=${id} protected=${protectedBlock}]]`,
|
|
203
|
+
body: stable ? escapeStructuralLines(renderMessage(source)) : renderMessage(source),
|
|
204
|
+
source,
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
const renderedBlocks = blocks.map((block) => `${block.header}\n${block.body}`);
|
|
208
|
+
const text = [
|
|
209
|
+
`[[LIVE_CONTEXT version=${DOCUMENT_VERSION} revision=${revision} document=${currentDocumentId} baseline=${baselineDigest}]]`,
|
|
210
|
+
EDIT_HINT,
|
|
211
|
+
...renderedBlocks,
|
|
212
|
+
].join("\n\n");
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
version: DOCUMENT_VERSION,
|
|
216
|
+
revision,
|
|
217
|
+
baselineDigest,
|
|
218
|
+
documentId: currentDocumentId,
|
|
219
|
+
stableDocument: stable,
|
|
220
|
+
text,
|
|
221
|
+
messages: [...messages],
|
|
222
|
+
blocks,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseDocument(text: string, snapshot: ContextDocumentSnapshot): ParsedDocument {
|
|
227
|
+
const metadata = META_RE.exec(text);
|
|
228
|
+
const blockRe = new RegExp(
|
|
229
|
+
`^\\[\\[CTX_TURN document=${escapeRegExp(snapshot.documentId)} index=(\\d+) role=([A-Za-z][A-Za-z0-9_-]*) id=([a-zA-Z0-9-]+) protected=(true|false)\\]\\]\\s*$`,
|
|
230
|
+
"gm",
|
|
231
|
+
);
|
|
232
|
+
const matches = [...text.matchAll(blockRe)];
|
|
233
|
+
const blocks: ParsedBlock[] = [];
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
const duplicateIds: string[] = [];
|
|
236
|
+
|
|
237
|
+
for (let index = 0; index < matches.length; index++) {
|
|
238
|
+
const match = matches[index];
|
|
239
|
+
const start = (match.index ?? 0) + match[0].length;
|
|
240
|
+
const end = index + 1 < matches.length ? (matches[index + 1].index ?? text.length) : text.length;
|
|
241
|
+
const id = match[3];
|
|
242
|
+
if (seen.has(id)) duplicateIds.push(id);
|
|
243
|
+
seen.add(id);
|
|
244
|
+
blocks.push({
|
|
245
|
+
index: Number(match[1]) - 1,
|
|
246
|
+
role: match[2],
|
|
247
|
+
id,
|
|
248
|
+
protected: match[4] === "true",
|
|
249
|
+
body: text.slice(start, end).trim(),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const validHeaderLines = new Set(matches.map((match) => match[0].trim()));
|
|
254
|
+
const currentPrefix = `[[CTX_TURN document=${snapshot.documentId} `;
|
|
255
|
+
const malformedCurrentHeaders = text
|
|
256
|
+
.split(/\r?\n/)
|
|
257
|
+
.map((line) => line.trim())
|
|
258
|
+
.filter((line) => line.startsWith(currentPrefix) && !validHeaderLines.has(line));
|
|
259
|
+
|
|
260
|
+
const metadataEnd = metadata ? metadata[0].length : 0;
|
|
261
|
+
const firstBlockStart = matches[0]?.index ?? text.length;
|
|
262
|
+
const preamble = text
|
|
263
|
+
.slice(metadataEnd, firstBlockStart)
|
|
264
|
+
.split("\n")
|
|
265
|
+
.filter((line) => !line.trim().startsWith(EDIT_HINT))
|
|
266
|
+
.join("\n")
|
|
267
|
+
.trim();
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
version: metadata ? Number(metadata[1]) : undefined,
|
|
271
|
+
revision: metadata ? Number(metadata[2]) : undefined,
|
|
272
|
+
documentId: metadata?.[3],
|
|
273
|
+
baselineDigest: metadata?.[4],
|
|
274
|
+
blocks,
|
|
275
|
+
preamble,
|
|
276
|
+
duplicateIds,
|
|
277
|
+
malformedCurrentHeaders,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* True when a line starts with a `[[CTX_TURN ` or `[[LIVE_CONTEXT ` frame of any nonce,
|
|
283
|
+
* other than a metadata line for the current revision and document.
|
|
284
|
+
*/
|
|
285
|
+
function hasForeignFraming(text: string, snapshot: ContextDocumentSnapshot): boolean {
|
|
286
|
+
return text.split(/\r?\n/).some((raw) => {
|
|
287
|
+
const line = raw.trimStart();
|
|
288
|
+
if (line.startsWith("[[CTX_TURN ")) return true;
|
|
289
|
+
if (!line.startsWith("[[LIVE_CONTEXT ")) return false;
|
|
290
|
+
const meta = META_RE.exec(line);
|
|
291
|
+
return !meta ||
|
|
292
|
+
Number(meta[1]) !== snapshot.version ||
|
|
293
|
+
Number(meta[2]) !== snapshot.revision ||
|
|
294
|
+
meta[3] !== snapshot.documentId;
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function contentToolCalls(message: LiveContextMessage): Array<{ id?: string; name?: string; arguments?: unknown }> {
|
|
299
|
+
if (!Array.isArray(message.content)) return [];
|
|
300
|
+
return message.content
|
|
301
|
+
.filter(
|
|
302
|
+
(part): part is Record<string, unknown> =>
|
|
303
|
+
Boolean(part && typeof part === "object" && (part as Record<string, unknown>).type === "toolCall"),
|
|
304
|
+
)
|
|
305
|
+
.map((part) => ({
|
|
306
|
+
id: typeof part.id === "string" ? part.id : undefined,
|
|
307
|
+
name: typeof part.name === "string" ? part.name : undefined,
|
|
308
|
+
arguments: part.arguments,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function toolCallIds(message: LiveContextMessage): Set<string> {
|
|
313
|
+
const ids = new Set<string>();
|
|
314
|
+
for (const call of contentToolCalls(message)) if (call.id) ids.add(call.id);
|
|
315
|
+
for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
|
|
316
|
+
if (call && typeof call === "object" && typeof (call as Record<string, unknown>).id === "string") {
|
|
317
|
+
ids.add(String((call as Record<string, unknown>).id));
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return ids;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Edited body text as it should enter a message: escaped structural lines restored. */
|
|
324
|
+
function bodyText(body: string, snapshot: ContextDocumentSnapshot): string {
|
|
325
|
+
return snapshot.stableDocument ? unescapeStructuralLines(body) : body;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function contextNote(body: string, source?: LiveContextMessage, requestedRole?: string): LiveContextMessage {
|
|
329
|
+
return {
|
|
330
|
+
role: "custom",
|
|
331
|
+
customType: NOTE_TYPE,
|
|
332
|
+
content: requestedRole ? `[context role=${requestedRole}]\n${body}` : body,
|
|
333
|
+
...(requestedRole ? { details: { contextRole: requestedRole } } : {}),
|
|
334
|
+
display: false,
|
|
335
|
+
timestamp: typeof source?.timestamp === "number" ? source.timestamp : Date.now(),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function changedMessage(source: LiveContextMessage, body: string, requestedRole: string, labelRole = false): LiveContextMessage {
|
|
340
|
+
if (source.role === "assistant" && requestedRole === "assistant") {
|
|
341
|
+
const changed: LiveContextMessage = {
|
|
342
|
+
...source,
|
|
343
|
+
content: [{ type: "text", text: body }],
|
|
344
|
+
stopReason: "stop",
|
|
345
|
+
edited: true,
|
|
346
|
+
};
|
|
347
|
+
delete changed.errorMessage;
|
|
348
|
+
delete changed.tool_calls;
|
|
349
|
+
return changed;
|
|
350
|
+
}
|
|
351
|
+
if (source.role === "user" && requestedRole === "user") {
|
|
352
|
+
return {
|
|
353
|
+
...source,
|
|
354
|
+
content: Array.isArray(source.content) ? [{ type: "text", text: body }] : body,
|
|
355
|
+
edited: true,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// OpenCode stores a tool call and its result in one part, so an edited result body can
|
|
359
|
+
// stay a structured tool result: the call keeps its pairing and only the output changes.
|
|
360
|
+
if (source.role === "toolResult" && requestedRole === "toolResult") {
|
|
361
|
+
return { ...source, content: [{ type: "text", text: body }], edited: true };
|
|
362
|
+
}
|
|
363
|
+
return contextNote(body, source, labelRole ? requestedRole : undefined);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function repairToolGroups(messages: LiveContextMessage[]): LiveContextMessage[] {
|
|
367
|
+
const repaired = [...messages];
|
|
368
|
+
|
|
369
|
+
for (let index = 0; index < repaired.length; index++) {
|
|
370
|
+
const message = repaired[index];
|
|
371
|
+
if (message.role !== "assistant") continue;
|
|
372
|
+
const ids = toolCallIds(message);
|
|
373
|
+
if (ids.size === 0) continue;
|
|
374
|
+
|
|
375
|
+
const resultIds = new Set<string>();
|
|
376
|
+
let cursor = index + 1;
|
|
377
|
+
while (cursor < repaired.length && repaired[cursor].role === "toolResult") {
|
|
378
|
+
const resultId = repaired[cursor].toolCallId;
|
|
379
|
+
if (typeof resultId === "string" && ids.has(resultId)) resultIds.add(resultId);
|
|
380
|
+
cursor++;
|
|
381
|
+
}
|
|
382
|
+
const complete = [...ids].every((id) => resultIds.has(id));
|
|
383
|
+
if (!complete) repaired[index] = contextNote(renderMessage(message), message);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
for (let index = 0; index < repaired.length; index++) {
|
|
387
|
+
const message = repaired[index];
|
|
388
|
+
if (message.role !== "toolResult") continue;
|
|
389
|
+
let assistantIndex = index - 1;
|
|
390
|
+
while (assistantIndex >= 0 && repaired[assistantIndex].role === "toolResult") assistantIndex--;
|
|
391
|
+
const assistant = assistantIndex >= 0 ? repaired[assistantIndex] : undefined;
|
|
392
|
+
const ids = assistant?.role === "assistant" ? toolCallIds(assistant) : new Set<string>();
|
|
393
|
+
if (typeof message.toolCallId !== "string" || !ids.has(message.toolCallId)) {
|
|
394
|
+
repaired[index] = contextNote(renderMessage(message), message);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return repaired;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function hasOrphanToolMessages(messages: LiveContextMessage[]): boolean {
|
|
402
|
+
for (let index = 0; index < messages.length; index++) {
|
|
403
|
+
const message = messages[index];
|
|
404
|
+
if (message.role === "assistant") {
|
|
405
|
+
const ids = toolCallIds(message);
|
|
406
|
+
if (ids.size === 0) continue;
|
|
407
|
+
const found = new Set<string>();
|
|
408
|
+
let cursor = index + 1;
|
|
409
|
+
while (cursor < messages.length && messages[cursor].role === "toolResult") {
|
|
410
|
+
const resultId = messages[cursor].toolCallId;
|
|
411
|
+
if (typeof resultId === "string" && ids.has(resultId)) found.add(resultId);
|
|
412
|
+
cursor++;
|
|
413
|
+
}
|
|
414
|
+
if ([...ids].some((id) => !found.has(id))) return true;
|
|
415
|
+
}
|
|
416
|
+
if (message.role === "toolResult") {
|
|
417
|
+
let cursor = index - 1;
|
|
418
|
+
while (cursor >= 0 && messages[cursor].role === "toolResult") cursor--;
|
|
419
|
+
const assistant = cursor >= 0 ? messages[cursor] : undefined;
|
|
420
|
+
if (
|
|
421
|
+
!assistant ||
|
|
422
|
+
assistant.role !== "assistant" ||
|
|
423
|
+
typeof message.toolCallId !== "string" ||
|
|
424
|
+
!toolCallIds(assistant).has(message.toolCallId)
|
|
425
|
+
) {
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function rejected(
|
|
434
|
+
snapshot: ContextDocumentSnapshot,
|
|
435
|
+
beforeEstimate: number,
|
|
436
|
+
reason: string,
|
|
437
|
+
diagnostics: string[] = [],
|
|
438
|
+
): ApplyDocumentResult {
|
|
439
|
+
return {
|
|
440
|
+
accepted: false,
|
|
441
|
+
changed: true,
|
|
442
|
+
messages: [...snapshot.messages],
|
|
443
|
+
beforeEstimate,
|
|
444
|
+
afterEstimate: beforeEstimate,
|
|
445
|
+
savingsEstimate: 0,
|
|
446
|
+
diagnostics,
|
|
447
|
+
reason,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function applyContextDocument(
|
|
452
|
+
editedText: string,
|
|
453
|
+
snapshot: ContextDocumentSnapshot,
|
|
454
|
+
options: ApplyDocumentOptions = {},
|
|
455
|
+
): ApplyDocumentResult {
|
|
456
|
+
const estimate = options.estimate ?? estimateContextCharacters;
|
|
457
|
+
const estimateUnit = options.estimateUnit ?? "characters";
|
|
458
|
+
const beforeEstimate = estimate(snapshot.messages);
|
|
459
|
+
if (editedText.trim() === snapshot.text.trim()) {
|
|
460
|
+
return {
|
|
461
|
+
accepted: true,
|
|
462
|
+
changed: false,
|
|
463
|
+
messages: [...snapshot.messages],
|
|
464
|
+
beforeEstimate,
|
|
465
|
+
afterEstimate: beforeEstimate,
|
|
466
|
+
savingsEstimate: 0,
|
|
467
|
+
diagnostics: [],
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const finalize = (
|
|
472
|
+
candidate: LiveContextMessage[],
|
|
473
|
+
candidateOrigins: Array<{ sourceIndex?: number; kind: ContextEditSourceKind | "added" }>,
|
|
474
|
+
removedSourceIndexes: Set<number>,
|
|
475
|
+
diagnostics: string[],
|
|
476
|
+
): ApplyDocumentResult => {
|
|
477
|
+
const messages = repairToolGroups(candidate);
|
|
478
|
+
if (hasOrphanToolMessages(messages)) {
|
|
479
|
+
return rejected(snapshot, beforeEstimate, "Context edit could not be normalized to legal tool-call structure.");
|
|
480
|
+
}
|
|
481
|
+
const afterEstimate = estimate(messages);
|
|
482
|
+
const gate = gateEdit({
|
|
483
|
+
gate: options.gate ?? (options.editingMode === "clm" ? "fit" : "shrink"),
|
|
484
|
+
before: beforeEstimate,
|
|
485
|
+
after: afterEstimate,
|
|
486
|
+
limit: options.limit,
|
|
487
|
+
unit: estimateUnit,
|
|
488
|
+
});
|
|
489
|
+
if (!gate.accepted) return rejected(snapshot, beforeEstimate, gate.reason ?? "Context edit rejected by the edit gate.", diagnostics);
|
|
490
|
+
|
|
491
|
+
const tracedSources: ContextEditTrace["sources"] = candidateOrigins.flatMap((origin, outputIndex) => {
|
|
492
|
+
if (origin.sourceIndex === undefined) return [];
|
|
493
|
+
const wasNormalized = canonicalMessage(candidate[outputIndex]) !== canonicalMessage(messages[outputIndex]);
|
|
494
|
+
const kind: ContextEditSourceKind = wasNormalized
|
|
495
|
+
? "normalized"
|
|
496
|
+
: origin.kind === "added" ? "edited" : origin.kind;
|
|
497
|
+
return [{ sourceIndex: origin.sourceIndex, outputIndex, kind }];
|
|
498
|
+
});
|
|
499
|
+
for (const sourceIndex of removedSourceIndexes) {
|
|
500
|
+
tracedSources.push({ sourceIndex, kind: "removed" });
|
|
501
|
+
}
|
|
502
|
+
tracedSources.sort((left, right) => left.sourceIndex - right.sourceIndex);
|
|
503
|
+
const editTrace: ContextEditTrace = {
|
|
504
|
+
version: 1,
|
|
505
|
+
sourceRevision: snapshot.revision,
|
|
506
|
+
sourceMessageCount: snapshot.messages.length,
|
|
507
|
+
outputMessageCount: messages.length,
|
|
508
|
+
sources: tracedSources,
|
|
509
|
+
additions: candidateOrigins.flatMap((origin, outputIndex) =>
|
|
510
|
+
origin.sourceIndex === undefined ? [{ outputIndex, kind: "added" as const }] : [],
|
|
511
|
+
),
|
|
512
|
+
};
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
accepted: true,
|
|
516
|
+
changed: true,
|
|
517
|
+
messages,
|
|
518
|
+
beforeEstimate,
|
|
519
|
+
afterEstimate,
|
|
520
|
+
savingsEstimate: beforeEstimate - afterEstimate,
|
|
521
|
+
diagnostics,
|
|
522
|
+
editTrace,
|
|
523
|
+
};
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const parsed = parseDocument(editedText, snapshot);
|
|
527
|
+
const metadataLine = snapshot.text.split("\n")[0] ?? "";
|
|
528
|
+
const staleMetadata = () =>
|
|
529
|
+
rejected(
|
|
530
|
+
snapshot,
|
|
531
|
+
beforeEstimate,
|
|
532
|
+
`Mirror metadata does not match the current context revision (expected revision ${snapshot.revision}, document ${snapshot.documentId.slice(0, 12)}…). ` +
|
|
533
|
+
`The first line must be exactly: ${metadataLine}`,
|
|
534
|
+
);
|
|
535
|
+
// A file with no current block headers but with framing lines from another document
|
|
536
|
+
// (or a stale revision) is an old mirror copied back, not a summary: reject it.
|
|
537
|
+
if (parsed.blocks.length === 0 && hasForeignFraming(editedText, snapshot)) return staleMetadata();
|
|
538
|
+
// CLM: a file with no block headers at all is the model replacing its whole context
|
|
539
|
+
// with free text (the paper's "collapse to a summary" move). Accept it as one notes
|
|
540
|
+
// block, keeping the first user turn so the task statement survives.
|
|
541
|
+
if (options.editingMode === "clm" && parsed.blocks.length === 0 && editedText.trim().length > 0) {
|
|
542
|
+
const body = unescapeStructuralLines(
|
|
543
|
+
editedText
|
|
544
|
+
.split(/\r?\n/)
|
|
545
|
+
.filter((line) => !META_RE.test(`${line}\n`) && !line.trim().startsWith(EDIT_HINT))
|
|
546
|
+
.join("\n")
|
|
547
|
+
.trim(),
|
|
548
|
+
);
|
|
549
|
+
if (body.length === 0) return rejected(snapshot, beforeEstimate, "Mirror is empty after removing metadata; nothing to apply.");
|
|
550
|
+
const candidate: LiveContextMessage[] = [];
|
|
551
|
+
const candidateOrigins: Array<{ sourceIndex?: number; kind: ContextEditSourceKind | "added" }> = [];
|
|
552
|
+
const removedSourceIndexes = new Set<number>();
|
|
553
|
+
const firstUserIndex = options.taskPinned ? -1 : snapshot.messages.findIndex((message) => message.role === "user");
|
|
554
|
+
for (const [index, message] of snapshot.messages.entries()) {
|
|
555
|
+
if (index === firstUserIndex) {
|
|
556
|
+
candidate.push(message);
|
|
557
|
+
candidateOrigins.push({ sourceIndex: index, kind: "kept" });
|
|
558
|
+
} else {
|
|
559
|
+
removedSourceIndexes.add(index);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
candidate.push(contextNote(body, undefined, "notes"));
|
|
563
|
+
candidateOrigins.push({ kind: "added" });
|
|
564
|
+
return finalize(candidate, candidateOrigins, removedSourceIndexes, [
|
|
565
|
+
"Accepted a headerless rewrite: every block was replaced by one notes block" +
|
|
566
|
+
(firstUserIndex >= 0 ? " after the first user turn" : "") +
|
|
567
|
+
". To edit blocks individually, keep the [[CTX_TURN ...]] headers.",
|
|
568
|
+
]);
|
|
569
|
+
}
|
|
570
|
+
// With a stable document the baseline digest still changes every render (new raw
|
|
571
|
+
// messages), so only the revision and nonce identify the document the model edited.
|
|
572
|
+
const baselineMatches = snapshot.stableDocument || parsed.baselineDigest === snapshot.baselineDigest;
|
|
573
|
+
if (
|
|
574
|
+
parsed.version !== snapshot.version ||
|
|
575
|
+
parsed.revision !== snapshot.revision ||
|
|
576
|
+
parsed.documentId !== snapshot.documentId ||
|
|
577
|
+
!baselineMatches
|
|
578
|
+
) {
|
|
579
|
+
return staleMetadata();
|
|
580
|
+
}
|
|
581
|
+
if (parsed.duplicateIds.length > 0) {
|
|
582
|
+
return rejected(snapshot, beforeEstimate, `Mirror contains duplicate block IDs: ${parsed.duplicateIds.join(", ")}.`);
|
|
583
|
+
}
|
|
584
|
+
if (parsed.malformedCurrentHeaders.length > 0) {
|
|
585
|
+
return rejected(snapshot, beforeEstimate, "Mirror contains malformed headers for the current document.");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const sourceById = new Map(snapshot.blocks.map((block) => [block.id, block]));
|
|
589
|
+
const parsedById = new Map<string, ParsedBlock>();
|
|
590
|
+
const unknownIds: string[] = [];
|
|
591
|
+
for (const block of parsed.blocks) {
|
|
592
|
+
if (!sourceById.has(block.id) && !(options.editingMode === "clm" && /^new-[a-zA-Z0-9-]+$/.test(block.id))) unknownIds.push(block.id);
|
|
593
|
+
else parsedById.set(block.id, block);
|
|
594
|
+
}
|
|
595
|
+
if (unknownIds.length > 0) {
|
|
596
|
+
return rejected(
|
|
597
|
+
snapshot,
|
|
598
|
+
beforeEstimate,
|
|
599
|
+
`Mirror contains unknown block IDs: ${unknownIds.join(", ")}. ` +
|
|
600
|
+
(options.editingMode === "clm"
|
|
601
|
+
? "Use ids from the current mirror headers, or prefix a new block's id with new-."
|
|
602
|
+
: "Use ids from the current mirror headers."),
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const diagnostics: string[] = [];
|
|
607
|
+
const candidate: LiveContextMessage[] = [];
|
|
608
|
+
const candidateOrigins: Array<{ sourceIndex?: number; kind: ContextEditSourceKind | "added" }> = [];
|
|
609
|
+
const removedSourceIndexes = new Set<number>();
|
|
610
|
+
// Stray text above the first block keeps its written position as a leading note.
|
|
611
|
+
if (parsed.preamble) {
|
|
612
|
+
candidate.push(contextNote(bodyText(parsed.preamble, snapshot)));
|
|
613
|
+
candidateOrigins.push({ kind: "added" });
|
|
614
|
+
}
|
|
615
|
+
// Conservative mode retains source order. CLM follows the edited order;
|
|
616
|
+
// removed originals are still visited for complete provenance/protected restoration.
|
|
617
|
+
const orderedIds = options.editingMode === "clm"
|
|
618
|
+
? [...parsed.blocks.map((block) => block.id), ...snapshot.blocks.filter((block) => !parsedById.has(block.id)).map((block) => block.id)]
|
|
619
|
+
: snapshot.blocks.map((block) => block.id);
|
|
620
|
+
for (const id of orderedIds) {
|
|
621
|
+
const sourceBlock = sourceById.get(id);
|
|
622
|
+
const parsedBlock = parsedById.get(id);
|
|
623
|
+
if (!sourceBlock) {
|
|
624
|
+
if (parsedBlock?.body) {
|
|
625
|
+
candidate.push(contextNote(bodyText(parsedBlock.body, snapshot), undefined, parsedBlock.role));
|
|
626
|
+
candidateOrigins.push({ kind: "added" });
|
|
627
|
+
}
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (sourceBlock.protected) {
|
|
631
|
+
const restored =
|
|
632
|
+
!parsedBlock ||
|
|
633
|
+
parsedBlock.body !== sourceBlock.body ||
|
|
634
|
+
parsedBlock.role !== sourceBlock.role ||
|
|
635
|
+
!parsedBlock.protected;
|
|
636
|
+
candidate.push(sourceBlock.source);
|
|
637
|
+
candidateOrigins.push({ sourceIndex: sourceBlock.index, kind: restored ? "restored" : "kept" });
|
|
638
|
+
if (restored) diagnostics.push(`Restored protected turn ${sourceBlock.index + 1}.`);
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
if (!parsedBlock || !parsedBlock.body) {
|
|
642
|
+
removedSourceIndexes.add(sourceBlock.index);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (
|
|
646
|
+
parsedBlock.body === sourceBlock.body &&
|
|
647
|
+
parsedBlock.role === sourceBlock.role &&
|
|
648
|
+
parsedBlock.protected === sourceBlock.protected
|
|
649
|
+
) {
|
|
650
|
+
candidate.push(sourceBlock.source);
|
|
651
|
+
candidateOrigins.push({ sourceIndex: sourceBlock.index, kind: "kept" });
|
|
652
|
+
} else {
|
|
653
|
+
candidate.push(changedMessage(sourceBlock.source, bodyText(parsedBlock.body, snapshot), parsedBlock.role, options.editingMode === "clm"));
|
|
654
|
+
candidateOrigins.push({ sourceIndex: sourceBlock.index, kind: "edited" });
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return finalize(candidate, candidateOrigins, removedSourceIndexes, diagnostics);
|
|
659
|
+
}
|