opencode-clm 0.1.1 → 0.2.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/NOTICE +9 -1
- package/README.md +85 -283
- package/index.ts +64 -14
- package/package.json +17 -2
- package/src/atomic.ts +27 -0
- package/src/budget.ts +6 -4
- package/src/clm.ts +251 -17
- package/src/commands.ts +18 -0
- package/src/mirror-store.ts +1 -1
- package/src/overrides.ts +193 -0
- package/src/panel/command.ts +69 -0
- package/src/panel/diff.ts +422 -0
- package/src/panel/files.ts +103 -0
- package/src/panel/keys.ts +235 -0
- package/src/panel/lines.ts +269 -0
- package/src/panel/model.ts +547 -0
- package/src/panel/text.ts +22 -0
- package/src/panel/timeline.ts +454 -0
- package/src/panel/view.ts +545 -0
- package/src/presentation.ts +6 -0
- package/src/session-files.ts +184 -0
- package/src/settings-table.ts +406 -0
- package/src/settings.ts +2 -2
- package/src/tui/data.ts +161 -0
- package/src/tui/intercept.ts +56 -0
- package/src/tui/panel.ts +194 -0
- package/src/tui/plugin.ts +280 -0
- package/tui.ts +7 -0
package/NOTICE
CHANGED
|
@@ -23,10 +23,18 @@ Derived from pi-clm (code and model-facing text, adapted for OpenCode):
|
|
|
23
23
|
src/types.ts src/types.ts
|
|
24
24
|
steering/house-brief.md steering/house-brief.md (byte-identical copy)
|
|
25
25
|
skills/clm-context/SKILL.md src/skills/live-context/SKILL.md
|
|
26
|
+
src/panel/timeline.ts src/timeline.ts (context chart and its zoom levels)
|
|
27
|
+
src/panel/diff.ts src/diff.ts (side-by-side diff)
|
|
28
|
+
src/panel/view.ts src/viewer.ts (panel pages)
|
|
29
|
+
src/panel/keys.ts src/viewer.ts (key handling)
|
|
30
|
+
src/panel/model.ts src/viewer.ts (revision view model)
|
|
31
|
+
src/panel/command.ts src/index.ts (/clm and /clm config parsing)
|
|
26
32
|
|
|
27
33
|
Written for this package (structure follows pi-clm, text does not):
|
|
28
34
|
src/presentation.ts (system-prompt protocol section and /clm status text),
|
|
29
|
-
src/policy.ts (edit gate and its refusal note), src/opencode.ts
|
|
35
|
+
src/policy.ts (edit gate and its refusal note), src/opencode.ts, and the OpenCode TUI
|
|
36
|
+
adapter (tui.ts, src/tui/*, src/panel/lines.ts, src/panel/files.ts,
|
|
37
|
+
src/session-files.ts).
|
|
30
38
|
|
|
31
39
|
Context Language Models
|
|
32
40
|
Paper: arXiv 2609.37725. Research code:
|
package/README.md
CHANGED
|
@@ -1,305 +1,107 @@
|
|
|
1
1
|
# opencode-clm
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
```
|
|
4
|
+
____ _ __ __
|
|
5
|
+
/ ___| | | \/ |
|
|
6
|
+
| | | | | |\/| |
|
|
7
|
+
| |___| |___| | | |
|
|
8
|
+
\____|_____|_| |_| opencode-clm: the model manages its own context in OpenCode
|
|
9
|
+
```
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
`opencode-clm` is an [OpenCode](https://opencode.ai) plugin that runs a model as a Context
|
|
12
|
+
Language Model. Before each request the plugin writes the conversation to a text file; the
|
|
13
|
+
model edits that file with its ordinary tools, and the edited text is what it receives on
|
|
14
|
+
the next request. OpenCode's stored session stays unchanged. It ports
|
|
15
|
+
[pi-clm](https://github.com/lolipopshock/pi-clm) 1.0.0, the Pi extension that accompanies
|
|
16
|
+
the paper [Context Language Models](https://arxiv.org/abs/2609.37725).
|
|
9
17
|
[PORTING.md](PORTING.md) maps each pi-clm file to its counterpart here.
|
|
10
18
|
|
|
11
19
|
## Install
|
|
12
20
|
|
|
13
|
-
Tested with OpenCode 1.18.34.
|
|
21
|
+
Tested with OpenCode 1.18.34. The package holds two plugins, and OpenCode loads them from
|
|
22
|
+
two lists:
|
|
14
23
|
|
|
15
|
-
|
|
24
|
+
- the **server plugin** (`index.ts`) in `plugin` of `opencode.json`: the mirror, edits,
|
|
25
|
+
budget, tools, and the `/clm` and `/clm-compact` commands;
|
|
26
|
+
- the **TUI plugin** (`tui.ts`) in `plugin` of `tui.json`: the `/clm` panel.
|
|
16
27
|
|
|
17
|
-
```
|
|
18
|
-
|
|
28
|
+
```sh
|
|
29
|
+
opencode plugin opencode-clm # project: adds it to .opencode/opencode.json and .opencode/tui.json
|
|
30
|
+
opencode plugin -g opencode-clm # global: opencode.json(c) and tui.json in ~/.config/opencode
|
|
19
31
|
```
|
|
20
32
|
|
|
21
|
-
|
|
33
|
+
`opencode plugin` detects both targets from the package and writes the spec to both
|
|
34
|
+
files. To do it by hand, add `"opencode-clm"` to both lists:
|
|
22
35
|
|
|
23
|
-
```
|
|
24
|
-
|
|
36
|
+
```jsonc
|
|
37
|
+
// opencode.json
|
|
38
|
+
{ "plugin": [["opencode-clm", { "budget": "64k" }]] }
|
|
39
|
+
// tui.json
|
|
40
|
+
{ "plugin": ["opencode-clm"] }
|
|
25
41
|
```
|
|
26
42
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
43
|
+
Options go on the `opencode.json` entry only; the TUI plugin finds that entry and reads
|
|
44
|
+
the same options (`mirrorDir` above all). It recognises `opencode-clm`,
|
|
45
|
+
`opencode-clm@<version or source>` (including the `opencode-clm@file:…` form `opencode
|
|
46
|
+
plugin` writes for a tarball), and `file://` URLs or paths that name this package or its
|
|
47
|
+
`index.ts`. From a local clone, use `file:///path/to/opencode-clm/index.ts` in
|
|
48
|
+
`opencode.json` and `file:///path/to/opencode-clm/tui.ts` in `tui.json`.
|
|
49
|
+
|
|
50
|
+
With the TUI plugin, a typed `/clm …` line runs in the TUI and costs no model turn: the
|
|
51
|
+
panel opens, or a toast answers, or the setting changes. Only `/clm reset` goes to the
|
|
52
|
+
server command. Without the TUI plugin (or with `opencode run --command clm`), the server
|
|
53
|
+
command answers `/clm` as text, which costs one model turn because OpenCode cannot cancel
|
|
54
|
+
a command. Version 0.1.0 on npm has the server plugin only; the panel and `/clm config`
|
|
55
|
+
arrive with 0.2.0.
|
|
56
|
+
|
|
57
|
+
## Quick start
|
|
58
|
+
|
|
59
|
+
| command | what it does |
|
|
60
|
+
|---------|--------------|
|
|
61
|
+
| `/clm` | open the panel: **overview** (context size per request, fixed overhead and usable budget, every accepted edit) <br><img src=".github/images/overview.png" alt="The overview page: context size per request, with the requests after which the model edited its context" width="720"> <br> **input** (what the next request will contain) · **edits** (each revision, message by message, with a side-by-side diff) <br><img src=".github/images/edits.png" alt="The edits page: a tool result before and after the model shortened it" width="720"> |
|
|
62
|
+
| `/clm status` | a toast: CLM on or off, revision, last request size, budget, fixed overhead, last outcome (the server command's text adds the changed settings) |
|
|
63
|
+
| `/clm config` | open **settings**; `/clm config <setting> <value>` changes one, `/clm config reset` drops this session's changes <br><img src=".github/images/settings.png" alt="The settings page: sizes, files, and every setting" width="720"> |
|
|
64
|
+
| `/clm-compact [instructions]` | ask the model to compact its context now; text you add (what to keep, for example) goes into the prompt. The result shows on the **edits** page |
|
|
65
|
+
| `/clm on` / `off` / `reset` | apply edits or send the raw history for this session (the `editing` setting); `reset` drops the accepted revision (server command, one model turn) |
|
|
66
|
+
|
|
67
|
+
In the panel: `1–4` or `Tab` switch pages, `← →` step through the overview's list
|
|
68
|
+
(edits, rejections, resets, compactions, now) or the edits page's revisions, `z` zooms
|
|
69
|
+
the chart, `Enter` opens the selection, `r` reloads, `q` closes.
|
|
70
|
+
|
|
71
|
+
## Docs
|
|
72
|
+
|
|
73
|
+
- [How it works](docs/how-it-works.md): the mirror, edits, the budget, compaction, the panel, safety.
|
|
74
|
+
- [Configuration](docs/configuration.md): sizing the budget, every setting and its environment variable, `/clm config`.
|
|
75
|
+
- [Architecture](docs/architecture.md): modules, session files, design notes, known limitations.
|
|
76
|
+
- [Development](docs/development.md): setup, checks, the integration suites, releasing.
|
|
77
|
+
|
|
78
|
+
## Citation
|
|
79
|
+
|
|
80
|
+
If you use opencode-clm in your research, please cite
|
|
81
|
+
[Context Language Models](https://arxiv.org/abs/2609.37725):
|
|
82
|
+
|
|
83
|
+
```bibtex
|
|
84
|
+
@article{shao2026context,
|
|
85
|
+
title = {Context Language Models},
|
|
86
|
+
author = {Shao, Rulin and Shen, Shannon Zejiang and Yin, Junjie Oscar and Li, Yuetai and
|
|
87
|
+
Wang, Minheng and Ivison, Hamish and Poovendran, Radha and Lambert, Nathan and
|
|
88
|
+
Xiao, Teng and Lewis, Mike and Yih, Wen-tau and Zettlemoyer, Luke and Koh, Pang Wei},
|
|
89
|
+
journal = {arXiv preprint arXiv:2609.37725},
|
|
90
|
+
year = {2026}
|
|
91
|
+
}
|
|
33
92
|
```
|
|
34
93
|
|
|
35
|
-
Each option falls back to its environment variable, then to the default. The plugin reads
|
|
36
|
-
them once at load; an invalid value fails the plugin load with an error that names the
|
|
37
|
-
option.
|
|
38
|
-
|
|
39
|
-
| option | env | default | values |
|
|
40
|
-
|---|---|---|---|
|
|
41
|
-
| `enabled` | `CLM_ENABLED` | `true` | `false` registers no hooks |
|
|
42
|
-
| `budget` | `CLM_BUDGET` | `32000` | tokens (`32000`, `32k`, `1.5m`, `32_000`), or `window` for the model's context window. The model window minus its output limit caps the value |
|
|
43
|
-
| `reserve` | `CLM_RESERVE` | `2048` | tokens held back below the budget for generation |
|
|
44
|
-
| `remindAt` | `CLM_REMIND_AT` | `0.25,0.5,0.75` | fractions (`0.25,0.5`) or percentages (`25/50%`) strictly between 0 and 1; `off` disables all reminders, the budget − reserve reminder included |
|
|
45
|
-
| `gate` | `CLM_EDIT_GATE` | `fit` | `fit`, `shrink` or `none` |
|
|
46
|
-
| `guard` | `CLM_OVERFLOW` | `withhold` | `withhold` or `off` |
|
|
47
|
-
| `observationCap` | `CLM_OBSERVATION_CAP` | off | `characters[:head fraction]`, e.g. `10000:0.5`; at least 200; head fraction defaults to 0.8; `off` or `0` disables |
|
|
48
|
-
| `mirrorDir` | `CLM_MIRROR_DIR` | `<project>/.opencode/clm` | parent of the per-session directories; relative paths resolve against the project |
|
|
49
|
-
| `steering` | `CLM_STEERING` | none | path to a steering document, or `house` for the bundled `steering/house-brief.md`; `none` or `off` disables |
|
|
50
|
-
| `compactPrompt` | `CLM_COMPACT_PROMPT` | built-in | path to a `/clm-compact` template; `default`, `none` or `off` mean the built-in prompt |
|
|
51
|
-
| `estimateFactor` | `CLM_ESTIMATE_FACTOR` | `1` | 1 to 4; multiplies the chars/4 token estimate (1.5–2 for dense content) |
|
|
52
|
-
| `reasoning` | `CLM_REASONING` | `true` | render assistant reasoning in the mirror |
|
|
53
|
-
| `dumpRequests` | `CLM_DUMP_REQUESTS` | `false` | write each transformed request to `<session dir>/requests/nN.json` |
|
|
54
|
-
| `skill` | — | `true` | register the `clm-context` skill; `false` matches pi-clm's CLM mode, which offers no skill |
|
|
55
|
-
| `commands` | — | `true` | register `/clm` and `/clm-compact` |
|
|
56
|
-
|
|
57
|
-
Booleans accept `true/false`, `1/0`, `on/off` and `yes/no`.
|
|
58
|
-
|
|
59
|
-
**Choosing `budget`.** The budget counts the whole request, OpenCode's system prompt and
|
|
60
|
-
tool schemas included. With OpenCode 1.18 and its default tools those take about 17,000
|
|
61
|
-
to 19,000 tokens of every request (measured with provider counts), before the
|
|
62
|
-
conversation starts. A budget must hold that overhead, the `reserve`, and a working margin
|
|
63
|
-
for the conversation; the plugin uses 8,000 tokens, room for the task, a few tool results
|
|
64
|
-
and edits. With the default reserve that gives a minimum of about 19,000 + 2,048 + 8,000 ≈
|
|
65
|
-
29,000 tokens; the default 32,000 clears it. More tools (MCP servers, plugins) or a
|
|
66
|
-
steering document raise the overhead.
|
|
67
|
-
|
|
68
|
-
When the budget is smaller, the session raises it. On the first request that follows a
|
|
69
|
-
measured reply, the plugin takes the provider's input count minus its own conversation
|
|
70
|
-
estimate as the fixed overhead. It measures only while the conversation is at most a
|
|
71
|
-
quarter of that count, so the estimate's error stays small; a session resumed with a long
|
|
72
|
-
history is measured once its context is short. If the provider reports no usage, the
|
|
73
|
-
plugin's own size estimate stands in until a provider count arrives. If budget − reserve
|
|
74
|
-
leaves less than 8,000 tokens after the overhead, the effective budget becomes overhead +
|
|
75
|
-
8,000 + reserve, never above the model window minus its output limit (uncapped while the
|
|
76
|
-
window is unknown). The plugin then logs a `budget-too-small` event, shows a warning
|
|
77
|
-
toast, and tells the model once. When the window cap leaves less than the margin, the
|
|
78
|
-
notice says so; lower `reserve` or use a model with a larger window. The overhead is
|
|
79
|
-
stored in `state.json`, so the raise holds after a restart, and it is measured once per
|
|
80
|
-
session: switching agent or model, or adding an MCP server, later does not update it.
|
|
81
|
-
`/clm` shows the fixed overhead, the usable budget (budget − reserve − overhead) and the
|
|
82
|
-
effective budget when raised.
|
|
83
|
-
|
|
84
|
-
A steering document carries context-management strategy (when to compact, what to keep);
|
|
85
|
-
the plugin itself states only the editing protocol. The plugin reads the document once at
|
|
86
|
-
load and appends it to the system prompt; `/clm` shows its name and SHA-256 prefix.
|
|
87
|
-
|
|
88
|
-
A `compactPrompt` template may use `{{mirror}}`, `{{current}}`, `{{budget}}` and
|
|
89
|
-
`{{instructions}}`. The plugin rereads it on every use.
|
|
90
|
-
|
|
91
|
-
## Commands
|
|
92
|
-
|
|
93
|
-
- `/clm [status | path | on | off | reset]`: `status` (the default) prints the session's
|
|
94
|
-
mirror path, revision, accepted and rejected edits, gate, guard, budget reading and
|
|
95
|
-
active revision, and shows a toast. `path` prints the mirror path. `on` and `off` switch
|
|
96
|
-
CLM for the session. `reset` drops the accepted revision, so the next request carries
|
|
97
|
-
the stored history. The command costs one model turn: OpenCode cannot cancel a command,
|
|
98
|
-
so the model receives the text and repeats it.
|
|
99
|
-
- `/clm-compact [instructions]`: asks the model to compact its context now by editing the
|
|
100
|
-
mirror. Text after the command is appended to the prompt.
|
|
101
|
-
|
|
102
|
-
If your config already defines `clm` or `clm-compact`, the plugin keeps your command,
|
|
103
|
-
logs a warning and leaves it alone. A failing command prints `[CLM] /<name> failed:
|
|
104
|
-
<reason>` and shows an error toast.
|
|
105
|
-
|
|
106
|
-
## Tools
|
|
107
|
-
|
|
108
|
-
- `clm_annotate`: `action` is `create`, `resolve` or `list`. `create` takes a mirror block
|
|
109
|
-
id (`source`), `title`, `reason`, `futureAction` and `retention`:
|
|
110
|
-
- `pin` keeps the exact source text in every request (up to 8,000 tokens);
|
|
111
|
-
- `continuity` keeps the title, reason, next action and recall id in every request;
|
|
112
|
-
- `archive` keeps nothing in the request; the source is available through recall.
|
|
113
|
-
- `clm_recall`: returns an annotation's saved source; `maxTokens` 128–8,000, default 2,000.
|
|
114
|
-
|
|
115
|
-
## Skill
|
|
116
|
-
|
|
117
|
-
`skills/clm-context/SKILL.md`, adapted from pi-clm's `live-context` skill, tells the model
|
|
118
|
-
when and how to edit the mirror: batched surgical edits, keeping conversational units
|
|
119
|
-
together, durable summaries, and Python scripts that list headers and replace or remove
|
|
120
|
-
blocks by id. The plugin adds the `skills` directory to OpenCode's `skills.paths`.
|
|
121
|
-
|
|
122
|
-
## How it works
|
|
123
|
-
|
|
124
|
-
**Mirror.** Before each model request, the plugin writes every message after the first
|
|
125
|
-
user message to `LIVE_CONTEXT.md` in the session directory. Line 1 is a `[[LIVE_CONTEXT
|
|
126
|
-
...]]` header with the revision and document id; each message follows as a block headed
|
|
127
|
-
`[[CTX_TURN document=<id> index=<n> role=<role> id=<block id> protected=<bool>]]`. The
|
|
128
|
-
system prompt and the first user message stay outside the mirror and are always sent. A
|
|
129
|
-
system-prompt section, `## Editable context`, teaches the model the format.
|
|
130
|
-
|
|
131
|
-
**Editing.** The model edits the mirror with the bash tool (python3, `sed -i`). The edit
|
|
132
|
-
and write tools keep a copy that goes stale when the plugin regenerates the file, so the
|
|
133
|
-
protocol tells the model to avoid them on the mirror. Deleting a block drops the message.
|
|
134
|
-
New text under a tool result replaces its output and keeps the call. New text under an
|
|
135
|
-
assistant header replaces the reply and turns its tool results into notes. A header copied
|
|
136
|
-
with `id=new-NAME` inserts a note. Replacing the whole file with text that has no block
|
|
137
|
-
headers replaces everything after the first user message with one notes block. When a
|
|
138
|
-
bash, edit, write or apply_patch call writes the mirror, the plugin appends a verdict to
|
|
139
|
-
its output (accepted, or the reason for refusal); a heuristic detects bash writes. A
|
|
140
|
-
`[CLM]` note on the next request reports what was applied.
|
|
141
|
-
|
|
142
|
-
**Commit.** OpenCode has no end-of-turn hook, so the plugin reads the mirror at the start
|
|
143
|
-
of the next request. If the file differs from the last render, the plugin parses it and
|
|
144
|
-
applies it. It refuses a file whose line 1 no longer parses or names another document, a
|
|
145
|
-
file with duplicate or unknown block ids, and a file the edit gate rejects; the context
|
|
146
|
-
then stays as it was.
|
|
147
|
-
|
|
148
|
-
**Edit gate.** Every gate accepts an edit that keeps the context the same size or smaller.
|
|
149
|
-
For growth: `fit` accepts it while the editable context stays within budget − reserve
|
|
150
|
-
minus the system prompt, tool schemas, pinned text and continuity notes, and accepts everything while the budget is
|
|
151
|
-
unknown; `shrink` rejects it; `none` applies no size check.
|
|
152
|
-
|
|
153
|
-
**Projection checkpoints.** An accepted edit becomes a revision. The checkpoint stores the
|
|
154
|
-
number of flattened raw messages it replaces, their OpenCode message ids, and a SHA-256
|
|
155
|
-
digest of them, with tool-result content excluded so OpenCode's prune does not invalidate
|
|
156
|
-
it. A pruned tool output inside an accepted revision goes out with the revision's text.
|
|
157
|
-
Each later request sends the projected messages plus the raw messages added since.
|
|
158
|
-
When the stored prefix no longer matches (a revert, for example), the plugin drops the
|
|
159
|
-
revision, sends the raw history, and tells the model. Compaction is the exception (see
|
|
160
|
-
Compaction).
|
|
161
|
-
|
|
162
|
-
**Budget tiers.** Token estimates are chars/4 × `estimateFactor` × a calibration factor
|
|
163
|
-
learned from the provider's reported input sizes (never below 1). The estimate covers the
|
|
164
|
-
system prompt, tool schemas, pinned task, editable context, continuity text and notices.
|
|
165
|
-
Until a size is known, the `[CLM BUDGET]` note names what the estimate leaves out. Reminders fire once per tier, as `[CLM BUDGET]` notes, at each
|
|
166
|
-
`remindAt` fraction and at budget − reserve. Requests are never blocked.
|
|
167
|
-
|
|
168
|
-
**Observation cap.** When set, each tool result in the sent context keeps at most
|
|
169
|
-
`characters` characters (the head fraction from the start, the rest from the end) plus a
|
|
170
|
-
marker stating how much was cut. The stored output stays complete.
|
|
171
|
-
|
|
172
|
-
**Overflow guard.** When the estimated request exceeds budget − reserve, the guard
|
|
173
|
-
replaces new tool results, oldest first, with a note naming the tool, call id, size and a
|
|
174
|
-
file in `withheld/` that holds the full output (or, if saving fails, a pointer to the
|
|
175
|
-
session history), until the estimate fits. User and
|
|
176
|
-
assistant messages stay untouched, and no tool runs again. `guard: "off"` disables it.
|
|
177
|
-
|
|
178
|
-
**Continuity.** Annotations (see Tools) let the model shorten or delete a block while a
|
|
179
|
-
pin, a continuity note or a recall copy keeps what later work needs. The plugin sends
|
|
180
|
-
active annotations as a user-role note after the conversation, and warns the model when they exceed
|
|
181
|
-
8,000 tokens.
|
|
182
|
-
|
|
183
|
-
**Compaction.** CLM replaces compaction: the model shrinks its own context by editing the
|
|
184
|
-
mirror. Set `"compaction": { "auto": false }` in `opencode.json` while CLM is on, and keep
|
|
185
|
-
auto-compaction only as a fallback for a session that outgrows the window anyway.
|
|
186
|
-
|
|
187
|
-
When OpenCode compacts:
|
|
188
|
-
1. The plugin applies the accepted revision to the history OpenCode summarizes, so the
|
|
189
|
-
summary reflects the edited context. It makes no commit or render.
|
|
190
|
-
2. It appends an instruction to the compaction prompt: copy every pin and continuity
|
|
191
|
-
annotation, listed after it, into the summary verbatim.
|
|
192
|
-
3. When a revision is active and OpenCode reports the finished compaction (the `session.compacted` event or the
|
|
193
|
-
`experimental.compaction.autocontinue` hook), the next request rebases: the summary
|
|
194
|
-
becomes the new baseline, the revision number moves on, and the model gets no
|
|
195
|
-
dropped-revision note. Pins and continuity annotations carry over unchanged, and
|
|
196
|
-
`events.jsonl` records a `compacted` event.
|
|
197
|
-
|
|
198
|
-
OpenCode's title, summary and compaction agents get no protocol text.
|
|
199
|
-
|
|
200
|
-
## State
|
|
201
|
-
|
|
202
|
-
Each session has a directory `<mirrorDir>/clm-<session id>/` (mode 0700; files 0600):
|
|
203
|
-
|
|
204
|
-
| file | content |
|
|
205
|
-
|---|---|
|
|
206
|
-
| `LIVE_CONTEXT.md` | the mirror |
|
|
207
|
-
| `state.json` | enabled flag, revision, active checkpoint, measured fixed overhead and budget decision |
|
|
208
|
-
| `annotations.jsonl` | continuity annotations |
|
|
209
|
-
| `events.jsonl` | one line per request, edit, reset, notice and error |
|
|
210
|
-
| `revisions/rN.md` | the mirror text of each accepted revision |
|
|
211
|
-
| `withheld/` | tool outputs held back by the overflow guard |
|
|
212
|
-
| `requests/nN.json` | transformed requests, with `dumpRequests` only |
|
|
213
|
-
|
|
214
|
-
The plugin deletes session directories untouched for 7 days when it opens another
|
|
215
|
-
session. The default `mirrorDir` lies inside the project; add `.opencode/clm/` to
|
|
216
|
-
`.gitignore`.
|
|
217
|
-
|
|
218
|
-
## Limitations
|
|
219
|
-
|
|
220
|
-
- Status, budget and edit outcomes appear as text, toasts and `events.jsonl`. pi-clm's
|
|
221
|
-
TUI viewer, timeline and diff are not ported: OpenCode server plugins cannot draw panels.
|
|
222
|
-
- No per-session settings panel; `/clm on|off|reset` are the only per-session switches.
|
|
223
|
-
- Model limits and the system-prompt size reach the plugin one request late. On the
|
|
224
|
-
first request of a process the model window does not yet cap the budget and the
|
|
225
|
-
estimate omits the system prompt; with `budget: "window"` that request has no budget
|
|
226
|
-
reading. `/clm-compact` before the first request reports about 0 tokens.
|
|
227
|
-
- Built-in tool descriptions are measured, but not their parameter schemas, so
|
|
228
|
-
calibration attributes that size to undercounting.
|
|
229
|
-
- A checkpoint ignores changes to stored tool output inside the prefix it covers. An
|
|
230
|
-
output rewritten by another plugin or an SDK client goes out with the new text, while
|
|
231
|
-
the mirror and estimate keep the revision's.
|
|
232
|
-
- Hooks fail open: if the transform throws, the request goes out with the raw history and
|
|
233
|
-
an error toast.
|
|
234
|
-
- The calibration factor lives in memory and restarts at 1 with each process.
|
|
235
|
-
- A subagent (task) session gets its own mirror and state.
|
|
236
|
-
- Not ported from pi-clm: branch-aware restore, the `max_tokens` clamp lift, cancelling
|
|
237
|
-
Pi's own compaction, and the `oneToolPerTurn` and `sizeTrailer` switches.
|
|
238
|
-
|
|
239
|
-
## Testing
|
|
240
|
-
|
|
241
|
-
No model has run through this package. Its tests are unit tests, a load smoke test and a
|
|
242
|
-
mock-server end-to-end test (see Development).
|
|
243
|
-
|
|
244
|
-
The integration suite (`test/e2e`, 10 tests) drives the real OpenCode binary
|
|
245
|
-
(1.18.34, pinned in CI) against a scripted OpenAI-compatible mock, with temporary HOME
|
|
246
|
-
and XDG directories. It covers: plugin load and command registration (`debug config`,
|
|
247
|
-
`/clm path`, `/clm-compact`); the mirror written and re-rendered each turn; an accepted
|
|
248
|
-
edit whose text reaches the next request; a `shrink` gate refusal and the note the model
|
|
249
|
-
sees; budget nudges at 50% and at budget − reserve; auto-compaction (the summary carries
|
|
250
|
-
the edited text and the summarizer gets the pin instruction; the next request rebases onto
|
|
251
|
-
the summary with no drop note, and a pin survives); receipts
|
|
252
|
-
from `tool.execute.after`; the observation cap; and a second `opencode run --session`
|
|
253
|
-
resuming the revision and mirror. Not covered: the overflow guard, the continuity tools
|
|
254
|
-
and a manual `/compact`.
|
|
255
|
-
|
|
256
|
-
The server-side suffix-cache work is experimental and unreleased.
|
|
257
|
-
|
|
258
|
-
## Development
|
|
259
|
-
|
|
260
|
-
- `bun test`: unit tests.
|
|
261
|
-
- `bun run typecheck`: `tsc --noEmit`.
|
|
262
|
-
- `scripts/smoke.sh`: offline, no model. Checks the `npm pack` file list, that OpenCode
|
|
263
|
-
loads the plugin from `file://` and registers `/clm`, `/clm-compact` and the skills
|
|
264
|
-
path with no plugin error in its logs, and how `@opencode-ai/plugin` resolves from the
|
|
265
|
-
repo, a bare tarball and an offline `npm install`.
|
|
266
|
-
- `bun run test:e2e`: the integration suite above (about 30-55 s). Needs the `opencode`
|
|
267
|
-
binary (`OPENCODE_BIN` overrides it; `OPENCODE_CLM_E2E_KEEP=1` keeps the temp tree) and
|
|
268
|
-
npm registry access, because OpenCode installs `@opencode-ai/plugin` into its temporary
|
|
269
|
-
config directory.
|
|
270
|
-
- CI (`.github/workflows/ci.yml`) runs on pull requests and pushes to `main`: frozen
|
|
271
|
-
install, typecheck, `bun test`, `npm pack --dry-run`, `scripts/smoke.sh --strict` and the
|
|
272
|
-
integration suite against `opencode-ai@1.18.34`.
|
|
273
|
-
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org)
|
|
274
|
-
(`feat:`, `fix:`, `docs:`, `ci:`, `test:`, `refactor:`, `chore:`; `!` for breaking
|
|
275
|
-
changes). `pre-commit install` adds a commit-msg hook that checks them.
|
|
276
|
-
|
|
277
|
-
## Releasing
|
|
278
|
-
|
|
279
|
-
Releases come from Conventional Commits through [git-cliff](https://git-cliff.org)
|
|
280
|
-
(`cliff.toml`). `feat` bumps minor; `fix` and any commit except `chore` and `style` bump
|
|
281
|
-
patch; a breaking change bumps minor below 1.0.0. v0.1.0 is a single commit,
|
|
282
|
-
`feat: initial release`.
|
|
283
|
-
|
|
284
|
-
1. Actions → "Prepare release" → Run workflow on `main` (tick `dry_run` to preview).
|
|
285
|
-
It computes the version (`git-cliff --bumped-version`), bumps `package.json` if needed,
|
|
286
|
-
commits `chore(release): vX.Y.Z`, tags, pushes and dispatches "Release".
|
|
287
|
-
2. "Release" checks the tag against `package.json`, runs CI on the tag, publishes to npm
|
|
288
|
-
through trusted publishing (OIDC, provenance, no token), then creates the GitHub
|
|
289
|
-
Release with git-cliff notes.
|
|
290
|
-
|
|
291
|
-
Preview locally: `git-cliff --bumped-version` and `git-cliff --unreleased --strip header`.
|
|
292
|
-
Notes for 0.1.1 and later live in GitHub Releases; CHANGELOG.md holds 0.1.0.
|
|
293
|
-
|
|
294
94
|
## Credits
|
|
295
95
|
|
|
296
96
|
- [pi-clm](https://github.com/lolipopshock/pi-clm) 1.0.0 (MIT, Copyright 2026 Emanuel
|
|
297
|
-
Casco): the design and most of the code derive from it
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
97
|
+
Casco): the design and most of the code derive from it, the `/clm` panel included
|
|
98
|
+
(`src/panel/timeline.ts`, `src/panel/diff.ts` and the page layouts in
|
|
99
|
+
`src/panel/view.ts` port its `timeline.ts`, `diff.ts` and `viewer.ts`). Model-facing text
|
|
100
|
+
taken from pi-clm, adapted: the compaction prompt (`src/compact.ts`), the `clm-context`
|
|
101
|
+
skill, the budget notices, the mirror rejection and receipt messages, and the overflow
|
|
102
|
+
and observation notes. `steering/house-brief.md` is byte-identical to pi-clm's. Written
|
|
103
|
+
for this package: the system-prompt protocol section (`src/presentation.ts`), the
|
|
104
|
+
edit-gate refusal note, and the plugin's own receipts, commands and tool descriptions.
|
|
303
105
|
[NOTICE](NOTICE) lists the files; [LICENSE](LICENSE) carries pi-clm's notice.
|
|
304
106
|
- Context Language Models, [arXiv 2609.37725](https://arxiv.org/abs/2609.37725), and
|
|
305
107
|
[facebookresearch/context-language-models](https://github.com/facebookresearch/context-language-models)
|
package/index.ts
CHANGED
|
@@ -34,12 +34,19 @@ import { buildCompactPrompt, loadCompactPrompt } from "./src/compact.ts";
|
|
|
34
34
|
import { continuityTools } from "./src/continuity.ts";
|
|
35
35
|
import { replaceInPlace, type OcMessage } from "./src/opencode.ts";
|
|
36
36
|
import { statusText, systemGuidance } from "./src/presentation.ts";
|
|
37
|
+
import { COMPACT_COMMAND, COMPACT_TEMPLATE, STATUS_COMMAND, STATUS_TEMPLATE } from "./src/commands.ts";
|
|
38
|
+
import { changeSetting, resetSettings, showSetting } from "./src/overrides.ts";
|
|
39
|
+
import { settingsText } from "./src/settings-table.ts";
|
|
40
|
+
import { parseClmCommand, type Page } from "./src/panel/command.ts";
|
|
41
|
+
import { buildPanelModel } from "./src/panel/model.ts";
|
|
42
|
+
import { panelPageText } from "./src/panel/text.ts";
|
|
43
|
+
import { readSessionDirectory } from "./src/session-files.ts";
|
|
44
|
+
import { fallbackBudget, settingsView } from "./src/tui/data.ts";
|
|
37
45
|
import { resolveSettings, SKILLS_DIR, type ClmSettings } from "./src/settings.ts";
|
|
38
46
|
import { loadSteeringDocument, steeringPromptSection, type SteeringDocument } from "./src/steering.ts";
|
|
39
47
|
|
|
40
48
|
export const PLUGIN_ID = "opencode-clm";
|
|
41
|
-
export
|
|
42
|
-
export const COMPACT_COMMAND = "clm-compact";
|
|
49
|
+
export { COMPACT_COMMAND, STATUS_COMMAND } from "./src/commands.ts";
|
|
43
50
|
|
|
44
51
|
/** Opening words of the system prompts of OpenCode 1.18 helper agents (title, summary, compaction). */
|
|
45
52
|
const HELPER_PROMPTS = [
|
|
@@ -48,7 +55,7 @@ const HELPER_PROMPTS = [
|
|
|
48
55
|
"You are a context summarization agent",
|
|
49
56
|
];
|
|
50
57
|
|
|
51
|
-
const STATUS_USAGE = "Usage: /clm [status | path | on | off | reset]";
|
|
58
|
+
const STATUS_USAGE = "Usage: /clm [overview | input | edits | settings | status | path | on | off | reset | config [setting [value] | reset]]";
|
|
52
59
|
|
|
53
60
|
type ToastVariant = "info" | "success" | "warning" | "error";
|
|
54
61
|
|
|
@@ -107,7 +114,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
107
114
|
const id = checkSessionID(sessionID);
|
|
108
115
|
let existing = sessions.get(id);
|
|
109
116
|
if (!existing) {
|
|
110
|
-
existing = ClmSession.open(id, settings).then((clm) => {
|
|
117
|
+
existing = ClmSession.open(id, settings, { steering }).then((clm) => {
|
|
111
118
|
if (clm.loadWarning) toast(`state reset for ${id}: ${clm.loadWarning}`, "warning");
|
|
112
119
|
return clm;
|
|
113
120
|
});
|
|
@@ -132,11 +139,51 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
132
139
|
return total;
|
|
133
140
|
};
|
|
134
141
|
|
|
135
|
-
|
|
142
|
+
/**
|
|
143
|
+
* `/clm config …` as text (pi-clm's `/clm config` semantics; the TUI opens the settings
|
|
144
|
+
* page instead). `words` keep their case: values may be paths.
|
|
145
|
+
*/
|
|
146
|
+
async function configCommand(clm: ClmSession, words: string[]): Promise<string> {
|
|
147
|
+
const [name, ...valueWords] = words;
|
|
148
|
+
const { base, effective } = clm.settingsValues();
|
|
149
|
+
const format = { modelWindow: clm.limits.context };
|
|
150
|
+
if (!name) return settingsText(base, effective, format, clm.settingsWarning);
|
|
151
|
+
if (name.toLowerCase() === "reset" && valueWords.length === 0) {
|
|
152
|
+
await resetSettings(clm.store.directory);
|
|
153
|
+
await clm.refreshSettings(true);
|
|
154
|
+
toast("CLM settings reset to the defaults.");
|
|
155
|
+
return "CLM settings reset to the defaults for this session.";
|
|
156
|
+
}
|
|
157
|
+
if (valueWords.length === 0) return showSetting(name, effective, format);
|
|
158
|
+
const result = await changeSetting(clm.changeRequest(input.directory), name, valueWords.join(" "));
|
|
159
|
+
await clm.refreshSettings(true);
|
|
160
|
+
toast(`CLM ${result.text}.`);
|
|
161
|
+
return `CLM ${result.text}. It applies from the next request.`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `/clm overview|input|edits|settings` outside the TUI: the panel page as plain text. */
|
|
165
|
+
async function pageCommand(clm: ClmSession, page: Page): Promise<string> {
|
|
166
|
+
const files = await readSessionDirectory(clm.store.directory, clm.sessionID);
|
|
167
|
+
const budget = fallbackBudget(clm.settings, clm.limits);
|
|
168
|
+
const model = buildPanelModel(files, budget ? { budget } : {});
|
|
169
|
+
model.enabled = clm.enabled;
|
|
170
|
+
const format = { ...(clm.limits.context ? { modelWindow: clm.limits.context } : {}) };
|
|
171
|
+
model.settings = settingsView(clm.settingsValues(), model, { format, ...(clm.settingsWarning ? { warning: clm.settingsWarning } : {}) });
|
|
172
|
+
return panelPageText(model, page);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function statusCommand(clm: ClmSession, args: string): Promise<string> {
|
|
176
|
+
await clm.refreshSettings();
|
|
177
|
+
const words = args.split(/\s+/).filter(Boolean);
|
|
178
|
+
const argument = (words[0] ?? "").toLowerCase();
|
|
179
|
+
if (argument === "config") return configCommand(clm, words.slice(1));
|
|
180
|
+
const command = parseClmCommand(`/clm ${args}`);
|
|
181
|
+
if (args !== "" && command?.kind === "open") return pageCommand(clm, command.page);
|
|
182
|
+
if (words.length > 1) return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
|
|
136
183
|
switch (argument) {
|
|
137
184
|
case "":
|
|
138
185
|
case "status": {
|
|
139
|
-
const text = statusText(clm.status(
|
|
186
|
+
const text = statusText(clm.status());
|
|
140
187
|
toast(text.split("\n").slice(0, 3).join("\n"));
|
|
141
188
|
return text;
|
|
142
189
|
}
|
|
@@ -144,7 +191,9 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
144
191
|
return `CLM mirror: ${clm.mirrorPath}`;
|
|
145
192
|
case "on":
|
|
146
193
|
case "off": {
|
|
147
|
-
|
|
194
|
+
// The same override the TUI and `/clm config editing` write.
|
|
195
|
+
await changeSetting(clm.changeRequest(input.directory), "editing", argument);
|
|
196
|
+
await clm.refreshSettings(true);
|
|
148
197
|
toast(`CLM ${argument} for this session`);
|
|
149
198
|
return argument === "on"
|
|
150
199
|
? "CLM is on for this session: the mirror is refreshed before the next request."
|
|
@@ -156,12 +205,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
156
205
|
return "CLM dropped the accepted revision: the next request carries the stored history, and the mirror is rewritten from it.";
|
|
157
206
|
}
|
|
158
207
|
default:
|
|
159
|
-
return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(
|
|
208
|
+
return `${STATUS_USAGE}\nUnknown argument: ${JSON.stringify(args)}`;
|
|
160
209
|
}
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
function compactCommand(clm: ClmSession, argument: string): string {
|
|
164
|
-
return buildCompactPrompt(loadCompactPrompt(settings.compactPromptPath), {
|
|
213
|
+
return buildCompactPrompt(loadCompactPrompt(clm.settings.compactPromptPath), {
|
|
165
214
|
mirror: clm.mirrorPath,
|
|
166
215
|
current: clm.lastReading?.estimated ?? 0,
|
|
167
216
|
budget: clm.resolvedBudget()?.budget,
|
|
@@ -176,11 +225,11 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
176
225
|
const ours = {
|
|
177
226
|
[STATUS_COMMAND]: {
|
|
178
227
|
description: "CLM: status of the editable context; /clm path | on | off | reset",
|
|
179
|
-
template:
|
|
228
|
+
template: STATUS_TEMPLATE,
|
|
180
229
|
},
|
|
181
230
|
[COMPACT_COMMAND]: {
|
|
182
231
|
description: "CLM: ask the model to compact its context by editing the mirror",
|
|
183
|
-
template:
|
|
232
|
+
template: COMPACT_TEMPLATE,
|
|
184
233
|
},
|
|
185
234
|
};
|
|
186
235
|
for (const [name, definition] of Object.entries(ours)) {
|
|
@@ -213,11 +262,12 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
213
262
|
} catch {
|
|
214
263
|
return;
|
|
215
264
|
}
|
|
216
|
-
|
|
265
|
+
await clm.refreshSettings();
|
|
266
|
+
if (!clm.enabled) return;
|
|
217
267
|
const limit = (hookInput.model as { limit?: { context?: number; output?: number } } | undefined)?.limit;
|
|
218
268
|
if (limit) clm.limits = { context: limit.context || undefined, output: limit.output || undefined };
|
|
219
269
|
const sections = [systemGuidance(clm.mirrorPath, clm.resolvedBudget()?.budget)];
|
|
220
|
-
if (steering) sections.push(steeringPromptSection(steering));
|
|
270
|
+
if (clm.steering) sections.push(steeringPromptSection(clm.steering));
|
|
221
271
|
output.system.push(sections.join("\n\n"));
|
|
222
272
|
clm.scope = { ...clm.scope, systemTokens: clm.textTokens(output.system.join("\n")) };
|
|
223
273
|
},
|
|
@@ -310,7 +360,7 @@ export const server: Plugin = async (input: PluginInput, options?: PluginOptions
|
|
|
310
360
|
const clm = await session(hookInput.sessionID);
|
|
311
361
|
const argument = (hookInput.arguments ?? "").trim();
|
|
312
362
|
text = hookInput.command === STATUS_COMMAND
|
|
313
|
-
? relay(await statusCommand(clm, argument
|
|
363
|
+
? relay(await statusCommand(clm, argument))
|
|
314
364
|
: compactCommand(clm, argument);
|
|
315
365
|
} catch (error) {
|
|
316
366
|
const message = `/${hookInput.command} failed: ${describe(error)}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-clm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,10 +23,13 @@
|
|
|
23
23
|
],
|
|
24
24
|
"main": "./index.ts",
|
|
25
25
|
"exports": {
|
|
26
|
-
".": "./index.ts"
|
|
26
|
+
".": "./index.ts",
|
|
27
|
+
"./server": "./index.ts",
|
|
28
|
+
"./tui": "./tui.ts"
|
|
27
29
|
},
|
|
28
30
|
"files": [
|
|
29
31
|
"index.ts",
|
|
32
|
+
"tui.ts",
|
|
30
33
|
"src/**/*.ts",
|
|
31
34
|
"skills/**",
|
|
32
35
|
"steering/**",
|
|
@@ -51,8 +54,20 @@
|
|
|
51
54
|
"dependencies": {
|
|
52
55
|
"@opencode-ai/plugin": "^1.18.34"
|
|
53
56
|
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@opentui/core": ">=0.4.5"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"@opentui/core": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
54
65
|
"devDependencies": {
|
|
66
|
+
"@opentui/core": "0.4.5",
|
|
67
|
+
"@opentui/keymap": "0.4.5",
|
|
68
|
+
"@opentui/solid": "0.4.5",
|
|
55
69
|
"@types/bun": "1.3.14",
|
|
70
|
+
"@xterm/headless": "6.0.0",
|
|
56
71
|
"typescript": "5.9.3"
|
|
57
72
|
}
|
|
58
73
|
}
|
package/src/atomic.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Atomic replacement of small session files (overrides.json, snapshot.json, rN.json): write
|
|
2
|
+
// a temp file with mode 0600 next to the target, then rename it over the target, so a
|
|
3
|
+
// reader never sees a torn file. Written for this package.
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { mkdir, open, rename, rm } from "node:fs/promises";
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
|
|
8
|
+
export async function writeFileAtomic(path: string, text: string): Promise<void> {
|
|
9
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
10
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
11
|
+
try {
|
|
12
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
13
|
+
try {
|
|
14
|
+
await handle.writeFile(text, "utf8");
|
|
15
|
+
} finally {
|
|
16
|
+
await handle.close();
|
|
17
|
+
}
|
|
18
|
+
await rename(temporary, path);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function writeJsonAtomic(path: string, value: unknown): Promise<void> {
|
|
26
|
+
return writeFileAtomic(path, `${JSON.stringify(value, null, 1)}\n`);
|
|
27
|
+
}
|