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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// The `compaction`, `one-tool` and `trailer` settings: pure decisions for index.ts.
|
|
2
|
+
// Semantics follow pi-clm's `PI_CLM_NATIVE_COMPACTION`, `oneToolPerTurn` and `sizeTrailer`
|
|
3
|
+
// (pi-clm src/index.ts, MIT, Copyright 2026 Emanuel Casco); texts and the OpenCode mapping
|
|
4
|
+
// are this package's.
|
|
5
|
+
//
|
|
6
|
+
// OpenCode 1.18.34 has one flag for automatic compaction, `compaction.auto` in the config.
|
|
7
|
+
// It gates both threshold compaction (session/overflow.ts:28, checked at
|
|
8
|
+
// session/processor.ts:491 after each step and session/prompt.ts:1161 before the next) and
|
|
9
|
+
// recovery from a provider overflow error (session/processor.ts:620-628). Every check reads
|
|
10
|
+
// `config.get()`, the object the plugin's `config` hook received (plugin/index.ts:152,245-252;
|
|
11
|
+
// config/config.ts:620-622), so setting the key on that object takes effect at once, for
|
|
12
|
+
// every session of the instance.
|
|
13
|
+
|
|
14
|
+
import type { CompactionMode } from "./settings.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `compaction.auto` value a mode asks for outside a pause; undefined = the user's own
|
|
18
|
+
* config value. pi's `on` is "Pi's default" (its handler leaves compaction alone), so `on`
|
|
19
|
+
* keeps the user's configuration. pi's `auto` cancels threshold compaction while the guard
|
|
20
|
+
* enforces a budget; here `auto` keeps the configuration and index.ts turns the flag off
|
|
21
|
+
* per request only when the threshold can fire (`thresholdReachable`, K1).
|
|
22
|
+
*/
|
|
23
|
+
export function compactionAuto(mode: CompactionMode, userAuto: boolean | undefined): boolean | undefined {
|
|
24
|
+
return mode === "off" ? false : userAuto;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Sets `config.compaction.auto` for `mode`, restoring the user's value (or its absence) for `auto`. */
|
|
28
|
+
export function applyCompactionMode(config: { compaction?: { auto?: boolean } }, mode: CompactionMode, userAuto: boolean | undefined): void {
|
|
29
|
+
const value = compactionAuto(mode, userAuto);
|
|
30
|
+
if (value === undefined) {
|
|
31
|
+
if (config.compaction && "auto" in config.compaction) delete config.compaction.auto;
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
(config.compaction ??= {}).auto = value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Toast after OpenCode compacted a session on its own. */
|
|
38
|
+
export function nativeCompactionText(overflow: boolean, mode: CompactionMode): string {
|
|
39
|
+
const why = overflow ? "after the provider reported a context overflow" : "at its token threshold";
|
|
40
|
+
const hint = mode === "off" ? "" : " Set /clm config compaction off to keep the model's own edits instead.";
|
|
41
|
+
return `OpenCode compacted this session ${why}; CLM continues from the summary.${hint}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Model notice when a provider overflow was not compacted because `compaction.auto` was false. */
|
|
45
|
+
export function overflowNotCompactedText(mirrorPath: string): string {
|
|
46
|
+
return `[CLM] The provider rejected the last request as too long, and OpenCode's automatic compaction is off, so nothing was compacted. Shrink the context by editing ${mirrorPath}, or ask the user to run /compact.`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Per-session count of tool calls since the session's last model request. `next` must run
|
|
51
|
+
* synchronously at the start of `tool.execute.before`: parallel calls of one response
|
|
52
|
+
* reach the hook concurrently, so "first" means first to arrive, not first in the response.
|
|
53
|
+
*/
|
|
54
|
+
export class ToolCallCounter {
|
|
55
|
+
private readonly counts = new Map<string, number>();
|
|
56
|
+
|
|
57
|
+
/** Called for every model request of the session. */
|
|
58
|
+
reset(sessionID: string): void {
|
|
59
|
+
this.counts.delete(sessionID);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** This call's position (1-based) in the current response. */
|
|
63
|
+
next(sessionID: string): number {
|
|
64
|
+
const count = (this.counts.get(sessionID) ?? 0) + 1;
|
|
65
|
+
this.counts.set(sessionID, count);
|
|
66
|
+
return count;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The error a blocked call returns to the model. */
|
|
71
|
+
export function oneToolText(tool: string, position: number): string {
|
|
72
|
+
return `[CLM] One tool call per response (setting one-tool): this ${tool} call was #${position} in the response and did not run. Call it again on its own in your next response.`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `\n[context: ~N of B tokens after this result]`, appended to a tool result. */
|
|
76
|
+
export function sizeTrailer(estimated: number, resultTokens: number, budget: number): string {
|
|
77
|
+
const format = (value: number) => value.toLocaleString("en-US");
|
|
78
|
+
return `\n[context: ~${format(estimated + resultTokens)} of ${format(budget)} tokens after this result]`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- `auto`: pausing threshold compaction while the guard enforces a budget (K1, K5) ----
|
|
82
|
+
//
|
|
83
|
+
// pi's `auto` cancels every threshold compaction while the overflow guard enforces a budget
|
|
84
|
+
// (pi-clm src/index.ts `session_before_compact`) and leaves overflow recovery to Pi. OpenCode
|
|
85
|
+
// gives no cancel hook and one flag for both. So before each request the plugin predicts
|
|
86
|
+
// whether OpenCode's threshold check on that request's count can fire, and sets the flag false
|
|
87
|
+
// only then; below the threshold the user's value (and with it overflow recovery) stays.
|
|
88
|
+
//
|
|
89
|
+
// The threshold check reads the count of one finished request (`tokens.total`, input plus
|
|
90
|
+
// output) after that request's step (session/processor.ts:491) and again before the next
|
|
91
|
+
// step (session/prompt.ts:1161, `lastFinished`); both run after this request's transform and
|
|
92
|
+
// before the next one, so the flag set here governs exactly this request's count.
|
|
93
|
+
|
|
94
|
+
/** OpenCode's default output cap (provider/transform.ts:18 `OUTPUT_TOKEN_MAX`). */
|
|
95
|
+
export const OPENCODE_OUTPUT_TOKEN_MAX = 32_000;
|
|
96
|
+
/** OpenCode's `COMPACTION_BUFFER` (session/overflow.ts:8). */
|
|
97
|
+
const OPENCODE_COMPACTION_BUFFER = 20_000;
|
|
98
|
+
|
|
99
|
+
/** The model limits OpenCode's threshold reads (`Provider.Model.limit`). */
|
|
100
|
+
export interface ThresholdModel {
|
|
101
|
+
context?: number;
|
|
102
|
+
input?: number;
|
|
103
|
+
output?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `ProviderTransform.maxOutputTokens` (provider/transform.ts:1481-1483). */
|
|
107
|
+
export function openCodeMaxOutput(output: number | undefined, outputTokenMax = OPENCODE_OUTPUT_TOKEN_MAX): number {
|
|
108
|
+
return Math.min(output ?? 0, outputTokenMax) || outputTokenMax;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` runtime flag (effect/runtime-flags.ts:52), a
|
|
113
|
+
* positive integer; undefined when unset or invalid.
|
|
114
|
+
*/
|
|
115
|
+
export function outputTokenMaxFlag(env: Record<string, string | undefined> = process.env): number | undefined {
|
|
116
|
+
const value = Number(env.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX);
|
|
117
|
+
return Number.isInteger(value) && value > 0 ? value : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* OpenCode's threshold (session/overflow.ts:10-20 `usable`); undefined when the window is
|
|
122
|
+
* unknown or 0 (OpenCode then never compacts at a threshold). processor.ts:491 calls it
|
|
123
|
+
* without the output-cap flag and prompt.ts:1161 with it; the lower of the two is returned.
|
|
124
|
+
*/
|
|
125
|
+
export function openCodeUsable(model: ThresholdModel, reserved: number | undefined, outputTokenMax?: number): number | undefined {
|
|
126
|
+
const context = model.context ?? 0;
|
|
127
|
+
if (context <= 0) return undefined;
|
|
128
|
+
const usable = (cap: number) => {
|
|
129
|
+
const maxOutput = openCodeMaxOutput(model.output, cap);
|
|
130
|
+
const reserve = reserved ?? Math.min(OPENCODE_COMPACTION_BUFFER, maxOutput);
|
|
131
|
+
return model.input ? Math.max(0, model.input - reserve) : Math.max(0, context - maxOutput);
|
|
132
|
+
};
|
|
133
|
+
return Math.min(usable(OPENCODE_OUTPUT_TOKEN_MAX), usable(outputTokenMax ?? OPENCODE_OUTPUT_TOKEN_MAX));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Token fields of an OpenCode assistant message (`SessionV1.Assistant["tokens"]`). */
|
|
137
|
+
export interface StepTokens {
|
|
138
|
+
total?: number;
|
|
139
|
+
input?: number;
|
|
140
|
+
output?: number;
|
|
141
|
+
cache?: { read?: number; write?: number };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The count OpenCode compares with the threshold (session/overflow.ts:31-32). */
|
|
145
|
+
export function openCodeCount(tokens: StepTokens): number {
|
|
146
|
+
const parts = Number(tokens.input ?? 0) + Number(tokens.output ?? 0) + Number(tokens.cache?.read ?? 0) + Number(tokens.cache?.write ?? 0);
|
|
147
|
+
return Number(tokens.total ?? 0) || parts;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* `auto` with the guard on: whether OpenCode's threshold check can fire on this request, i.e.
|
|
152
|
+
* the request's estimate plus the most it may generate reaches the threshold. The output
|
|
153
|
+
* allowance is OpenCode's own output cap for the request, so the prediction errs toward
|
|
154
|
+
* pausing (pi pauses every threshold compaction).
|
|
155
|
+
*/
|
|
156
|
+
export function thresholdReachable(estimated: number, usable: number, maxOutput: number): boolean {
|
|
157
|
+
return estimated + maxOutput >= usable;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Per-session record of the flag the plugin set for each request and of threshold
|
|
162
|
+
* crossings, for K5: a step whose count reached the threshold while the plugin held the flag
|
|
163
|
+
* false is a threshold compaction the plugin cancelled. Reported once per crossing; a step
|
|
164
|
+
* below the threshold or a compaction re-arms it.
|
|
165
|
+
*/
|
|
166
|
+
export class ThresholdWatch {
|
|
167
|
+
private readonly sessions = new Map<string, { by?: "auto" | "off"; checked?: string; crossing: boolean }>();
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The flag this session's request ran under: `by` names the mode that turned it off
|
|
171
|
+
* against the user's value (`auto` paused it, `off` forced it); undefined when it was not.
|
|
172
|
+
*/
|
|
173
|
+
set(sessionID: string, by: "auto" | "off" | undefined): void {
|
|
174
|
+
const entry = this.sessions.get(sessionID) ?? { crossing: false };
|
|
175
|
+
entry.by = by;
|
|
176
|
+
this.sessions.set(sessionID, entry);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The mode that turned the flag off for the session's last request, if any. */
|
|
180
|
+
by(sessionID: string): "auto" | "off" | undefined {
|
|
181
|
+
return this.sessions.get(sessionID)?.by;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A compaction ran: the next crossing is a new one. */
|
|
185
|
+
reset(sessionID: string): void {
|
|
186
|
+
const entry = this.sessions.get(sessionID);
|
|
187
|
+
if (entry) entry.crossing = false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The newest finished step of the session (not a summary), seen at the next transform.
|
|
192
|
+
* Returns its count and the mode when the plugin's flag cancelled a threshold compaction on
|
|
193
|
+
* it and this crossing was not reported yet; undefined otherwise. Each step is judged once.
|
|
194
|
+
*/
|
|
195
|
+
observe(sessionID: string, step: { id: string; count: number } | undefined, usable: number | undefined): { count: number; by: "auto" | "off" } | undefined {
|
|
196
|
+
const entry = this.sessions.get(sessionID);
|
|
197
|
+
if (!entry || !step || step.id === entry.checked) return undefined;
|
|
198
|
+
entry.checked = step.id;
|
|
199
|
+
if (usable === undefined || step.count < usable) {
|
|
200
|
+
entry.crossing = false;
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
if (!entry.by || entry.crossing) return undefined;
|
|
204
|
+
entry.crossing = true;
|
|
205
|
+
return { count: step.count, by: entry.by };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Toast when `auto` paused a threshold compaction (pi: toast only). */
|
|
210
|
+
export function thresholdPausedText(count: number, usable: number, guardLimit: number | undefined): string {
|
|
211
|
+
const format = (value: number) => value.toLocaleString("en-US");
|
|
212
|
+
const guard = guardLimit !== undefined ? `; the overflow guard keeps the context under ~${format(guardLimit)} tokens` : "";
|
|
213
|
+
return `Paused OpenCode's threshold compaction: the last request counted ${format(count)} of OpenCode's ${format(usable)}-token threshold${guard}. Set /clm config compaction on to let OpenCode compact.`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Model notice when `off` cancelled a threshold compaction (pi: notice and toast). */
|
|
217
|
+
export function thresholdCancelledNoticeText(mirrorPath: string): string {
|
|
218
|
+
return `[CLM] OpenCode's automatic compaction (threshold) was cancelled: CLM manages this context. Free space by editing ${mirrorPath}.`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Toast when `off` cancelled a threshold compaction. */
|
|
222
|
+
export function thresholdCancelledText(count: number, usable: number): string {
|
|
223
|
+
const format = (value: number) => value.toLocaleString("en-US");
|
|
224
|
+
return `Cancelled OpenCode's threshold compaction (setting compaction off): the last request counted ${format(count)} of ${format(usable)} tokens.`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The step OpenCode's pre-step check reads (`MessageV2.latest(...).finished`,
|
|
229
|
+
* session/message-v2.ts:586-602): the newest assistant message with `finish` set, by
|
|
230
|
+
* creation time then id. Undefined when there is none or it is a compaction summary
|
|
231
|
+
* (prompt.ts:1163 skips summaries).
|
|
232
|
+
*/
|
|
233
|
+
export function lastFinishedStep(raw: readonly { info: { id: string; role: string; [key: string]: any } }[]): { id: string; count: number } | undefined {
|
|
234
|
+
let finished: { id: string; role: string; [key: string]: any } | undefined;
|
|
235
|
+
for (const { info } of raw) {
|
|
236
|
+
if (info.role !== "assistant" || !info.finish) continue;
|
|
237
|
+
const created = Number(info.time?.created ?? 0);
|
|
238
|
+
const newest = Number(finished?.time?.created ?? 0);
|
|
239
|
+
if (!finished || created > newest || (created === newest && info.id > finished.id)) finished = info;
|
|
240
|
+
}
|
|
241
|
+
if (!finished || finished.summary === true || !finished.tokens) return undefined;
|
|
242
|
+
return { id: finished.id, count: openCodeCount(finished.tokens as StepTokens) };
|
|
243
|
+
}
|
package/src/context-document.ts
CHANGED
|
@@ -132,7 +132,8 @@ export function estimateContextCharacters(messages: LiveContextMessage[]): numbe
|
|
|
132
132
|
return messages.reduce((total, message) => total + renderMessage(message).length, 0);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
/** The mirror block id of `message` at position `index` of the rendered messages. */
|
|
136
|
+
export function blockId(message: LiveContextMessage, index: number): string {
|
|
136
137
|
const digest = createHash("sha256").update(canonicalMessage(message)).digest("hex").slice(0, 12);
|
|
137
138
|
return `${index + 1}-${digest}`;
|
|
138
139
|
}
|
package/src/continuity.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { resolve } from "node:path";
|
|
|
17
17
|
|
|
18
18
|
import type { ToolContext, ToolDefinition, tool } from "@opencode-ai/plugin";
|
|
19
19
|
|
|
20
|
-
import { digestMessages, renderMessage } from "./context-document.ts";
|
|
20
|
+
import { blockId, digestMessages, renderMessage } from "./context-document.ts";
|
|
21
21
|
import type { LiveContextMessage } from "./types.ts";
|
|
22
22
|
|
|
23
23
|
export const ANNOTATIONS_FILE = "annotations.jsonl";
|
|
@@ -413,6 +413,113 @@ export interface CreateAnnotationInput {
|
|
|
413
413
|
*/
|
|
414
414
|
const fileQueues = new Map<string, Promise<unknown>>();
|
|
415
415
|
|
|
416
|
+
/**
|
|
417
|
+
* The origin's annotations as a fork of it carries them (pi: a branch carries the annotation
|
|
418
|
+
* entries on its path). OpenCode's `Session.fork` copies messages with new ids
|
|
419
|
+
* (session/session.ts:691-730) and keeps their times, and an annotation's `contentHash` and
|
|
420
|
+
* block id digest the source message with its id. So:
|
|
421
|
+
* - an annotation created after `cutoff` (ms; the fork's newest copied message) belongs to
|
|
422
|
+
* the origin's later history and is left out; one resolved after it is carried unresolved;
|
|
423
|
+
* - a source found in the fork's `messages` by its rendered text (role and text, or the head
|
|
424
|
+
* of a truncated snapshot) is re-pointed at the fork's copy: hash, block id (its position
|
|
425
|
+
* in `messages`) and session; an unmatched source keeps the origin's values and
|
|
426
|
+
* stays recallable from its stored text. Identical messages (a repeated prompt, the same
|
|
427
|
+
* tool output twice) map to the first copy: same text and hash, possibly another block id.
|
|
428
|
+
* Block ids index `messages` as the caller passes them (the effective messages before the
|
|
429
|
+
* overflow guard, which replaces results in place and so keeps positions).
|
|
430
|
+
* Ids, titles, text, creation times and `source.revision` (the origin's revision) are kept.
|
|
431
|
+
*/
|
|
432
|
+
export function forkAnnotations(
|
|
433
|
+
annotations: readonly LiveContextAnnotation[],
|
|
434
|
+
messages: readonly LiveContextMessage[],
|
|
435
|
+
options: { sessionId: string; cutoff?: number },
|
|
436
|
+
): LiveContextAnnotation[] {
|
|
437
|
+
const rendered = messages.map((message) => renderMessage(message));
|
|
438
|
+
const before = (iso: string | undefined) => iso !== undefined && (options.cutoff === undefined || Date.parse(iso) <= options.cutoff);
|
|
439
|
+
const out: LiveContextAnnotation[] = [];
|
|
440
|
+
for (const annotation of annotations) {
|
|
441
|
+
if (!before(annotation.createdAt)) continue;
|
|
442
|
+
const copy: LiveContextAnnotation = { ...annotation, source: { ...annotation.source } };
|
|
443
|
+
if (copy.resolvedAt !== undefined && !before(copy.resolvedAt)) {
|
|
444
|
+
delete copy.resolvedAt;
|
|
445
|
+
delete copy.resolution;
|
|
446
|
+
}
|
|
447
|
+
const { source } = copy;
|
|
448
|
+
const index = messages.findIndex((message, position) =>
|
|
449
|
+
message.role === source.role &&
|
|
450
|
+
(source.truncated ? rendered[position]!.startsWith(source.text) : rendered[position] === source.text));
|
|
451
|
+
if (index >= 0) {
|
|
452
|
+
copy.source = { ...source, sessionId: options.sessionId, contentHash: sourceContentHash(messages[index]!), blockId: blockId(messages[index]!, index) };
|
|
453
|
+
}
|
|
454
|
+
out.push(copy);
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Bound on the annotations a session without a mirror keeps in OpenCode's session metadata, bytes of JSON. */
|
|
460
|
+
export const PERSISTED_ANNOTATIONS_BYTES = 64 * 1024;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Annotations of a session without a mirror, as kept in OpenCode's session metadata
|
|
464
|
+
* (`metadata.clm.annotations`) so they survive a restart; the session's files then live in a
|
|
465
|
+
* per-process temp directory. `resolved` records every resolution (small); `annotations`
|
|
466
|
+
* holds full snapshots, active ones first, as many as fit the byte bound.
|
|
467
|
+
*/
|
|
468
|
+
export interface PersistedAnnotations {
|
|
469
|
+
version: 1;
|
|
470
|
+
resolved: Array<{ id: string; resolvedAt: string; resolution?: string }>;
|
|
471
|
+
annotations: LiveContextAnnotation[];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** The record for `metadata.clm.annotations`, at most `maxBytes` of JSON. */
|
|
475
|
+
export function persistAnnotations(annotations: readonly LiveContextAnnotation[], maxBytes = PERSISTED_ANNOTATIONS_BYTES): PersistedAnnotations {
|
|
476
|
+
const out: PersistedAnnotations = { version: 1, resolved: [], annotations: [] };
|
|
477
|
+
let size = Buffer.byteLength(JSON.stringify(out));
|
|
478
|
+
const fits = (value: unknown) => {
|
|
479
|
+
const added = Buffer.byteLength(JSON.stringify(value)) + 1;
|
|
480
|
+
if (size + added > maxBytes) return false;
|
|
481
|
+
size += added;
|
|
482
|
+
return true;
|
|
483
|
+
};
|
|
484
|
+
for (const annotation of annotations) {
|
|
485
|
+
if (annotation.resolvedAt === undefined) continue;
|
|
486
|
+
const stub = { id: annotation.id, resolvedAt: annotation.resolvedAt, ...(annotation.resolution !== undefined ? { resolution: annotation.resolution } : {}) };
|
|
487
|
+
if (fits(stub)) out.resolved.push(stub);
|
|
488
|
+
}
|
|
489
|
+
const rank = (annotation: LiveContextAnnotation) => (annotation.resolvedAt === undefined && annotation.retention !== "archive" ? 0 : 1);
|
|
490
|
+
for (const annotation of [...annotations].sort((left, right) => rank(left) - rank(right))) {
|
|
491
|
+
if (fits(annotation)) out.annotations.push(annotation);
|
|
492
|
+
}
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Snapshots to append to a store holding `current` so it reflects `persisted` (a
|
|
498
|
+
* `PersistedAnnotations` read back from metadata; anything else yields nothing): annotations
|
|
499
|
+
* the store lacks, then resolutions of ones it holds unresolved.
|
|
500
|
+
*/
|
|
501
|
+
export function restorePersisted(current: readonly LiveContextAnnotation[], persisted: unknown): LiveContextAnnotation[] {
|
|
502
|
+
if (!persisted || typeof persisted !== "object") return [];
|
|
503
|
+
const record = persisted as Partial<PersistedAnnotations>;
|
|
504
|
+
if (record.version !== 1) return [];
|
|
505
|
+
const byId = new Map(current.map((annotation) => [annotation.id, annotation]));
|
|
506
|
+
const out: LiveContextAnnotation[] = [];
|
|
507
|
+
for (const annotation of Array.isArray(record.annotations) ? record.annotations : []) {
|
|
508
|
+
if (!isLiveContextAnnotation(annotation) || byId.has(annotation.id)) continue;
|
|
509
|
+
byId.set(annotation.id, annotation);
|
|
510
|
+
out.push(annotation);
|
|
511
|
+
}
|
|
512
|
+
for (const stub of Array.isArray(record.resolved) ? record.resolved : []) {
|
|
513
|
+
if (!stub || typeof stub !== "object" || typeof stub.id !== "string" || typeof stub.resolvedAt !== "string") continue;
|
|
514
|
+
const annotation = byId.get(stub.id);
|
|
515
|
+
if (!annotation || annotation.resolvedAt !== undefined) continue;
|
|
516
|
+
const resolved = resolveAnnotation(annotation, typeof stub.resolution === "string" ? stub.resolution : undefined, stub.resolvedAt);
|
|
517
|
+
byId.set(stub.id, resolved);
|
|
518
|
+
out.push(resolved);
|
|
519
|
+
}
|
|
520
|
+
return out;
|
|
521
|
+
}
|
|
522
|
+
|
|
416
523
|
/** Append-only `annotations.jsonl` in one session directory. */
|
|
417
524
|
export class AnnotationStore {
|
|
418
525
|
readonly filePath: string;
|
|
@@ -492,6 +599,25 @@ export class AnnotationStore {
|
|
|
492
599
|
});
|
|
493
600
|
}
|
|
494
601
|
|
|
602
|
+
/** Appends snapshots as they are (`restorePersisted`); the latest per id wins on load. */
|
|
603
|
+
appendAll(annotations: readonly LiveContextAnnotation[]): Promise<void> {
|
|
604
|
+
return this.serialize(async () => {
|
|
605
|
+
for (const annotation of annotations) await this.append(annotation);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Writes another session's annotations into this store as they are (`forkAnnotations`),
|
|
611
|
+
* only while this store holds none; returns how many were written.
|
|
612
|
+
*/
|
|
613
|
+
importAll(annotations: readonly LiveContextAnnotation[]): Promise<number> {
|
|
614
|
+
return this.serialize(async () => {
|
|
615
|
+
if (annotations.length === 0 || (await this.list()).length > 0) return 0;
|
|
616
|
+
for (const annotation of annotations) await this.append(annotation);
|
|
617
|
+
return annotations.length;
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
495
621
|
async recall(
|
|
496
622
|
id: string,
|
|
497
623
|
maxTokens?: number,
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Checkpoint history: every accepted revision's checkpoint, kept in
|
|
2
|
+
// `<session dir>/checkpoints/rN.json` (the newest HISTORY_CAP files). pi-clm rebuilds its
|
|
3
|
+
// history from the session entries on the active branch (pi-clm src/index.ts `restore`,
|
|
4
|
+
// MIT, Copyright 2026 Emanuel Casco) and restores the checkpoint valid on the branch `/tree`
|
|
5
|
+
// selects. OpenCode has no branches; its equivalents are revert
|
|
6
|
+
// (packages/opencode/src/session/revert.ts), which cuts the history, and `Session.fork`
|
|
7
|
+
// (packages/opencode/src/session/session.ts:691-731), which copies it into a new session
|
|
8
|
+
// under new message ids. clm.ts uses this history to restore the newest older revision that
|
|
9
|
+
// still fits after either. Written for this package.
|
|
10
|
+
import { mkdir, readdir, readFile, rm } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { writeJsonAtomic } from "./atomic.ts";
|
|
14
|
+
import { sourceMessageId, type ProjectionCheckpoint } from "./projection.ts";
|
|
15
|
+
import { isProjectionCheckpoint } from "./state.ts";
|
|
16
|
+
import type { LiveContextMessage } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export const HISTORY_DIR = "checkpoints";
|
|
19
|
+
/** Checkpoints kept per session; each holds a whole projected context, so the count is small. */
|
|
20
|
+
export const HISTORY_CAP = 8;
|
|
21
|
+
|
|
22
|
+
const FILE_RE = /^r(\d+)\.json$/;
|
|
23
|
+
|
|
24
|
+
export interface HistoryEntry {
|
|
25
|
+
version: 1;
|
|
26
|
+
checkpoint: ProjectionCheckpoint;
|
|
27
|
+
/** `digestSourceContent` of the checkpoint's source: matches a forked copy of it. */
|
|
28
|
+
contentDigest: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface LoadedHistory {
|
|
32
|
+
/** Newest revision first. */
|
|
33
|
+
entries: HistoryEntry[];
|
|
34
|
+
/** Files that were skipped (unreadable, malformed, or naming another revision). */
|
|
35
|
+
ignored: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function historyDirectory(sessionDirectory: string): string {
|
|
39
|
+
return join(sessionDirectory, HISTORY_DIR);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isHistoryEntry(value: unknown): value is HistoryEntry {
|
|
43
|
+
if (!value || typeof value !== "object") return false;
|
|
44
|
+
const entry = value as Partial<HistoryEntry>;
|
|
45
|
+
return entry.version === 1 && isProjectionCheckpoint(entry.checkpoint) &&
|
|
46
|
+
typeof entry.contentDigest === "string" && /^[a-f0-9]{64}$/.test(entry.contentDigest);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Write one checkpoint, then delete all but the newest `cap` files. */
|
|
50
|
+
export async function saveHistoryEntry(sessionDirectory: string, entry: HistoryEntry, cap = HISTORY_CAP): Promise<void> {
|
|
51
|
+
const directory = historyDirectory(sessionDirectory);
|
|
52
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
53
|
+
await writeJsonAtomic(join(directory, `r${entry.checkpoint.revision}.json`), entry);
|
|
54
|
+
const revisions = (await readdir(directory))
|
|
55
|
+
.map((name) => FILE_RE.exec(name))
|
|
56
|
+
.filter((match): match is RegExpExecArray => match !== null)
|
|
57
|
+
.map((match) => Number(match[1]))
|
|
58
|
+
.sort((left, right) => right - left);
|
|
59
|
+
await Promise.all(revisions.slice(Math.max(1, cap)).map((revision) =>
|
|
60
|
+
rm(join(directory, `r${revision}.json`), { force: true })));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** All usable entries, newest first. Never throws: a missing directory is an empty history. */
|
|
64
|
+
export async function loadHistory(sessionDirectory: string): Promise<LoadedHistory> {
|
|
65
|
+
const directory = historyDirectory(sessionDirectory);
|
|
66
|
+
let names: string[];
|
|
67
|
+
try {
|
|
68
|
+
names = await readdir(directory);
|
|
69
|
+
} catch {
|
|
70
|
+
return { entries: [], ignored: [] };
|
|
71
|
+
}
|
|
72
|
+
const entries: HistoryEntry[] = [];
|
|
73
|
+
const ignored: string[] = [];
|
|
74
|
+
for (const name of names) {
|
|
75
|
+
const match = FILE_RE.exec(name);
|
|
76
|
+
if (!match) continue;
|
|
77
|
+
try {
|
|
78
|
+
const parsed: unknown = JSON.parse(await readFile(join(directory, name), "utf8"));
|
|
79
|
+
if (isHistoryEntry(parsed) && parsed.checkpoint.revision === Number(match[1])) entries.push(parsed);
|
|
80
|
+
else ignored.push(name);
|
|
81
|
+
} catch {
|
|
82
|
+
ignored.push(name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
entries.sort((left, right) => right.checkpoint.revision - left.checkpoint.revision);
|
|
86
|
+
return { entries, ignored };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Forget every checkpoint (`/clm reset`: the user dropped the edits on purpose). */
|
|
90
|
+
export async function clearHistory(sessionDirectory: string): Promise<void> {
|
|
91
|
+
await rm(historyDirectory(sessionDirectory), { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The checkpoint's projected messages with the source session's message ids replaced by
|
|
96
|
+
* the fork's, position by position over the source prefix (`source` is the fork's prefix,
|
|
97
|
+
* same length as `checkpoint.sourceIds`). Ids outside the prefix stay as they are.
|
|
98
|
+
*/
|
|
99
|
+
export function remapProjection(checkpoint: ProjectionCheckpoint, source: readonly LiveContextMessage[]): LiveContextMessage[] {
|
|
100
|
+
const ids = new Map<string, string>();
|
|
101
|
+
checkpoint.sourceIds.forEach((id, index) => {
|
|
102
|
+
const next = source[index] ? sourceMessageId(source[index]) : "";
|
|
103
|
+
if (id && next && !ids.has(id)) ids.set(id, next);
|
|
104
|
+
});
|
|
105
|
+
return checkpoint.projectedMessages.map((message) => {
|
|
106
|
+
const id = typeof message.ocMessageID === "string" ? ids.get(message.ocMessageID) : undefined;
|
|
107
|
+
return id === undefined ? message : { ...message, ocMessageID: id };
|
|
108
|
+
});
|
|
109
|
+
}
|
package/src/mirror-store.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Adapted from pi-clm src/mirror-store.ts (MIT, Copyright 2026 Emanuel Casco).
|
|
2
2
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
7
|
import { basename, join } from "node:path";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
8
9
|
|
|
@@ -14,6 +15,13 @@ export function defaultMirrorParent(): string {
|
|
|
14
15
|
return join(stateHome, "opencode-clm", "mirrors");
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
/** The session directory under a mirror parent could not be created or was refused. */
|
|
19
|
+
export class MirrorDirectoryError extends Error {
|
|
20
|
+
constructor(readonly parent: string, readonly cause: unknown) {
|
|
21
|
+
super(`could not use ${parent}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
export function safeSessionId(sessionId: string): string {
|
|
18
26
|
const safe = sessionId.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64);
|
|
19
27
|
return safe || "session";
|
|
@@ -76,6 +84,36 @@ async function assertPrivateDirectory(directory: string): Promise<void> {
|
|
|
76
84
|
}
|
|
77
85
|
}
|
|
78
86
|
|
|
87
|
+
/** A fresh private (0700) directory under the OS temp directory, for sessions without a usable mirror directory. */
|
|
88
|
+
export function privateTemporaryParent(): Promise<string> {
|
|
89
|
+
return mkdtemp(join(tmpdir(), "opencode-clm-"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Copies `name` from `fromDirectory` into `toDirectory` (mode 0600), only when the source
|
|
94
|
+
* directory passes the store's own check (a real directory owned by this user) and the file
|
|
95
|
+
* is a regular file. Never overwrites. Returns whether a copy was made; never throws.
|
|
96
|
+
*/
|
|
97
|
+
export async function copyPrivateFile(fromDirectory: string, toDirectory: string, name: string): Promise<boolean> {
|
|
98
|
+
try {
|
|
99
|
+
await assertPrivateDirectory(fromDirectory);
|
|
100
|
+
// O_NOFOLLOW and a check on the open handle: a file swapped for a symlink is not followed.
|
|
101
|
+
// O_NONBLOCK: opening a FIFO must not block before the isFile() check.
|
|
102
|
+
const handle = await open(join(fromDirectory, name), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
103
|
+
let content: Buffer;
|
|
104
|
+
try {
|
|
105
|
+
if (!(await handle.stat()).isFile()) return false;
|
|
106
|
+
content = await handle.readFile();
|
|
107
|
+
} finally {
|
|
108
|
+
await handle.close();
|
|
109
|
+
}
|
|
110
|
+
await writeFile(join(toDirectory, name), content, { mode: 0o600, flag: "wx" });
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
79
117
|
export class MirrorStore {
|
|
80
118
|
readonly directory: string;
|
|
81
119
|
readonly filePath: string;
|
package/src/opencode.ts
CHANGED
|
@@ -337,3 +337,62 @@ export function unflatten(messages: readonly LiveContextMessage[], context: Unfl
|
|
|
337
337
|
export function replaceInPlace(target: OcMessage[], next: readonly OcMessage[]): void {
|
|
338
338
|
target.splice(0, target.length, ...next);
|
|
339
339
|
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The part of a stored history OpenCode sends to the model: port of OpenCode 1.18.34
|
|
343
|
+
* `MessageV2.filterCompacted` (packages/opencode/src/session/message-v2.ts:525-576), which
|
|
344
|
+
* the prompt loop applies to the stored stream (session/prompt.ts:1092). `client.session.messages`
|
|
345
|
+
* returns the full history, oldest first (no filter); OpenCode's `stream` walks it newest
|
|
346
|
+
* first, so this walks `messages` backwards. After a completed compaction the result is
|
|
347
|
+
* [compaction user message, summary, retained tail (`tail_start_id`), newer messages];
|
|
348
|
+
* without one it is `messages` unchanged.
|
|
349
|
+
*/
|
|
350
|
+
export function filterCompacted(messages: readonly OcMessage[]): OcMessage[] {
|
|
351
|
+
const result: OcMessage[] = [];
|
|
352
|
+
const completed = new Set<string>();
|
|
353
|
+
let retain: string | undefined;
|
|
354
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
355
|
+
const message = messages[i]!;
|
|
356
|
+
result.push(message);
|
|
357
|
+
if (retain) {
|
|
358
|
+
if (message.info.id === retain) break;
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (message.info.role === "user" && completed.has(message.info.id)) {
|
|
362
|
+
const part = message.parts.find((item) => item.type === "compaction");
|
|
363
|
+
if (!part) continue;
|
|
364
|
+
if (!part.tail_start_id) break;
|
|
365
|
+
retain = String(part.tail_start_id);
|
|
366
|
+
if (message.info.id === retain) break;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (message.info.role === "assistant" && message.info.summary && message.info.finish && !message.info.error) {
|
|
370
|
+
completed.add(message.info.parentID);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
result.reverse();
|
|
374
|
+
const isTailCompaction = (item: OcPart) => item.type === "compaction" && item.tail_start_id !== undefined;
|
|
375
|
+
let compactionIndex = -1;
|
|
376
|
+
for (let i = result.length - 1; i >= 0; i--) {
|
|
377
|
+
const message = result[i]!;
|
|
378
|
+
if (message.info.role === "user" && message.parts.some(isTailCompaction)) {
|
|
379
|
+
compactionIndex = i;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const compaction = result[compactionIndex];
|
|
384
|
+
const part = compaction?.parts.find(isTailCompaction);
|
|
385
|
+
const summaryIndex = compaction
|
|
386
|
+
? result.findIndex((message, index) =>
|
|
387
|
+
index > compactionIndex && message.info.role === "assistant" && message.info.summary && message.info.parentID === compaction.info.id)
|
|
388
|
+
: -1;
|
|
389
|
+
const tailIndex = part?.tail_start_id ? result.findIndex((message) => message.info.id === part.tail_start_id) : -1;
|
|
390
|
+
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
|
|
391
|
+
return [
|
|
392
|
+
...result.slice(compactionIndex, summaryIndex + 1),
|
|
393
|
+
...result.slice(tailIndex, compactionIndex),
|
|
394
|
+
...result.slice(summaryIndex + 1),
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
return result;
|
|
398
|
+
}
|
package/src/overrides.ts
CHANGED
|
@@ -170,9 +170,21 @@ export async function resetSettings(sessionDirectory: string): Promise<void> {
|
|
|
170
170
|
* Current values of one session, read from its overrides.json (non-strict, never throws for
|
|
171
171
|
* bad files). `mtimeMs`: when overrides.json was last written, if it exists.
|
|
172
172
|
*/
|
|
173
|
-
export async function sessionValues(sessionDirectory: string, base: ClmSettings, baseEditing: boolean): Promise<
|
|
173
|
+
export async function sessionValues(sessionDirectory: string, base: ClmSettings, baseEditing: boolean): Promise<SessionValues> {
|
|
174
174
|
const read = await readOverrides(sessionDirectory);
|
|
175
175
|
const mtimeMs = await stat(overridesPath(sessionDirectory)).then((info) => info.mtimeMs, () => undefined);
|
|
176
|
+
return overridesValues(read, base, baseEditing, mtimeMs);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface SessionValues {
|
|
180
|
+
values: SettingsValues;
|
|
181
|
+
warning?: string;
|
|
182
|
+
overrides: ClmOverrides;
|
|
183
|
+
mtimeMs?: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** `sessionValues` for overrides already read (e.g. through the server's file API). */
|
|
187
|
+
export function overridesValues(read: OverridesRead, base: ClmSettings, baseEditing: boolean, mtimeMs?: number): SessionValues {
|
|
176
188
|
const at = mtimeMs !== undefined ? { mtimeMs } : {};
|
|
177
189
|
try {
|
|
178
190
|
return { values: valuesOf(base, baseEditing, read.overrides), overrides: read.overrides, ...(read.warning ? { warning: read.warning } : {}), ...at };
|