opencode-clm 0.4.0 → 0.4.1
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/NOTICE +2 -2
- package/package.json +2 -2
- package/src/clm.ts +22 -9
- package/src/context-document.ts +4 -1
- package/src/opencode.ts +10 -3
- package/src/projection.ts +157 -14
- package/src/settings.ts +1 -1
- package/src/state.ts +50 -1
- package/src/types.ts +2 -0
- /package/{skills → .agents/skills}/clm-context/SKILL.md +0 -0
package/NOTICE
CHANGED
|
@@ -22,7 +22,7 @@ Derived from pi-clm (code and model-facing text, adapted for OpenCode):
|
|
|
22
22
|
src/steering.ts src/steering.ts
|
|
23
23
|
src/types.ts src/types.ts
|
|
24
24
|
steering/house-brief.md steering/house-brief.md (byte-identical copy)
|
|
25
|
-
skills/clm-context/SKILL.md src/skills/live-context/SKILL.md
|
|
25
|
+
.agents/skills/clm-context/SKILL.md src/skills/live-context/SKILL.md
|
|
26
26
|
src/panel/timeline.ts src/timeline.ts (context chart and its zoom levels)
|
|
27
27
|
src/panel/diff.ts src/diff.ts (side-by-side diff)
|
|
28
28
|
src/panel/view.ts src/viewer.ts (panel pages)
|
|
@@ -45,5 +45,5 @@ Context Language Models
|
|
|
45
45
|
Two design choices follow its harness: the fit/shrink edit gate and the default
|
|
46
46
|
25/50/75% reminder steps. pi-clm's skill guidance on edit cost (batch edits, mind the
|
|
47
47
|
tail below an edit, write detailed summaries), carried into
|
|
48
|
-
skills/clm-context/SKILL.md, covers the same points as the harness prompt in
|
|
48
|
+
.agents/skills/clm-context/SKILL.md, covers the same points as the harness prompt in
|
|
49
49
|
different words.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-clm",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "OpenCode plugin for Context Language Models: the model edits a file mirror of its own context, and the edited mirror becomes its next input.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"index.ts",
|
|
32
32
|
"tui.ts",
|
|
33
33
|
"src/**/*.ts",
|
|
34
|
-
"skills/**",
|
|
34
|
+
".agents/skills/clm-context/**",
|
|
35
35
|
"steering/**",
|
|
36
36
|
"LICENSE",
|
|
37
37
|
"NOTICE",
|
package/src/clm.ts
CHANGED
|
@@ -38,7 +38,6 @@
|
|
|
38
38
|
* calibrator learns only from requests whose full scope (system prompt and tool schemas
|
|
39
39
|
* included) was estimated; without those sizes it stays at 1.
|
|
40
40
|
*/
|
|
41
|
-
import { randomUUID } from "node:crypto";
|
|
42
41
|
import { appendFile, mkdir, stat, writeFile } from "node:fs/promises";
|
|
43
42
|
import { join } from "node:path";
|
|
44
43
|
|
|
@@ -261,11 +260,6 @@ export class ClmSession {
|
|
|
261
260
|
private pendingAlerts: string[] = [];
|
|
262
261
|
/** The settings warning last shown to the user, so a new or changed one toasts once. */
|
|
263
262
|
private shownSettingsWarning?: string;
|
|
264
|
-
/**
|
|
265
|
-
* Per-process seed of the stable document id. Fresh on every open, so a document id never
|
|
266
|
-
* repeats even when a corrupt state file resets the revision counter to 0.
|
|
267
|
-
*/
|
|
268
|
-
private readonly nonce = randomUUID();
|
|
269
263
|
private readonly tokenCache = new WeakMap<LiveContextMessage, number>();
|
|
270
264
|
private saving: Promise<void> = Promise.resolve();
|
|
271
265
|
private running: Promise<unknown> = Promise.resolve();
|
|
@@ -1264,6 +1258,24 @@ export class ClmSession {
|
|
|
1264
1258
|
} else if (this.state.checkpoint) {
|
|
1265
1259
|
this.invalidationStreak = 0;
|
|
1266
1260
|
}
|
|
1261
|
+
if (projection.valid && projection.reanchor) {
|
|
1262
|
+
// OpenCode rewrote messages of the current turn after the checkpoint was captured
|
|
1263
|
+
// (an assistant message still being finalized). Trim the anchor to the stable head
|
|
1264
|
+
// instead of dropping the revision: the trimmed tail becomes ordinary suffix,
|
|
1265
|
+
// exactly as if the edit had been committed a moment later. Silent by design;
|
|
1266
|
+
// `reanchored` in events.jsonl records it for the panel and for debugging.
|
|
1267
|
+
this.invalidationStreak = 0;
|
|
1268
|
+
const reanchor = projection.reanchor;
|
|
1269
|
+
const revision = this.state.revision;
|
|
1270
|
+
await this.updateState(
|
|
1271
|
+
(state) =>
|
|
1272
|
+
state.checkpoint && state.revision === revision
|
|
1273
|
+
? { ...state, checkpoint: { ...state.checkpoint, ...reanchor } }
|
|
1274
|
+
: state,
|
|
1275
|
+
true,
|
|
1276
|
+
).catch(() => undefined);
|
|
1277
|
+
await this.log({ event: "reanchored", revision, sourceMessageCount: reanchor.sourceMessageCount });
|
|
1278
|
+
}
|
|
1267
1279
|
const origin = this.forkOrigin;
|
|
1268
1280
|
this.forkOrigin = undefined;
|
|
1269
1281
|
if (origin && !noticesOnly && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
|
|
@@ -1331,9 +1343,10 @@ export class ClmSession {
|
|
|
1331
1343
|
const snapshot = noticesOnly ? undefined : renderContextDocument(effective, {
|
|
1332
1344
|
revision: this.state.revision,
|
|
1333
1345
|
protectedIndexes: new Set<number>(),
|
|
1334
|
-
// Constant until the next accepted edit or reset
|
|
1335
|
-
// remain valid on the next;
|
|
1336
|
-
|
|
1346
|
+
// Constant until the next accepted edit or reset — and across process restarts,
|
|
1347
|
+
// as pi-clm's is — so header ids read on one call remain valid on the next;
|
|
1348
|
+
// bodies are escaped accordingly.
|
|
1349
|
+
documentSeed: `${this.sessionID}:${checkpoint?.sourceDigest ?? "raw"}`,
|
|
1337
1350
|
});
|
|
1338
1351
|
if (snapshot) {
|
|
1339
1352
|
try {
|
package/src/context-document.ts
CHANGED
|
@@ -54,7 +54,10 @@ function normalizeJson(value: unknown): unknown {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
export function canonicalMessage(message: LiveContextMessage): string {
|
|
57
|
-
|
|
57
|
+
// `updatedAt` is OpenCode write metadata, not content: OpenCode rewrites it in place while
|
|
58
|
+
// a message is still being finalized, so it must not reach any digest or block id.
|
|
59
|
+
const { updatedAt: _updatedAt, ...rest } = message;
|
|
60
|
+
return JSON.stringify(normalizeJson(rest));
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
export function digestMessages(messages: LiveContextMessage[]): string {
|
package/src/opencode.ts
CHANGED
|
@@ -27,7 +27,10 @@
|
|
|
27
27
|
* "[Tool execution was interrupted]".
|
|
28
28
|
* Flattening never depends on settings: the projection digest is computed from it, and a
|
|
29
29
|
* settings toggle must not discard an accepted revision. Reasoning is always flattened;
|
|
30
|
-
* `withoutReasoning` hides it from the mirror view only.
|
|
30
|
+
* `withoutReasoning` hides it from the mirror view only. Every flat message carries
|
|
31
|
+
* `updatedAt` (`info.time.updated`, else `completed`, else `created`), OpenCode's in-place
|
|
32
|
+
* write marker: digests exclude it (`canonicalMessage`), and the projection reads it only to
|
|
33
|
+
* re-anchor a late rewrite of the tail instead of dropping the revision (projection.ts).
|
|
31
34
|
*
|
|
32
35
|
* Known gap: an assistant message whose only sendable parts are empty text parts has no
|
|
33
36
|
* flat form and is left out of the request. OpenCode sends it as an empty assistant turn.
|
|
@@ -125,9 +128,12 @@ export function flatten(messages: readonly OcMessage[]): LiveContextMessage[] {
|
|
|
125
128
|
if (!sentToModel(message)) continue;
|
|
126
129
|
const id = message.info.id;
|
|
127
130
|
const timestamp = Number(message.info.time?.created ?? 0);
|
|
131
|
+
// OpenCode writes `completed` (and, on some hosts, `updated`); both move when a message
|
|
132
|
+
// is written in place, `created` does not.
|
|
133
|
+
const updatedAt = Number(message.info.time?.updated ?? message.info.time?.completed ?? message.info.time?.created ?? 0);
|
|
128
134
|
if (message.info.role === "user") {
|
|
129
135
|
const content = userContent(message);
|
|
130
|
-
if (content.length > 0) out.push({ role: "user", content, ocMessageID: id, timestamp });
|
|
136
|
+
if (content.length > 0) out.push({ role: "user", content, ocMessageID: id, timestamp, updatedAt });
|
|
131
137
|
continue;
|
|
132
138
|
}
|
|
133
139
|
const content: Record<string, unknown>[] = [];
|
|
@@ -147,11 +153,12 @@ export function flatten(messages: readonly OcMessage[]): LiveContextMessage[] {
|
|
|
147
153
|
isError: output.isError,
|
|
148
154
|
ocMessageID: id,
|
|
149
155
|
timestamp,
|
|
156
|
+
updatedAt,
|
|
150
157
|
});
|
|
151
158
|
}
|
|
152
159
|
}
|
|
153
160
|
if (content.length === 0) continue;
|
|
154
|
-
out.push({ role: "assistant", content, ocMessageID: id, timestamp });
|
|
161
|
+
out.push({ role: "assistant", content, ocMessageID: id, timestamp, updatedAt });
|
|
155
162
|
out.push(...results);
|
|
156
163
|
}
|
|
157
164
|
return out;
|
package/src/projection.ts
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
// whole messages (ids change) or truncates the parts of a kept message (its toolCall and
|
|
14
14
|
// toolResult disappear, so the digest changes); both still fail closed.
|
|
15
15
|
//
|
|
16
|
+
// One mismatch source is OpenCode's own late writes: it keeps updating message objects
|
|
17
|
+
// after `experimental.chat.messages.transform` returns (an assistant message being
|
|
18
|
+
// finalized, turn diffs attached to a user message). A checkpoint taken mid-turn digests
|
|
19
|
+
// those still-mutable tail messages, and the late write flips the digest. `sourceStamps`
|
|
20
|
+
// (per-message digest + captured `time.updated`) lets `applyProjection` attribute such a
|
|
21
|
+
// mismatch to a post-capture OpenCode write and re-anchor the checkpoint to the stable
|
|
22
|
+
// head instead of dropping the revision (see `reanchorTail`); anything not attributable
|
|
23
|
+
// stays fail-closed.
|
|
24
|
+
//
|
|
16
25
|
// pi-clm's `recoverProjectionFromRetryErrors` is not ported. Pi writes failed assistant
|
|
17
26
|
// responses to its session log but drops them from live state, so a resumed log hashes
|
|
18
27
|
// differently. OpenCode keeps the error on the assistant message (`info.error`), and the
|
|
@@ -29,6 +38,19 @@ export const PROJECTION_SHORTER_REASON = "Raw context is shorter than the projec
|
|
|
29
38
|
|
|
30
39
|
export type EstimateUnit = "characters" | "tokens";
|
|
31
40
|
|
|
41
|
+
/** One source message's content digest plus the write time captured with it. */
|
|
42
|
+
export interface SourceStamp {
|
|
43
|
+
digest: string;
|
|
44
|
+
updatedAt: number;
|
|
45
|
+
/**
|
|
46
|
+
* Digest of the same message with OpenCode's `<system-reminder>` blocks removed
|
|
47
|
+
* (whitespace-normalized). OpenCode appends and removes mode-change reminders on user
|
|
48
|
+
* messages between requests without moving any write-time field; a change confined to
|
|
49
|
+
* those blocks is attributable against this fingerprint.
|
|
50
|
+
*/
|
|
51
|
+
strippedDigest?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
export interface ProjectionCheckpoint {
|
|
33
55
|
version: 1;
|
|
34
56
|
revision: number;
|
|
@@ -38,6 +60,12 @@ export interface ProjectionCheckpoint {
|
|
|
38
60
|
sourceIds: string[];
|
|
39
61
|
/** `digestSourcePrefix` of those messages. */
|
|
40
62
|
sourceDigest: string;
|
|
63
|
+
/**
|
|
64
|
+
* Per-message digest and captured write time (length is sourceMessageCount). Lets a
|
|
65
|
+
* mismatch be attributed to specific messages and a late OpenCode write of the tail be
|
|
66
|
+
* re-anchored instead of dropped; absent on checkpoints written by older versions.
|
|
67
|
+
*/
|
|
68
|
+
sourceStamps?: SourceStamp[];
|
|
41
69
|
projectedMessages: LiveContextMessage[];
|
|
42
70
|
beforeEstimate: number;
|
|
43
71
|
afterEstimate: number;
|
|
@@ -47,11 +75,21 @@ export interface ProjectionCheckpoint {
|
|
|
47
75
|
editTrace?: ContextEditTrace;
|
|
48
76
|
}
|
|
49
77
|
|
|
78
|
+
/** The trimmed anchor returned when a late tail rewrite was re-anchored instead of dropped. */
|
|
79
|
+
export interface ProjectionReanchor {
|
|
80
|
+
sourceMessageCount: number;
|
|
81
|
+
sourceIds: string[];
|
|
82
|
+
sourceStamps: SourceStamp[];
|
|
83
|
+
sourceDigest: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
50
86
|
export type ProjectionApplication =
|
|
51
87
|
| {
|
|
52
88
|
valid: true;
|
|
53
89
|
messages: LiveContextMessage[];
|
|
54
90
|
suffix: LiveContextMessage[];
|
|
91
|
+
/** Set when the anchor was trimmed to the stable head (see `reanchorTail`). */
|
|
92
|
+
reanchor?: ProjectionReanchor;
|
|
55
93
|
}
|
|
56
94
|
| {
|
|
57
95
|
valid: false;
|
|
@@ -64,6 +102,60 @@ export function sourceMessageId(message: LiveContextMessage): string {
|
|
|
64
102
|
return typeof message.ocMessageID === "string" ? message.ocMessageID : "";
|
|
65
103
|
}
|
|
66
104
|
|
|
105
|
+
/** The prune-invariant form of one source message (see `digestSourcePrefix`). */
|
|
106
|
+
function sourceDigestForm(message: LiveContextMessage): LiveContextMessage {
|
|
107
|
+
if (message.role !== "toolResult") return message;
|
|
108
|
+
return {
|
|
109
|
+
role: message.role,
|
|
110
|
+
toolCallId: message.toolCallId,
|
|
111
|
+
toolName: message.toolName,
|
|
112
|
+
isError: message.isError,
|
|
113
|
+
ocMessageID: message.ocMessageID,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
118
|
+
|
|
119
|
+
/** Whitespace-normalized text without OpenCode's reminder blocks, for the stripped fingerprint. */
|
|
120
|
+
function normalizeReminderFingerprint(text: string): string {
|
|
121
|
+
return text
|
|
122
|
+
.replace(/\r\n/g, "\n")
|
|
123
|
+
.replace(SYSTEM_REMINDER_RE, "")
|
|
124
|
+
.replace(/\n{2,}/g, "\n\n")
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The message with OpenCode's `<system-reminder>` blocks removed from its text parts; parts
|
|
130
|
+
* that become empty are dropped. OpenCode appends and removes mode-change reminders on user
|
|
131
|
+
* messages between requests without touching any write-time field, so a digest over this
|
|
132
|
+
* form tells a reminder-only edit apart from a real content change.
|
|
133
|
+
*/
|
|
134
|
+
export function stripSystemReminders(message: LiveContextMessage): LiveContextMessage {
|
|
135
|
+
const content = message.content;
|
|
136
|
+
if (!Array.isArray(content)) return message;
|
|
137
|
+
const stripped: unknown[] = [];
|
|
138
|
+
for (const part of content) {
|
|
139
|
+
if (!part || typeof part !== "object") {
|
|
140
|
+
stripped.push(part);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const block = part as Record<string, unknown>;
|
|
144
|
+
if (block.type !== "text" || typeof block.text !== "string") {
|
|
145
|
+
stripped.push(block);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const text = normalizeReminderFingerprint(block.text);
|
|
149
|
+
if (text !== "") stripped.push({ ...block, text });
|
|
150
|
+
}
|
|
151
|
+
return { ...message, content: stripped };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `digestSourceMessage` of the message without OpenCode's system-reminder blocks. */
|
|
155
|
+
export function digestSourceMessageWithoutReminders(message: LiveContextMessage): string {
|
|
156
|
+
return digestMessages([sourceDigestForm(stripSystemReminders(message))]);
|
|
157
|
+
}
|
|
158
|
+
|
|
67
159
|
/**
|
|
68
160
|
* Digest a raw source prefix in a form OpenCode's prune cannot change: every `toolResult`
|
|
69
161
|
* keeps only role, toolCallId, toolName, isError and ocMessageID. Content is dropped for
|
|
@@ -71,18 +163,22 @@ export function sourceMessageId(message: LiveContextMessage): string {
|
|
|
71
163
|
* created and may be cleared when it is validated.
|
|
72
164
|
*/
|
|
73
165
|
export function digestSourcePrefix(messages: readonly LiveContextMessage[]): string {
|
|
74
|
-
return digestMessages(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
166
|
+
return digestMessages(messages.map(sourceDigestForm));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `digestSourcePrefix` of one message: per-message attribution for tail re-anchoring. */
|
|
170
|
+
export function digestSourceMessage(message: LiveContextMessage): string {
|
|
171
|
+
return digestMessages([sourceDigestForm(message)]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A message's stamp: its content digest, the write time OpenCode last wrote, and the
|
|
175
|
+
* reminder-stripped fingerprint. */
|
|
176
|
+
export function sourceStamp(message: LiveContextMessage): SourceStamp {
|
|
177
|
+
return {
|
|
178
|
+
digest: digestSourceMessage(message),
|
|
179
|
+
updatedAt: typeof message.updatedAt === "number" ? message.updatedAt : 0,
|
|
180
|
+
strippedDigest: digestSourceMessageWithoutReminders(message),
|
|
181
|
+
};
|
|
86
182
|
}
|
|
87
183
|
|
|
88
184
|
/**
|
|
@@ -109,6 +205,7 @@ export function createProjectionCheckpoint(options: {
|
|
|
109
205
|
sourceMessageCount: options.sourceMessages.length,
|
|
110
206
|
sourceIds: options.sourceMessages.map(sourceMessageId),
|
|
111
207
|
sourceDigest: digestSourcePrefix(options.sourceMessages),
|
|
208
|
+
sourceStamps: options.sourceMessages.map(sourceStamp),
|
|
112
209
|
projectedMessages: [...options.projectedMessages],
|
|
113
210
|
beforeEstimate: options.beforeEstimate,
|
|
114
211
|
afterEstimate: options.afterEstimate,
|
|
@@ -124,7 +221,11 @@ export function createProjectionCheckpoint(options: {
|
|
|
124
221
|
|
|
125
222
|
/**
|
|
126
223
|
* Replace the checkpoint's raw source prefix and keep every raw message appended after
|
|
127
|
-
* it. A mismatch fails closed and returns a copy of the untouched raw context
|
|
224
|
+
* it. A mismatch fails closed and returns a copy of the untouched raw context — unless the
|
|
225
|
+
* checkpoint has per-message stamps and every changed message was written by OpenCode after
|
|
226
|
+
* it was captured (the current turn's tail still being finalized): then the anchor is
|
|
227
|
+
* trimmed to the stable head, the trimmed tail flows as ordinary suffix, and the caller
|
|
228
|
+
* persists `reanchor` on the checkpoint.
|
|
128
229
|
*/
|
|
129
230
|
export function applyProjection(
|
|
130
231
|
rawMessages: readonly LiveContextMessage[],
|
|
@@ -147,8 +248,50 @@ export function applyProjection(
|
|
|
147
248
|
return { valid: false, messages: [...rawMessages], reason: PROJECTION_SOURCE_IDS_REASON };
|
|
148
249
|
}
|
|
149
250
|
if (digestSourcePrefix(prefix) !== checkpoint.sourceDigest) {
|
|
150
|
-
return
|
|
251
|
+
return reanchorTail(rawMessages, prefix, checkpoint) ??
|
|
252
|
+
{ valid: false, messages: [...rawMessages], reason: PROJECTION_PREFIX_MISMATCH_REASON };
|
|
151
253
|
}
|
|
152
254
|
const suffix = rawMessages.slice(checkpoint.sourceMessageCount);
|
|
153
255
|
return { valid: true, messages: [...checkpoint.projectedMessages, ...suffix], suffix };
|
|
154
256
|
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A mismatch OpenCode itself caused. Every changed message must be explained by an
|
|
260
|
+
* OpenCode write: either its write time (`time.completed`/`updated`, not `created`) moved
|
|
261
|
+
* past the stamp, or the change is confined to OpenCode's `<system-reminder>` mode-change
|
|
262
|
+
* blocks (verified against the reminder-stripped per-message digest). That is the tail of
|
|
263
|
+
* the current turn still being finalized or a mode switch — not a revert, a compaction or
|
|
264
|
+
* a foreign transform, which change ids or content in ways neither check explains and stay
|
|
265
|
+
* fail-closed. The anchor is trimmed to the last stable message; the trimmed tail becomes
|
|
266
|
+
* ordinary suffix, the same contract as pi-clm's rebasing for appended messages.
|
|
267
|
+
*/
|
|
268
|
+
function reanchorTail(
|
|
269
|
+
rawMessages: readonly LiveContextMessage[],
|
|
270
|
+
prefix: readonly LiveContextMessage[],
|
|
271
|
+
checkpoint: ProjectionCheckpoint,
|
|
272
|
+
): ProjectionApplication | undefined {
|
|
273
|
+
const stamps = checkpoint.sourceStamps;
|
|
274
|
+
if (!Array.isArray(stamps) || stamps.length !== prefix.length) return undefined;
|
|
275
|
+
const changed = prefix.map((message, index) => digestSourceMessage(message) !== stamps[index]!.digest);
|
|
276
|
+
const first = changed.indexOf(true);
|
|
277
|
+
if (first <= 0) return undefined;
|
|
278
|
+
const attributable = prefix.every((message, index) => {
|
|
279
|
+
if (!changed[index]) return true;
|
|
280
|
+
const stamp = stamps[index]!;
|
|
281
|
+
if (typeof message.updatedAt === "number" && message.updatedAt > stamp.updatedAt) return true;
|
|
282
|
+
// OpenCode appends and removes `<system-reminder>` blocks (mode changes) on user
|
|
283
|
+
// messages without moving any write time; a change confined to those blocks is still
|
|
284
|
+
// an OpenCode edit. Everything else fails closed.
|
|
285
|
+
return typeof stamp.strippedDigest === "string" && digestSourceMessageWithoutReminders(message) === stamp.strippedDigest;
|
|
286
|
+
});
|
|
287
|
+
if (!attributable) return undefined;
|
|
288
|
+
const stable = prefix.slice(0, first);
|
|
289
|
+
const reanchor: ProjectionReanchor = {
|
|
290
|
+
sourceMessageCount: stable.length,
|
|
291
|
+
sourceIds: stable.map(sourceMessageId),
|
|
292
|
+
sourceStamps: stable.map(sourceStamp),
|
|
293
|
+
sourceDigest: digestSourcePrefix(stable),
|
|
294
|
+
};
|
|
295
|
+
const suffix = rawMessages.slice(stable.length);
|
|
296
|
+
return { valid: true, messages: [...checkpoint.projectedMessages, ...suffix], suffix, reanchor };
|
|
297
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -75,7 +75,7 @@ export function parseCompaction(value: unknown, name = "compaction"): Compaction
|
|
|
75
75
|
|
|
76
76
|
export const PACKAGE_DIR = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
|
77
77
|
export const HOUSE_STEERING = join(PACKAGE_DIR, "steering", "house-brief.md");
|
|
78
|
-
export const SKILLS_DIR = join(PACKAGE_DIR, "skills");
|
|
78
|
+
export const SKILLS_DIR = join(PACKAGE_DIR, ".agents/skills/clm-context");
|
|
79
79
|
|
|
80
80
|
type Env = Record<string, string | undefined>;
|
|
81
81
|
|
package/src/state.ts
CHANGED
|
@@ -96,6 +96,23 @@ function isMessageArray(value: unknown): value is LiveContextMessage[] {
|
|
|
96
96
|
);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** Per-message stamps (`projection.ts` SourceStamp): a hex digest and a finite `updatedAt`. */
|
|
100
|
+
function isSourceStamps(value: unknown, count: unknown): boolean {
|
|
101
|
+
return (
|
|
102
|
+
Array.isArray(value) &&
|
|
103
|
+
value.length === count &&
|
|
104
|
+
value.every(
|
|
105
|
+
(stamp) =>
|
|
106
|
+
isObject(stamp) &&
|
|
107
|
+
typeof stamp.digest === "string" &&
|
|
108
|
+
/^[a-f0-9]{64}$/.test(stamp.digest) &&
|
|
109
|
+
Number.isFinite(stamp.updatedAt) &&
|
|
110
|
+
(stamp.strippedDigest === undefined ||
|
|
111
|
+
(typeof stamp.strippedDigest === "string" && /^[a-f0-9]{64}$/.test(stamp.strippedDigest))),
|
|
112
|
+
)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
function isContextEditTrace(value: unknown): value is ContextEditTrace {
|
|
100
117
|
if (!isObject(value)) return false;
|
|
101
118
|
const trace = value as Partial<ContextEditTrace>;
|
|
@@ -134,6 +151,7 @@ export function isProjectionCheckpoint(value: unknown): value is ProjectionCheck
|
|
|
134
151
|
checkpoint.sourceIds.every((id) => typeof id === "string") &&
|
|
135
152
|
typeof checkpoint.sourceDigest === "string" &&
|
|
136
153
|
/^[a-f0-9]{64}$/.test(checkpoint.sourceDigest) &&
|
|
154
|
+
(checkpoint.sourceStamps === undefined || isSourceStamps(checkpoint.sourceStamps, checkpoint.sourceMessageCount)) &&
|
|
137
155
|
isMessageArray(checkpoint.projectedMessages) &&
|
|
138
156
|
Number.isFinite(checkpoint.beforeEstimate) &&
|
|
139
157
|
Number.isFinite(checkpoint.afterEstimate) &&
|
|
@@ -238,12 +256,43 @@ export async function loadLiveContextState(directory: string): Promise<LoadedSta
|
|
|
238
256
|
delete parsed.budgetCheck;
|
|
239
257
|
repaired = `${path} had an invalid budgetCheck; dropped it, the fixed overhead is measured again.`;
|
|
240
258
|
}
|
|
259
|
+
// A malformed outcome costs only itself: the panel loses one line, the checkpoint stays.
|
|
260
|
+
if (isObject(parsed) && parsed.lastOutcome !== undefined && !isOutcome(parsed.lastOutcome)) {
|
|
261
|
+
delete parsed.lastOutcome;
|
|
262
|
+
repaired = `${path} had an invalid lastOutcome; dropped it, the last outcome is unknown.`;
|
|
263
|
+
}
|
|
264
|
+
// Malformed per-message stamps cost only tail re-anchoring: the checkpoint keeps working
|
|
265
|
+
// with the whole-prefix digest and fails closed on a mismatch, as before.
|
|
266
|
+
if (
|
|
267
|
+
isObject(parsed) &&
|
|
268
|
+
isObject(parsed.checkpoint) &&
|
|
269
|
+
parsed.checkpoint.sourceStamps !== undefined &&
|
|
270
|
+
!isSourceStamps(parsed.checkpoint.sourceStamps, parsed.checkpoint.sourceMessageCount)
|
|
271
|
+
) {
|
|
272
|
+
delete parsed.checkpoint.sourceStamps;
|
|
273
|
+
repaired = `${path} had invalid sourceStamps; dropped them, tail re-anchoring is off for this checkpoint.`;
|
|
274
|
+
}
|
|
241
275
|
if (!isLiveContextState(parsed)) {
|
|
242
|
-
return { state: initialLiveContextState(), warning: `${path} has an invalid shape; starting clean.` };
|
|
276
|
+
return { state: initialLiveContextState(), warning: `${path} has an invalid shape (${stateProblem(parsed)}); starting clean.` };
|
|
243
277
|
}
|
|
244
278
|
return repaired ? { state: parsed, repaired } : { state: parsed };
|
|
245
279
|
}
|
|
246
280
|
|
|
281
|
+
/** Names the first state check `value` fails, so a load warning locates the bad field. */
|
|
282
|
+
function stateProblem(value: unknown): string {
|
|
283
|
+
if (!isObject(value)) return "not an object";
|
|
284
|
+
if (value.version !== STATE_VERSION) return `unsupported version ${JSON.stringify(value.version)}`;
|
|
285
|
+
if (typeof value.enabled !== "boolean") return "enabled is not a boolean";
|
|
286
|
+
if (!isCount(value.revision)) return "revision is not a count";
|
|
287
|
+
if (value.checkpoint !== undefined) {
|
|
288
|
+
if (!isProjectionCheckpoint(value.checkpoint)) return "checkpoint is invalid";
|
|
289
|
+
if (value.checkpoint.revision !== value.revision) return "checkpoint revision does not match the state revision";
|
|
290
|
+
}
|
|
291
|
+
if (value.lastOutcome !== undefined && !isOutcome(value.lastOutcome)) return "lastOutcome is invalid";
|
|
292
|
+
if (value.budgetCheck !== undefined && !isBudgetCheck(value.budgetCheck)) return "budgetCheck is invalid";
|
|
293
|
+
return "unknown";
|
|
294
|
+
}
|
|
295
|
+
|
|
247
296
|
/**
|
|
248
297
|
* Write `state.json` atomically with mode 0600: temp file, fsync, rename, then fsync the
|
|
249
298
|
* directory (best effort). Concurrent saves are not ordered: the last rename wins, so an
|
package/src/types.ts
CHANGED
|
File without changes
|