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 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.0",
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, so header ids read on one call
1335
- // remain valid on the next; bodies are escaped accordingly.
1336
- documentSeed: `${this.sessionID}:${this.nonce}:${checkpoint?.sourceDigest ?? "raw"}`,
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 {
@@ -54,7 +54,10 @@ function normalizeJson(value: unknown): unknown {
54
54
  }
55
55
 
56
56
  export function canonicalMessage(message: LiveContextMessage): string {
57
- return JSON.stringify(normalizeJson(message));
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
- messages.map((message) => {
76
- if (message.role !== "toolResult") return message;
77
- return {
78
- role: message.role,
79
- toolCallId: message.toolCallId,
80
- toolName: message.toolName,
81
- isError: message.isError,
82
- ocMessageID: message.ocMessageID,
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 { valid: false, messages: [...rawMessages], reason: PROJECTION_PREFIX_MISMATCH_REASON };
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
@@ -4,6 +4,8 @@ export type LiveContextMessage = {
4
4
  role: string;
5
5
  content?: unknown;
6
6
  timestamp?: number;
7
+ /** OpenCode's `info.time.updated`: the in-place write marker used for tail re-anchoring. */
8
+ updatedAt?: number;
7
9
  [key: string]: unknown;
8
10
  };
9
11
 
File without changes