opencode-clm 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -2
- package/README.md +17 -7
- package/index.ts +29 -10
- package/package.json +2 -2
- package/src/budget.ts +160 -16
- package/src/clm.ts +141 -56
- package/src/compaction.ts +14 -6
- package/src/context-document.ts +4 -1
- package/src/opencode.ts +10 -3
- package/src/overflow.ts +6 -3
- package/src/panel/keys.ts +11 -1
- package/src/panel/model.ts +3 -1
- package/src/panel/view.ts +29 -9
- package/src/presentation.ts +8 -2
- package/src/projection.ts +157 -14
- package/src/settings-table.ts +83 -17
- package/src/settings.ts +70 -3
- package/src/state.ts +50 -1
- package/src/tui/data.ts +8 -2
- package/src/tui/panel.ts +3 -2
- package/src/tui/plugin.ts +3 -2
- package/src/types.ts +2 -0
- /package/{skills → .agents/skills}/clm-context/SKILL.md +0 -0
package/src/panel/view.ts
CHANGED
|
@@ -52,6 +52,7 @@ const EDIT_TONE: Tone = "edit";
|
|
|
52
52
|
const KEPT_PREVIEW_CHARACTERS = 2_000;
|
|
53
53
|
const MIRROR_TEXT_MAX_LINES = 200;
|
|
54
54
|
const MIRROR_UNAVAILABLE_TEXT = "No mirror: requests carry the raw history plus the continuity annotations; edits are off.";
|
|
55
|
+
const NOTICES_ONLY_TEXT = "Mode notices-only: requests carry the raw history plus the budget notices; no mirror, so the model cannot edit its context (/clm config mode edit).";
|
|
55
56
|
|
|
56
57
|
/** Body rows available for the page. */
|
|
57
58
|
export function viewportHeight(size: PanelSize): number {
|
|
@@ -194,6 +195,8 @@ function overviewLines(model: PanelModel, state: PanelState, width: number, view
|
|
|
194
195
|
? text("Projection off: the model sees the raw history (/clm on).", "warning")
|
|
195
196
|
: model.mirrorUnavailable !== undefined
|
|
196
197
|
? text(MIRROR_UNAVAILABLE_TEXT, "warning")
|
|
198
|
+
: model.noticesOnly
|
|
199
|
+
? text(NOTICES_ONLY_TEXT, "warning")
|
|
197
200
|
: model.lastOutcome?.kind === "rejected"
|
|
198
201
|
? text(`Last edit rejected: ${model.lastOutcome.message}`, "warning")
|
|
199
202
|
: undefined;
|
|
@@ -478,12 +481,26 @@ export function settingsPage(model: PanelModel, state: PanelState, width: number
|
|
|
478
481
|
const row = settings.rows[selected]!;
|
|
479
482
|
if (row.description) lines.push([], ...wrapAll([text(row.description, "dim")], width));
|
|
480
483
|
lines.push([], ...wrapAll([text(row.choices?.length ? "Enter: next value" : "Enter: type a value", "dim")], width));
|
|
481
|
-
if (state.message) {
|
|
482
|
-
lines.push(...wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width));
|
|
483
|
-
}
|
|
484
484
|
return { lines, focus };
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Lines pinned below the scrolling page: the settings page's result message ("✓ Budget:
|
|
489
|
+
* 20k"), so it stays on screen when the page is longer than the viewport. At most
|
|
490
|
+
* `viewport` − 1 lines, so one page line always shows.
|
|
491
|
+
*/
|
|
492
|
+
export function pinnedLines(state: PanelState, width: number, viewport: number): Line[] {
|
|
493
|
+
if (state.page !== "settings" || !state.message) return [];
|
|
494
|
+
const message = wrapAll([text(`${state.message.warning ? "⚠" : "✓"} ${state.message.text}`, state.message.warning ? "warning" : "success")], width);
|
|
495
|
+
return message.slice(0, Math.max(0, viewport - 1));
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Body rows left for the scrolling page once the pinned lines take theirs. */
|
|
499
|
+
export function pageViewport(state: PanelState, size: PanelSize): number {
|
|
500
|
+
const viewport = viewportHeight(size);
|
|
501
|
+
return Math.max(1, viewport - pinnedLines(state, contentWidth(size), viewport).length);
|
|
502
|
+
}
|
|
503
|
+
|
|
487
504
|
// ---- frame ---------------------------------------------------------------------------
|
|
488
505
|
|
|
489
506
|
/** The whole page at `width` (unclipped; the frame scrolls it). */
|
|
@@ -514,7 +531,7 @@ export function helpText(model: PanelModel, state: PanelState, width: number): s
|
|
|
514
531
|
}
|
|
515
532
|
|
|
516
533
|
export function maxScroll(model: PanelModel, state: PanelState, size: PanelSize): number {
|
|
517
|
-
return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length -
|
|
534
|
+
return Math.max(0, pageLines(model, state, contentWidth(size), viewportHeight(size)).length - pageViewport(state, size));
|
|
518
535
|
}
|
|
519
536
|
|
|
520
537
|
/** The framed panel, exactly `size.height` lines of `size.width` cells. */
|
|
@@ -522,15 +539,18 @@ export function renderPanel(model: PanelModel, state: PanelState, size: PanelSiz
|
|
|
522
539
|
const width = Math.max(8, size.width);
|
|
523
540
|
const inner = width - 2;
|
|
524
541
|
const content = contentWidth({ width, height: size.height });
|
|
525
|
-
const
|
|
526
|
-
const page = pageLines(model, state, content,
|
|
542
|
+
const body = viewportHeight(size);
|
|
543
|
+
const page = pageLines(model, state, content, body);
|
|
544
|
+
const pinned = pinnedLines(state, content, body);
|
|
545
|
+
const viewport = pageViewport(state, size);
|
|
527
546
|
const scroll = Math.max(0, Math.min(state.scroll, page.length - viewport));
|
|
528
|
-
|
|
529
|
-
|
|
547
|
+
// The pinned lines follow the page directly when it fits, else sit at the bottom.
|
|
548
|
+
const visible = [...page.slice(scroll, scroll + viewport), ...pinned];
|
|
549
|
+
while (visible.length < body) visible.push([]);
|
|
530
550
|
|
|
531
551
|
const border = (value: string) => span(value, "border");
|
|
532
552
|
const row = (line: Line): Line => normalize([border("│"), span(" "), ...fit(line, inner - 1), border("│")]);
|
|
533
|
-
const title = ` Live Context Viewer · r${model.revision} `;
|
|
553
|
+
const title = ` Live Context Viewer · r${model.revision}${model.noticesOnly && model.enabled ? " · notices-only" : ""} `;
|
|
534
554
|
const titleText = truncate([span(title)], inner, "").map((part) => part.text).join("");
|
|
535
555
|
const left = Math.max(0, Math.floor((inner - titleText.length) / 2));
|
|
536
556
|
const right = Math.max(0, inner - titleText.length - left);
|
package/src/presentation.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface ClmStatus {
|
|
|
98
98
|
changed?: string;
|
|
99
99
|
/** Saved settings that could not be used. */
|
|
100
100
|
settingsWarning?: string;
|
|
101
|
+
/** Set in `mode notices-only`; absent in `edit`. */
|
|
102
|
+
mode?: "notices-only";
|
|
101
103
|
}
|
|
102
104
|
|
|
103
105
|
/** One line for a toast. */
|
|
@@ -105,7 +107,8 @@ export function statusLine(status: ClmStatus): string {
|
|
|
105
107
|
const size = status.reading
|
|
106
108
|
? ` · ${formatTokens(status.reading.estimated)} of ${formatTokens(status.reading.budget)} tok`
|
|
107
109
|
: "";
|
|
108
|
-
|
|
110
|
+
const mode = status.mode ? ` · mode ${status.mode}` : "";
|
|
111
|
+
return `CLM r${status.revision}${mode} · ${status.accepted} accepted / ${status.rejected} rejected${size}`;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
/** Multi-line status for `/clm`. */
|
|
@@ -113,6 +116,7 @@ export function statusText(status: ClmStatus): string {
|
|
|
113
116
|
const lines = [
|
|
114
117
|
`CLM status for session ${status.sessionID}`,
|
|
115
118
|
`mirror: ${status.mirrorPath}`,
|
|
119
|
+
...(status.mode ? ["mode notices-only: budget notices and the overflow guard run; no mirror and no protocol prompt, so the model cannot edit its context"] : []),
|
|
116
120
|
`revision ${status.revision} · edits accepted ${status.accepted} · rejected ${status.rejected} · gate ${status.gate} · guard ${status.guard}`,
|
|
117
121
|
status.reading
|
|
118
122
|
? `${budgetSummaryLine(status.reading)} · model window ${status.modelWindow ? formatTokens(status.modelWindow) : "unknown"}`
|
|
@@ -121,7 +125,9 @@ export function statusText(status: ClmStatus): string {
|
|
|
121
125
|
if (status.fit) lines.push(budgetFitLine(status.fit));
|
|
122
126
|
const checkpoint = status.checkpoint;
|
|
123
127
|
lines.push(
|
|
124
|
-
checkpoint
|
|
128
|
+
checkpoint && status.mode
|
|
129
|
+
? `revision ${checkpoint.revision} kept but not applied in notices-only mode: the model sees the raw history`
|
|
130
|
+
: checkpoint
|
|
125
131
|
? `active revision ${checkpoint.revision}: covers ${checkpoint.anchorCount} raw messages; ~${formatTokens(checkpoint.beforeEstimate)}→${formatTokens(checkpoint.afterEstimate)} tokens when accepted`
|
|
126
132
|
: "no accepted edit: the model sees the raw history",
|
|
127
133
|
);
|
package/src/projection.ts
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
// whole messages (ids change) or truncates the parts of a kept message (its toolCall and
|
|
14
14
|
// toolResult disappear, so the digest changes); both still fail closed.
|
|
15
15
|
//
|
|
16
|
+
// One mismatch source is OpenCode's own late writes: it keeps updating message objects
|
|
17
|
+
// after `experimental.chat.messages.transform` returns (an assistant message being
|
|
18
|
+
// finalized, turn diffs attached to a user message). A checkpoint taken mid-turn digests
|
|
19
|
+
// those still-mutable tail messages, and the late write flips the digest. `sourceStamps`
|
|
20
|
+
// (per-message digest + captured `time.updated`) lets `applyProjection` attribute such a
|
|
21
|
+
// mismatch to a post-capture OpenCode write and re-anchor the checkpoint to the stable
|
|
22
|
+
// head instead of dropping the revision (see `reanchorTail`); anything not attributable
|
|
23
|
+
// stays fail-closed.
|
|
24
|
+
//
|
|
16
25
|
// pi-clm's `recoverProjectionFromRetryErrors` is not ported. Pi writes failed assistant
|
|
17
26
|
// responses to its session log but drops them from live state, so a resumed log hashes
|
|
18
27
|
// differently. OpenCode keeps the error on the assistant message (`info.error`), and the
|
|
@@ -29,6 +38,19 @@ export const PROJECTION_SHORTER_REASON = "Raw context is shorter than the projec
|
|
|
29
38
|
|
|
30
39
|
export type EstimateUnit = "characters" | "tokens";
|
|
31
40
|
|
|
41
|
+
/** One source message's content digest plus the write time captured with it. */
|
|
42
|
+
export interface SourceStamp {
|
|
43
|
+
digest: string;
|
|
44
|
+
updatedAt: number;
|
|
45
|
+
/**
|
|
46
|
+
* Digest of the same message with OpenCode's `<system-reminder>` blocks removed
|
|
47
|
+
* (whitespace-normalized). OpenCode appends and removes mode-change reminders on user
|
|
48
|
+
* messages between requests without moving any write-time field; a change confined to
|
|
49
|
+
* those blocks is attributable against this fingerprint.
|
|
50
|
+
*/
|
|
51
|
+
strippedDigest?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
32
54
|
export interface ProjectionCheckpoint {
|
|
33
55
|
version: 1;
|
|
34
56
|
revision: number;
|
|
@@ -38,6 +60,12 @@ export interface ProjectionCheckpoint {
|
|
|
38
60
|
sourceIds: string[];
|
|
39
61
|
/** `digestSourcePrefix` of those messages. */
|
|
40
62
|
sourceDigest: string;
|
|
63
|
+
/**
|
|
64
|
+
* Per-message digest and captured write time (length is sourceMessageCount). Lets a
|
|
65
|
+
* mismatch be attributed to specific messages and a late OpenCode write of the tail be
|
|
66
|
+
* re-anchored instead of dropped; absent on checkpoints written by older versions.
|
|
67
|
+
*/
|
|
68
|
+
sourceStamps?: SourceStamp[];
|
|
41
69
|
projectedMessages: LiveContextMessage[];
|
|
42
70
|
beforeEstimate: number;
|
|
43
71
|
afterEstimate: number;
|
|
@@ -47,11 +75,21 @@ export interface ProjectionCheckpoint {
|
|
|
47
75
|
editTrace?: ContextEditTrace;
|
|
48
76
|
}
|
|
49
77
|
|
|
78
|
+
/** The trimmed anchor returned when a late tail rewrite was re-anchored instead of dropped. */
|
|
79
|
+
export interface ProjectionReanchor {
|
|
80
|
+
sourceMessageCount: number;
|
|
81
|
+
sourceIds: string[];
|
|
82
|
+
sourceStamps: SourceStamp[];
|
|
83
|
+
sourceDigest: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
50
86
|
export type ProjectionApplication =
|
|
51
87
|
| {
|
|
52
88
|
valid: true;
|
|
53
89
|
messages: LiveContextMessage[];
|
|
54
90
|
suffix: LiveContextMessage[];
|
|
91
|
+
/** Set when the anchor was trimmed to the stable head (see `reanchorTail`). */
|
|
92
|
+
reanchor?: ProjectionReanchor;
|
|
55
93
|
}
|
|
56
94
|
| {
|
|
57
95
|
valid: false;
|
|
@@ -64,6 +102,60 @@ export function sourceMessageId(message: LiveContextMessage): string {
|
|
|
64
102
|
return typeof message.ocMessageID === "string" ? message.ocMessageID : "";
|
|
65
103
|
}
|
|
66
104
|
|
|
105
|
+
/** The prune-invariant form of one source message (see `digestSourcePrefix`). */
|
|
106
|
+
function sourceDigestForm(message: LiveContextMessage): LiveContextMessage {
|
|
107
|
+
if (message.role !== "toolResult") return message;
|
|
108
|
+
return {
|
|
109
|
+
role: message.role,
|
|
110
|
+
toolCallId: message.toolCallId,
|
|
111
|
+
toolName: message.toolName,
|
|
112
|
+
isError: message.isError,
|
|
113
|
+
ocMessageID: message.ocMessageID,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const SYSTEM_REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g;
|
|
118
|
+
|
|
119
|
+
/** Whitespace-normalized text without OpenCode's reminder blocks, for the stripped fingerprint. */
|
|
120
|
+
function normalizeReminderFingerprint(text: string): string {
|
|
121
|
+
return text
|
|
122
|
+
.replace(/\r\n/g, "\n")
|
|
123
|
+
.replace(SYSTEM_REMINDER_RE, "")
|
|
124
|
+
.replace(/\n{2,}/g, "\n\n")
|
|
125
|
+
.trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The message with OpenCode's `<system-reminder>` blocks removed from its text parts; parts
|
|
130
|
+
* that become empty are dropped. OpenCode appends and removes mode-change reminders on user
|
|
131
|
+
* messages between requests without touching any write-time field, so a digest over this
|
|
132
|
+
* form tells a reminder-only edit apart from a real content change.
|
|
133
|
+
*/
|
|
134
|
+
export function stripSystemReminders(message: LiveContextMessage): LiveContextMessage {
|
|
135
|
+
const content = message.content;
|
|
136
|
+
if (!Array.isArray(content)) return message;
|
|
137
|
+
const stripped: unknown[] = [];
|
|
138
|
+
for (const part of content) {
|
|
139
|
+
if (!part || typeof part !== "object") {
|
|
140
|
+
stripped.push(part);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const block = part as Record<string, unknown>;
|
|
144
|
+
if (block.type !== "text" || typeof block.text !== "string") {
|
|
145
|
+
stripped.push(block);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const text = normalizeReminderFingerprint(block.text);
|
|
149
|
+
if (text !== "") stripped.push({ ...block, text });
|
|
150
|
+
}
|
|
151
|
+
return { ...message, content: stripped };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `digestSourceMessage` of the message without OpenCode's system-reminder blocks. */
|
|
155
|
+
export function digestSourceMessageWithoutReminders(message: LiveContextMessage): string {
|
|
156
|
+
return digestMessages([sourceDigestForm(stripSystemReminders(message))]);
|
|
157
|
+
}
|
|
158
|
+
|
|
67
159
|
/**
|
|
68
160
|
* Digest a raw source prefix in a form OpenCode's prune cannot change: every `toolResult`
|
|
69
161
|
* keeps only role, toolCallId, toolName, isError and ocMessageID. Content is dropped for
|
|
@@ -71,18 +163,22 @@ export function sourceMessageId(message: LiveContextMessage): string {
|
|
|
71
163
|
* created and may be cleared when it is validated.
|
|
72
164
|
*/
|
|
73
165
|
export function digestSourcePrefix(messages: readonly LiveContextMessage[]): string {
|
|
74
|
-
return digestMessages(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
166
|
+
return digestMessages(messages.map(sourceDigestForm));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** `digestSourcePrefix` of one message: per-message attribution for tail re-anchoring. */
|
|
170
|
+
export function digestSourceMessage(message: LiveContextMessage): string {
|
|
171
|
+
return digestMessages([sourceDigestForm(message)]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A message's stamp: its content digest, the write time OpenCode last wrote, and the
|
|
175
|
+
* reminder-stripped fingerprint. */
|
|
176
|
+
export function sourceStamp(message: LiveContextMessage): SourceStamp {
|
|
177
|
+
return {
|
|
178
|
+
digest: digestSourceMessage(message),
|
|
179
|
+
updatedAt: typeof message.updatedAt === "number" ? message.updatedAt : 0,
|
|
180
|
+
strippedDigest: digestSourceMessageWithoutReminders(message),
|
|
181
|
+
};
|
|
86
182
|
}
|
|
87
183
|
|
|
88
184
|
/**
|
|
@@ -109,6 +205,7 @@ export function createProjectionCheckpoint(options: {
|
|
|
109
205
|
sourceMessageCount: options.sourceMessages.length,
|
|
110
206
|
sourceIds: options.sourceMessages.map(sourceMessageId),
|
|
111
207
|
sourceDigest: digestSourcePrefix(options.sourceMessages),
|
|
208
|
+
sourceStamps: options.sourceMessages.map(sourceStamp),
|
|
112
209
|
projectedMessages: [...options.projectedMessages],
|
|
113
210
|
beforeEstimate: options.beforeEstimate,
|
|
114
211
|
afterEstimate: options.afterEstimate,
|
|
@@ -124,7 +221,11 @@ export function createProjectionCheckpoint(options: {
|
|
|
124
221
|
|
|
125
222
|
/**
|
|
126
223
|
* Replace the checkpoint's raw source prefix and keep every raw message appended after
|
|
127
|
-
* it. A mismatch fails closed and returns a copy of the untouched raw context
|
|
224
|
+
* it. A mismatch fails closed and returns a copy of the untouched raw context — unless the
|
|
225
|
+
* checkpoint has per-message stamps and every changed message was written by OpenCode after
|
|
226
|
+
* it was captured (the current turn's tail still being finalized): then the anchor is
|
|
227
|
+
* trimmed to the stable head, the trimmed tail flows as ordinary suffix, and the caller
|
|
228
|
+
* persists `reanchor` on the checkpoint.
|
|
128
229
|
*/
|
|
129
230
|
export function applyProjection(
|
|
130
231
|
rawMessages: readonly LiveContextMessage[],
|
|
@@ -147,8 +248,50 @@ export function applyProjection(
|
|
|
147
248
|
return { valid: false, messages: [...rawMessages], reason: PROJECTION_SOURCE_IDS_REASON };
|
|
148
249
|
}
|
|
149
250
|
if (digestSourcePrefix(prefix) !== checkpoint.sourceDigest) {
|
|
150
|
-
return
|
|
251
|
+
return reanchorTail(rawMessages, prefix, checkpoint) ??
|
|
252
|
+
{ valid: false, messages: [...rawMessages], reason: PROJECTION_PREFIX_MISMATCH_REASON };
|
|
151
253
|
}
|
|
152
254
|
const suffix = rawMessages.slice(checkpoint.sourceMessageCount);
|
|
153
255
|
return { valid: true, messages: [...checkpoint.projectedMessages, ...suffix], suffix };
|
|
154
256
|
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A mismatch OpenCode itself caused. Every changed message must be explained by an
|
|
260
|
+
* OpenCode write: either its write time (`time.completed`/`updated`, not `created`) moved
|
|
261
|
+
* past the stamp, or the change is confined to OpenCode's `<system-reminder>` mode-change
|
|
262
|
+
* blocks (verified against the reminder-stripped per-message digest). That is the tail of
|
|
263
|
+
* the current turn still being finalized or a mode switch — not a revert, a compaction or
|
|
264
|
+
* a foreign transform, which change ids or content in ways neither check explains and stay
|
|
265
|
+
* fail-closed. The anchor is trimmed to the last stable message; the trimmed tail becomes
|
|
266
|
+
* ordinary suffix, the same contract as pi-clm's rebasing for appended messages.
|
|
267
|
+
*/
|
|
268
|
+
function reanchorTail(
|
|
269
|
+
rawMessages: readonly LiveContextMessage[],
|
|
270
|
+
prefix: readonly LiveContextMessage[],
|
|
271
|
+
checkpoint: ProjectionCheckpoint,
|
|
272
|
+
): ProjectionApplication | undefined {
|
|
273
|
+
const stamps = checkpoint.sourceStamps;
|
|
274
|
+
if (!Array.isArray(stamps) || stamps.length !== prefix.length) return undefined;
|
|
275
|
+
const changed = prefix.map((message, index) => digestSourceMessage(message) !== stamps[index]!.digest);
|
|
276
|
+
const first = changed.indexOf(true);
|
|
277
|
+
if (first <= 0) return undefined;
|
|
278
|
+
const attributable = prefix.every((message, index) => {
|
|
279
|
+
if (!changed[index]) return true;
|
|
280
|
+
const stamp = stamps[index]!;
|
|
281
|
+
if (typeof message.updatedAt === "number" && message.updatedAt > stamp.updatedAt) return true;
|
|
282
|
+
// OpenCode appends and removes `<system-reminder>` blocks (mode changes) on user
|
|
283
|
+
// messages without moving any write time; a change confined to those blocks is still
|
|
284
|
+
// an OpenCode edit. Everything else fails closed.
|
|
285
|
+
return typeof stamp.strippedDigest === "string" && digestSourceMessageWithoutReminders(message) === stamp.strippedDigest;
|
|
286
|
+
});
|
|
287
|
+
if (!attributable) return undefined;
|
|
288
|
+
const stable = prefix.slice(0, first);
|
|
289
|
+
const reanchor: ProjectionReanchor = {
|
|
290
|
+
sourceMessageCount: stable.length,
|
|
291
|
+
sourceIds: stable.map(sourceMessageId),
|
|
292
|
+
sourceStamps: stable.map(sourceStamp),
|
|
293
|
+
sourceDigest: digestSourcePrefix(stable),
|
|
294
|
+
};
|
|
295
|
+
const suffix = rawMessages.slice(stable.length);
|
|
296
|
+
return { valid: true, messages: [...checkpoint.projectedMessages, ...suffix], suffix, reanchor };
|
|
297
|
+
}
|
package/src/settings-table.ts
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { basename, resolve } from "node:path";
|
|
12
12
|
|
|
13
|
-
import { resolveBudgetPolicy } from "./budget.ts";
|
|
13
|
+
import { FALLBACK_BUDGET, formatPercent, resolveBudget, resolveBudgetPolicy, type BudgetPolicyConfig } from "./budget.ts";
|
|
14
14
|
import { MIN_OBSERVATION_CAP, resolveObservationCap } from "./observation.ts";
|
|
15
15
|
import { toEditGate, type EditGate } from "./policy.ts";
|
|
16
|
-
import { HOUSE_STEERING, parseCompaction, parseFlag, parseFractions, parseTokens, type ClmSettings, type CompactionMode } from "./settings.ts";
|
|
16
|
+
import { HOUSE_STEERING, parseBudget, parseCompaction, parseCooldown, parseFlag, parseFractions, parseMode, parseTokens, type ClmMode, type ClmSettings, type CompactionMode } from "./settings.ts";
|
|
17
17
|
|
|
18
18
|
export type SettingKey =
|
|
19
|
-
| "editing" | "budget" | "reserve" | "reminders" | "gate" | "guard" | "compaction" | "cap" | "steering" | "oneTool" | "trailer"
|
|
19
|
+
| "editing" | "budget" | "reserve" | "reminders" | "cooldown" | "gate" | "guard" | "compaction" | "cap" | "steering" | "mode" | "oneTool" | "trailer"
|
|
20
20
|
| "compactPrompt" | "reasoning";
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -26,10 +26,13 @@ export type SettingKey =
|
|
|
26
26
|
*/
|
|
27
27
|
export interface ClmOverrides {
|
|
28
28
|
editing?: boolean;
|
|
29
|
-
|
|
29
|
+
/** Tokens, a share of the model window as text (`"50%"`), or null for the whole window. */
|
|
30
|
+
budget?: number | BudgetPercent | null;
|
|
30
31
|
reserve?: number;
|
|
31
32
|
/** Reminder fractions; empty disables every reminder, the budget − reserve one included. */
|
|
32
33
|
reminders?: number[];
|
|
34
|
+
/** Reminder cooldown as a share of the budget; 0 is off. */
|
|
35
|
+
cooldown?: number;
|
|
33
36
|
gate?: EditGate;
|
|
34
37
|
guard?: "withhold" | "off";
|
|
35
38
|
compaction?: CompactionMode;
|
|
@@ -42,8 +45,12 @@ export interface ClmOverrides {
|
|
|
42
45
|
reasoning?: boolean;
|
|
43
46
|
oneTool?: boolean;
|
|
44
47
|
trailer?: boolean;
|
|
48
|
+
mode?: ClmMode;
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
/** A budget percentage as stored in overrides.json: `"50%"`, `"12.5%"`. */
|
|
52
|
+
export type BudgetPercent = `${number}%`;
|
|
53
|
+
|
|
47
54
|
/** Settings in force for one session: the resolved settings plus whether edits apply. */
|
|
48
55
|
export interface SettingsValues {
|
|
49
56
|
editing: boolean;
|
|
@@ -53,6 +60,8 @@ export interface SettingsValues {
|
|
|
53
60
|
export interface FormatContext {
|
|
54
61
|
/** Model context window, shown next to a `window` budget. */
|
|
55
62
|
modelWindow?: number;
|
|
63
|
+
/** Model output limit; a percentage budget is a share of the window minus this. */
|
|
64
|
+
modelOutput?: number;
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
export interface ParseContext {
|
|
@@ -82,6 +91,24 @@ export function compactTokens(value: number): string {
|
|
|
82
91
|
return value.toLocaleString("en-US");
|
|
83
92
|
}
|
|
84
93
|
|
|
94
|
+
const percentText = (fraction: number) => formatPercent(fraction) as BudgetPercent;
|
|
95
|
+
|
|
96
|
+
/** `"50%"` → 0.5; undefined for anything but a percentage from 1% to 100%. */
|
|
97
|
+
export function budgetPercentFraction(value: unknown): number | undefined {
|
|
98
|
+
if (typeof value !== "string" || !/^\d+(?:\.\d+)?%$/.test(value)) return undefined;
|
|
99
|
+
const percent = Number(value.slice(0, -1));
|
|
100
|
+
return percent >= 1 && percent <= 100 ? percent / 100 : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The budget fields of a policy for a `budget` override. */
|
|
104
|
+
function budgetFields(budget: number | BudgetPercent | null): Pick<BudgetPolicyConfig, "contextBudget" | "contextFraction"> {
|
|
105
|
+
if (budget === null) return { contextBudget: undefined, contextFraction: undefined };
|
|
106
|
+
if (typeof budget === "number") return { contextBudget: budget, contextFraction: undefined };
|
|
107
|
+
const fraction = budgetPercentFraction(budget);
|
|
108
|
+
if (fraction === undefined) throw new Error(`budget must be a percentage from 1% to 100%, got ${JSON.stringify(budget)}`);
|
|
109
|
+
return { contextBudget: undefined, contextFraction: fraction };
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
const onOff = (value: boolean) => (value ? "on" : "off");
|
|
86
113
|
|
|
87
114
|
function formatReminders(fractions: readonly number[]): string {
|
|
@@ -138,19 +165,25 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
|
|
|
138
165
|
key: "budget",
|
|
139
166
|
name: "budget",
|
|
140
167
|
label: "Budget",
|
|
141
|
-
description:
|
|
142
|
-
placeholder: "32k, 1.5m, or window",
|
|
168
|
+
description: `Token budget the reminders, the edit gate and the overflow guard measure against, OpenCode's system prompt and tool schemas included. A percentage of the model window minus its output limit (50%; ${compactTokens(FALLBACK_BUDGET)} while the window is unknown), a number (32k), or "window" for all of it. Env: CLM_BUDGET.`,
|
|
169
|
+
placeholder: "50%, 32k, 1.5m, or window",
|
|
143
170
|
format: (values, context) => {
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
171
|
+
const policy = values.settings.budget;
|
|
172
|
+
if (policy.contextBudget !== undefined) return compactTokens(policy.contextBudget);
|
|
173
|
+
if (policy.contextFraction !== undefined) {
|
|
174
|
+
const resolved = context?.modelWindow ? resolveBudget(policy, context.modelWindow, context.modelOutput) : undefined;
|
|
175
|
+
return `${formatPercent(policy.contextFraction)}${resolved ? ` (${compactTokens(resolved.budget)})` : ""}`;
|
|
176
|
+
}
|
|
146
177
|
return `window${context?.modelWindow ? ` (${compactTokens(context.modelWindow)})` : ""}`;
|
|
147
178
|
},
|
|
179
|
+
// The settings page pre-fills its prompt with the formatted value: `50% (131,072)`.
|
|
148
180
|
parse: (text) => {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
181
|
+
const parsed = parseBudget(text.replace(/\s*\([^)]*\)\s*$/, ""), "budget");
|
|
182
|
+
switch (parsed.kind) {
|
|
183
|
+
case "window": return { budget: null };
|
|
184
|
+
case "fraction": return { budget: percentText(parsed.fraction) };
|
|
185
|
+
case "tokens": return { budget: parsed.tokens };
|
|
186
|
+
}
|
|
154
187
|
},
|
|
155
188
|
},
|
|
156
189
|
{
|
|
@@ -171,6 +204,17 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
|
|
|
171
204
|
format: (values) => formatReminders(values.settings.budget.remindAtFractions),
|
|
172
205
|
parse: (text) => ({ reminders: parseReminders(text) }),
|
|
173
206
|
},
|
|
207
|
+
{
|
|
208
|
+
key: "cooldown",
|
|
209
|
+
// Named after the reminders it governs; `cooldown` is an alias.
|
|
210
|
+
name: "reminder-cooldown",
|
|
211
|
+
label: "Reminder cooldown",
|
|
212
|
+
description: "After an accepted edit, percentage reminders stay silent until the context grows by this share of the budget; tiers crossed meanwhile are skipped. The budget − reserve reminder always fires. A share (10%, 0.1) or off. Env: CLM_REMINDER_COOLDOWN.",
|
|
213
|
+
choices: ["10%", "25%", "5%", "off"],
|
|
214
|
+
placeholder: "10%, 0.1, or off",
|
|
215
|
+
format: (values) => (values.settings.budget.reminderCooldown > 0 ? formatPercent(values.settings.budget.reminderCooldown) : "off"),
|
|
216
|
+
parse: (text) => ({ cooldown: parseCooldown(text, "reminder-cooldown") }),
|
|
217
|
+
},
|
|
174
218
|
{
|
|
175
219
|
key: "gate",
|
|
176
220
|
name: "gate",
|
|
@@ -223,6 +267,15 @@ export const SETTINGS_TABLE: readonly SettingDescriptor[] = [
|
|
|
223
267
|
return { steering: parsePathSetting(text, context.directory, ["none", "off", ""]) };
|
|
224
268
|
},
|
|
225
269
|
},
|
|
270
|
+
{
|
|
271
|
+
key: "mode",
|
|
272
|
+
name: "mode",
|
|
273
|
+
label: "Mode",
|
|
274
|
+
description: "edit: the model edits its context through the mirror. notices-only: no mirror, no protocol prompt and no edits; budget notices and reminders, the overflow guard, the observation cap and the size trailer still apply, to compare telling the model its budget with letting it edit. An accepted revision is kept but not applied while notices-only is in force. Env: CLM_MODE.",
|
|
275
|
+
choices: ["edit", "notices-only"],
|
|
276
|
+
format: (values) => values.settings.mode,
|
|
277
|
+
parse: (text) => ({ mode: parseMode(text) }),
|
|
278
|
+
},
|
|
226
279
|
{
|
|
227
280
|
key: "oneTool",
|
|
228
281
|
name: "one-tool",
|
|
@@ -270,6 +323,9 @@ const ALIASES: Record<string, SettingKey> = {
|
|
|
270
323
|
remind: "reminders",
|
|
271
324
|
"remind-at": "reminders",
|
|
272
325
|
remindat: "reminders",
|
|
326
|
+
cooldown: "cooldown",
|
|
327
|
+
"reminder-cooldown": "cooldown",
|
|
328
|
+
remindercooldown: "cooldown",
|
|
273
329
|
gate: "gate",
|
|
274
330
|
"edit-gate": "gate",
|
|
275
331
|
guard: "guard",
|
|
@@ -290,6 +346,7 @@ const ALIASES: Record<string, SettingKey> = {
|
|
|
290
346
|
"compact-prompt": "compactPrompt",
|
|
291
347
|
compactprompt: "compactPrompt",
|
|
292
348
|
reasoning: "reasoning",
|
|
349
|
+
mode: "mode",
|
|
293
350
|
};
|
|
294
351
|
|
|
295
352
|
/** Names `/clm config` lists. */
|
|
@@ -313,10 +370,13 @@ export function unknownSettingText(name: string): string {
|
|
|
313
370
|
export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmSettings {
|
|
314
371
|
const budget = resolveBudgetPolicy({
|
|
315
372
|
...base.budget,
|
|
316
|
-
|
|
373
|
+
...(overrides.budget !== undefined
|
|
374
|
+
? budgetFields(overrides.budget)
|
|
375
|
+
: { contextBudget: base.budget.contextBudget, contextFraction: base.budget.contextFraction }),
|
|
317
376
|
reserve: overrides.reserve ?? base.budget.reserve,
|
|
318
377
|
remindAtFractions: overrides.reminders ?? base.budget.remindAtFractions,
|
|
319
378
|
remindAtReserve: overrides.reminders !== undefined ? overrides.reminders.length > 0 : base.budget.remindAtReserve,
|
|
379
|
+
reminderCooldown: overrides.cooldown ?? base.budget.reminderCooldown,
|
|
320
380
|
});
|
|
321
381
|
const observationCap = resolveObservationCap({
|
|
322
382
|
maxCharacters: overrides.cap === null ? undefined : overrides.cap ?? base.observationCap.maxCharacters,
|
|
@@ -330,6 +390,7 @@ export function applyOverrides(base: ClmSettings, overrides: ClmOverrides): ClmS
|
|
|
330
390
|
compaction: overrides.compaction ?? base.compaction,
|
|
331
391
|
oneTool: overrides.oneTool ?? base.oneTool,
|
|
332
392
|
trailer: overrides.trailer ?? base.trailer,
|
|
393
|
+
mode: overrides.mode ?? base.mode,
|
|
333
394
|
observationCap,
|
|
334
395
|
steeringPath: overrides.steering === null ? undefined : overrides.steering ?? base.steeringPath,
|
|
335
396
|
compactPromptPath: overrides.compactPrompt === null ? undefined : overrides.compactPrompt ?? base.compactPromptPath,
|
|
@@ -342,9 +403,11 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
|
|
|
342
403
|
const settings = values.settings;
|
|
343
404
|
switch (key) {
|
|
344
405
|
case "editing": return values.editing;
|
|
345
|
-
case "budget": return settings.budget.contextBudget ??
|
|
406
|
+
case "budget": return settings.budget.contextBudget ??
|
|
407
|
+
(settings.budget.contextFraction !== undefined ? percentText(settings.budget.contextFraction) : null);
|
|
346
408
|
case "reserve": return settings.budget.reserve;
|
|
347
409
|
case "reminders": return settings.budget.remindAtReserve || settings.budget.remindAtFractions.length > 0 ? [...settings.budget.remindAtFractions] : [];
|
|
410
|
+
case "cooldown": return settings.budget.reminderCooldown;
|
|
348
411
|
case "gate": return settings.gate;
|
|
349
412
|
case "guard": return settings.guard;
|
|
350
413
|
case "compaction": return settings.compaction;
|
|
@@ -355,6 +418,7 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
|
|
|
355
418
|
case "steering": return settings.steeringPath ?? null;
|
|
356
419
|
case "compactPrompt": return settings.compactPromptPath ?? null;
|
|
357
420
|
case "reasoning": return settings.reasoning;
|
|
421
|
+
case "mode": return settings.mode;
|
|
358
422
|
}
|
|
359
423
|
}
|
|
360
424
|
|
|
@@ -366,7 +430,7 @@ function baseValue(key: keyof ClmOverrides, values: SettingsValues): unknown {
|
|
|
366
430
|
*/
|
|
367
431
|
export function settingsAsOverrides(settings: ClmSettings): ClmOverrides {
|
|
368
432
|
const values: SettingsValues = { editing: true, settings };
|
|
369
|
-
const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "gate", "guard", "compaction", "cap", "capHead", "steering", "oneTool", "trailer", "compactPrompt", "reasoning"];
|
|
433
|
+
const keys: Array<keyof ClmOverrides> = ["budget", "reserve", "reminders", "cooldown", "gate", "guard", "compaction", "cap", "capHead", "steering", "mode", "oneTool", "trailer", "compactPrompt", "reasoning"];
|
|
370
434
|
return Object.fromEntries(keys.map((key) => [key, baseValue(key, values)])) as ClmOverrides;
|
|
371
435
|
}
|
|
372
436
|
|
|
@@ -385,9 +449,10 @@ const isPath = (value: unknown) => value === null || (typeof value === "string"
|
|
|
385
449
|
const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
|
|
386
450
|
editing: (value) => typeof value === "boolean",
|
|
387
451
|
// Integers: state.json's budget check stores them as counts.
|
|
388
|
-
budget: (value) => value === null || (Number.isInteger(value) && (value as number) > 0),
|
|
452
|
+
budget: (value) => value === null || (Number.isInteger(value) && (value as number) > 0) || budgetPercentFraction(value) !== undefined,
|
|
389
453
|
reserve: (value) => Number.isInteger(value) && (value as number) >= 0,
|
|
390
454
|
reminders: (value) => Array.isArray(value) && value.every((fraction) => isNumber(fraction) && fraction > 0 && fraction < 1),
|
|
455
|
+
cooldown: (value) => isNumber(value) && value >= 0 && value < 1,
|
|
391
456
|
gate: (value) => value === "fit" || value === "shrink" || value === "none",
|
|
392
457
|
guard: (value) => value === "withhold" || value === "off",
|
|
393
458
|
compaction: (value) => value === "auto" || value === "off" || value === "on",
|
|
@@ -398,6 +463,7 @@ const VALID: Record<keyof ClmOverrides, (value: unknown) => boolean> = {
|
|
|
398
463
|
steering: isPath,
|
|
399
464
|
compactPrompt: isPath,
|
|
400
465
|
reasoning: (value) => typeof value === "boolean",
|
|
466
|
+
mode: (value) => value === "edit" || value === "notices-only",
|
|
401
467
|
};
|
|
402
468
|
|
|
403
469
|
/**
|