opencode-clm 0.2.0 → 0.3.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 +1 -1
- package/README.md +77 -66
- package/index.ts +730 -36
- package/package.json +6 -6
- package/src/budget.ts +6 -6
- package/src/channel.ts +187 -0
- package/src/clm.ts +369 -20
- package/src/compaction.ts +243 -0
- package/src/context-document.ts +2 -1
- package/src/continuity.ts +127 -1
- package/src/history.ts +109 -0
- package/src/mirror-store.ts +40 -2
- package/src/opencode.ts +59 -0
- package/src/overrides.ts +13 -1
- package/src/panel/command.ts +8 -2
- package/src/panel/files.ts +4 -0
- package/src/panel/model.ts +23 -1
- package/src/panel/timeline.ts +7 -1
- package/src/panel/view.ts +10 -1
- package/src/projection.ts +8 -0
- package/src/session-files.ts +134 -32
- package/src/settings-table.ts +51 -3
- package/src/settings.ts +31 -3
- package/src/tui/complete.ts +102 -0
- package/src/tui/data.ts +83 -3
- package/src/tui/intercept.ts +10 -6
- package/src/tui/locator.ts +101 -0
- package/src/tui/panel.ts +11 -0
- package/src/tui/plugin.ts +219 -33
- package/src/tui/remote.ts +199 -0
package/src/clm.ts
CHANGED
|
@@ -54,17 +54,20 @@ import {
|
|
|
54
54
|
AnnotationStore,
|
|
55
55
|
ContinuitySizeTracker,
|
|
56
56
|
continuitySizeNoticeText,
|
|
57
|
+
forkAnnotations,
|
|
57
58
|
formatContinuityMessage,
|
|
58
59
|
type ContinuityBlockSource,
|
|
59
60
|
type LiveContextAnnotation,
|
|
60
61
|
} from "./continuity.ts";
|
|
61
62
|
import { classifyMirrorToolCall } from "./mirror-guard.ts";
|
|
62
|
-
import { MirrorStore } from "./mirror-store.ts";
|
|
63
|
+
import { MirrorDirectoryError, MirrorStore } from "./mirror-store.ts";
|
|
63
64
|
import { capObservations } from "./observation.ts";
|
|
64
65
|
import { flatten, noteMessage, unflatten, withoutReasoning, type OcInfo, type OcMessage } from "./opencode.ts";
|
|
66
|
+
import { sizeTrailer } from "./compaction.ts";
|
|
65
67
|
import { applyOverflowGuard, overflowGuardLimit, overflowNoticeText } from "./overflow.ts";
|
|
66
68
|
import type { ClmStatus } from "./presentation.ts";
|
|
67
|
-
import { applyProjection, createProjectionCheckpoint, type ProjectionCheckpoint } from "./projection.ts";
|
|
69
|
+
import { applyProjection, createProjectionCheckpoint, digestSourceContent, type ProjectionCheckpoint } from "./projection.ts";
|
|
70
|
+
import { clearHistory, loadHistory, remapProjection, saveHistoryEntry, type HistoryEntry } from "./history.ts";
|
|
68
71
|
import type { ClmSettings } from "./settings.ts";
|
|
69
72
|
import {
|
|
70
73
|
keptFields,
|
|
@@ -86,6 +89,8 @@ export interface ModelLimits {
|
|
|
86
89
|
context?: number;
|
|
87
90
|
/** Model output limit, tokens; subtracted from the window by `resolveBudget`. */
|
|
88
91
|
output?: number;
|
|
92
|
+
/** Model input limit, tokens, when the provider sets one; only OpenCode's threshold reads it (src/compaction.ts). */
|
|
93
|
+
input?: number;
|
|
89
94
|
}
|
|
90
95
|
|
|
91
96
|
/** Sizes of the request parts the plugin does not see in the messages hook. */
|
|
@@ -96,6 +101,11 @@ export interface RequestScope {
|
|
|
96
101
|
toolTokens?: number;
|
|
97
102
|
}
|
|
98
103
|
|
|
104
|
+
/** Toast when edits keep being dropped (pi `compositionWarningText`, reworded for OpenCode). */
|
|
105
|
+
export const COMPOSITION_WARNING = "CLM edits keep being dropped: OpenCode's history changed between consecutive requests. " +
|
|
106
|
+
"Another plugin that rewrites already-sent messages (experimental.chat.messages.transform) is the usual cause; " +
|
|
107
|
+
"load opencode-clm before it (earlier in the `plugin` list), or turn CLM off for this session with /clm off.";
|
|
108
|
+
|
|
99
109
|
export interface TransformResult {
|
|
100
110
|
/** The messages to send; the caller writes them into OpenCode's array in place. */
|
|
101
111
|
messages: OcMessage[];
|
|
@@ -104,8 +114,10 @@ export interface TransformResult {
|
|
|
104
114
|
/** Calibrated estimate of this request, tokens (system prompt and tool schemas included when known); undefined when CLM did not run. */
|
|
105
115
|
estimated?: number;
|
|
106
116
|
reading?: BudgetReading;
|
|
107
|
-
/** User-facing warning for a toast (
|
|
117
|
+
/** User-facing warning for a toast (budget too small, composition warning, ignored saved settings); set on one request only. */
|
|
108
118
|
alert?: string;
|
|
119
|
+
/** User-facing errors for toasts: the mirror could not be read or refreshed, a revision could not be saved. */
|
|
120
|
+
errors?: string[];
|
|
109
121
|
}
|
|
110
122
|
|
|
111
123
|
export interface MirrorCheck {
|
|
@@ -196,6 +208,23 @@ export class ClmSession {
|
|
|
196
208
|
* `experimental.compaction.autocontinue` hook); the next transform rebases onto the summary.
|
|
197
209
|
*/
|
|
198
210
|
compacted = false;
|
|
211
|
+
/**
|
|
212
|
+
* Set by the plugin when OpenCode's session metadata names another session as the origin
|
|
213
|
+
* (a fork, `Session.fork` copies metadata). The first transform restores the newest of the
|
|
214
|
+
* origin's checkpoints whose source the forked history still contains, then clears it.
|
|
215
|
+
*/
|
|
216
|
+
forkOrigin?: { sessionID: string; directory: string; /** The fork's creation time (ms), when OpenCode reported it. */ created?: number };
|
|
217
|
+
/** The origin's annotations from cloned session metadata (a fork without a mirror); used when the origin's store yields none. */
|
|
218
|
+
forkPersisted?: LiveContextAnnotation[];
|
|
219
|
+
/** Set when a fork's first request copied annotations in; the plugin then persists them (no mirror). */
|
|
220
|
+
annotationsImported = false;
|
|
221
|
+
/**
|
|
222
|
+
* Why no mirror directory could be used, set by the plugin (M1). The store then sits in a
|
|
223
|
+
* private temporary directory the model is never told about; requests carry the raw
|
|
224
|
+
* history plus the continuity annotations and their size notice, as pi-clm does when its
|
|
225
|
+
* mirror store fails to initialize.
|
|
226
|
+
*/
|
|
227
|
+
mirrorUnavailable?: string;
|
|
199
228
|
/** What the last transform rendered; the next transform commits the mirror against it. */
|
|
200
229
|
baseline?: Baseline;
|
|
201
230
|
/** The snapshot now in the mirror file; continuity tools resolve block ids against it. */
|
|
@@ -205,9 +234,21 @@ export class ClmSession {
|
|
|
205
234
|
requests = 0;
|
|
206
235
|
|
|
207
236
|
private pendingNotices: string[] = [];
|
|
237
|
+
/** Errors for the user (toasts), returned with the next transform result. */
|
|
238
|
+
private pendingErrors: string[] = [];
|
|
239
|
+
/** The mirror could not be written last time; its error toast is not repeated until a write succeeds. */
|
|
240
|
+
private refreshFailing = false;
|
|
241
|
+
/** Why the steering document in the settings did not load, for snapshot.json (the panel). */
|
|
242
|
+
steeringError?: string;
|
|
208
243
|
/** Raw length and conversation estimate (calibrated, tokens) of the last request, for the overhead measurement. */
|
|
209
244
|
private previousRequest?: { rawCount: number; conversation: number };
|
|
210
245
|
private invalidationStreak = 0;
|
|
246
|
+
/** The composition warning toast is shown once per session (pi: `compositionWarned`). */
|
|
247
|
+
private compositionWarned = false;
|
|
248
|
+
/** User-facing warnings for the next transform result (`alert`). */
|
|
249
|
+
private pendingAlerts: string[] = [];
|
|
250
|
+
/** The settings warning last shown to the user, so a new or changed one toasts once. */
|
|
251
|
+
private shownSettingsWarning?: string;
|
|
211
252
|
/**
|
|
212
253
|
* Per-process seed of the stable document id. Fresh on every open, so a document id never
|
|
213
254
|
* repeats even when a corrupt state file resets the revision counter to 0.
|
|
@@ -244,11 +285,14 @@ export class ClmSession {
|
|
|
244
285
|
|
|
245
286
|
/**
|
|
246
287
|
* Open (or resume) the session directory `<mirrorDir>/clm-<sessionID>/`. `steering` is the
|
|
247
|
-
* document the base settings name, already loaded
|
|
288
|
+
* document the base settings name, already loaded; undefined when it failed to load at start
|
|
289
|
+
* (each session then retries the path and records the error in `settingsWarning`).
|
|
248
290
|
*/
|
|
249
291
|
static async open(sessionID: string, settings: ClmSettings, options: { steering?: SteeringDocument } = {}): Promise<ClmSession> {
|
|
250
292
|
if (!SESSION_ID_RE.test(sessionID)) throw new Error(`Invalid session id for CLM: ${JSON.stringify(sessionID)}`);
|
|
251
|
-
const store = await MirrorStore.create(sessionID, settings.mirrorDir)
|
|
293
|
+
const store = await MirrorStore.create(sessionID, settings.mirrorDir).catch((error: unknown) => {
|
|
294
|
+
throw new MirrorDirectoryError(settings.mirrorDir, error);
|
|
295
|
+
});
|
|
252
296
|
const loaded = await loadLiveContextState(store.directory);
|
|
253
297
|
const session = new ClmSession(sessionID, store, settings, loaded, options.steering);
|
|
254
298
|
if (loaded.warning) await session.log({ event: "state-warning", warning: loaded.warning });
|
|
@@ -309,11 +353,25 @@ export class ClmSession {
|
|
|
309
353
|
} catch (error) {
|
|
310
354
|
warnings.push(`ignored saved settings: ${describe(error)}`);
|
|
311
355
|
overrides = {};
|
|
312
|
-
|
|
356
|
+
// The base settings resolved at load; staging them again keeps a base steering error.
|
|
357
|
+
staged = stageSettings(this.baseSettings, {}, { strict: false, loaded: this.baseSteering });
|
|
313
358
|
}
|
|
314
|
-
|
|
359
|
+
// Only warnings about saved settings toast here. The base steering document failing
|
|
360
|
+
// again (no override names another path) already toasted once at load (index.ts).
|
|
361
|
+
const alerting = [...warnings];
|
|
362
|
+
if (staged.steeringError) {
|
|
363
|
+
const warning = `steering document not loaded: ${staged.steeringError}`;
|
|
364
|
+
warnings.push(warning);
|
|
365
|
+
if (staged.settings.steeringPath !== this.baseSettings.steeringPath) alerting.push(warning);
|
|
366
|
+
}
|
|
367
|
+
this.steeringError = staged.steeringError;
|
|
315
368
|
await this.activateSettings(staged, overrides);
|
|
316
369
|
this.settingsWarning = warnings.length > 0 ? warnings.join("; ") : undefined;
|
|
370
|
+
const alert = alerting.length > 0 ? alerting.join("; ") : undefined;
|
|
371
|
+
if (alert && alert !== this.shownSettingsWarning) {
|
|
372
|
+
this.pendingAlerts.push(`saved settings partly ignored: ${alert}`);
|
|
373
|
+
}
|
|
374
|
+
this.shownSettingsWarning = alert;
|
|
317
375
|
}
|
|
318
376
|
|
|
319
377
|
private async activateSettings(staged: StagedSettings, overrides: ClmOverrides): Promise<void> {
|
|
@@ -341,6 +399,34 @@ export class ClmSession {
|
|
|
341
399
|
|
|
342
400
|
// ---- measurement -------------------------------------------------------------------
|
|
343
401
|
|
|
402
|
+
/**
|
|
403
|
+
* System prompt and tool schemas, when the hooks reported them (one request late).
|
|
404
|
+
* A provider-measured overhead replaces them: it is in real tokens, so it is not
|
|
405
|
+
* calibrated, and it includes the built-in tool schemas the hooks cannot size. The
|
|
406
|
+
* hook sizes stay a floor, since they count text that is certainly sent.
|
|
407
|
+
*/
|
|
408
|
+
private scopeSize(): { measuredOverhead?: number; scopeTokens: number } {
|
|
409
|
+
const { systemTokens, toolTokens } = this.scope;
|
|
410
|
+
const measuredOverhead = this.state.budgetCheck?.source === "provider"
|
|
411
|
+
? Math.max(this.state.budgetCheck.overhead, (systemTokens ?? 0) + (toolTokens ?? 0))
|
|
412
|
+
: undefined;
|
|
413
|
+
return { ...(measuredOverhead !== undefined ? { measuredOverhead } : {}), scopeTokens: measuredOverhead ?? this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0)) };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* `/clm-compact`: about how large the next request would be, from the stored history
|
|
418
|
+
* (as OpenCode sends it, see `filterCompacted`) through the accepted revision (continuity and notices not
|
|
419
|
+
* counted). Undefined when the history holds no message to compact.
|
|
420
|
+
*/
|
|
421
|
+
idleEstimate(raw: OcMessage[]): number | undefined {
|
|
422
|
+
const pinned = pinnedCount(raw);
|
|
423
|
+
const source = flatten(raw.slice(pinned));
|
|
424
|
+
if (source.length === 0) return undefined;
|
|
425
|
+
const projection = applyProjection(source, this.state.checkpoint);
|
|
426
|
+
const effective = projection.valid ? projection.messages : source;
|
|
427
|
+
return this.scopeSize().scopeTokens + this.estimate(flatten(raw.slice(0, pinned))) + this.estimate(effective);
|
|
428
|
+
}
|
|
429
|
+
|
|
344
430
|
/** Uncalibrated tokens of a text: chars/4 × estimateFactor. */
|
|
345
431
|
textTokens(text: string): number {
|
|
346
432
|
return Math.ceil((text.length / CHARS_PER_TOKEN) * this.settings.estimateFactor);
|
|
@@ -520,6 +606,9 @@ export class ClmSession {
|
|
|
520
606
|
this.baseline = undefined;
|
|
521
607
|
this.tracker.reset();
|
|
522
608
|
this.continuitySize.reset();
|
|
609
|
+
// The user dropped the edits on purpose: a later revert must not bring one back. Queued
|
|
610
|
+
// behind any history write in flight; writes queued later see no checkpoint and skip.
|
|
611
|
+
await this.historyTask(() => clearHistory(this.store.directory));
|
|
523
612
|
await this.log({ event: "reset", revision: next.revision, reason });
|
|
524
613
|
}
|
|
525
614
|
|
|
@@ -570,6 +659,34 @@ export class ClmSession {
|
|
|
570
659
|
: `[CLM] Mirror edit would be refused: ${check.message} Correct the file before this step ends, or the context stays as it is.`;
|
|
571
660
|
}
|
|
572
661
|
|
|
662
|
+
/**
|
|
663
|
+
* `setting trailer`: the size trailer for one tool result (the last reading plus the
|
|
664
|
+
* result's calibrated estimate, against the budget in force); undefined when off.
|
|
665
|
+
*/
|
|
666
|
+
sizeTrailer(output: string): string | undefined {
|
|
667
|
+
if (!this.settings.enabled || !this.enabled || !this.settings.trailer || this.mirrorUnavailable !== undefined) return undefined;
|
|
668
|
+
const budget = this.resolvedBudget()?.budget;
|
|
669
|
+
if (!budget) return undefined;
|
|
670
|
+
return sizeTrailer(this.lastReading?.estimated ?? 0, this.calibrator.apply(this.textTokens(output)), budget);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Before an OpenCode compaction (`experimental.session.compacting`): commit the edit the
|
|
675
|
+
* model made in its final step. No request followed that step (e.g. the user typed
|
|
676
|
+
* /compact next), so the commit at the start of a transform has not run; without this the
|
|
677
|
+
* compaction would summarize the pre-edit context and the edit would be lost.
|
|
678
|
+
*/
|
|
679
|
+
async commitBeforeCompaction(): Promise<void> {
|
|
680
|
+
await this.refreshSettings();
|
|
681
|
+
if (!this.settings.enabled || !this.enabled) return;
|
|
682
|
+
await this.commit();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Adds a notice to the next request (e.g. from a bus event). */
|
|
686
|
+
queueNotice(text: string): void {
|
|
687
|
+
this.pendingNotices.push(text);
|
|
688
|
+
}
|
|
689
|
+
|
|
573
690
|
/** The mirror block the model last saw, for continuity annotations. */
|
|
574
691
|
blockSource(blockId: string): ContinuityBlockSource | undefined {
|
|
575
692
|
const snapshot = this.lastSnapshot;
|
|
@@ -587,6 +704,7 @@ export class ClmSession {
|
|
|
587
704
|
text = await this.store.read();
|
|
588
705
|
} catch (error) {
|
|
589
706
|
this.pendingNotices.push(`[CLM] Could not read the mirror: ${describe(error)}`);
|
|
707
|
+
this.pendingErrors.push(`could not read the mirror, the edit was not applied: ${describe(error)}`);
|
|
590
708
|
return;
|
|
591
709
|
}
|
|
592
710
|
if (text === undefined || text.trim() === baseline.snapshot.text.trim()) return;
|
|
@@ -639,9 +757,11 @@ export class ClmSession {
|
|
|
639
757
|
} catch (error) {
|
|
640
758
|
// Persist before activate: an unsaved revision would vanish on restart.
|
|
641
759
|
this.pendingNotices.push(`[CLM] Revision ${revision} was not applied: it could not be saved (${describe(error)}); the previous context stays in effect.`);
|
|
760
|
+
this.pendingErrors.push(`revision ${revision} could not be saved, the previous context stays in effect: ${describe(error)}`);
|
|
642
761
|
return;
|
|
643
762
|
}
|
|
644
763
|
this.accepted += 1;
|
|
764
|
+
await this.remember({ version: 1, checkpoint, contentDigest: digestSourceContent(baseline.rawMessages) });
|
|
645
765
|
const revisions = join(this.store.directory, "revisions");
|
|
646
766
|
await mkdir(revisions, { recursive: true, mode: 0o700 }).catch(() => undefined);
|
|
647
767
|
await writeFile(join(revisions, `r${revision}.md`), text, { mode: 0o600 }).catch(() => undefined);
|
|
@@ -746,6 +866,7 @@ export class ClmSession {
|
|
|
746
866
|
budget: { budget: base.budget, reserve: base.reserve, limit: overflowGuardLimit(resolved.budget, resolved.reserve), source: base.source },
|
|
747
867
|
calibration: { factor: this.calibrator.factor, samples: this.calibrator.sampleCount },
|
|
748
868
|
...(steering ? { steering: { name: steering.name, hash: steering.hash, path: steering.path } } : {}),
|
|
869
|
+
...(this.steeringError ? { steeringError: this.steeringError } : {}),
|
|
749
870
|
sizes: { estimated: input.estimated, ...(input.observed !== undefined ? { observedPrevious: input.observed } : {}) },
|
|
750
871
|
input: {
|
|
751
872
|
raw: input.raw,
|
|
@@ -812,6 +933,172 @@ export class ClmSession {
|
|
|
812
933
|
await this.log({ event: "compacted", revision: next.revision, previous, summary: summaryId, written });
|
|
813
934
|
}
|
|
814
935
|
|
|
936
|
+
// ---- checkpoint history (revert and fork) ------------------------------------------
|
|
937
|
+
|
|
938
|
+
/** History writes and clears run one at a time (`/clm reset` runs outside the transform queue). */
|
|
939
|
+
private historyQueue: Promise<void> = Promise.resolve();
|
|
940
|
+
|
|
941
|
+
private historyTask(task: () => Promise<void>): Promise<void> {
|
|
942
|
+
const run = this.historyQueue.then(task).catch(() => undefined);
|
|
943
|
+
this.historyQueue = run;
|
|
944
|
+
return run;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Add an accepted checkpoint to `checkpoints/`. Best effort: the revision is already saved.
|
|
949
|
+
* Skipped when the checkpoint is no longer the active one by the time the write runs (a
|
|
950
|
+
* `/clm reset` came in between), so a reset edit never lands in the history.
|
|
951
|
+
*/
|
|
952
|
+
private remember(entry: HistoryEntry): Promise<void> {
|
|
953
|
+
return this.historyTask(async () => {
|
|
954
|
+
if (this.state.checkpoint?.revision !== entry.checkpoint.revision) return;
|
|
955
|
+
await saveHistoryEntry(this.store.directory, entry)
|
|
956
|
+
.catch((error: unknown) => this.log({ event: "history-error", revision: entry.checkpoint.revision, error: describe(error) }));
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Make `entry`'s projection, with `projected` standing for `source`, the next revision.
|
|
962
|
+
* Persisted before it takes effect, like an accepted edit.
|
|
963
|
+
*/
|
|
964
|
+
private async activateRestored(
|
|
965
|
+
entry: HistoryEntry,
|
|
966
|
+
source: readonly LiveContextMessage[],
|
|
967
|
+
projected: readonly LiveContextMessage[],
|
|
968
|
+
describeRestore: (revision: number) => string,
|
|
969
|
+
): Promise<boolean> {
|
|
970
|
+
const at = new Date().toISOString();
|
|
971
|
+
const old = entry.checkpoint;
|
|
972
|
+
const revision = this.state.revision + 1;
|
|
973
|
+
let checkpoint: ProjectionCheckpoint;
|
|
974
|
+
try {
|
|
975
|
+
checkpoint = createProjectionCheckpoint({
|
|
976
|
+
revision,
|
|
977
|
+
sourceMessages: source,
|
|
978
|
+
projectedMessages: projected,
|
|
979
|
+
beforeEstimate: old.beforeEstimate,
|
|
980
|
+
afterEstimate: old.afterEstimate,
|
|
981
|
+
estimateUnit: old.estimateUnit,
|
|
982
|
+
createdAt: at,
|
|
983
|
+
...(old.editTrace ? { editTrace: old.editTrace } : {}),
|
|
984
|
+
});
|
|
985
|
+
} catch {
|
|
986
|
+
return false;
|
|
987
|
+
}
|
|
988
|
+
const message = describeRestore(revision);
|
|
989
|
+
try {
|
|
990
|
+
await this.updateState((state) => {
|
|
991
|
+
if (state.revision + 1 !== revision) throw new Error("the session state changed while the revision was restored");
|
|
992
|
+
return {
|
|
993
|
+
version: 1,
|
|
994
|
+
enabled: state.enabled,
|
|
995
|
+
revision,
|
|
996
|
+
checkpoint,
|
|
997
|
+
lastOutcome: { kind: "applied", message, beforeEstimate: old.beforeEstimate, afterEstimate: old.afterEstimate, estimateUnit: old.estimateUnit, at },
|
|
998
|
+
...keptFields(state),
|
|
999
|
+
};
|
|
1000
|
+
});
|
|
1001
|
+
} catch {
|
|
1002
|
+
return false;
|
|
1003
|
+
}
|
|
1004
|
+
this.tracker.reset();
|
|
1005
|
+
await this.remember({ version: 1, checkpoint, contentDigest: entry.contentDigest });
|
|
1006
|
+
this.pendingNotices.push(`[CLM] ${message} The mirror shows it.`);
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* The active revision no longer fits the history (a revert cut into its prefix): restore
|
|
1012
|
+
* the newest older checkpoint that still fits, as pi-clm restores the checkpoint of the
|
|
1013
|
+
* branch `/tree` selects. False when none fits; the caller then resets.
|
|
1014
|
+
*/
|
|
1015
|
+
private async restoreAfterMismatch(source: readonly LiveContextMessage[], reason: string): Promise<boolean> {
|
|
1016
|
+
const dropped = this.state.checkpoint?.revision;
|
|
1017
|
+
const { entries, ignored } = await loadHistory(this.store.directory);
|
|
1018
|
+
if (ignored.length > 0) await this.log({ event: "history-ignored", files: ignored });
|
|
1019
|
+
for (const entry of entries) {
|
|
1020
|
+
if (entry.checkpoint.revision === dropped) continue;
|
|
1021
|
+
const candidate = applyProjection(source, entry.checkpoint);
|
|
1022
|
+
if (!candidate.valid) continue;
|
|
1023
|
+
const from = entry.checkpoint.revision;
|
|
1024
|
+
const prefix = source.slice(0, entry.checkpoint.sourceMessageCount);
|
|
1025
|
+
const restored = await this.activateRestored(entry, prefix, entry.checkpoint.projectedMessages, (revision) =>
|
|
1026
|
+
`Revision ${dropped} no longer matches OpenCode's history: ${reason.replace(/\.\s*$/, "")}; restored revision ${from}, which still does, as revision ${revision}.`);
|
|
1027
|
+
if (!restored) return false;
|
|
1028
|
+
await this.log({ event: "restored", revision: this.state.revision, from, dropped, reason });
|
|
1029
|
+
return true;
|
|
1030
|
+
}
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* First request of a forked session: find the newest origin checkpoint whose source the
|
|
1036
|
+
* fork's history starts with (compared without message ids, which the fork renews) and
|
|
1037
|
+
* restore it with the ids mapped to the fork's.
|
|
1038
|
+
*/
|
|
1039
|
+
private async restoreFromFork(origin: { sessionID: string; directory: string }, source: readonly LiveContextMessage[]): Promise<boolean> {
|
|
1040
|
+
const { entries, ignored } = await loadHistory(origin.directory);
|
|
1041
|
+
if (ignored.length > 0) await this.log({ event: "history-ignored", origin: origin.sessionID, files: ignored });
|
|
1042
|
+
for (const entry of entries) {
|
|
1043
|
+
const count = entry.checkpoint.sourceMessageCount;
|
|
1044
|
+
if (count > source.length) continue;
|
|
1045
|
+
const prefix = source.slice(0, count);
|
|
1046
|
+
if (digestSourceContent(prefix) !== entry.contentDigest) continue;
|
|
1047
|
+
const from = entry.checkpoint.revision;
|
|
1048
|
+
const restored = await this.activateRestored(entry, prefix, remapProjection(entry.checkpoint, prefix), (revision) =>
|
|
1049
|
+
`This session is a fork of ${origin.sessionID}; restored its revision ${from} as revision ${revision}.`);
|
|
1050
|
+
if (!restored) return false;
|
|
1051
|
+
await this.log({ event: "restored", revision: this.state.revision, from, origin: origin.sessionID });
|
|
1052
|
+
return true;
|
|
1053
|
+
}
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* First request of a forked session: copy the origin's annotations made up to the fork
|
|
1059
|
+
* point, their sources re-pointed at the fork's messages (`forkAnnotations`). The fork
|
|
1060
|
+
* point is the newest completion among the messages created before the fork itself
|
|
1061
|
+
* (OpenCode keeps message times in a fork); newer messages are the fork's own.
|
|
1062
|
+
*/
|
|
1063
|
+
private async copyForkAnnotations(origin: { sessionID: string; directory: string; created?: number }, raw: readonly OcMessage[], effective: readonly LiveContextMessage[]): Promise<void> {
|
|
1064
|
+
let annotations: LiveContextAnnotation[] = [];
|
|
1065
|
+
try {
|
|
1066
|
+
annotations = await new AnnotationStore(origin.directory).list();
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
await this.log({ event: "annotations-unreadable", origin: origin.sessionID, error: describe(error) });
|
|
1069
|
+
}
|
|
1070
|
+
// Without a mirror the origin's directory is usually out of reach too: fall back to the
|
|
1071
|
+
// set the origin kept in OpenCode's metadata, which the fork cloned (index.ts).
|
|
1072
|
+
if (annotations.length === 0) annotations = this.forkPersisted ?? [];
|
|
1073
|
+
this.forkPersisted = undefined;
|
|
1074
|
+
if (annotations.length === 0) return;
|
|
1075
|
+
let cutoff: number | undefined;
|
|
1076
|
+
for (const { info } of raw) {
|
|
1077
|
+
const created = Number(info.time?.created ?? 0);
|
|
1078
|
+
if (origin.created !== undefined && created >= origin.created) continue;
|
|
1079
|
+
const end = Number(info.time?.completed ?? 0) || created;
|
|
1080
|
+
if (cutoff === undefined || end > cutoff) cutoff = end;
|
|
1081
|
+
}
|
|
1082
|
+
const copied = forkAnnotations(annotations, effective, { sessionId: this.sessionID, ...(cutoff !== undefined ? { cutoff } : {}) });
|
|
1083
|
+
try {
|
|
1084
|
+
const written = await this.annotations.importAll(copied);
|
|
1085
|
+
// Without the fork's creation time the fork's own new messages count too, so origin
|
|
1086
|
+
// annotations made between the fork and its first request are carried as well.
|
|
1087
|
+
if (written > 0) {
|
|
1088
|
+
this.annotationsImported = true;
|
|
1089
|
+
await this.log({
|
|
1090
|
+
event: "annotations-forked",
|
|
1091
|
+
origin: origin.sessionID,
|
|
1092
|
+
count: written,
|
|
1093
|
+
mapped: copied.filter((annotation) => annotation.source.sessionId === this.sessionID).length,
|
|
1094
|
+
cutoff: origin.created === undefined ? "unbounded" : cutoff ?? null,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
} catch (error) {
|
|
1098
|
+
await this.log({ event: "annotations-fork-error", origin: origin.sessionID, error: describe(error) });
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
815
1102
|
private async loadAnnotations(): Promise<LiveContextAnnotation[]> {
|
|
816
1103
|
try {
|
|
817
1104
|
return await this.annotations.list();
|
|
@@ -821,13 +1108,46 @@ export class ClmSession {
|
|
|
821
1108
|
}
|
|
822
1109
|
}
|
|
823
1110
|
|
|
1111
|
+
/**
|
|
1112
|
+
* Without a mirror (`mirrorUnavailable`): the raw history, then the continuity message and
|
|
1113
|
+
* its size notice. Nothing is projected, rendered or measured; queued notices name the
|
|
1114
|
+
* mirror, so they are dropped. pi-clm src/index.ts `context` handler, `!store` branch.
|
|
1115
|
+
*/
|
|
1116
|
+
private async transformWithoutMirror(raw: OcMessage[]): Promise<TransformResult> {
|
|
1117
|
+
this.baseline = undefined;
|
|
1118
|
+
this.compacted = false;
|
|
1119
|
+
this.pendingNotices = [];
|
|
1120
|
+
if (this.compacting) {
|
|
1121
|
+
this.compacting = false;
|
|
1122
|
+
return { messages: raw, notices: [] };
|
|
1123
|
+
}
|
|
1124
|
+
this.requests += 1;
|
|
1125
|
+
const origin = this.forkOrigin;
|
|
1126
|
+
this.forkOrigin = undefined;
|
|
1127
|
+
if (origin) await this.copyForkAnnotations(origin, raw, flatten(raw));
|
|
1128
|
+
const continuity = formatContinuityMessage({ annotations: await this.loadAnnotations(), effectiveMessages: flatten(raw) });
|
|
1129
|
+
const tokens = continuity ? this.textTokens(continuity) : 0;
|
|
1130
|
+
const notices = this.continuitySize.observe(tokens) ? [continuitySizeNoticeText(tokens)] : [];
|
|
1131
|
+
const context = this.context(raw);
|
|
1132
|
+
const messages = [
|
|
1133
|
+
...raw,
|
|
1134
|
+
...(continuity ? [noteMessage(continuity, "continuity", context)] : []),
|
|
1135
|
+
...(notices.length > 0 ? [noteMessage(notices.join("\n\n"), `notice:${this.requests}`, context)] : []),
|
|
1136
|
+
];
|
|
1137
|
+
this.lastRequest = { rawMessages: raw.length, sentMessages: messages.length, mirrorBlocks: 0 };
|
|
1138
|
+
const errors = this.pendingErrors;
|
|
1139
|
+
this.pendingErrors = [];
|
|
1140
|
+
return { messages, notices, ...(errors.length > 0 ? { errors } : {}) };
|
|
1141
|
+
}
|
|
1142
|
+
|
|
824
1143
|
private async transformNow(raw: OcMessage[]): Promise<TransformResult> {
|
|
825
1144
|
await this.refreshSettings();
|
|
826
1145
|
if (!this.settings.enabled || !this.enabled) {
|
|
827
1146
|
this.baseline = undefined;
|
|
828
1147
|
this.compacting = false;
|
|
829
|
-
return { messages: raw, notices: [] };
|
|
1148
|
+
return { messages: raw, notices: [], ...this.drainAlerts() };
|
|
830
1149
|
}
|
|
1150
|
+
if (this.mirrorUnavailable !== undefined) return await this.transformWithoutMirror(raw);
|
|
831
1151
|
const pinned = pinnedCount(raw);
|
|
832
1152
|
const pinnedMessages = raw.slice(0, pinned);
|
|
833
1153
|
const context = this.context(raw);
|
|
@@ -835,11 +1155,12 @@ export class ClmSession {
|
|
|
835
1155
|
// OpenCode's compaction passes a head slice of the history through this hook. Apply
|
|
836
1156
|
// the accepted revision when it still covers that slice; never commit, render,
|
|
837
1157
|
// notify or reset (the slice is shorter than the history the checkpoint covers).
|
|
1158
|
+
// A pending edit was committed by `commitBeforeCompaction` just before.
|
|
838
1159
|
if (this.compacting) {
|
|
839
1160
|
this.compacting = false;
|
|
840
1161
|
const projection = applyProjection(flatten(raw.slice(pinned)), this.state.checkpoint);
|
|
841
|
-
if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [] };
|
|
842
|
-
return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [] };
|
|
1162
|
+
if (!projection.valid || !this.state.checkpoint) return { messages: raw, notices: [], ...this.drainAlerts() };
|
|
1163
|
+
return { messages: [...pinnedMessages, ...unflatten(projection.messages, context)], notices: [], ...this.drainAlerts() };
|
|
843
1164
|
}
|
|
844
1165
|
|
|
845
1166
|
await this.commit();
|
|
@@ -867,6 +1188,10 @@ export class ClmSession {
|
|
|
867
1188
|
if (rebase) {
|
|
868
1189
|
await this.rebaseAfterCompaction(summaryId);
|
|
869
1190
|
projection = applyProjection(source, undefined);
|
|
1191
|
+
} else if (!projection.valid && (await this.restoreAfterMismatch(source, projection.reason))) {
|
|
1192
|
+
// A revert cut into the active revision's prefix; an older revision still fits.
|
|
1193
|
+
this.invalidationStreak = 0;
|
|
1194
|
+
projection = applyProjection(source, this.state.checkpoint);
|
|
870
1195
|
} else if (!projection.valid) {
|
|
871
1196
|
const dropped = this.state.checkpoint?.revision;
|
|
872
1197
|
this.invalidationStreak += 1;
|
|
@@ -875,6 +1200,10 @@ export class ClmSession {
|
|
|
875
1200
|
let notice = `[CLM] Revision ${dropped} was dropped because OpenCode's history changed under it: ${projection.reason} The mirror now shows the stored history.`;
|
|
876
1201
|
if (this.invalidationStreak >= 2) {
|
|
877
1202
|
notice += " This happened on consecutive requests; edits keep being dropped until the start of the history stops changing.";
|
|
1203
|
+
if (!this.compositionWarned) {
|
|
1204
|
+
this.compositionWarned = true;
|
|
1205
|
+
this.pendingAlerts.push(COMPOSITION_WARNING);
|
|
1206
|
+
}
|
|
878
1207
|
}
|
|
879
1208
|
this.pendingNotices.push(notice);
|
|
880
1209
|
await this.log({ event: "projection-reset", revision: dropped, reason: projection.reason });
|
|
@@ -882,6 +1211,11 @@ export class ClmSession {
|
|
|
882
1211
|
} else if (this.state.checkpoint) {
|
|
883
1212
|
this.invalidationStreak = 0;
|
|
884
1213
|
}
|
|
1214
|
+
const origin = this.forkOrigin;
|
|
1215
|
+
this.forkOrigin = undefined;
|
|
1216
|
+
if (origin && !this.state.checkpoint && this.state.revision === 0 && (await this.restoreFromFork(origin, source))) {
|
|
1217
|
+
projection = applyProjection(source, this.state.checkpoint);
|
|
1218
|
+
}
|
|
885
1219
|
const checkpoint = this.state.checkpoint;
|
|
886
1220
|
const suffixLength = projection.valid ? projection.suffix.length : 0;
|
|
887
1221
|
|
|
@@ -889,21 +1223,15 @@ export class ClmSession {
|
|
|
889
1223
|
if (!this.settings.reasoning) effective = withoutReasoning(effective);
|
|
890
1224
|
effective = capObservations(effective, this.settings.observationCap);
|
|
891
1225
|
|
|
1226
|
+
if (origin) await this.copyForkAnnotations(origin, raw, effective);
|
|
892
1227
|
const annotations = await this.loadAnnotations();
|
|
893
1228
|
const continuityText = (messages: LiveContextMessage[]) =>
|
|
894
1229
|
formatContinuityMessage({ annotations, effectiveMessages: messages });
|
|
895
1230
|
const pinnedTokens = this.estimate(flatten(pinnedMessages));
|
|
896
1231
|
const resolved = this.resolvedBudget();
|
|
897
1232
|
const limit = resolved ? overflowGuardLimit(resolved.budget, resolved.reserve) : undefined;
|
|
898
|
-
// System prompt and tool schemas, when the hooks reported them (one request late).
|
|
899
|
-
// A provider-measured overhead replaces them: it is in real tokens, so it is not
|
|
900
|
-
// calibrated, and it includes the built-in tool schemas the hooks cannot size. The
|
|
901
|
-
// hook sizes stay a floor, since they count text that is certainly sent.
|
|
902
1233
|
const { systemTokens, toolTokens } = this.scope;
|
|
903
|
-
const measuredOverhead = this.
|
|
904
|
-
? Math.max(this.state.budgetCheck.overhead, (systemTokens ?? 0) + (toolTokens ?? 0))
|
|
905
|
-
: undefined;
|
|
906
|
-
const scopeTokens = measuredOverhead ?? this.calibrator.apply((systemTokens ?? 0) + (toolTokens ?? 0));
|
|
1234
|
+
const { measuredOverhead, scopeTokens } = this.scopeSize();
|
|
907
1235
|
const excluded = measuredOverhead !== undefined ? [] : [
|
|
908
1236
|
...(systemTokens === undefined ? ["the system prompt"] : []),
|
|
909
1237
|
...(toolTokens === undefined ? ["tool schemas"] : []),
|
|
@@ -949,6 +1277,7 @@ export class ClmSession {
|
|
|
949
1277
|
});
|
|
950
1278
|
try {
|
|
951
1279
|
await this.store.write(snapshot.text);
|
|
1280
|
+
this.refreshFailing = false;
|
|
952
1281
|
this.lastSnapshot = snapshot;
|
|
953
1282
|
this.baseline = {
|
|
954
1283
|
rawMessages: source,
|
|
@@ -961,6 +1290,8 @@ export class ClmSession {
|
|
|
961
1290
|
} catch (error) {
|
|
962
1291
|
this.baseline = undefined;
|
|
963
1292
|
this.pendingNotices.push(`[CLM] Could not refresh the mirror: ${describe(error)}`);
|
|
1293
|
+
if (!this.refreshFailing) this.pendingErrors.push(`could not refresh the mirror, edits are off until it can be written: ${describe(error)}`);
|
|
1294
|
+
this.refreshFailing = true;
|
|
964
1295
|
}
|
|
965
1296
|
|
|
966
1297
|
const conversationTokens = pinnedTokens + this.estimate(effective) + continuityTokens;
|
|
@@ -1043,7 +1374,25 @@ export class ClmSession {
|
|
|
1043
1374
|
calibration: this.calibrator.factor,
|
|
1044
1375
|
notices: notices.map((notice) => notice.slice(0, 80)),
|
|
1045
1376
|
});
|
|
1046
|
-
return {
|
|
1377
|
+
return {
|
|
1378
|
+
messages,
|
|
1379
|
+
notices,
|
|
1380
|
+
estimated: reading?.estimated ?? requestTokens,
|
|
1381
|
+
reading,
|
|
1382
|
+
...this.drainAlerts(alert),
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** The queued toasts (`alert`, `errors`) for a transform result, cleared once taken. */
|
|
1387
|
+
private drainAlerts(alert?: string): Pick<TransformResult, "alert" | "errors"> {
|
|
1388
|
+
const alerts = [...(alert ? [alert] : []), ...this.pendingAlerts];
|
|
1389
|
+
this.pendingAlerts = [];
|
|
1390
|
+
const errors = this.pendingErrors;
|
|
1391
|
+
this.pendingErrors = [];
|
|
1392
|
+
return {
|
|
1393
|
+
...(alerts.length > 0 ? { alert: alerts.join("\n") } : {}),
|
|
1394
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
1395
|
+
};
|
|
1047
1396
|
}
|
|
1048
1397
|
|
|
1049
1398
|
/** Inputs for presentation.ts `statusText` / `statusLine`. */
|
|
@@ -1055,7 +1404,7 @@ export class ClmSession {
|
|
|
1055
1404
|
...(changed ? { changed } : {}),
|
|
1056
1405
|
...(this.settingsWarning ? { settingsWarning: this.settingsWarning } : {}),
|
|
1057
1406
|
sessionID: this.sessionID,
|
|
1058
|
-
mirrorPath: this.mirrorPath,
|
|
1407
|
+
mirrorPath: this.mirrorUnavailable === undefined ? this.mirrorPath : `unavailable, requests carry the raw history (${this.mirrorUnavailable})`,
|
|
1059
1408
|
revision: this.state.revision,
|
|
1060
1409
|
accepted: this.accepted,
|
|
1061
1410
|
rejected: this.rejected,
|