opencode-lossless 0.1.0 → 0.1.2

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 CHANGED
@@ -1 +1,16 @@
1
1
  # opencode-lossless
2
+
3
+ ## 0.1.2
4
+
5
+ ### Patch Changes
6
+
7
+ - de419eb: Ship a versioned Intent skill with each package (`skills/<name>/SKILL.md` in the published tarball): install, command and recall vocabulary, config essentials, and common mistakes, stamped with the package version on release. Adds `skills` to each package's published files and validates skills on publish and in CI.
8
+
9
+ ## 0.1.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 6748df1: Refresh every external dependency to its newest published version: Vite+ 1.4.0 and Vitest 5.0.5, TypeScript 7.0.2, `@types/node` 26.6.5, `@changesets/cli` 3.0.3, Pi 1.4.0, OpenCode 2.1.4, Typebox 1.4.3, Blume 2.2.5, and Bun 1.4.4. Pi host packages now use the `*` peer range its package guide documents for packages the host supplies.
14
+ - 6748df1: Derive the heal source window from the summary target instead of clipping every source message at a fixed 3,000 tokens. The window is clamped to the summarizer request budget and has an explicit `healSourceTokens` override in Pi and OpenCode. A leaf that exceeds one window is mapped through up to eight exact windows and reduced once, so a large tool result can become clean without one enormous prompt. A source beyond the bounded plan is blocked without another model call, while a mechanical node gets one improvement. Heal reports now separate clean repairs from improvements and record source-blocked nodes.
15
+ - 6748df1: Add per-node summary timing, session-correlated engine rows, and adapter-routed metrics. OpenCode now writes engine and summarizer rows to its sidecar log, exposes the summarizer call and pass budgets, marks character-derived usage as estimated, and reports summary latency, cap outcomes, and abandoned rows. Add persistent summary quality: clean, clipped, mechanical, and derived. Existing stores migrate to generation 2, and a failed parent stays visible as derived. Add a bounded heal command in both adapters. It finds tainted summaries and their ancestors, re-derives them from stored source bottom-up, rejects another mechanical result, and supports a dry run. A heal owns its store connection so a plugin reload cannot finalize it, and one session allows one heal at a time. Mechanical work runs before a clipped retry, so a bounded run keeps making progress.
16
+ - 6748df1: Add the OpenCode v2 adapter. It mirrors prompts, assistant text, and tool traffic verbatim into a per-session SQLite sidecar; registers `lcm_grep`, `lcm_describe`, `lcm_expand`, and the `/lossless-status`, `/lossless-report`, `/lossless-pass`, `/lossless-backup`, `/lossless-export`, `/lossless-import`, `/lossless-diagnose`, `/lossless-migrate`, `/lossless-gc`, and `/lossless-heal` commands; and records the host's compaction boundaries and summaries beside the rows they replace. `assembly` defaults to true: it builds the leaf and condensed DAG, projects the aged frontier into `session.context`, and hands the same frontier to the host as its compaction checkpoint, marked so the next request re-applies it. `assembly: false` leaves compaction to the host and omits `/lossless-pass` and `lcm_expand_query`. `lcm_expand_query` runs a child session narrowed to the three recall tools, reads the parent's sidecar, returns findings only, and removes the child with its transcript sidecar. Every seam writes a diagnostic row to `metrics.jsonl` (projection, checkpoint, pass, recall, reader), and `/lossless-report` summarizes this session's rows. `lcm_grep` gains `scope` and `lcm_expand` gains `session`, which read other sessions' sidecars read-only; OpenCode keeps one global sidecar directory, so `sessions` and `all_sessions` are equivalent and the reader remains session-scoped. `/lossless-diagnose` builds a redacted bundle and can repair the full-text index. `/lossless-gc` is age-based because the plugin API cannot list host sessions; it exports each transcript before deleting and never touches the current or open sidecar. There is no cockpit in V2 scope: the host TUI extension point exists, so a separate TUI plugin could reuse the same command core later. The adapter has no runtime dependencies, and sidecar files and their WAL are owner-only (0600) with a 0700 directory.
package/README.md CHANGED
@@ -1,274 +1,88 @@
1
1
  # opencode-lossless
2
2
 
3
- Lossless Context Management for OpenCode v2, built on
4
- [`lossless-core`](https://npmx.dev/package/lossless-core). The adapter mirrors
5
- every prompt, assistant reply, and tool exchange into a per-session SQLite
6
- sidecar. The model reads that history back through `lcm_grep`, `lcm_describe`,
7
- and `lcm_expand`; with `assembly` true (the default) it also gets
8
- `lcm_expand_query`, which answers through a child session without pulling the
9
- originals into the parent.
3
+ **Lossless Context Management (LCM) for OpenCode v2. Long sessions keep working past the point where the model would forget, and every verbatim message stays searchable and readable.**
10
4
 
11
- With assembly on, the adapter also owns the context boundary: once a request
12
- crosses the swap point, the DAG projection replaces the aged history, and when
13
- the host compacts, the adapter hands it the frontier as the checkpoint instead
14
- of letting it summarize. With `assembly: false`, the host compacts natively.
15
- Either way the sidecar keeps every address the reader can name, including the
16
- compaction summaries.
5
+ Install it, and the adapter mirrors every prompt, assistant reply, and tool exchange into a per-session SQLite sidecar. The model reads that history back through `lcm_grep`, `lcm_describe`, and `lcm_expand`; with `assembly` true (the default) it also gets `lcm_expand_query`, which answers through a child session without pulling the originals into the parent. With assembly on, the adapter also owns the context boundary: past the swap point the DAG projection replaces the aged history, and when the host compacts, the adapter hands it the frontier as the checkpoint instead of letting it summarize.
17
6
 
18
- Verified against OpenCode v2.1.3 in live sessions: the plugin loads from its
19
- directory; prompt text, assistant text, tool input and result, and a manual
20
- compaction mark with its summary text all mirror verbatim; `lcm_grep` answers
21
- from the sidecar; and auto-compaction ran after a project config narrowed the
22
- window to 6k tokens. Compaction marks and summaries keep distinct ids across a
23
- plugin reload. In the live run, the host stored the DAG frontier as its
24
- checkpoint with the adapter's marker on the message, and the next request
25
- re-applied it.
7
+ This package is the OpenCode v2 adapter over
8
+ [`lossless-core`](https://npmx.dev/package/lossless-core), the engine that
9
+ ingests the history and builds the summaries. Verified against OpenCode v2 in
10
+ live sessions: the plugin loads from its directory, all mirrored text matches
11
+ verbatim, and host compactions store the DAG frontier as their checkpoint.
26
12
 
27
13
  ## Install
28
14
 
29
- Install it with the OpenCode CLI, which adds the `plugins` entry to the global
30
- config:
31
-
32
15
  ```sh
33
16
  opencode plugin add opencode-lossless
34
17
  ```
35
18
 
36
- The equivalent entry is:
19
+ The equivalent entry in `opencode.json`:
37
20
 
38
21
  ```json
39
22
  {
40
- "plugins": [
41
- {
42
- "package": "opencode-lossless",
43
- "options": { "sidecarDir": "./.lossless" }
44
- }
45
- ]
23
+ "plugins": [{ "package": "opencode-lossless", "options": { "sidecarDir": "./.lossless" } }]
46
24
  }
47
25
  ```
48
26
 
49
- ## Live loop
27
+ ## Quick start
50
28
 
51
- From the checkout:
29
+ Zero config required: passive mirroring starts immediately, and swaps begin
30
+ once context crosses the swap point. Ten commands, all `lossless-*`:
52
31
 
53
32
  ```sh
54
- bun run pack # build the bundle the host loads
55
- bun run opencode:check # read-only: host version, config, bundle hash, sidecar counts
56
- opencode2 plugin list # confirms the host loaded this checkout's dist/index.mjs
57
- bun run opencode:smoke # one real model call through the running server
58
- bun run opencode:compact --mode manual # compaction gate, makes model calls
33
+ /lossless-status # message and compaction summary counts per live sidecar
34
+ /lossless-report # this session's metrics: projection, checkpoint, pass, recall, reader
35
+ /lossless-pass # run one DAG pass over the aged span now, without waiting for the swap
36
+ /lossless-heal # dry-run repair of tainted summaries; --apply spends model calls
37
+ /lossless-backup # copy this session's sidecar beside it
38
+ /lossless-export # write the verbatim transcript and summaries as JSONL (mode 0600)
39
+ /lossless-import # restore rows from an export
40
+ /lossless-diagnose # redacted, pasteable diagnostics block; --save writes it at mode 0600
41
+ /lossless-migrate # upgrade older-schema sidecars in place (dry-run by default)
42
+ /lossless-gc # list old sidecars, or export and delete them (never the current session)
59
43
  ```
60
44
 
61
- `bun run opencode:check` makes no model calls. It prints the host version, the
62
- config path, the plugin entry resolved from the config document's directory,
63
- the bundle's sha256 and mtime, the resolved sidecar directory, and one line per
64
- store with message counts by role, the compaction summary count, and the newest
65
- entry. It takes `--json` and `--limit N`. The live scripts require an absolute
66
- `sidecarDir` in the plugin options, because the host resolves a relative one
67
- from its own working directory.
68
-
69
- The server watches the configured plugin directory and reloads it after
70
- `bun run pack`. If a change does not take effect, run `opencode2 reload`; a
71
- service restart is the fallback.
72
-
73
- `bun run opencode:smoke` sends `Reply with exactly: pong.` through
74
- `opencode2 run` in a temporary directory, then asserts that the session sidecar
75
- holds a user row and an assistant row with the reply, that the sidecar
76
- directory is 0700, and that the database files are 0600. It deletes the sidecar
77
- afterwards unless `--keep` is passed. `--model provider/model[#variant]`
78
- overrides the model, which defaults to the config's `model`. The npm free tier
79
- rejects CLI runs, so the smoke does not default to a free model.
80
-
81
- `bun run opencode:compact` is the compaction gate, and it makes real model
82
- calls. `--mode manual` asks for a compaction through the API, waits for the
83
- step boundary to store a mark and a summary, reloads the plugin, then compacts
84
- again and asserts that both runs left distinct ids and that the first rows
85
- survived. `--mode auto` (the default is manual) writes a temporary project
86
- config with a 6k token window and auto-compaction, then sends a long prompt
87
- until the host compacts on its own. Both modes assert the 0700 directory and the
88
- 0600 database files, delete the sidecar unless `--keep` is passed, and take
89
- `--model` and `--timeout-ms`. Auto mode uses `--model`, or the script's fallback
90
- model when none is passed; manual mode uses `--model`, then the config's
91
- `model`, then the script fallback.
45
+ The model gets four recall tools: `lcm_grep` (search this session, `scope`
46
+ reaches past sessions), `lcm_describe` (inspect one summary without expanding
47
+ it), `lcm_expand` (read the originals a summary covers), and
48
+ `lcm_expand_query` (a bounded child session reads and answers, so the
49
+ originals never enter the parent's context).
92
50
 
93
51
  ## Config
94
52
 
95
53
  ```json
96
54
  {
97
55
  "enabled": true,
98
- "sidecarDir": "./.lossless"
56
+ "sidecarDir": "./.lossless",
57
+ "assembly": true
99
58
  }
100
59
  ```
101
60
 
102
- `enabled` is a master flag and defaults to true. Listing the plugin is intent
103
- enough, while `false` registers nothing. `sidecarDir` holds one SQLite file
104
- per session and is created when missing. It defaults to `./.lossless`,
105
- resolved from the server working directory. Delete a sidecar to forget that
106
- session. The mirror never writes to the session record. With assembly on, the
107
- host stores the checkpoint summary and its marker on the compaction message.
108
-
109
- `assembly` (default true) wires the context boundary. It registers the
110
- `session.context` projection, the blocking `session.compaction` checkpoint,
111
- `lcm_expand_query`, and the `/lossless-pass` command. The projection swaps the
112
- aged history for the DAG frontier at `swapAtRatio` (default 0.7) and re-cuts at
113
- `recutAtRatio` (default 0.85); `keepRecentTokens` (default 20000) is the
114
- verbatim tail, `summaryTokens` (default 1500) the render budget, `contextWindow`
115
- (default 128000) the window used when the catalogue cannot name one, `smartZone`
116
- and `zones` override the thresholds, and `summarizer` names the model chain,
117
- where `auto` is the session model. This host exposes no synchronous model
118
- lookup, so a concrete model id in the chain falls back to the session model.
119
- `summarizerTokensPerCall` (default 60000) caps one request, estimate plus output
120
- cap, before it is refused rather than paid for; `summarizerTokensPerPass`
121
- (default 250000) caps what one pass may spend across its calls.
122
- `summarizerTokensPerHeal` (default 120000) caps what one
123
- `/lossless-heal --apply` run may spend across its calls; a dry run spends
124
- nothing. `healSourceTokens` (default 8x `summaryTokens`, clamped 3000 through
125
- 24000 and to `summarizerTokensPerCall`) is the source window one heal map call
126
- may read. A leaf that fits one window is sent once; a larger leaf maps up to
127
- eight windows and reduces them once. A source beyond eight windows is blocked
128
- without a model call, and a mechanical node gets one improvement.
129
- `readerTimeoutMs` (default 120000) is how long one expand-query child may run
130
- before it is interrupted. A malformed value keeps its default rather than
131
- failing the plugin load. Set `assembly: false` to leave compaction to the host.
132
-
133
- ## What the model gets
134
-
135
- Three tools always, and a fourth by default; `assembly: false` leaves three.
136
- `lcm_grep`, `lcm_describe`, and `lcm_expand` use the same recall vocabulary as
137
- the Pi adapter; the OpenCode descriptions add scope and session text.
138
- `lcm_expand_query` matches Pi's tool: it runs a bounded
139
- reader and returns findings, so the raw originals never enter the parent's
140
- context.
141
-
142
- - `lcm_grep`: full-text search over the calling session's stored history.
143
- Each hit names the summary that covers it. `scope: "sessions"` or
144
- `"all_sessions"` searches the other sidecars in the directory; OpenCode keeps
145
- one global sidecar directory, so the two scopes are equivalent today and no
146
- project index is kept. A past-session hit is labelled `[lcm:session <id>]`
147
- and capped at 20 store files, 40 hits, and 1.5 s.
148
- - `lcm_describe`: inspect one summary without expanding it. This reads the
149
- calling session only.
150
- - `lcm_expand`: read the original messages a summary covers, or one named
151
- entry. Pass `session` with an `[lcm:session <id>]` hit to read that past
152
- session's sidecar read-only; the text comes back labelled as a past
153
- session's history.
154
- - `lcm_expand_query` (assembly default): recover compacted history and answer a
155
- question about it. Pass `summary_id` or `entry_id` to read that source, or
156
- neither to search first. `max_tokens` (default 10000, clamped to 500..40000)
157
- sets the budget stated in the brief, and `readerTimeoutMs` bounds the child's
158
- run. The reader is session-scoped in this version: it reads the calling
159
- session's history, not a past session's, and the child's recall tools refuse
160
- `scope: "sessions"` and a past `session` argument.
161
-
162
- The reader is an ordinary child session created against the parent. It runs
163
- the host's `explore` agent with a session ruleset that denies everything and
164
- then allows only `lcm_grep`, `lcm_describe`, and `lcm_expand`; the
165
- `session.context` hook replaces its system prompt with the reader instructions
166
- and skips the projection and checkpoint. Its recall tools resolve to the
167
- parent's sidecar. The parent's `lcm_expand_query` waits for the child, reads
168
- the last assistant text, redacts it, removes the child, and deletes the child's
169
- transcript sidecar. A named summary or entry the store does not hold is refused
170
- before a child exists. The reader is session-scoped, so there is no past-session
171
- reader, and there is no per-child token or step cap: the child's model decides
172
- how much it reads within the brief.
173
-
174
- Ten commands by default; `/lossless-pass` is absent with `assembly: false`:
61
+ `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files; delete
62
+ one to forget that session). `assembly: false` leaves compaction to the host
63
+ and drops `/lossless-pass` and `lcm_expand_query`. Every other key (thresholds,
64
+ summarizer chain and budgets, heal window, reader timeout) lives on the
65
+ [config reference](https://stainless-code.com/lossless/reference/opencode/config)
66
+ with its default.
175
67
 
176
- - `/lossless-status`: report the message and compaction summary counts per live
177
- sidecar. A command cannot return text directly, so the status arrives as a
178
- prompt the loop answers from.
179
- - `/lossless-report`: summarize this session's rows in `metrics.jsonl`:
180
- projection decisions with their top bail and kick reasons, checkpoints with
181
- the last summary ids and text size, pass outcomes with leaf and condensed
182
- totals, summary node counts with the level-3 fallback share and node latency,
183
- summarizer usage, errors, caps, and estimated counts, abandoned pass counts,
184
- heal runs, recall calls per tool, reader runs with their average duration, and
185
- the last decision's action and occupancy.
186
- - `/lossless-pass`: run one DAG pass over the aged span now, without waiting for
187
- the swap threshold.
188
- - `/lossless-heal [--apply] [--max-nodes <n>]`: repair tainted summaries from
189
- their stored sources. Dry-run by default and makes no model call; `--apply`
190
- spends model calls, and `--max-nodes` overrides the engine's 32-node cap.
191
- - `/lossless-backup [path]`: copy this session's sidecar beside it, or to the
192
- path you name.
193
- - `/lossless-export [path]`: write the verbatim transcript and summaries as
194
- JSONL, mode 0600. The default lands beside the sidecars.
195
- - `/lossless-import <path>`: restore rows from an export. Refused while a pass
196
- is running, or into a store that already holds foreign messages.
197
- - `/lossless-diagnose [--save <path>] [--repair]`: build a redacted, pasteable
198
- diagnostics block; `--save` writes it at mode 0600, and `--repair` rebuilds
199
- the full-text index first.
200
- - `/lossless-migrate [--apply]`: upgrade older-schema sidecars in place.
201
- Dry-run by default.
202
- - `/lossless-gc [--older-than <days>] [--session <id>] [--apply]`: list old
203
- sidecars, or export and delete them. Never touches the current session.
68
+ ## Verify
204
69
 
205
- `gc` is age-based because the plugin API cannot list host sessions: it defaults
206
- to 30 days, never touches the current session, and exports each transcript
207
- before deleting it.
70
+ From a checkout of this repo:
208
71
 
209
- There is no cockpit in V2 scope: the host exposes a TUI extension point with slot mounts (including `session.panel`), so a separate TUI plugin could reuse the same command core later.
210
-
211
- ## How it works
212
-
213
- The plugin mirrors four boundaries into the sidecar, each under a stable
214
- entry id, so a redelivered event inserts nothing:
215
-
216
- | Boundary | Entry |
217
- | --------------------------------------------------------- | --------------------------------------------------------------------------- |
218
- | `session.prompt` hook | the user text plus attached file names |
219
- | `session.text.ended` bus event | each assistant text block at its full-value boundary |
220
- | `tool.execute.after` hook | the tool name, the input that caused it, and the result or error text |
221
- | `session.compaction` hook plus `session.compaction.ended` | a checkpoint mark naming what left the window, then the summary text itself |
222
-
223
- Only text is stored. Media, reasoning, and streaming deltas stay out, which
224
- matches what the model saw as text. Native checkpoints that publish an empty
225
- summary are skipped because there is nothing to store.
226
-
227
- With assembly enabled (the default), the same compaction hook is the blocking boundary. It reads
228
- the stored history, cuts the aged prefix the way the projection does, and hands
229
- the host the frontier render as `event.result.summary` plus a marker
230
- (`lcm`, `summaryIds`, `cutCount`, `checkpoint`, `keepID`). The host stores the summary and
231
- the marker on the compaction message, so the next request's projection
232
- recognizes its own checkpoint by the marker, not by the text, and re-applies the
233
- host's rendering of it. A checkpoint the adapter did not write leaves the
234
- request alone. If the frontier does not cover the aged span the hook runs one
235
- pass and waits; a failed pass leaves the result unset so the host summarizes
236
- natively, and records a `compaction-checkpoint` metric row.
237
-
238
- Every seam writes a row to `metrics.jsonl` beside the sidecars: one
239
- `projection` decision per request, one `compaction-checkpoint` per boundary,
240
- one `pass` per summarizer run, one `recall` per recall-tool call, and one
241
- `reader` per expand-query child with its outcome, duration, answer size, and
242
- cited ids. The engine and summarizer rows a pass causes (`summary-stored`,
243
- `summarizer-usage`, `compaction-complete`, summarizer errors and caps, and
244
- abandoned counts) land in that same OpenCode log through the pass's sink, not
245
- in Pi's metrics file. A heal writes `heal-start`, `heal-node`, and
246
- `heal-complete` rows to the same log. The log is created 0600 in a 0700 directory, rotates to
247
- `metrics.jsonl.<timestamp>` at 5 MB, keeps three generations, and a read skips
248
- a missing file or a malformed line. `/lossless-report` renders it per session.
249
-
250
- The published bundle names no host SDK. `src/host.ts` is the whole part of
251
- the plugin API this package uses, written as local structural types, and
252
- `test/host-compat.test.ts` assigns the real host types to each one. A host
253
- upgrade that changes the API fails typecheck instead of packing a silent
254
- mismatch.
72
+ ```sh
73
+ bun run opencode:check # read-only: host version, config, bundle hash, sidecar counts
74
+ bun run opencode:smoke # one real model call, asserts the verbatim mirror
75
+ bun run opencode:compact # compaction gate, makes real model calls
76
+ ```
255
77
 
256
- ## Security notes
78
+ ## Documentation
257
79
 
258
- - Sidecars are plaintext SQLite files under `sidecarDir`. The directory is
259
- created 0700, and the database, WAL, and shared-memory files are 0600. A
260
- sidecar holds the session's secrets verbatim, so it deserves the same care
261
- as the session itself.
262
- - Redaction is best-effort pattern matching, not a hard boundary. It runs on
263
- the way out, at every tool that hands stored text to a model, and it never
264
- touches a stored row.
265
- - Recall tools read the calling session's sidecar, except inside an
266
- expand-query child, which reads its parent's. A `lcm_grep` scope of
267
- `sessions` or `all_sessions` opens other sidecars through a `queryOnly`
268
- handle, so a scan never rewrites a session it is only reading, and
269
- `lcm_expand` with `session` reads the same way. A session with no history
270
- gets an explanation, not an empty result, and a lookup never creates a store. The
271
- child's own sidecar is a transcript and is deleted with the child.
80
+ | | |
81
+ | ------------------------------------------------------------------------------- | --------------------------------------------------------- |
82
+ | [The docs site](https://stainless-code.com/lossless/) | How LCM behaves in a session, the config keys, the limits |
83
+ | [Commands](https://stainless-code.com/lossless/reference/opencode/command) | What each `/lossless-*` command does |
84
+ | [Comparison](https://stainless-code.com/lossless/reference/opencode/comparison) | The competing engines on one set of axes, verified cells |
85
+ | [CHANGELOG.md](./CHANGELOG.md) | Version history (changesets) |
272
86
 
273
87
  ## Credits
274
88
 
package/dist/index.d.mts CHANGED
@@ -1,19 +1,4 @@
1
- /** One model's thresholds. A token field takes precedence over its ratio
2
- * counterpart when both are set. */
3
- interface ThresholdConfig {
4
- /** Swap when the request reaches this share of the model window. */
5
- swapAtRatio?: number;
6
- /** Swap when the request reaches this many tokens; takes precedence over
7
- * `swapAtRatio`. */
8
- swapAtTokens?: number;
9
- /** Re-cut when the request reaches this share of the model window. */
10
- recutAtRatio?: number;
11
- /** Re-cut when the request reaches this many tokens; takes precedence over
12
- * `recutAtRatio`. */
13
- recutAtTokens?: number;
14
- /** Swap at this absolute token count, deriving the recut from it. */
15
- smartZone?: number;
16
- }
1
+ import { LcmStore, SidecarStores, ThresholdConfig } from "lossless-core";
17
2
  /** The part of the OpenCode v2 plugin API this package uses, written
18
3
  * as local structural types. The published bundle and its declarations name
19
4
  * no host SDK: every host value arrives through these shapes, and