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/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.