opencode-clm 0.1.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 +26 -0
- package/LICENSE +35 -0
- package/NOTICE +41 -0
- package/README.md +294 -0
- package/index.ts +332 -0
- package/package.json +28 -0
- package/skills/clm-context/SKILL.md +166 -0
- package/src/budget.ts +304 -0
- package/src/clm.ts +725 -0
- package/src/compact.ts +73 -0
- package/src/context-document.ts +659 -0
- package/src/continuity.ts +722 -0
- package/src/mirror-guard.ts +95 -0
- package/src/mirror-store.ts +154 -0
- package/src/observation.ts +155 -0
- package/src/opencode.ts +339 -0
- package/src/overflow.ts +212 -0
- package/src/policy.ts +60 -0
- package/src/presentation.ts +127 -0
- package/src/projection.ts +146 -0
- package/src/settings.ts +189 -0
- package/src/state.ts +241 -0
- package/src/steering.ts +54 -0
- package/src/types.ts +102 -0
- package/steering/house-brief.md +43 -0
package/index.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-clm: Context Language Model mode for OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* Before every model request the plugin writes the model-visible conversation to a mirror
|
|
5
|
+
* file. The model may rewrite that file with its ordinary tools, and what it leaves there
|
|
6
|
+
* is what it is sent on the following request. OpenCode's stored history is never
|
|
7
|
+
* rewritten: the edit is applied in `experimental.chat.messages.transform`, which OpenCode
|
|
8
|
+
* calls before every LLM request.
|
|
9
|
+
*
|
|
10
|
+
* Port of pi-clm (https://github.com/lolipopshock/pi-clm, MIT, Copyright 2026 Emanuel
|
|
11
|
+
* Casco), whose src/index.ts registers the Pi equivalents of these hooks.
|
|
12
|
+
*
|
|
13
|
+
* Hook behaviour, checked against the opencode 1.18.34 bundle:
|
|
14
|
+
* - `experimental.chat.messages.transform` gets `{}` as input; the session id comes from
|
|
15
|
+
* `messages[0].info.sessionID`, and OpenCode keeps its own reference to the array, so the
|
|
16
|
+
* result is written into it in place. The main loop calls it before
|
|
17
|
+
* `experimental.chat.system.transform` for the same request, so system-prompt sizes and
|
|
18
|
+
* model limits reach the session one request late.
|
|
19
|
+
* - `experimental.session.compacting` runs right before the compaction's own
|
|
20
|
+
* `messages.transform` over a clone of the history's head slice; its `output.context`
|
|
21
|
+
* strings are appended to the compaction prompt.
|
|
22
|
+
* - A finished compaction is signalled by `experimental.compaction.autocontinue` (auto
|
|
23
|
+
* compaction, before the synthetic "continue" turn) and by the `session.compacted` bus
|
|
24
|
+
* event (every successful compaction). Either one marks the session for a rebase.
|
|
25
|
+
* - `experimental.chat.system.transform` also runs for OpenCode's helper agents (title,
|
|
26
|
+
* summary, compaction); those get no protocol text.
|
|
27
|
+
* - `tool.definition` carries no session id; it runs per tool each time OpenCode builds the
|
|
28
|
+
* tool set, before the request's `messages.transform`.
|
|
29
|
+
*/
|
|
30
|
+
import { tool, type Hooks, type Plugin, type PluginInput, type PluginOptions } from "@opencode-ai/plugin";
|
|
31
|
+
|
|
32
|
+
import { ClmSession } from "./src/clm.ts";
|
|
33
|
+
import { buildCompactPrompt, loadCompactPrompt } from "./src/compact.ts";
|
|
34
|
+
import { continuityTools } from "./src/continuity.ts";
|
|
35
|
+
import { replaceInPlace, type OcMessage } from "./src/opencode.ts";
|
|
36
|
+
import { statusText, systemGuidance } from "./src/presentation.ts";
|
|
37
|
+
import { resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
|
|
38
|
+
import { loadSteeringDocument, steeringPromptSection, type SteeringDocument } from "./src/steering.ts";
|
|
39
|
+
|
|
40
|
+
export const PLUGIN_ID = "opencode-clm";
|
|
41
|
+
export const STATUS_COMMAND = "clm";
|
|
42
|
+
export const COMPACT_COMMAND = "clm-compact";
|
|
43
|
+
|
|
44
|
+
/** Opening words of the system prompts of OpenCode 1.18 helper agents (title, summary, compaction). */
|
|
45
|
+
const HELPER_PROMPTS = [
|
|
46
|
+
"You are a title generator",
|
|
47
|
+
"Summarize what was done in this conversation",
|
|
48
|
+
"You are a context summarization agent",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const STATUS_USAGE = "Usage: /clm [status | path | on | off | reset]";
|
|
52
|
+
|
|
53
|
+
type ToastVariant = "info" | "success" | "warning" | "error";
|
|
54
|
+
|
|
55
|
+
function describe(error: unknown): string {
|
|
56
|
+
return error instanceof Error ? error.message : String(error);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Session ids name a directory (`<mirrorDir>/clm-<id>`). A separator or `..` would reach
|
|
61
|
+
* outside it, so such ids are refused before anything touches the file system.
|
|
62
|
+
*/
|
|
63
|
+
export function checkSessionID(sessionID: unknown): string {
|
|
64
|
+
if (typeof sessionID !== "string" || sessionID === "" || /[/\\]/.test(sessionID) || sessionID.includes("..")) {
|
|
65
|
+
throw new Error(`CLM refuses session id ${JSON.stringify(sessionID)}: it must not contain "/", "\\" or "..".`);
|
|
66
|
+
}
|
|
67
|
+
return sessionID;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isHelperPrompt(system: readonly string[]): boolean {
|
|
71
|
+
const first = system[0]?.trimStart() ?? "";
|
|
72
|
+
return HELPER_PROMPTS.some((prompt) => first.startsWith(prompt));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The text the model receives in place of the `/clm` template. */
|
|
76
|
+
function relay(text: string): string {
|
|
77
|
+
return `${text}\n\nShow the CLM text above to the user exactly as written. Do not edit the mirror or run tools.`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const server: Plugin = async (input: PluginInput, options?: PluginOptions): Promise<Hooks> => {
|
|
81
|
+
const settings: ClmSettings = resolveSettings(options ?? {}, process.env, input.directory);
|
|
82
|
+
if (!settings.enabled) return {};
|
|
83
|
+
// Read once at load: a missing or empty steering file fails the plugin with its path.
|
|
84
|
+
const steering: SteeringDocument | undefined = settings.steeringPath ? loadSteeringDocument(settings.steeringPath) : undefined;
|
|
85
|
+
|
|
86
|
+
const sessions = new Map<string, Promise<ClmSession>>();
|
|
87
|
+
/** Estimated tokens per tool schema, from `tool.definition` (no session id there). */
|
|
88
|
+
const toolSizes = new Map<string, number>();
|
|
89
|
+
|
|
90
|
+
const toast = (message: string, variant: ToastVariant = "info") => {
|
|
91
|
+
// The TUI shows it; `opencode run` and a bare server ignore it.
|
|
92
|
+
void Promise.resolve()
|
|
93
|
+
.then(() => input.client?.tui?.showToast?.({ body: { title: "CLM", message, variant } }))
|
|
94
|
+
.catch(() => undefined);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** Server log (`opencode` log file); best effort, never throws. */
|
|
98
|
+
const log = (level: "info" | "warn" | "error", message: string) => {
|
|
99
|
+
void Promise.resolve()
|
|
100
|
+
.then(() => input.client?.app?.log?.({ body: { service: PLUGIN_ID, level, message } }))
|
|
101
|
+
.catch(() => undefined);
|
|
102
|
+
};
|
|
103
|
+
/** Command names this plugin defined; a user's own `clm` / `clm-compact` stays theirs. */
|
|
104
|
+
const ownCommands = new Set<string>();
|
|
105
|
+
|
|
106
|
+
const session = (sessionID: string): Promise<ClmSession> => {
|
|
107
|
+
const id = checkSessionID(sessionID);
|
|
108
|
+
let existing = sessions.get(id);
|
|
109
|
+
if (!existing) {
|
|
110
|
+
existing = ClmSession.open(id, settings).then((clm) => {
|
|
111
|
+
if (clm.loadWarning) toast(`state reset for ${id}: ${clm.loadWarning}`, "warning");
|
|
112
|
+
return clm;
|
|
113
|
+
});
|
|
114
|
+
existing.catch(() => sessions.delete(id));
|
|
115
|
+
sessions.set(id, existing);
|
|
116
|
+
}
|
|
117
|
+
return existing;
|
|
118
|
+
};
|
|
119
|
+
/** An already open session, without opening one. */
|
|
120
|
+
const opened = async (sessionID: string): Promise<ClmSession | undefined> => {
|
|
121
|
+
try {
|
|
122
|
+
return await sessions.get(sessionID);
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const toolTokens = (): number | undefined => {
|
|
129
|
+
if (toolSizes.size === 0) return undefined;
|
|
130
|
+
let total = 0;
|
|
131
|
+
for (const size of toolSizes.values()) total += size;
|
|
132
|
+
return total;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
async function statusCommand(clm: ClmSession, argument: string): Promise<string> {
|
|
136
|
+
switch (argument) {
|
|
137
|
+
case "":
|
|
138
|
+
case "status": {
|
|
139
|
+
const text = statusText(clm.status(steering));
|
|
140
|
+
toast(text.split("\n").slice(0, 3).join("\n"));
|
|
141
|
+
return text;
|
|
142
|
+
}
|
|
143
|
+
case "path":
|
|
144
|
+
return `CLM mirror: ${clm.mirrorPath}`;
|
|
145
|
+
case "on":
|
|
146
|
+
case "off": {
|
|
147
|
+
await clm.setEnabled(argument === "on");
|
|
148
|
+
toast(`CLM ${argument} for this session`);
|
|
149
|
+
return argument === "on"
|
|
150
|
+
? "CLM is on for this session: the mirror is refreshed before the next request."
|
|
151
|
+
: "CLM is off for this session: requests carry the raw history and the mirror is no longer read.";
|
|
152
|
+
}
|
|
153
|
+
case "reset": {
|
|
154
|
+
await clm.resetProjection("/clm reset");
|
|
155
|
+
toast("CLM revision dropped");
|
|
156
|
+
return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
|
|
157
|
+
}
|
|
158
|
+
default:
|
|
159
|
+
return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(argument)}`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function compactCommand(clm: ClmSession, argument: string): string {
|
|
164
|
+
return buildCompactPrompt(loadCompactPrompt(settings.compactPromptPath), {
|
|
165
|
+
mirror: clm.mirrorPath,
|
|
166
|
+
current: clm.lastReading?.estimated ?? 0,
|
|
167
|
+
budget: clm.resolvedBudget()?.budget,
|
|
168
|
+
instructions: argument,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hooks: Hooks = {
|
|
173
|
+
async config(config) {
|
|
174
|
+
if (settings.commands) {
|
|
175
|
+
const commands = (config.command ??= {});
|
|
176
|
+
const ours = {
|
|
177
|
+
[STATUS_COMMAND]: {
|
|
178
|
+
description: "CLM: status of the editable context; /clm path | on | off | reset",
|
|
179
|
+
template: "Show the CLM status. $ARGUMENTS",
|
|
180
|
+
},
|
|
181
|
+
[COMPACT_COMMAND]: {
|
|
182
|
+
description: "CLM: ask the model to compact its context by editing the mirror",
|
|
183
|
+
template: "Compact your context. $ARGUMENTS",
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
for (const [name, definition] of Object.entries(ours)) {
|
|
187
|
+
if (commands[name] === undefined) {
|
|
188
|
+
commands[name] = definition;
|
|
189
|
+
ownCommands.add(name);
|
|
190
|
+
} else if (!ownCommands.has(name)) {
|
|
191
|
+
log("warn", `the config already defines /${name}; CLM leaves it alone and does not handle it`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (settings.skill) {
|
|
196
|
+
// `skills.paths` exists in the 1.18 config schema; the SDK's v1 Config type lacks it.
|
|
197
|
+
const skills = ((config as { skills?: { paths?: string[] } }).skills ??= {});
|
|
198
|
+
const paths = (skills.paths ??= []);
|
|
199
|
+
if (!paths.includes(SKILLS_DIR)) paths.push(SKILLS_DIR);
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async "experimental.chat.system.transform"(hookInput, output) {
|
|
204
|
+
if (!hookInput.sessionID) return;
|
|
205
|
+
const existing = await opened(hookInput.sessionID);
|
|
206
|
+
// The compaction request follows the compaction's messages.transform; a flag that
|
|
207
|
+
// transform did not consume (an empty head) must not leak into the next request.
|
|
208
|
+
if (existing) existing.compacting = false;
|
|
209
|
+
if (isHelperPrompt(output.system)) return;
|
|
210
|
+
let clm: ClmSession;
|
|
211
|
+
try {
|
|
212
|
+
clm = existing ?? (await session(hookInput.sessionID));
|
|
213
|
+
} catch {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!clm.state.enabled) return;
|
|
217
|
+
const limit = (hookInput.model as { limit?: { context?: number; output?: number } } | undefined)?.limit;
|
|
218
|
+
if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined };
|
|
219
|
+
const sections = [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
|
|
220
|
+
if (steering) sections.push(steeringPromptSection(steering));
|
|
221
|
+
output.system.push(sections.join("\n\n"));
|
|
222
|
+
clm.scope = { ...clm.scope, systemTokens: clm.textTokens(output.system.join("\n")) };
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
async "experimental.chat.messages.transform"(_hookInput, output) {
|
|
226
|
+
const raw = output.messages as unknown as OcMessage[];
|
|
227
|
+
const sessionID = raw[0]?.info?.sessionID;
|
|
228
|
+
if (!sessionID) return;
|
|
229
|
+
let clm: ClmSession;
|
|
230
|
+
try {
|
|
231
|
+
clm = await session(sessionID);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
toast(`not active for session ${sessionID}: ${describe(error)}`, "error");
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const tools = toolTokens();
|
|
237
|
+
if (tools !== undefined) clm.scope = { ...clm.scope, toolTokens: tools };
|
|
238
|
+
try {
|
|
239
|
+
const result = await clm.transform(raw);
|
|
240
|
+
replaceInPlace(raw, result.messages);
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Fail open: the request goes out with the raw history.
|
|
243
|
+
await clm.log({ event: "error", message: describe(error), stack: error instanceof Error ? error.stack : undefined });
|
|
244
|
+
toast(`transform failed: ${describe(error)}`, "error");
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async "experimental.session.compacting"(hookInput, output) {
|
|
249
|
+
const clm = await opened(hookInput.sessionID);
|
|
250
|
+
if (!clm) return;
|
|
251
|
+
clm.compacting = true;
|
|
252
|
+
try {
|
|
253
|
+
const context = await clm.compactionContext();
|
|
254
|
+
if (context) output.context.push(context);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
await clm.log({ event: "error", message: describe(error) });
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async "experimental.compaction.autocontinue"(hookInput) {
|
|
261
|
+
const clm = await opened(hookInput.sessionID);
|
|
262
|
+
if (clm) clm.compacted = true;
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async event({ event }) {
|
|
266
|
+
if (event.type !== "session.compacted") return;
|
|
267
|
+
const clm = await opened(event.properties.sessionID);
|
|
268
|
+
if (clm) clm.compacted = true;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async "tool.definition"(hookInput, output) {
|
|
272
|
+
// Runtime output also carries `jsonSchema` (set for plugin and MCP tools). OpenCode's
|
|
273
|
+
// built-in tools carry an Effect schema instead, which is not measured here.
|
|
274
|
+
const schema = (output as { jsonSchema?: unknown }).jsonSchema;
|
|
275
|
+
let size = output.description?.length ?? 0;
|
|
276
|
+
if (schema && typeof schema === "object") {
|
|
277
|
+
try {
|
|
278
|
+
size += JSON.stringify(schema).length;
|
|
279
|
+
} catch {
|
|
280
|
+
// unmeasurable schema: description only
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
toolSizes.set(hookInput.toolID, Math.ceil((size / 4) * settings.estimateFactor));
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async "tool.execute.after"(hookInput, output) {
|
|
287
|
+
const clm = await opened(hookInput.sessionID);
|
|
288
|
+
if (!clm || typeof output.output !== "string") return;
|
|
289
|
+
// Runs inside the tool's own call: a throw here would fail the model's command.
|
|
290
|
+
let receipt: string | undefined;
|
|
291
|
+
try {
|
|
292
|
+
receipt = clm.receipt(hookInput.tool, hookInput.args, input.directory);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
await clm.log({ event: "receipt-error", tool: hookInput.tool, message: describe(error) });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (!receipt) return;
|
|
298
|
+
output.output = output.output ? `${output.output}${output.output.endsWith("\n") ? "\n" : "\n\n"}${receipt}` : receipt;
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
async "command.execute.before"(hookInput, output) {
|
|
302
|
+
if (!settings.commands || !ownCommands.has(hookInput.command)) return;
|
|
303
|
+
// A rejected hook fails the command request: report errors as the command's text.
|
|
304
|
+
let text: string;
|
|
305
|
+
try {
|
|
306
|
+
const clm = await session(hookInput.sessionID);
|
|
307
|
+
const argument = (hookInput.arguments ?? "").trim();
|
|
308
|
+
text = hookInput.command === STATUS_COMMAND
|
|
309
|
+
? relay(await statusCommand(clm, argument.toLowerCase()))
|
|
310
|
+
: compactCommand(clm, argument);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
const message = `/${hookInput.command} failed: ${describe(error)}`;
|
|
313
|
+
toast(message, "error");
|
|
314
|
+
log("error", message);
|
|
315
|
+
text = relay(`[CLM] ${message}`);
|
|
316
|
+
}
|
|
317
|
+
const parts = output.parts as Array<{ type: string; text?: string }>;
|
|
318
|
+
const first = parts.find((part) => part.type === "text");
|
|
319
|
+
if (first) first.text = text;
|
|
320
|
+
else parts.unshift({ type: "text", text } as never);
|
|
321
|
+
},
|
|
322
|
+
|
|
323
|
+
tool: continuityTools({
|
|
324
|
+
schema: tool.schema,
|
|
325
|
+
store: async (sessionID) => (await session(sessionID)).annotations,
|
|
326
|
+
block: async (sessionID, blockId) => (await session(sessionID)).blockSource(blockId),
|
|
327
|
+
}),
|
|
328
|
+
};
|
|
329
|
+
return hooks;
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
export default { id: PLUGIN_ID, server };
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-clm",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin for Context Language Models: the model edits a file mirror of its own context, and the edited mirror becomes its next input.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "B Guest",
|
|
8
|
+
"homepage": "https://github.com/bcmyguest/opencode-clm#readme",
|
|
9
|
+
"bugs": { "url": "https://github.com/bcmyguest/opencode-clm/issues" },
|
|
10
|
+
"repository": { "type": "git", "url": "git+https://github.com/bcmyguest/opencode-clm.git" },
|
|
11
|
+
"keywords": ["opencode", "opencode-plugin", "context-management", "context-window", "context-language-models", "clm"],
|
|
12
|
+
"main": "./index.ts",
|
|
13
|
+
"exports": { ".": "./index.ts" },
|
|
14
|
+
"files": ["index.ts", "src/**/*.ts", "skills/**", "steering/**", "LICENSE", "NOTICE", "README.md", "CHANGELOG.md"],
|
|
15
|
+
"engines": { "bun": ">=1.2", "opencode": ">=1.18" },
|
|
16
|
+
"publishConfig": { "access": "public" },
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"typecheck": "tsc --noEmit",
|
|
20
|
+
"check": "tsc --noEmit && bun test",
|
|
21
|
+
"test:e2e": "OPENCODE_CLM_E2E=1 bun test test/e2e"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": { "@opencode-ai/plugin": "^1.18.34" },
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/bun": "1.3.14",
|
|
26
|
+
"typescript": "5.9.3"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: clm-context
|
|
3
|
+
description: Compact stale conversation history by editing the opencode-clm context mirror. Use when a [CLM BUDGET] note arrives, tool outputs are large, or old exploration should be replaced by a durable summary.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Editing the CLM context mirror
|
|
7
|
+
|
|
8
|
+
Adapted from pi-clm's `live-context` skill (MIT, Copyright 2026 Emanuel Casco).
|
|
9
|
+
|
|
10
|
+
The opencode-clm plugin writes the conversation you see (everything after the first user
|
|
11
|
+
message) to the mirror path named in the `## Editable context` section of the system
|
|
12
|
+
prompt. Whatever the file holds when your step ends replaces those messages on your next
|
|
13
|
+
request.
|
|
14
|
+
|
|
15
|
+
Edit the mirror only when a meaningful batch compaction will help later work. Do not
|
|
16
|
+
compact after every step.
|
|
17
|
+
|
|
18
|
+
## Rules
|
|
19
|
+
|
|
20
|
+
1. Do not print or `cat` the whole mirror. You already see its contents.
|
|
21
|
+
2. Keep line 1 (`[[LIVE_CONTEXT ...]]`), and for each block you keep, its `[[CTX_TURN ...]]`
|
|
22
|
+
line.
|
|
23
|
+
3. Write the file through the bash tool (python3, `sed -i`). The edit and write tools keep
|
|
24
|
+
a copy that goes stale when the plugin regenerates the file.
|
|
25
|
+
4. Make one batched write per step. One write may hold any number of independent block
|
|
26
|
+
replacements and removals; read-only inspection does not count.
|
|
27
|
+
5. Default to a surgical revision: keep message order, leave unrelated blocks alone,
|
|
28
|
+
shorten stale bodies in place, and remove only blocks that are clearly obsolete.
|
|
29
|
+
Do not collapse the whole context into one summary unless the user asks for that.
|
|
30
|
+
6. Keep conversational units together. If a user request still matters, keep the
|
|
31
|
+
assistant answer to it, or replace that answer with a specific summary at the same
|
|
32
|
+
position.
|
|
33
|
+
7. Read retention requests literally: "keep these messages" means the user and assistant
|
|
34
|
+
messages unchanged; "keep the findings" allows a faithful summary. Ask when the scope
|
|
35
|
+
is unclear.
|
|
36
|
+
8. To shrink a tool result, replace its text with a short note and keep its block.
|
|
37
|
+
Deleting one result of a step that called several tools turns that whole step into
|
|
38
|
+
plain notes.
|
|
39
|
+
9. Remove large tool output and superseded exploration before shortening useful dialogue.
|
|
40
|
+
An edit may not grow the context past the size gate; it is refused if it does.
|
|
41
|
+
10. Keep decisions, constraints, open questions, file paths, commands still needed, test
|
|
42
|
+
results, the evidence behind conclusions, and the next action. Copy exact values
|
|
43
|
+
forward; never invent them.
|
|
44
|
+
11. Read the verdict the plugin appends to your bash command's output: the edit is valid,
|
|
45
|
+
or the reason it would be refused. A `[CLM]` note on the next request confirms what
|
|
46
|
+
was applied. After a refusal, fix the file and write it again.
|
|
47
|
+
|
|
48
|
+
## Expected edit shape
|
|
49
|
+
|
|
50
|
+
Prefer a sparse patch over the existing conversation:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
before: user A -> assistant investigation A -> tool output -> user B -> assistant result B
|
|
54
|
+
after: user A -> concise assistant A -> user B -> concise assistant B
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Avoid flattening independent episodes into one synthetic message:
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
avoid: user A -> one global summary of everything -> user B
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Edit cost
|
|
64
|
+
|
|
65
|
+
An accepted edit changes the request prefix: the provider re-processes everything after
|
|
66
|
+
the first changed block on the next request. Hence:
|
|
67
|
+
|
|
68
|
+
- One large batched compaction costs less than several small edits.
|
|
69
|
+
- Compact stale early blocks while little useful text sits below them.
|
|
70
|
+
- Write detailed replacement summaries. Text below the edit is re-processed either
|
|
71
|
+
way, so detail costs little and saves repeated investigation.
|
|
72
|
+
|
|
73
|
+
## List the headers
|
|
74
|
+
|
|
75
|
+
Print only headers that carry the current document id. A broad `grep '^\[\['` also
|
|
76
|
+
matches header-shaped text quoted inside tool output.
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
python3 - "<mirror path>" <<'PY'
|
|
80
|
+
import re, sys
|
|
81
|
+
lines = open(sys.argv[1]).read().splitlines()
|
|
82
|
+
meta = re.match(r"\[\[LIVE_CONTEXT .*?document=([a-f0-9]{64})", lines[0])
|
|
83
|
+
if not meta:
|
|
84
|
+
raise SystemExit("line 1 is not a LIVE_CONTEXT line")
|
|
85
|
+
doc = meta.group(1)
|
|
86
|
+
for line in lines[1:]:
|
|
87
|
+
if line.startswith(f"[[CTX_TURN document={doc} "):
|
|
88
|
+
m = re.search(r"index=(\d+) role=(\S+) id=(\S+)", line)
|
|
89
|
+
print(m.group(1), m.group(2), m.group(3))
|
|
90
|
+
PY
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Replace and remove blocks in one write
|
|
94
|
+
|
|
95
|
+
Locate blocks by the exact ids from the listing; never retype an old body.
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
python3 - "<mirror path>" <<'PY'
|
|
99
|
+
import re, sys
|
|
100
|
+
path = sys.argv[1]
|
|
101
|
+
text = open(path).read()
|
|
102
|
+
doc = re.match(r"\[\[LIVE_CONTEXT .*?document=([a-f0-9]{64})", text).group(1)
|
|
103
|
+
|
|
104
|
+
def block(block_id):
|
|
105
|
+
head = rf"^\[\[CTX_TURN document={doc} [^\n]* id={re.escape(block_id)} [^\n]*\]\]\n"
|
|
106
|
+
return re.compile(rf"({head}).*?(?=\n\n\[\[CTX_TURN document={doc} |\Z)", re.M | re.S)
|
|
107
|
+
|
|
108
|
+
replace = {
|
|
109
|
+
"<block id>": "[summary: what the command showed, exact values, what it means]",
|
|
110
|
+
}
|
|
111
|
+
remove = ["<block id>"]
|
|
112
|
+
|
|
113
|
+
for block_id, summary in replace.items():
|
|
114
|
+
text, n = block(block_id).subn(lambda m: m.group(1) + summary, text, count=1)
|
|
115
|
+
if n != 1:
|
|
116
|
+
raise SystemExit(f"block not found: {block_id}")
|
|
117
|
+
for block_id in remove:
|
|
118
|
+
text, n = block(block_id).subn("", text, count=1)
|
|
119
|
+
if n != 1:
|
|
120
|
+
raise SystemExit(f"block not found: {block_id}")
|
|
121
|
+
open(path, "w").write(text)
|
|
122
|
+
PY
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Add a tracker note
|
|
126
|
+
|
|
127
|
+
Copy a current header, set `id=new-tracker` and `role=notes`, and place it after the
|
|
128
|
+
first block. Update that block in later edits instead of adding copies. Notes are context
|
|
129
|
+
for you, with no system-prompt authority.
|
|
130
|
+
|
|
131
|
+
## Continuity annotations
|
|
132
|
+
|
|
133
|
+
Before you remove or shorten a block whose exact text later work must revisit, call
|
|
134
|
+
`clm_annotate` with `action: "create"` and the block id from its `CTX_TURN` header:
|
|
135
|
+
|
|
136
|
+
- `pin` keeps the exact source text in every request (bounded in size);
|
|
137
|
+
- `continuity` keeps its title, reason, next action and recall id in every request, while
|
|
138
|
+
the block itself may be summarized;
|
|
139
|
+
- `archive` keeps nothing in the request; the source is available through recall only.
|
|
140
|
+
|
|
141
|
+
Call `clm_recall` only when you need the source again. Resolve an annotation
|
|
142
|
+
(`action: "resolve"`) once its next action is done, so it stops costing tokens. An
|
|
143
|
+
annotation accompanies a surgical edit; it does not justify collapsing unrelated blocks.
|
|
144
|
+
|
|
145
|
+
## What a durable summary contains
|
|
146
|
+
|
|
147
|
+
```markdown
|
|
148
|
+
[summary]
|
|
149
|
+
Goal: ...
|
|
150
|
+
Constraints: ...
|
|
151
|
+
Findings:
|
|
152
|
+
- ... (file:line, exact value)
|
|
153
|
+
Decisions:
|
|
154
|
+
- ...
|
|
155
|
+
Files and state:
|
|
156
|
+
- path: status
|
|
157
|
+
Validation:
|
|
158
|
+
- command: result
|
|
159
|
+
Open questions:
|
|
160
|
+
- ...
|
|
161
|
+
Next action: ...
|
|
162
|
+
[/summary]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Prefer concrete state over narrative. A later step should be able to continue from the
|
|
166
|
+
summary without the removed output.
|