diff --git a/.changeset/gc-soft-guards.md b/.changeset/gc-soft-guards.md new file mode 100644 index 0000000..7f44023 --- /dev/null +++ b/.changeset/gc-soft-guards.md @@ -0,0 +1,5 @@ +--- +"opencode-lossless": patch +--- + +Harden `lossless-gc` against deleting sidecars that are live in another process. Sweeps skip stores active within the last 60 minutes and name them. Bare `--older-than 0` without `--session` is refused. `--session` on a store active within 60 minutes needs `--force`. Candidates younger than 24 hours carry a warning. diff --git a/.changeset/opencode-sidecar-global-default.md b/.changeset/opencode-sidecar-global-default.md new file mode 100644 index 0000000..726012c --- /dev/null +++ b/.changeset/opencode-sidecar-global-default.md @@ -0,0 +1,5 @@ +--- +"opencode-lossless": patch +--- + +Change the default `sidecarDir` to the auto-detected global path. The plugin uses `XDG_DATA_HOME` with a `~/.local/share` fallback, uses `LOCALAPPDATA` with an `APPDATA` fallback and then a `/AppData/Local` fallback on Windows, and uses `./.lossless` when no home resolves. To keep per-project storage, set `"sidecarDir": "./.lossless"`. After upgrading a project that used `./.lossless`, keep that setting to keep its history: `/lossless-migrate` upgrades in place in the configured directory only (dry run by default) and moves nothing, so it cannot recover history left in `./.lossless`. To carry history across, export per session under the old config and import into an empty store under the new one, or stop the server first and copy the `.sqlite`, `-wal`, and `-shm` files across and then migrate. The plugin never moves sidecars. diff --git a/apps/docs/components/seo/lcm-faqs.ts b/apps/docs/components/seo/lcm-faqs.ts index 57ac40a..5964139 100644 --- a/apps/docs/components/seo/lcm-faqs.ts +++ b/apps/docs/components/seo/lcm-faqs.ts @@ -17,7 +17,7 @@ export const SHARED_FAQS = { lossy: { question: "Is it lossy?", answer: - "The projection is lossy by construction, because the model sees a smaller view. The store is not. Every message is held whole, and `lcm_expand_query` reads the stored bytes back by address.", + "The projection is lossy by construction, because the model sees a smaller view. The store is not. The store holds every message whole, and `lcm_expand_query` reads the stored bytes back by address.", }, caching: { question: "Does it break provider prompt caching?", @@ -55,7 +55,7 @@ export const LCM_FAQS: readonly LcmFaq[] = [ { question: "Where does the data live?", answer: - "One SQLite file per session in the adapter's sidecar directory. Pi uses `~/.pi/agent/lcm/`; OpenCode uses the configured `sidecarDir` (default `./.lossless`, resolved from the server working directory). The directory is 0700; the database, WAL, and shared-memory files are 0600. Metrics sit beside the stores. Nothing leaves the machine except the provider calls the summarizer and reader make.", + "One SQLite file per session in the adapter's sidecar directory. Pi uses `~/.pi/agent/lcm/`. OpenCode uses the configured `sidecarDir`. The default is a global auto-detected path. [Config](/reference/opencode/config) lists the exact paths. The directory is 0700. The database, WAL, and shared-memory files are 0600. Metrics files are beside the stores. Nothing leaves the machine except the provider calls the summarizer and reader make.", }, SHARED_FAQS.caching, { diff --git a/apps/docs/components/seo/opencode-faqs.ts b/apps/docs/components/seo/opencode-faqs.ts index bc5bb24..3b92b0d 100644 --- a/apps/docs/components/seo/opencode-faqs.ts +++ b/apps/docs/components/seo/opencode-faqs.ts @@ -12,12 +12,12 @@ export const OPENCODE_FAQS: readonly LcmFaq[] = [ { question: "Do I have to configure anything?", answer: - "No. The defaults (swap at 70%, re-cut at 85%, keep 20,000 tokens verbatim) work with no options. Configuration is the plugin's `options` in `opencode.json`; there is no separate config file or interactive settings panel. `sidecarDir` defaults to `./.lossless`, resolved from the server working directory.", + 'No. The defaults (swap at 70%, re-cut at 85%, keep 20,000 tokens verbatim) work with no options. Configuration is the plugin\'s `options` in `opencode.json`. There is no separate config file or interactive settings panel. `sidecarDir` defaults to a global auto-detected path (see [Config](/reference/opencode/config)). Set it to `"./.lossless"` to keep sidecars inside the project.', }, { question: "Where does the data live?", answer: - "One SQLite sidecar per session under `sidecarDir` (default `./.lossless`, resolved from the OpenCode server working directory). The directory is 0700; the database, WAL, and shared-memory files are 0600. Nothing leaves the machine except the provider calls the summarizer and reader make.", + "One SQLite sidecar per session under `sidecarDir`. The default is a global auto-detected path. [Config](/reference/opencode/config) lists the exact paths. The directory is 0700. The database, WAL, and shared-memory files are 0600. Nothing leaves the machine except the provider calls the summarizer and reader make.", }, { question: "Does it replace OpenCode compaction?", @@ -32,7 +32,7 @@ export const OPENCODE_FAQS: readonly LcmFaq[] = [ { question: "Can the model read a past OpenCode session?", answer: - 'Yes. `lcm_grep` scans the global sidecar directory, so `"sessions"` and `"all_sessions"` are equivalent today. The reader runs in a child session limited to the recall tools, and its transcript sidecar is removed after it answers.', + 'Yes. `lcm_grep` scans the global sidecar directory, so `"sessions"` and `"all_sessions"` are equivalent today. The reader runs in a child session limited to the recall tools, and the plugin removes its transcript sidecar after it answers.', }, SHARED_FAQS.caching, { diff --git a/apps/docs/content/_snippets/guides/configuration/opencode.mdx b/apps/docs/content/_snippets/guides/configuration/opencode.mdx index c587913..85bd907 100644 --- a/apps/docs/content/_snippets/guides/configuration/opencode.mdx +++ b/apps/docs/content/_snippets/guides/configuration/opencode.mdx @@ -26,6 +26,12 @@ The threshold vocabularies are the same: ratios, `smartZone`, and token pairs. ` `summarizerTokensPerCall` and `summarizerTokensPerPass` bound a pass. `summarizerTokensPerHeal` bounds an on-demand `/lossless-heal --apply` repair. A malformed value keeps its default. -A heal reads source through windows derived from `summaryTokens` (8x, clamped 3,000 through 24,000 and to `summarizerTokensPerCall`). A leaf that fits one window is sent once; a larger leaf maps up to eight windows and reduces them once. Set `healSourceTokens` to change the window. A source beyond eight windows is blocked with no model call, not retried at the same size; a mechanical node gets one improvement. Raising the window makes the next run attempt it again. +A heal reads source through windows derived from `summaryTokens` (8x, clamped 3,000 through 24,000 and to `summarizerTokensPerCall`). A leaf that fits one window is sent once. A larger leaf maps up to eight windows and reduces them once. Set `healSourceTokens` to change the window. A source beyond eight windows is blocked with no model call, not retried at the same size. A mechanical node gets one improvement. Raising the window makes the next run attempt it again. -`assembly: false` keeps the mirror and recall tools and leaves compaction to the host. Edit the options and reload the plugin to change them; there is no live settings panel. [Config](/reference/opencode/config) lists every key and default. +`assembly: false` keeps the mirror and recall tools and leaves compaction to the host. Edit the options and reload the plugin to change them. There is no live settings panel. [Config](/reference/opencode/config) lists every key and default. + +To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. Without this setting, the plugin stores sidecars in one global directory that holds sessions from every project. The global directory uses `XDG_DATA_HOME` with a `~/.local/share` fallback on Linux and macOS, and `LOCALAPPDATA` with an `APPDATA` fallback and then a `/AppData/Local` fallback on Windows. To use a different location, set `sidecarDir` to a path outside the global directory. [Config](/reference/opencode/config) lists the exact default paths. + +To remove old sidecars from the shared directory, run `/lossless-gc`. Orphan sidecars accumulate there because the directory is shared. + +After upgrading a project that stored sidecars in `./.lossless`, keep `sidecarDir` set to `"./.lossless"` to keep reading that history. `/lossless-migrate` upgrades sidecars in place in the configured directory only: it is a dry run by default, moves nothing between directories, and cannot recover history left behind in `./.lossless`. To carry history to a new directory, run `/lossless-export` per session under the old config and `/lossless-import` each file into an empty store under the new config, or stop the server first and copy the `.sqlite`, `-wal`, and `-shm` files across and then run `/lossless-migrate --apply`. The plugin never moves sidecars, and the startup warning names both paths. diff --git a/apps/docs/content/_snippets/guides/troubleshooting/opencode.mdx b/apps/docs/content/_snippets/guides/troubleshooting/opencode.mdx index 504c5cf..4998f31 100644 --- a/apps/docs/content/_snippets/guides/troubleshooting/opencode.mdx +++ b/apps/docs/content/_snippets/guides/troubleshooting/opencode.mdx @@ -2,11 +2,11 @@ Start with `/lossless-status` and `/lossless-report`. | Symptom | First check | | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| No stored history | `opencode.json` lists the plugin, `enabled` is not false, and `sidecarDir` is writable. A relative `sidecarDir` resolves from the server working directory; prefer an absolute path. | +| No stored history | `opencode.json` lists the plugin, `enabled` is not false, and `sidecarDir` is writable. An explicit `sidecarDir` resolves from the server working directory when relative. Prefer an absolute path. When `sidecarDir` is unset, the plugin uses the global default. [Config](/reference/opencode/config) lists the exact default paths. | | Nothing has been swapped | `assembly` is true, the request is above `swapAtRatio`, and `metrics.jsonl` has a `projection` row with `action: bail`. | -| The model sees raw history after a swap | The `reason` on the last projection row. `unaligned-outgoing` means the host draft and the stored history did not line up; the adapter fails closed rather than guessing. | +| The model sees raw history after a swap | The `reason` on the last projection row. `unaligned-outgoing` means the host draft and the stored history did not line up. The adapter fails closed rather than guessing. | | A reader times out | Raise `readerTimeoutMs` or narrow the query. The reader has no per-child token or step cap, so the timeout is the bound. | | The summary looks estimated | The host's `generate.text` returns text without usage, so summarizer spend is estimated from characters. Main-session and reader usage are exact. | | A summary is mechanically truncated | Run `/lossless-heal` to count the tainted nodes, then `/lossless-heal --apply` to re-summarize them from stored source within `summarizerTokensPerHeal`. | -`/lossless-diagnose` builds a redacted block with versions, store facts, the effective options, and this session's metrics; `/lossless-diagnose --save lossless-diagnose.txt` writes it at mode 0600. [Config](/reference/opencode/config) lists every option. The package README's live loop is the reproduction path. +`/lossless-diagnose` builds a redacted block with versions, store facts, the effective options, and this session's metrics. `/lossless-diagnose --save lossless-diagnose.txt` writes it at mode 0600. [Config](/reference/opencode/config) lists every option. The package README's live loop is the reproduction path. diff --git a/apps/docs/content/_snippets/reference/command/opencode.mdx b/apps/docs/content/_snippets/reference/command/opencode.mdx index e3e905d..c84a860 100644 --- a/apps/docs/content/_snippets/reference/command/opencode.mdx +++ b/apps/docs/content/_snippets/reference/command/opencode.mdx @@ -8,14 +8,14 @@ /lossless-import /lossless-diagnose [--save ] [--repair] /lossless-migrate [--apply] -/lossless-gc [--older-than ] [--session ] [--apply] +/lossless-gc [--older-than ] [--session ] [--force] [--apply] ``` -Ten commands. `/lossless-pass` is absent with `assembly: false`; `/lossless-heal` stays available. +There are ten commands. `/lossless-pass` is absent with `assembly: false`. `/lossless-heal` stays available. ## Settings -There is no interactive settings panel in V2 scope; the host exposes a TUI extension point, so a separate TUI plugin could reuse the same command core later. Edit the plugin's `options` in `opencode.json` and reload the plugin. [Config](/reference/opencode/config) lists every key. +There is no interactive settings panel in V2 scope. The host exposes a TUI extension point, so a separate TUI plugin could reuse the same command core later. Edit the plugin's `options` in `opencode.json` and reload the plugin. [Config](/reference/opencode/config) lists every key. ## status @@ -23,7 +23,7 @@ There is no interactive settings panel in V2 scope; the host exposes a TUI exten /lossless-status ``` -Message and compaction-summary counts for the live sidecars. A command cannot return text directly, so the status arrives as a prompt the loop answers from. +Message and compaction-summary counts for the live sidecars. A command cannot return text directly, so the status arrives as a prompt that the loop answers. ## report @@ -31,7 +31,7 @@ Message and compaction-summary counts for the live sidecars. A command cannot re /lossless-report ``` -Projection, checkpoint, pass, summary, heal, recall, and reader rows for this session. It says so when the session has no rows. +Projection, checkpoint, pass, summary, heal, recall, and reader rows for this session. When the session has no rows, it says so. ## pass @@ -39,7 +39,7 @@ Projection, checkpoint, pass, summary, heal, recall, and reader rows for this se /lossless-pass ``` -One DAG pass over the stored span, on demand. Mounted only with `assembly` on. +It runs one DAG pass over the stored span on demand. The plugin mounts it only with `assembly` on. ## heal @@ -49,7 +49,7 @@ One DAG pass over the stored span, on demand. Mounted only with `assembly` on. /lossless-heal --apply --max-nodes 8 ``` -A non-clean summary is a candidate. A mechanical level-3 node starts with `[lcm:truncated]`; a clipped leaf or a derived parent may not. Heal reads the exact source rows that node points at, re-summarizes the tainted node, then recomputes every ancestor whose child changed. It rejects another level-3 answer, an empty answer, or an answer over twice the target, so a failed repair keeps the old text. `--max-nodes` bounds one run; `summarizerTokensPerHeal` bounds its spend. The dry run makes no provider call. +A non-clean summary is a candidate. A mechanical level-3 node starts with `[lcm:truncated]`. A clipped leaf or a derived parent may not. Heal reads the exact source rows that node points at, re-summarizes the tainted node, then recomputes every ancestor whose child changed. It rejects another level-3 answer, an empty answer, or an answer over twice the target, so a failed repair keeps the old text. `--max-nodes` bounds one run. `summarizerTokensPerHeal` bounds its spend. The dry run makes no provider call. ## backup @@ -65,7 +65,7 @@ Copies this session's sidecar to `.backup-`, or to the path /lossless-export [path] ``` -Writes the verbatim transcript and summaries as JSONL at mode 0600. The default lands beside the sidecars. +Writes the verbatim transcript and summaries as JSONL at mode 0600. Without a path, it writes beside the sidecars. ## import @@ -73,16 +73,26 @@ Writes the verbatim transcript and summaries as JSONL at mode 0600. The default /lossless-import ``` -Restores rows from a file written by `/lossless-export`. Refused while a pass is running, or into a store that already holds foreign messages. +Restores rows from a file written by `/lossless-export`. It refuses the import while a pass is running or when the store already holds foreign messages. ## gc ```text /lossless-gc # dry-run list /lossless-gc --apply # export each transcript, then delete +/lossless-gc --session --apply --force # delete one recently active store ``` -The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never touches the current session and exports each transcript before deleting it. +The host plugin API cannot list sessions, so `gc` selects by sidecar age (default 30 days) or an explicit id. It never deletes the current session. It exports each transcript before deleting it. It only excludes sidecars open in this process, so against the shared global directory `--apply` can delete a sidecar that is live in another process. Run a dry run first, and bare `--older-than 0` without `--session` is refused. + +The default sweep applies these guards. It never deletes a store active within the last 60 minutes, measured by the newest mtime across the `.sqlite`, `-wal`, and `-shm` files. mtime measures activity, not liveness; a session open in another process but idle over 60 minutes is still eligible. Future mtimes count as age 0. Bare `--older-than 0` without `--session` is refused before any I/O. `--session ` on a store active within 60 minutes is refused unless `--force` is also passed. `--force` without `--session` is refused. `--force` means the operator checked that no other process is using the target. Candidates younger than 24 hours carry a one-line warning naming up to 5 ids. Every sweep re-stats each candidate immediately before export then delete. The sweep skips a store that received a write in between and names it. Skipped stores are counted and named in dry-run and apply output. + +Revisit these guards if any of the following holds: + +1. A real incident report shows gc deleted a live second-process sidecar and the export and import path did not recover it cleanly. +2. A reliable host owned liveness primitive exists, such as session listing or leases from the host API. An owner scoped lockfile is a separate engine decision with its own design note, so this trigger does not assume the engine already solves cross-process coordination. +3. Telemetry or user reports show `--older-than 0` or `--session` used routinely in multi-server setups. +4. A change makes the default unsafe. For example, the default cutoff drops toward hours, gc runs on a timer without an operator reading output, or one sidecar directory is shared across machines over a network filesystem. ## migrate @@ -91,7 +101,7 @@ The host plugin API cannot list sessions, so `gc` selects by sidecar age (defaul /lossless-migrate --apply # upgrade them in place ``` -The only path that writes to a past session's sidecar, and the reason cross-session search can read sidecars written by older builds. +It is the only path that writes to a past session's sidecar. It lets cross-session search read sidecars written by older builds. ## diagnose @@ -101,4 +111,4 @@ The only path that writes to a past session's sidecar, and the reason cross-sess /lossless-diagnose --repair # rebuild the index and repair integrity ``` -One redacted, pasteable block: versions, store facts, the effective options, and this session's metrics. `--save` writes the exact bytes at mode 0600; `--repair` rebuilds the index and repairs integrity before the bundle is built. +One redacted, pasteable block: versions, store facts, the effective options, and this session's metrics. Its first line names the sidecar directory and its source (`explicit`, `fallback`, or the `default:*` auto-detected source). `--save` writes the exact bytes at mode 0600. `--repair` rebuilds the index and repairs integrity before the bundle is built. diff --git a/apps/docs/content/_snippets/reference/config/opencode.mdx b/apps/docs/content/_snippets/reference/config/opencode.mdx index fa67fdc..64c37ed 100644 --- a/apps/docs/content/_snippets/reference/config/opencode.mdx +++ b/apps/docs/content/_snippets/reference/config/opencode.mdx @@ -1,27 +1,27 @@ -The config is the plugin's `options` object in `opencode.json`; there is no separate config file. +The config is the plugin's `options` object in `opencode.json`. There is no separate config file. ## Keys | Key | Type | Default | Range | Effect | | ------------------ | ------------------ | --------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `enabled` | boolean | `true` | `true` or `false` | Master flag. `false` registers nothing. | -| `sidecarDir` | string | `"./.lossless"` | any path | One SQLite file per session; resolved from the server working directory. | +| `sidecarDir` | string | global auto-detected path | any path | One SQLite file per session. An explicit value overrides the default. A blank value falls back to the default. The default is absolute `XDG_DATA_HOME/opencode/lossless`, else `~/.local/share/opencode/lossless`. On Windows the default is `%LOCALAPPDATA%/opencode/lossless`, else `%APPDATA%/opencode/lossless`, else `/AppData/Local/opencode/lossless`. With no resolvable home, the default is `./.lossless` (source `fallback`). The global default holds sessions from every project. The plugin never moves existing sidecars. | | `assembly` | boolean | `true` | `true` or `false` | Build the DAG, project the frontier, and return the checkpoint. `false` keeps the mirror and recall tools. | -| `summarizer` | string or string[] | `"auto"` | one id, or an ordered chain | Summarizer chain. `auto` is the session model; a concrete id falls back to the session model because the host has no synchronous model lookup. | +| `summarizer` | string or string[] | `"auto"` | one id, or an ordered chain | Summarizer chain. `auto` is the session model. A concrete id falls back to the session model because the host has no synchronous model lookup. | | `summarizerTokensPerCall` | number | `60000` | `>= 1` | Ceiling on one summarizer request, estimate plus output cap, refused before the provider is called. | | `summarizerTokensPerPass` | number | `250000` | `>= 1` | Ceiling on one pass across its calls. | | `summarizerTokensPerHeal` | number | `120000` | `>= 1` | Ceiling on one `/lossless-heal --apply` repair run. | -| `swapAtRatio` | number | `0.7` | `> 0`, `<= 1` | Swap line as a share of the window; the engine clamps it to 0.9. | +| `swapAtRatio` | number | `0.7` | `> 0`, `<= 1` | Swap line as a share of the window. The engine clamps it to 0.9. | | `recutAtRatio` | number | `0.85` | `> 0`, `<= 1` | Re-cut line as a share of the window. | -| `smartZone` | number | unset | `> 0` | Swap at this many tokens; re-cut at `x 1.18`. | +| `smartZone` | number | unset | `> 0` | Swap at this many tokens. Re-cut at `x 1.18`. | | `zones` | object | unset | map of `provider/model` to thresholds | Per-model threshold overrides. | | `keepRecentTokens` | number | `20000` | `>= 1` | Tokens kept verbatim after the cut. | | `summaryTokens` | number | `1500` | `>= 1` | Target size of one summary. | | `healSourceTokens` | number | derived | `1` to `24000` | Source tokens one heal map window may read. Defaults to `8 x summaryTokens`, clamped to `3000` through `24000` and to `summarizerTokensPerCall`. A larger leaf uses up to eight map windows and one reduce call. | | `contextWindow` | number | `128000` | `>= 1` | Window used when the host catalogue cannot name the model's limit. | -| `readerTimeoutMs` | number | `120000` | `>= 1` | How long one `lcm_expand_query` child may run before it is interrupted. | +| `readerTimeoutMs` | number | `120000` | `>= 1` | How long one `lcm_expand_query` child may run before the timeout interrupts it. | -`zones` accepts only the five threshold keys: `smartZone`, `swapAtTokens`, `recutAtTokens`, `swapAtRatio`, `recutAtRatio`. Global token pairs are not options; set them per model under `zones`. Anything else is dropped. +`zones` accepts only the five threshold keys: `smartZone`, `swapAtTokens`, `recutAtTokens`, `swapAtRatio`, `recutAtRatio`. Global token pairs are not options. Set them per model under `zones`. Anything else is dropped. ## Threshold resolution @@ -35,10 +35,10 @@ Explicit tokens win over `smartZone`, which wins over ratios. Per-model `zones` ## Parse rules -- A value of the wrong type or range keeps its default; the plugin load never fails on options. +- A value of the wrong type or range keeps its default. The plugin load never fails on options. - `enabled` disables everything only when it is exactly `false`. - `assembly` stays on unless it is exactly `false`. - `summarizer` accepts one string or an array of strings. An empty or malformed value falls back to `auto`. -- The three summarizer token budgets must be positive integers; a malformed value keeps the default. +- The three summarizer token budgets must be positive integers. A malformed value keeps the default. - `zones` entries with no valid threshold key are dropped. `swapAtTokens` and `recutAtTokens` are accepted there only, not at the top level. - The options are read once at setup. Edit `opencode.json` and reload the plugin to change them. diff --git a/packages/opencode/README.md b/packages/opencode/README.md index 6f631a2..d655401 100644 --- a/packages/opencode/README.md +++ b/packages/opencode/README.md @@ -2,12 +2,12 @@ **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.** -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. +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. This package is the OpenCode v2 adapter over [`lossless-core`](https://npmx.dev/package/lossless-core), the engine that ingests the history and builds the summaries. Verified against OpenCode v2 in -live sessions: the plugin loads from its directory, all mirrored text matches +live sessions. The plugin loads from its directory, all mirrored text matches verbatim, and host compactions store the DAG frontier as their checkpoint. ## Install @@ -20,14 +20,16 @@ The equivalent entry in `opencode.json`: ```json { - "plugins": [{ "package": "opencode-lossless", "options": { "sidecarDir": "./.lossless" } }] + "plugins": [{ "package": "opencode-lossless" }] } ``` +To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. + ## Quick start -Zero config required: passive mirroring starts immediately, and swaps begin -once context crosses the swap point. Eleven commands, all `lossless-*`: +The plugin needs no configuration. Passive mirroring starts immediately, and swaps begin +once context crosses the swap point. The commands all start with `lossless-*`: ```sh /lossless-status # message and compaction summary counts per live sidecar @@ -43,7 +45,7 @@ once context crosses the swap point. Eleven commands, all `lossless-*`: /lossless-dump # read-only dump of this session's sidecar; --include-removed keeps removed rows, --save writes mode 0600 ``` -The model gets four recall tools: `lcm_grep` (search this session, `scope` +The model gets recall tools: `lcm_grep` (search this session, `scope` reaches past sessions, `role`, `date_start`, `date_end`, and `tool_name` filter rows), `lcm_describe` (inspect one summary without expanding it), `lcm_expand` (read the originals a summary covers, `anchor` centers @@ -61,12 +63,12 @@ originals never enter the parent's context). } ``` -`sidecarDir` holds one SQLite file per session (0700 dir, 0600 files; delete -one to forget that session). `assembly: false` leaves compaction to the host -and drops `/lossless-pass` and `lcm_expand_query`. Every other key (thresholds, -summarizer chain and budgets, heal window, reader timeout) lives on the +The example stores sidecars in the project. Without `sidecarDir`, the plugin uses the global auto-detected path, which holds sessions from every project. The [config reference](https://stainless-code.com/lossless/reference/opencode/config) lists the exact default paths. `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files). +Delete one file to forget that session. `assembly: false` leaves compaction to the host +and drops `/lossless-pass` and `lcm_expand_query`. The [config reference](https://stainless-code.com/lossless/reference/opencode/config) -with its default. +documents every other key and its default: thresholds, +summarizer chain and budgets, heal window, and reader timeout. ## Verify diff --git a/packages/opencode/skills/opencode-lossless/SKILL.md b/packages/opencode/skills/opencode-lossless/SKILL.md index dc9de9d..eee407e 100644 --- a/packages/opencode/skills/opencode-lossless/SKILL.md +++ b/packages/opencode/skills/opencode-lossless/SKILL.md @@ -14,7 +14,7 @@ sources: # opencode-lossless -Passive mirroring starts with zero config: every prompt, assistant reply, and tool exchange lands in a per-session SQLite sidecar. Past the swap point the DAG projection replaces aged history, and on host compaction the adapter hands over the frontier as the checkpoint instead of a summary. +Passive mirroring starts with no configuration. The adapter stores every prompt, assistant reply, and tool exchange in a per-session SQLite sidecar. Past the swap point the DAG projection replaces aged history, and on host compaction the adapter hands over the frontier as the checkpoint instead of a summary. ## Install @@ -25,22 +25,24 @@ opencode plugin add opencode-lossless Equivalent `opencode.json` entry: ```json -{ "plugins": [{ "package": "opencode-lossless", "options": { "sidecarDir": "./.lossless" } }] } +{ "plugins": [{ "package": "opencode-lossless" }] } ``` +To keep sidecars inside the project, set `sidecarDir` to `"./.lossless"`. + ## Commands -- `/lossless-status` — message and compaction summary counts per live sidecar. -- `/lossless-report` — this session's metrics: projection, checkpoint, pass, recall, reader. -- `/lossless-pass` — run one DAG pass over the aged span now, without waiting for the swap. -- `/lossless-heal` — dry-run repair of tainted summaries; `--apply` spends model calls. -- `/lossless-backup` — copy this session's sidecar beside it. -- `/lossless-export` — write the verbatim transcript and summaries as JSONL (mode 0600). -- `/lossless-import` — restore rows from an export. -- `/lossless-diagnose` — redacted, pasteable diagnostics block; `--save` writes it at mode 0600. -- `/lossless-migrate` — upgrade older-schema sidecars in place (dry-run by default). -- `/lossless-gc` — list old sidecars, or export and delete them (never the current session). -- `/lossless-dump` prints a read-only dump of the session sidecar. `--include-removed` keeps removed-branch rows and `--save` writes the output to a file at mode 0600. +- `/lossless-status`, message and compaction summary counts per live sidecar. +- `/lossless-report`, this session's metrics: projection, checkpoint, pass, recall, reader. +- `/lossless-pass`, run one DAG pass over the aged span now, without waiting for the swap. +- `/lossless-heal`, dry-run repair of tainted summaries. `--apply` spends model calls. +- `/lossless-backup`, copy this session's sidecar beside it. +- `/lossless-export`, write the verbatim transcript and summaries as JSONL (mode 0600). +- `/lossless-import`, restore rows from an export. +- `/lossless-diagnose`, redacted, pasteable diagnostics block. `--save` writes it at mode 0600. +- `/lossless-migrate`, upgrade older-schema sidecars in place (dry-run by default). +- `/lossless-gc`, list old sidecars, or export and delete them (never the current session). +- `/lossless-dump`, read-only dump of the session sidecar. `--include-removed` keeps removed-branch rows, `--save` writes the output to a file at mode 0600. ## Recall tools @@ -55,9 +57,11 @@ Equivalent `opencode.json` entry: { "enabled": true, "sidecarDir": "./.lossless", "assembly": true } ``` -- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files; delete one to forget that session). -- `assembly: true` (default) owns the context boundary: projection swaps aged history and compaction stores the DAG frontier. -- Every other key (swap thresholds, summarizer chain and budgets, heal window, reader timeout) lives on the config reference with its default: https://stainless-code.com/lossless/reference/opencode/config +The example stores sidecars in the project. Without `sidecarDir`, the plugin uses the global auto-detected path, which holds sessions from every project. The config reference lists the exact paths at https://stainless-code.com/lossless/reference/opencode/config + +- `sidecarDir` holds one SQLite file per session (0700 dir, 0600 files). Delete one to forget that session. After upgrading a project that used `./.lossless`, keep `sidecarDir` set to `"./.lossless"` to keep that history: `/lossless-migrate` works in place in the configured directory only (dry run by default, moves nothing). To carry history elsewhere, `/lossless-export` each session under the old config and `/lossless-import` into an empty store under the new one, or stop the server first and copy the `.sqlite`, `-wal`, and `-shm` files across and then migrate. +- `assembly: true` (default) owns the context boundary. Projection swaps aged history and compaction stores the DAG frontier. +- The config reference documents every other key and its default: swap thresholds, summarizer chain and budgets, heal window, and reader timeout. See https://stainless-code.com/lossless/reference/opencode/config ## Verify @@ -71,8 +75,8 @@ bun run opencode:compact # compaction gate, makes real model calls ## Common mistakes -- **`assembly: false` drops `/lossless-pass` and `lcm_expand_query`** — keep the default `true` unless the host must own compaction; with `false`, compaction falls back to the host. -- **`scope` equivalence** — `scope: "session"` (default) reads this session only; pass `scope: "sessions"` or `"all_sessions"` to reach past sessions. -- **Reader is session-scoped** — `lcm_expand_query` returns findings only through its child session, never the raw transcript; ask a question it can answer from the sources, not for a dump. -- **Forgetting is file deletion** — remove that session's sidecar file; there is no delete tool. -- **Expecting host compaction to summarize** — with assembly on, the checkpoint is the DAG frontier, not a prose summary; read it with `lcm_describe`, not by asking the host. +- **`assembly: false` drops `/lossless-pass` and `lcm_expand_query`.** Keep the default `true` unless the host must own compaction. With `false`, compaction falls back to the host. +- **`scope` equivalence.** `scope: "session"` (default) reads this session only. Pass `scope: "sessions"` or `"all_sessions"` to reach past sessions. +- **Reader is session-scoped.** `lcm_expand_query` returns findings only through its child session, never the raw transcript. Ask a question it can answer from the sources, not for a dump. +- **Forgetting is file deletion.** Remove that session's sidecar file. There is no delete tool. +- **Expecting host compaction to summarize.** With assembly on, the checkpoint is the DAG frontier, not a prose summary. Read it with `lcm_describe`, not by asking the host. diff --git a/packages/opencode/src/default-sidecar-dir.ts b/packages/opencode/src/default-sidecar-dir.ts new file mode 100644 index 0000000..5570347 --- /dev/null +++ b/packages/opencode/src/default-sidecar-dir.ts @@ -0,0 +1,81 @@ +import { homedir as osHomedir } from "node:os"; +import { join, posix, win32 } from "node:path"; + +export type DefaultSidecarSource = + | "default:XDG_DATA_HOME" + | "default:home" + | "default:LOCALAPPDATA" + | "default:APPDATA" + | "fallback"; + +export type DefaultSidecarEnv = Readonly>; + +export interface ResolveDefaultSidecarDirInput { + readonly env: DefaultSidecarEnv; + readonly homedir: string | undefined; + readonly platform: string; +} + +export interface ResolvedDefaultSidecarDir { + readonly dir: string; + readonly source: DefaultSidecarSource; +} + +/** This path stays relative. It resolves from the server working directory. */ +export const FALLBACK_SIDECAR_DIR = "./.lossless"; + +function nonBlank(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function isAbsoluteForPlatform(path: string, platform: string): boolean { + return platform === "win32" ? win32.isAbsolute(path) : posix.isAbsolute(path); +} + +/** Pure resolver for the default sidecar directory. Only absolute env dirs win; anything else falls through toward home. */ +export function resolveDefaultSidecarDir( + input: ResolveDefaultSidecarDirInput, +): ResolvedDefaultSidecarDir { + const env = input.env; + const xdg = nonBlank(env["XDG_DATA_HOME"]); + if (xdg !== undefined && isAbsoluteForPlatform(xdg, input.platform)) { + return { dir: join(xdg, "opencode", "lossless"), source: "default:XDG_DATA_HOME" }; + } + const home = nonBlank(input.homedir); + if (home !== undefined) { + if (input.platform === "win32") { + const local = nonBlank(env["LOCALAPPDATA"]); + if (local !== undefined && isAbsoluteForPlatform(local, input.platform)) { + return { dir: join(local, "opencode", "lossless"), source: "default:LOCALAPPDATA" }; + } + const roaming = nonBlank(env["APPDATA"]); + if (roaming !== undefined && isAbsoluteForPlatform(roaming, input.platform)) { + return { dir: join(roaming, "opencode", "lossless"), source: "default:APPDATA" }; + } + return { + dir: join(home, "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }; + } + return { + dir: join(home, ".local", "share", "opencode", "lossless"), + source: "default:home", + }; + } + return { dir: FALLBACK_SIDECAR_DIR, source: "fallback" }; +} + +/** Resolve the default from the live process. Never throws: anything odd falls back to {@link FALLBACK_SIDECAR_DIR}. */ +export function defaultSidecarDir(): ResolvedDefaultSidecarDir { + try { + return resolveDefaultSidecarDir({ + env: process.env, + homedir: osHomedir(), + platform: process.platform, + }); + } catch { + return { dir: FALLBACK_SIDECAR_DIR, source: "fallback" }; + } +} diff --git a/packages/opencode/src/lifecycle.ts b/packages/opencode/src/lifecycle.ts index 40ddd1b..f54ccfb 100644 --- a/packages/opencode/src/lifecycle.ts +++ b/packages/opencode/src/lifecycle.ts @@ -1,9 +1,3 @@ -/** Store lifecycle commands for the OpenCode adapter: backup, export, import, - * diagnose, migrate, heal, and a manual gc. They all read the same per-session - * sidecars the recall tools use, so they are registered with or without - * `assembly`. The cockpit is not here in V2 scope: the host exposes a - * TUI extension point, so a separate TUI plugin could reuse the same command core later. */ - import { appendFileSync, chmodSync, @@ -51,19 +45,17 @@ export interface LifecycleOptions extends SummarizerOptions { readonly swapAtRatio: number; readonly recutAtRatio: number; readonly keepRecentTokens: number; + /** Where the sidecar dir came from. Diagnose prints it. `explicit` when the option is set, else the default source. */ + readonly sidecarSource?: string; readonly healSourceTokens?: number; readonly smartZone?: number; readonly zones?: Readonly>; - /** Reserved seam for the shared redactor. The lifecycle reader never sets - * it, so the adapter always redacts; wiring `false` through will skip. */ readonly redactSecrets?: boolean; } export interface HealRunRequest { readonly sessionID: string; readonly store: LcmStore; - /** The store's own first and last entry id, which is the span every call in - * the run summarizes. */ readonly span: { readonly first: string; readonly last: string }; readonly dryRun: boolean; readonly maxNodes?: number; @@ -76,12 +68,7 @@ export interface LifecycleDeps { readonly host: () => string; readonly prompt: (sessionID: string, text: string) => Promise; readonly record: (row: Record) => void; - /** Run the bounded repair loop over this session's sidecar. A dry run reads - * the plan and must not call a model. */ readonly heal: (request: HealRunRequest) => Promise; - /** The session's model, when the host can name it. Diagnose uses it to - * report the window and the effective thresholds; the commands still work - * without it. */ readonly model?: (sessionID: string) => Promise; readonly window?: (model: ModelRef) => Promise; } @@ -105,8 +92,6 @@ function valueOf(args: readonly string[], name: string): string | undefined { function currentStore(deps: LifecycleDeps, sessionID: string): LcmStore | undefined { if (!existsSync(deps.sidecars.pathFor(sessionID))) return undefined; - // The adapter's own map owns the handle, so a command does not leak a - // store it opened only to read one block. return deps.sidecars.for(sessionID); } @@ -117,8 +102,7 @@ function chmodFile(path: string): void { } function writeDumpTarget(target: string, text: string): void { - // Unique temp with exclusive creation, then an atomic rename: a planted - // predictable path is never touched and the destination swaps at once. + // Exclusive create plus atomic rename. The code never touches a planted path. const dir = dirname(target); const base = basename(target); let tmp = ""; @@ -133,9 +117,7 @@ function writeDumpTarget(target: string, text: string): void { break; } catch (error) { if ((error as { code?: string })?.code === "EEXIST") continue; - // A write can create the private file and then fail (disk full), so - // remove the candidate before surfacing the error. EEXIST keeps its - // continue above and never unlinks a pre-existing collision. + // Only unlink what `wx` created. An EEXIST collision belongs to someone else. try { unlinkSync(candidate); } catch {} @@ -154,6 +136,15 @@ function writeDumpTarget(target: string, text: string): void { } } +function isAbsolutePath(value: string): boolean { + return value.startsWith("/") || value.startsWith("\\\\") || /^[A-Za-z]:[\\/]/.test(value); +} + +function maskSidecarDir(dir: string, home: string): string { + if (dir === home || dir.startsWith(`${home}/`)) return `~${dir.slice(home.length)}`; + if (isAbsolutePath(dir)) return ""; + return dir; +} function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -260,8 +251,6 @@ export function importCommand(deps: LifecycleDeps): CommandDefinition { } export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { - // The lifecycle reader never sets `redactSecrets`, so this always redacts; - // the flag check lives in the shared adapter, not at each call site. const redact = makeRedactConfigAdapter(deps.options); return { name: "lossless-diagnose", @@ -291,8 +280,6 @@ export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { liveSidecarPaths: livePaths, sidecarExtension: deps.sidecars.extension, }); - // The bundle is a diagnostic, not a probe: a host model lookup that - // fails says less, it does not fail the command. let model: ModelRef | undefined; try { model = await deps.model?.(input.sessionID); @@ -375,6 +362,7 @@ export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { home: homedir(), }; const bundle = buildDiagnoseBundle(inputBundle); + const body = `sidecar: ${maskSidecarDir(deps.sidecars.dir, inputBundle.home)} (${deps.options.sidecarSource ?? "explicit"})\n${bundle}`; if (args.includes("--save") && !valueOf(args, "--save")) { await deps.prompt( input.sessionID, @@ -385,15 +373,15 @@ export function diagnoseCommand(deps: LifecycleDeps): CommandDefinition { const save = valueOf(args, "--save"); if (save !== undefined) { const target = resolve(save); - writeFileSync(target, `${bundle}\n`, { encoding: "utf8", mode: 0o600 }); + writeFileSync(target, `${body}\n`, { encoding: "utf8", mode: 0o600 }); chmodFile(target); await deps.prompt( input.sessionID, - `LCM diagnose: wrote ${Buffer.byteLength(bundle, "utf8")} bytes to ${target} (mode 0600).`, + `LCM diagnose: wrote ${Buffer.byteLength(body, "utf8")} bytes to ${target} (mode 0600).`, ); return; } - await deps.prompt(input.sessionID, bundle); + await deps.prompt(input.sessionID, body); } catch (error) { await deps.prompt( input.sessionID, @@ -411,8 +399,6 @@ export function migrateCommand(deps: LifecycleDeps): CommandDefinition { execute: async (input) => { const args = argsOf(input.prompt.text); const apply = args.includes("--apply"); - // A store this process holds open is already current: its constructor - // migrated it. Opening a second writer would only race the live session. const live = new Set(deps.sidecars.ids()); const currentPath = deps.sidecars.pathFor(input.sessionID); const refs = [ @@ -479,14 +465,47 @@ export function migrateCommand(deps: LifecycleDeps): CommandDefinition { }; } +/** A `--session` target younger than this is treated as possibly live in another process. */ +export const GC_RECENCY_GRACE_MINUTES = 60; +/** Candidates younger than this carry a warning naming up to 5 ids. */ +export const GC_WARN_YOUNG_HOURS = 24; +const GC_GRACE_MS = GC_RECENCY_GRACE_MINUTES * 60_000; +const GC_YOUNG_MS = GC_WARN_YOUNG_HOURS * 3_600_000; + +function maxSidecarMtime(path: string): { stat: Stats; mtimeMs: number } | undefined { + try { + const stat = statSync(path); + let mtimeMs = stat.mtimeMs; + for (const suffix of ["-wal", "-shm"] as const) { + try { + mtimeMs = Math.max(mtimeMs, statSync(`${path}${suffix}`).mtimeMs); + } catch {} + } + return { stat, mtimeMs }; + } catch { + return undefined; + } +} + +function gcYoungWarning(ids: readonly string[]): string | undefined { + if (ids.length === 0) return undefined; + const shown = ids.slice(0, 5).join(", "); + return `Note: ${ids.length} sidecar(s) younger than 24 hour(s): ${shown}${ids.length > 5 ? ", ..." : ""}. Confirm no other opencode process is using them before --apply.`; +} + +function gcSkippedLine(ids: readonly string[]): string { + return `Skipped ${ids.length} sidecar(s) active within the last 60 minute(s). Pass --session to target one explicitly: ${ids.join(", ")}.`; +} + export function gcCommand(deps: LifecycleDeps): CommandDefinition { return { name: "lossless-gc", description: - "List old sidecars, or with --apply export and delete them. Never touches this session.", + "List old sidecars, or with --apply export and delete them. It never touches this session.", execute: async (input) => { const args = argsOf(input.prompt.text); const apply = args.includes("--apply"); + const force = args.includes("--force"); if (args.includes("--session") && valueOf(args, "--session") === undefined) { await deps.prompt(input.sessionID, "LCM gc: --session takes a session id."); return; @@ -496,13 +515,19 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { return; } const sessionId = valueOf(args, "--session"); + if (force && sessionId === undefined) { + await deps.prompt(input.sessionID, "LCM gc: --force needs --session ."); + return; + } const live = new Set(deps.sidecars.ids()); - // Disk stems are sanitized session ids, so compare those, not raw ids. const liveStems = new Set([...live].map((id) => sanitizeSidecarId(id))); - if (sessionId !== undefined && live.has(sessionId)) { + if ( + sessionId !== undefined && + (live.has(sessionId) || liveStems.has(sanitizeSidecarId(sessionId))) + ) { await deps.prompt( input.sessionID, - `LCM gc: session ${sessionId} is open in this process; gc never deletes an open sidecar.`, + `LCM gc: session ${sessionId} is open in this process. The command never deletes an open sidecar.`, ); return; } @@ -512,40 +537,169 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { await deps.prompt(input.sessionID, "LCM gc: --older-than takes a number of days."); return; } + if (days <= 0 && sessionId === undefined) { + await deps.prompt( + input.sessionID, + "LCM gc: --older-than 0 needs --session . Without --session it would match every other sidecar, including ones live in another process.", + ); + return; + } const refs = deps.sidecars.list(input.sessionID).filter((ref) => !liveStems.has(ref.id)); - const selected = sessionId === undefined ? refs : refs.filter((ref) => ref.id === sessionId); + const sanitizedSession = sessionId === undefined ? undefined : sanitizeSidecarId(sessionId); + const selected = + sanitizedSession === undefined ? refs : refs.filter((ref) => ref.id === sanitizedSession); if (sessionId !== undefined && selected.length === 0) { await deps.prompt( input.sessionID, - `LCM gc: no sidecar for session ${sessionId} (the current session is never a candidate).`, + `LCM gc: no sidecar for session ${sessionId}. The current session is never a candidate.`, ); return; } - const cutoff = Date.now() - days * 86_400_000; - const candidates: Array<{ ref: SidecarRef; stat: Stats; mtimeMs: number }> = []; + const now = Date.now(); + const ageMsOf = (mtimeMs: number): number => Math.max(0, now - mtimeMs); + const cutoff = now - days * 86_400_000; + const entries: Array<{ ref: SidecarRef; stat: Stats; mtimeMs: number }> = []; for (const ref of selected) { - try { - const stat = statSync(ref.path); - // A write can land in the WAL while the database file's mtime stays - // put. The newest of the three files is the sidecar's last activity. - let mtimeMs = stat.mtimeMs; - for (const suffix of ["-wal", "-shm"]) { + const found = maxSidecarMtime(ref.path); + if (found !== undefined) entries.push({ ref, ...found }); + } + if (sessionId !== undefined) { + if (entries.length === 0) { + await deps.prompt( + input.sessionID, + `LCM gc: nothing eligible for session ${sessionId} (sidecar unreadable).`, + ); + return; + } + const target = entries[0]!; + if (!force && ageMsOf(target.mtimeMs) < GC_GRACE_MS) { + deps.record({ + kind: "gc", + action: "skipped", + session: target.ref.id, + reason: "recency-grace", + }); + const minutes = Math.floor(ageMsOf(target.mtimeMs) / 60_000); + await deps.prompt( + input.sessionID, + `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`, + ); + return; + } + const candidates = entries; + const lines = candidates.map(({ ref, stat, mtimeMs }) => { + const ageDays = Math.max(0, (Date.now() - mtimeMs) / 86_400_000); + return ` ${ref.id}: ${(stat.size / 1024).toFixed(0)} KB, ${ageDays.toFixed(1)} day(s) old`; + }); + const young = gcYoungWarning( + candidates + .filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_YOUNG_MS) + .map(({ ref }) => ref.id), + ); + if (!apply) { + await deps.prompt( + input.sessionID, + `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}${young === undefined ? "" : `\n${young}`}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`, + ); + return; + } + const fresh = maxSidecarMtime(target.ref.path); + if ( + fresh !== undefined && + !force && + Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS + ) { + deps.record({ + kind: "gc", + action: "skipped", + session: target.ref.id, + reason: "recency-grace", + }); + const minutes = Math.floor(Math.max(0, Date.now() - fresh.mtimeMs) / 60_000); + await deps.prompt( + input.sessionID, + `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`, + ); + return; + } + const exportTarget = join(deps.sidecars.dir, `lossless-export-gc-${STAMP()}.jsonl`); + let removed = 0; + let exported = 0; + let failed = 0; + for (const { ref } of candidates) { + let sizeBytes = target.stat.size; + try { + const fresh = maxSidecarMtime(ref.path); + if (fresh === undefined) throw new Error("sidecar unreadable before delete"); + sizeBytes = fresh.stat.size; + if (!force && Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) { + deps.record({ + kind: "gc", + action: "skipped", + session: ref.id, + reason: "recency-grace", + }); + const minutes = Math.floor(Math.max(0, Date.now() - fresh.mtimeMs) / 60_000); + await deps.prompt( + input.sessionID, + `LCM gc: session ${sessionId} was active ${minutes} minute(s) ago. It may be live in another process. Re-run with --session ${sessionId} --force to delete it anyway.`, + ); + return; + } + const store = new LcmStore(ref.path, { queryOnly: true }); + let rows = ""; try { - mtimeMs = Math.max(mtimeMs, statSync(`${ref.path}${suffix}`).mtimeMs); - } catch {} - } - if (sessionId !== undefined || mtimeMs <= cutoff) { - candidates.push({ ref, stat, mtimeMs }); + rows = exportTranscript(store).toJSONL(); + } finally { + store.close(); + } + if (rows.length > 0) { + appendFileSync(exportTarget, `${rows}\n`, { encoding: "utf8", mode: 0o600 }); + exported++; + } + deps.sidecars.drop(ref.id); + removed++; + deps.record({ + kind: "gc", + action: "deleted", + session: ref.id, + sizeBytes, + exportedTo: exportTarget, + }); + } catch (error) { + failed++; + deps.record({ + kind: "gc", + action: "failed", + session: ref.id, + error: error instanceof Error ? error.message.slice(0, 300) : String(error), + }); } - } catch { - // A sidecar that vanished between the listing and the stat is not a - // candidate. } + if (exported > 0) chmodFile(exportTarget); + await deps.prompt( + input.sessionID, + `LCM gc: removed ${removed} of ${candidates.length} sidecar(s). ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `. ${failed} skipped. Migrate or inspect first`}.${young === undefined ? "" : ` ${young}`}`, + ); + return; + } + const candidates = entries.filter( + ({ mtimeMs }) => mtimeMs <= cutoff && ageMsOf(mtimeMs) >= GC_GRACE_MS, + ); + const skipped = entries.filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_GRACE_MS); + const skippedIds = skipped.map(({ ref }) => ref.id); + for (const { ref } of skipped) { + deps.record({ + kind: "gc", + action: "skipped", + session: ref.id, + reason: "recency-grace", + }); } if (candidates.length === 0) { await deps.prompt( input.sessionID, - `LCM gc: nothing eligible (${refs.length} other sidecar(s), none older than ${days} day(s)).`, + `LCM gc: nothing eligible. ${refs.length} other sidecar(s), none older than ${days} day(s).${skippedIds.length === 0 ? "" : ` ${gcSkippedLine(skippedIds)}`}`, ); return; } @@ -553,20 +707,44 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { const ageDays = Math.max(0, (Date.now() - mtimeMs) / 86_400_000); return ` ${ref.id}: ${(stat.size / 1024).toFixed(0)} KB, ${ageDays.toFixed(1)} day(s) old`; }); + const young = gcYoungWarning( + candidates.filter(({ mtimeMs }) => ageMsOf(mtimeMs) < GC_YOUNG_MS).map(({ ref }) => ref.id), + ); if (!apply) { await deps.prompt( input.sessionID, - `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`, + `LCM gc dry-run: ${candidates.length} sidecar(s) removable:\n${lines.join("\n")}${young === undefined ? "" : `\n${young}`}${skipped.length === 0 ? "" : `\n${gcSkippedLine(skippedIds)}`}\nRun \`/lossless-gc --apply\` to export then delete them. Backups are never touched.`, ); return; } const exportTarget = join(deps.sidecars.dir, `lossless-export-gc-${STAMP()}.jsonl`); - // The export file is created on the first real write, so a no-op gc leaves no file. let removed = 0; let exported = 0; let failed = 0; - for (const { ref, stat } of candidates) { + const restatSkipped: string[] = []; + for (const { ref } of candidates) { try { + const fresh = maxSidecarMtime(ref.path); + if (fresh === undefined) { + failed++; + deps.record({ + kind: "gc", + action: "failed", + session: ref.id, + error: "sidecar unreadable before delete; skipped", + }); + continue; + } + if (Math.max(0, Date.now() - fresh.mtimeMs) < GC_GRACE_MS) { + restatSkipped.push(ref.id); + deps.record({ + kind: "gc", + action: "skipped", + session: ref.id, + reason: "recency-grace", + }); + continue; + } const store = new LcmStore(ref.path, { queryOnly: true }); let rows = ""; try { @@ -584,7 +762,7 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { kind: "gc", action: "deleted", session: ref.id, - sizeBytes: stat.size, + sizeBytes: fresh.stat.size, exportedTo: exportTarget, }); } catch (error) { @@ -598,9 +776,10 @@ export function gcCommand(deps: LifecycleDeps): CommandDefinition { } } if (exported > 0) chmodFile(exportTarget); + const allSkipped = [...skippedIds, ...restatSkipped]; await deps.prompt( input.sessionID, - `LCM gc: removed ${removed} of ${candidates.length} sidecar(s); ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `; ${failed} skipped (migrate or inspect first)`}.`, + `LCM gc: removed ${removed} of ${candidates.length} sidecar(s). ${exported} transcript(s) exported to ${exportTarget}${failed === 0 ? "" : `. ${failed} skipped. Migrate or inspect first`}.${young === undefined ? "" : ` ${young}`}${allSkipped.length === 0 ? "" : ` ${gcSkippedLine(allSkipped)}`}`, ); }, }; @@ -652,8 +831,6 @@ export function healCommand(deps: LifecycleDeps): CommandDefinition { }; } -/** Read-only dump of this session's sidecar plus its metrics, opened with - * `queryOnly` so a dump never mutates the store. */ export function dumpCommand(deps: LifecycleDeps): CommandDefinition { const redact = makeRedactConfigAdapter(deps.options); return { @@ -710,8 +887,6 @@ export function dumpCommand(deps: LifecycleDeps): CommandDefinition { }; } -/** The compact result a command prompt carries. No summary or message text - * leaves the store; the counts are the whole answer. */ export function healReport(outcome: HealOutcome): string { if (outcome.dryRun) { return outcome.counts.candidates === 0 diff --git a/packages/opencode/src/plugin.ts b/packages/opencode/src/plugin.ts index 4fc445c..f7b7c12 100644 --- a/packages/opencode/src/plugin.ts +++ b/packages/opencode/src/plugin.ts @@ -1,3 +1,5 @@ +import { readdirSync } from "node:fs"; +import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -18,6 +20,7 @@ import { import { statusCommand, statusLine, passCommand, reportCommand } from "./command.ts"; import { createCheckpointWriter } from "./compaction.ts"; import { createContextProjector, type ProjectionOptions } from "./context.ts"; +import { defaultSidecarDir, FALLBACK_SIDECAR_DIR } from "./default-sidecar-dir.ts"; import type { ModelRef, OpencodeContext, Registration, SessionMessageInfo } from "./host.ts"; import { compactionMark, mirror, mirrorBusEvent, toolEntry, userEntry } from "./ingest.ts"; import { @@ -42,85 +45,49 @@ import { registerExpandQueryTool, registerRecallTools } from "./tools.ts"; export const PLUGIN_ID = "opencode-lossless"; -/** Options read from the plugin's `plugins[].options` entry. Every field is - * optional; an unknown or malformed value keeps its default rather than - * failing the plugin load. */ export interface OpencodeLosslessOptions { - /** Master flag. Nothing registers, mirrors, or mounts until true, unless - * the flag is absent, in which case listing the plugin is intent enough. - * @default true */ + /** @default true */ readonly enabled?: boolean; - /** Directory for one sidecar file per session. Created when missing. - * @default "./.lossless" (resolved from the server working directory) */ + /** @default global sidecar dir, `./.lossless` only as fallback */ readonly sidecarDir?: string; - /** Wire the context projection, the blocking checkpoint, the reader, and the - * on-demand `lossless-pass` command. `false` leaves compaction to the host and - * registers only the mirror, the three recall tools, and the commands. - * @default true + /** @default true * @example { assembly: false } */ readonly assembly?: boolean; - /** Models the pass tries for its summaries, in order. `auto` is the session - * model; this host has no synchronous model lookup, so a concrete id falls - * back to the session model. - * @default "auto" */ + /** @default "auto" */ readonly summarizer?: string | string[]; - /** Tokens one summarizer request may cost, estimate plus output cap, before - * it is refused rather than paid for. - * @default 60000 */ + /** @default 60000 */ readonly summarizerTokensPerCall?: number; - /** Tokens one pass may spend across all of its calls, counted from what the - * provider reported. - * @default 250000 */ + /** @default 250000 */ readonly summarizerTokensPerPass?: number; - /** Tokens one `/lossless-heal --apply` run may spend across all of its calls. - * A dry run spends nothing. - * @default 120000 */ + /** @default 120000 */ readonly summarizerTokensPerHeal?: number; - /** Tool names whose spans compact without a model call. Empty keeps the - * LLM path. - * @default [] */ + /** @default [] */ readonly mechanicalTools?: readonly string[]; - /** The occupancy share at which a request swaps in the projection. The - * engine clamps it to 0.9 of the window. - * @default 0.7 */ + /** @default 0.7 */ readonly swapAtRatio?: number; - /** The occupancy share at which a new cut replaces the pinned one. - * @default 0.85 */ + /** @default 0.85 */ readonly recutAtRatio?: number; - /** Absolute token count where model quality starts to degrade, used in - * place of `swapAtRatio`. The recut is derived from it. - * @default unset */ + /** @default unset */ readonly smartZone?: number; - /** Per-model threshold overrides, keyed `provider/model`. */ readonly zones?: Readonly>; - /** Tokens the projection keeps verbatim after the cut. - * @default 20000 */ + /** @default 20000 */ readonly keepRecentTokens?: number; - /** Tokens one summary aims for. - * @default 1500 */ + /** @default 1500 */ readonly summaryTokens?: number; - /** Estimated tokens one heal node may read as source. Defaults to - * `8 * summaryTokens`, clamped to 3,000 through 24,000 and to - * `summarizerTokensPerCall`. It is a whole-node window, not a per-message - * clip. Raise it to repair summaries over unusually large source messages. - * @default 8 x summaryTokens, clamped 3000..24000 */ + /** @default 8 x summaryTokens, clamped 3000..24000 */ readonly healSourceTokens?: number; - /** Window used when the host catalogue cannot name this model's limit. - * @default 128000 */ + /** @default 128000 */ readonly contextWindow?: number; - /** How long one `lcm_expand_query` child may run before it is interrupted. - * @default 120000 */ + /** @default 120000 */ readonly readerTimeoutMs?: number; } -const DEFAULT_SIDECAR_DIR = "./.lossless"; const DEFAULT_SWAP_RATIO = 0.7; const DEFAULT_RECUT_RATIO = 0.85; const DEFAULT_KEEP_RECENT_TOKENS = 20_000; const DEFAULT_SUMMARY_TOKENS = 1500; const DEFAULT_CONTEXT_WINDOW = 128_000; -/** The extension's own version, from the manifest that ships it. */ export function pluginVersion(): string { return ( packageVersionNear(fileURLToPath(import.meta.url), "opencode-lossless") ?? @@ -132,28 +99,14 @@ export function pluginLabel(): string { return `opencode-lossless ${pluginVersion()}`; } -/** Wire lossless memory into an OpenCode v2 plugin context: mirror prompts, - * assistant text, tool traffic, and compaction boundaries into per-session - * sidecars, and register the recall tools plus the `/lossless-status`, - * `/lossless-report`, `/lossless-pass`, and `/lossless-heal` commands. With - * `assembly` (the default), also register the context projection and the blocking checkpoint - * (`session.compaction`); `/lossless-pass` runs one DAG pass over the session's - * stored span on demand. The host stops summarizing for itself once the sidecar - * covers the aged span: it stores the DAG frontier as its checkpoint, and the - * next request's projection continues from it. The sidecar keeps every address - * the reader can name. Resolves to the cleanup that unregisters everything and - * closes the sidecars. The context is the local structural subset - * (`./host.ts`), so the real host context flows in without the bundle naming - * the host SDK. `rawOptions` is validated at runtime; a malformed value - * degrades to defaults rather than throwing into the host load. */ +/** Wire lossless memory into an OpenCode v2 plugin context: mirror traffic into per-session sidecars and register recall tools plus lifecycle commands. Resolves to the cleanup that unregisters everything and closes the sidecars. */ export async function setupOpencodeLossless( ctx: OpencodeContext, rawOptions?: unknown, ): Promise<() => Promise> { const options = readOptions(rawOptions); if (options.enabled === false) return async () => undefined; - // The plugin reader never sets `redactSecrets`, so this always redacts; - // the flag check lives in the shared adapter, not at each call site. + logSidecarDir(options.sidecarDir, options.sidecarSource); const redact = makeRedactConfigAdapter(options); const sidecars = new Sidecars(options.sidecarDir); const registrations: Registration[] = []; @@ -180,8 +133,6 @@ export async function setupOpencodeLossless( registrations.push( await ctx.session.hook("prompt", (event) => { - // A retired reader has no session and no store. Late bus traffic for it - // must not reopen the sidecar that was just deleted. if (reader.isReader(event.sessionID) && !reader.isActive(event.sessionID)) return; try { mirror(sidecars.for(event.sessionID), userEntry(event.sessionID, event)); @@ -202,8 +153,6 @@ export async function setupOpencodeLossless( ); registrations.push( await ctx.session.hook("compaction", async (event) => { - // A reader child is a leaf: its transcript is never compacted and its - // sidecar never becomes a checkpoint. if (reader.isReader(event.sessionID)) return; try { mirror(sidecars.for(event.sessionID), compactionMark(event.sessionID, event)); @@ -216,7 +165,6 @@ export async function setupOpencodeLossless( registrations.push( await ctx.tool.transform((editor) => { registerRecallTools(editor, { - // A reader child reads the parent's sidecar, never its own. store: (sessionId) => sessionId === undefined ? undefined : sidecars.peek(reader.storeSession(sessionId)), redact, @@ -260,6 +208,7 @@ export async function setupOpencodeLossless( sidecars, options: { assembly: options.assembly, + sidecarSource: options.sidecarSource, swapAtRatio: options.swapAtRatio, recutAtRatio: options.recutAtRatio, keepRecentTokens: options.keepRecentTokens, @@ -281,10 +230,9 @@ export async function setupOpencodeLossless( if (healingSessions.has(sessionID)) { throw new Error("a heal is already running for this session"); } - // A heal can outlive the plugin instance that started it: the host - // reloads plugins when its watched files change. Sidecars held by the - // plugin are closed on that reload, so the repair owns a separate - // connection for the duration of the run. + // A heal can outlive the plugin instance that started it. The host + // reloads plugins on watched-file changes and closes sidecars, so the run + // owns a separate connection for its duration. const runStore = new LcmStore(sidecars.pathFor(sessionID)); if (runStore.hasLiveRun()) { runStore.close(); @@ -357,8 +305,8 @@ export async function setupOpencodeLossless( const projector = createContextProjector({ ctx, options, sidecars, windowOf }); registrations.push( await ctx.session.hook("context", (event) => { - // The reader child gets one system part, its instructions, and nothing - // else: the agent prompt and the parent's projection both stay out. + // The reader child gets its instructions as the only system part. + // The agent prompt and the parent projection stay out. if (reader.isReader(event.sessionID)) { reader.resetSystem(event.system); return; @@ -383,7 +331,6 @@ export async function setupOpencodeLossless( const mapped = storedEntries(sessionID, messages); const model = sessionModel(messages); if (model === undefined) { - // An empty history needs no model and no pass. if (mapped.entries.length === 0) { return { kind: "nothing", reason: "no-entries", ingested: 0 }; } @@ -441,8 +388,6 @@ export async function setupOpencodeLossless( data: event.data, }); } catch (error) { - // One bad event must not end the subscription for the rest of the - // process. The hooks and tools keep working either way. if (sessionId !== undefined) failMirror(sessionId, error); } }; @@ -460,16 +405,14 @@ export async function setupOpencodeLossless( handle(next.value); } } catch { - // The host documents the event stream as volatile. Resubscribe below; - // assistant text has no other boundary in this host. + // The host documents the event stream as volatile. Resubscribe below. + // Assistant text has no other boundary in this host. } finally { current = undefined; if (iterator !== undefined) { try { await iterator.return?.(); - } catch { - // The stream is already gone; the next subscription starts clean. - } + } catch {} } } if (stopped) return; @@ -486,10 +429,7 @@ export async function setupOpencodeLossless( if (wake !== undefined) clearTimeout(wake); try { await current?.return?.(); - } catch { - // The pump is stopping either way; a stream that cannot close must not - // leave the registrations and stores open. - } + } catch {} for (const registration of registrations.splice(0).reverse()) { await registration.dispose(); } @@ -500,6 +440,8 @@ export async function setupOpencodeLossless( export interface RuntimeOptions extends ProjectionOptions { readonly enabled: boolean; readonly sidecarDir: string; + /** Where the sidecar dir came from. `explicit` when the option is set, else the default source. */ + readonly sidecarSource: string; readonly assembly: boolean; readonly readerTimeoutMs: number; readonly healSourceTokens?: number; @@ -517,9 +459,11 @@ export function readOptions(raw: unknown): RuntimeOptions { const zones = readZones(record.zones); const healSourceTokens = readHealSourceTokens(record.healSourceTokens); const mechanicalTools = readMechanicalTools(record.mechanicalTools); + const sidecar = readSidecarDir(record.sidecarDir); return { enabled: typeof record.enabled === "boolean" ? record.enabled : true, - sidecarDir: typeof record.sidecarDir === "string" ? record.sidecarDir : DEFAULT_SIDECAR_DIR, + sidecarDir: sidecar.dir, + sidecarSource: sidecar.source, assembly: record.assembly !== false, swapAtRatio, recutAtRatio, @@ -541,6 +485,31 @@ export function readOptions(raw: unknown): RuntimeOptions { }; } +function readSidecarDir(raw: unknown): { dir: string; source: string } { + if (typeof raw === "string" && raw.trim().length > 0) { + return { dir: raw, source: "explicit" }; + } + const resolved = defaultSidecarDir(); + return { dir: resolved.dir, source: resolved.source }; +} + +function logSidecarDir(dir: string, source: string): void { + try { + console.log(`LCM sidecar dir: ${dir} (${source})`); + } catch {} + if (source === "explicit" || source === "fallback") return; + try { + if (resolve(FALLBACK_SIDECAR_DIR) === resolve(dir)) return; + const entries = readdirSync(FALLBACK_SIDECAR_DIR); + if (entries.length === 0) return; + console.warn( + `LCM sidecar: legacy ${resolve(FALLBACK_SIDECAR_DIR)} holds ${entries.length} file(s) ` + + `and is not read. Sidecars now live in ${dir} (${source}). Nothing was moved. ` + + `To keep the old dir set sidecarDir to "./.lossless". See /lossless-migrate.`, + ); + } catch {} +} + function readSummarizer(raw: unknown): string | string[] | undefined { if (typeof raw === "string") return raw.trim().length === 0 ? undefined : raw; if (!Array.isArray(raw)) return undefined; @@ -548,8 +517,6 @@ function readSummarizer(raw: unknown): string | string[] | undefined { return chain.length === 0 ? undefined : chain; } -/** Tool names that skip the model. One name or a list; blanks and repeats - * drop, and an empty result keeps the LLM path. */ function readMechanicalTools(raw: unknown): string[] | undefined { const list = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : undefined; if (list === undefined) return undefined; @@ -576,16 +543,12 @@ function positiveInteger(raw: unknown): number | undefined { return value !== undefined && Number.isInteger(value) ? value : undefined; } -/** A source window above the hard ceiling is a malformed override, not a - * request for a prompt the summarizer call cap cannot carry. Falling back to - * the derived default is safer than clamping a typo into a paid call. */ +/** Above the call cap a value is a typo, not a paid call. Fall back instead of clamping. */ function readHealSourceTokens(raw: unknown): number | undefined { const value = positiveInteger(raw); return value !== undefined && value <= HEAL_SOURCE_MAX_TOKENS ? value : undefined; } -/** A share of the window: above zero and at most one. Anything else keeps the - * default rather than throwing, the way the other options read. */ function readRatio(raw: unknown): number | undefined { const value = positiveNumber(raw); return value !== undefined && value <= 1 ? value : undefined; @@ -622,9 +585,6 @@ function readZones(raw: unknown): Record | undefined { return Object.keys(zones).length === 0 ? undefined : zones; } -/** The model the session last ran under, read from the stored messages so the - * pass needs no extra host call. A model switch or a compaction carries one, - * and an assistant message carries the one it answered with. */ function sessionModel(messages: readonly SessionMessageInfo[]): ModelRef | undefined { for (let index = messages.length - 1; index >= 0; index--) { const model = messages[index]?.model; @@ -643,10 +603,6 @@ function errorText(error: unknown): string { return error instanceof Error ? error.message : String(error); } -/** The plugin definition the host loads. Options come from the config entry, - * so the default export reads them late, at setup time. The setup parameter - * is the local context subset; the host's richer context satisfies it, which - * `test/host-compat.test.ts` pins against the real host types. */ const definition = { id: PLUGIN_ID, setup: (ctx: OpencodeContext) => setupOpencodeLossless(ctx, ctx.options), diff --git a/packages/opencode/test/default-sidecar-dir.test.ts b/packages/opencode/test/default-sidecar-dir.test.ts new file mode 100644 index 0000000..d02b5cc --- /dev/null +++ b/packages/opencode/test/default-sidecar-dir.test.ts @@ -0,0 +1,181 @@ +import { join } from "node:path"; + +import { describe, expect, it } from "vite-plus/test"; + +import { + defaultSidecarDir, + resolveDefaultSidecarDir, + type DefaultSidecarEnv, +} from "../src/default-sidecar-dir.ts"; +import { readOptions } from "../src/plugin.ts"; + +function resolve( + env: DefaultSidecarEnv, + homedir: string | undefined, + platform: string, +): { dir: string; source: string } { + return resolveDefaultSidecarDir({ env, homedir, platform }); +} + +describe("resolveDefaultSidecarDir", () => { + it("prefers a non-blank absolute XDG_DATA_HOME", () => { + expect(resolve({ XDG_DATA_HOME: "/data" }, "/home/u", "linux")).toEqual({ + dir: join("/data", "opencode", "lossless"), + source: "default:XDG_DATA_HOME", + }); + }); + + it("treats an empty XDG_DATA_HOME as unset", () => { + expect(resolve({ XDG_DATA_HOME: "" }, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("treats a whitespace-only XDG_DATA_HOME as unset", () => { + expect(resolve({ XDG_DATA_HOME: " " }, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("ignores a relative XDG_DATA_HOME", () => { + expect(resolve({ XDG_DATA_HOME: "rel/path" }, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("ignores a Windows-style XDG_DATA_HOME on posix", () => { + expect(resolve({ XDG_DATA_HOME: "C:\\data" }, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("ignores a relative XDG_DATA_HOME on win32", () => { + const home = "C:\\Users\\u"; + expect(resolve({ XDG_DATA_HOME: "rel\\path" }, home, "win32")).toEqual({ + dir: join(home, "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("accepts a posix XDG_DATA_HOME on win32 under the win32 rule", () => { + expect(resolve({ XDG_DATA_HOME: "/data" }, "C:\\Users\\u", "win32")).toEqual({ + dir: join("/data", "opencode", "lossless"), + source: "default:XDG_DATA_HOME", + }); + }); + + it("falls back to the home share dir on darwin", () => { + expect(resolve({}, "/Users/u", "darwin")).toEqual({ + dir: join("/Users/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("falls back to the home share dir on linux", () => { + expect(resolve({}, "/home/u", "linux")).toEqual({ + dir: join("/home/u", ".local", "share", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("prefers LOCALAPPDATA on win32", () => { + const local = "C:\\Users\\u\\AppData\\Local"; + expect( + resolve( + { LOCALAPPDATA: local, APPDATA: "C:\\Users\\u\\AppData\\Roaming" }, + "C:\\Users\\u", + "win32", + ), + ).toEqual({ + dir: join(local, "opencode", "lossless"), + source: "default:LOCALAPPDATA", + }); + }); + + it("uses APPDATA on win32 when LOCALAPPDATA is unset", () => { + const roaming = "C:\\Users\\u\\AppData\\Roaming"; + expect(resolve({ APPDATA: roaming }, "C:\\Users\\u", "win32")).toEqual({ + dir: join(roaming, "opencode", "lossless"), + source: "default:APPDATA", + }); + }); + + it("treats a blank LOCALAPPDATA as unset on win32", () => { + const roaming = "C:\\Users\\u\\AppData\\Roaming"; + expect(resolve({ LOCALAPPDATA: " ", APPDATA: roaming }, "C:\\Users\\u", "win32")).toEqual({ + dir: join(roaming, "opencode", "lossless"), + source: "default:APPDATA", + }); + }); + + it("treats a relative LOCALAPPDATA as unset on win32", () => { + const roaming = "C:\\Users\\u\\AppData\\Roaming"; + expect( + resolve({ LOCALAPPDATA: "rel\\path", APPDATA: roaming }, "C:\\Users\\u", "win32"), + ).toEqual({ + dir: join(roaming, "opencode", "lossless"), + source: "default:APPDATA", + }); + }); + + it("treats relative win32 env dirs as unset and uses home AppData Local", () => { + expect( + resolve({ LOCALAPPDATA: "rel\\local", APPDATA: "rel\\roaming" }, "C:\\Users\\u", "win32"), + ).toEqual({ + dir: join("C:\\Users\\u", "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }); + }); + it("uses home AppData Local on win32 when no env dir is set", () => { + expect(resolve({}, "C:\\Users\\u", "win32")).toEqual({ + dir: join("C:\\Users\\u", "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("treats blank win32 env dirs as unset and uses home AppData Local", () => { + expect(resolve({ LOCALAPPDATA: "", APPDATA: " " }, "C:\\Users\\u", "win32")).toEqual({ + dir: join("C:\\Users\\u", "AppData", "Local", "opencode", "lossless"), + source: "default:home", + }); + }); + + it("falls back to ./.lossless on win32 with no home", () => { + expect(resolve({}, undefined, "win32")).toEqual({ + dir: "./.lossless", + source: "fallback", + }); + }); + + it("falls back to ./.lossless with no home on posix", () => { + expect(resolve({}, undefined, "linux")).toEqual({ + dir: "./.lossless", + source: "fallback", + }); + }); +}); + +describe("readOptions sidecarDir", () => { + it("passes an explicit relative dir through byte identical", () => { + const options = readOptions({ sidecarDir: "./.lossless" }); + expect(options.sidecarDir).toBe("./.lossless"); + expect(options.sidecarSource).toBe("explicit"); + }); + + it("passes an explicit absolute dir through byte identical", () => { + const options = readOptions({ sidecarDir: "/tmp/lcm-abs" }); + expect(options.sidecarDir).toBe("/tmp/lcm-abs"); + expect(options.sidecarSource).toBe("explicit"); + }); + + it("falls back to the default for a blank dir", () => { + const expected = defaultSidecarDir(); + const options = readOptions({ sidecarDir: " " }); + expect(options.sidecarDir).toBe(expected.dir); + expect(options.sidecarSource).toBe(expected.source); + }); +}); diff --git a/packages/opencode/test/lifecycle.test.ts b/packages/opencode/test/lifecycle.test.ts index b3b4a6c..20580b1 100644 --- a/packages/opencode/test/lifecycle.test.ts +++ b/packages/opencode/test/lifecycle.test.ts @@ -1,5 +1,13 @@ -import { existsSync, mkdtempSync, readdirSync, readFileSync, statSync, utimesSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + statSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { homedir, tmpdir } from "node:os"; import { join } from "node:path"; import { LcmStore, ingestEntries } from "lossless-core"; @@ -30,8 +38,6 @@ function seed(sidecars: Sidecars, sessionID: string, text: string): void { ]); } -/** A store the adapter has not opened: gc's candidate set is what is on disk, - * not what this process holds. */ function seedDetached(sidecars: Sidecars, sessionID: string, text: string): void { const store = new LcmStore(sidecars.pathFor(sessionID)); try { @@ -43,6 +49,15 @@ function seedDetached(sidecars: Sidecars, sessionID: string, text: string): void } } +function ageAll(sidecars: Sidecars, sessionID: string, date: Date): void { + const base = sidecars.pathFor(sessionID); + for (const path of [base, `${base}-wal`, `${base}-shm`]) { + try { + utimesSync(path, date, date); + } catch {} + } +} + interface Harness { readonly dir: string; readonly sidecars: Sidecars; @@ -147,9 +162,14 @@ describe("lifecycle commands", () => { const text = await run(diagnoseCommand, h, "/lossless-diagnose --repair"); expect(text).toContain("LCM diagnose bundle"); expect(text).toContain("messages: 1"); + const [first, ...rest] = text.split("\n"); + const home = homedir(); + const maskedDir = + h.dir === home || h.dir.startsWith(`${home}/`) ? `~${h.dir.slice(home.length)}` : ""; + expect(first).toBe(`sidecar: ${maskedDir} (explicit)`); + expect(rest.join("\n")).not.toContain(h.dir); expect(text).toContain("model: test/model, window: 100.0k"); expect(text).toContain("rebuilt the full-text index"); - expect(text).not.toContain(h.dir); } finally { h.sidecars.close(); } @@ -178,8 +198,6 @@ describe("lifecycle commands", () => { try { seed(h.sidecars, SESSION, "the current session"); seedDetached(h.sidecars, "ses_retained", "the retained session"); - // Restart: drop every open handle, then diagnose through a fresh cache - // that holds only the diagnosing session open. h.sidecars.close(); const fresh = new Sidecars(h.dir); try { @@ -211,7 +229,7 @@ describe("lifecycle commands", () => { } }); - it("lists and deletes an old sidecar, exporting it first", async () => { + it("gc lists an old sidecar and deletes it after export", async () => { const h = harness(); try { seed(h.sidecars, SESSION, "the current session"); @@ -240,4 +258,268 @@ describe("lifecycle commands", () => { h.sidecars.close(); } }); + + it("gc grace skips a recently active store and lists an old one", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_old", "the old alpha"); + seedDetached(h.sidecars, "ses_gc_fresh", "the fresh beta"); + ageAll(h.sidecars, "ses_gc_old", new Date(0)); + ageAll(h.sidecars, "ses_gc_fresh", new Date(Date.now() - 30 * 60_000)); + + const dry = await run(gcCommand, h, "/lossless-gc --older-than 0.01"); + expect(dry).toContain("ses_gc_old"); + expect(dry).toContain("Skipped 1 sidecar(s) active within the last 60 minute(s)"); + expect(dry).toContain("ses_gc_fresh"); + expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(true); + expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(true); + expect( + h.records.some( + (row) => + row.kind === "gc" && + row.action === "skipped" && + row.session === "ses_gc_fresh" && + row.reason === "recency-grace", + ), + ).toBe(true); + } finally { + h.sidecars.close(); + } + }); + + it("gc refuses bare --older-than 0 without --session before any I/O", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_old", "the old session"); + ageAll(h.sidecars, "ses_gc_old", new Date(0)); + for (const raw of ["0", "0d"]) { + const text = await run(gcCommand, h, `/lossless-gc --older-than ${raw} --apply`); + expect(text).toBe( + "LCM gc: --older-than 0 needs --session . Without --session it would match every other sidecar, including ones live in another process.", + ); + } + expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(true); + expect(readdirSync(h.dir).some((name) => name.startsWith("lossless-export-gc-"))).toBe(false); + expect(h.records.length).toBe(0); + } finally { + h.sidecars.close(); + } + }); + + it("gc --older-than 0 with --session targets only that id", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_a", "the alpha transcript"); + seedDetached(h.sidecars, "ses_gc_b", "the beta transcript"); + ageAll(h.sidecars, "ses_gc_a", new Date(0)); + ageAll(h.sidecars, "ses_gc_b", new Date(0)); + + const applied = await run( + gcCommand, + h, + "/lossless-gc --older-than 0 --session ses_gc_a --apply", + ); + expect(applied).toContain("removed 1 of 1"); + expect(existsSync(h.sidecars.pathFor("ses_gc_a"))).toBe(false); + expect(existsSync(h.sidecars.pathFor("ses_gc_b"))).toBe(true); + const exported = applied.split("exported to ")[1]?.split(" ")[0]?.replace(/\.$/u, ""); + expect(exported).toBeDefined(); + expect(readFileSync(exported!, "utf8")).toContain("the alpha transcript"); + } finally { + h.sidecars.close(); + } + }); + + it("gc --session on a recently active store refuses without --force", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_fresh", "the fresh session"); + + const applied = await run(gcCommand, h, "/lossless-gc --session ses_gc_fresh --apply"); + expect(applied).toContain("LCM gc: session ses_gc_fresh was active"); + expect(applied).toContain("minute(s) ago"); + expect(applied).toContain("--force"); + const dry = await run(gcCommand, h, "/lossless-gc --session ses_gc_fresh"); + expect(dry).toContain("LCM gc: session ses_gc_fresh was active"); + expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(true); + expect(readdirSync(h.dir).some((name) => name.startsWith("lossless-export-gc-"))).toBe(false); + expect(h.records.some((row) => row.kind === "gc" && row.action === "deleted")).toBe(false); + expect( + h.records.some( + (row) => + row.kind === "gc" && + row.action === "skipped" && + row.session === "ses_gc_fresh" && + row.reason === "recency-grace", + ), + ).toBe(true); + } finally { + h.sidecars.close(); + } + }); + + it("gc --session --force exports then deletes exactly that fresh store", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_fresh", "the fresh force transcript"); + seedDetached(h.sidecars, "ses_gc_other", "the other transcript"); + ageAll(h.sidecars, "ses_gc_other", new Date(0)); + + const applied = await run( + gcCommand, + h, + "/lossless-gc --session ses_gc_fresh --force --apply", + ); + expect(applied).toContain("removed 1 of 1"); + expect(applied).toContain("younger than 24 hour(s)"); + expect(applied).toContain("ses_gc_fresh"); + expect(existsSync(h.sidecars.pathFor("ses_gc_fresh"))).toBe(false); + expect(existsSync(h.sidecars.pathFor("ses_gc_other"))).toBe(true); + const exported = applied.split("exported to ")[1]?.split(" ")[0]?.replace(/\.$/u, ""); + expect(exported).toBeDefined(); + expect(readFileSync(exported!, "utf8")).toContain("the fresh force transcript"); + expect( + h.records.some( + (row) => row.kind === "gc" && row.action === "deleted" && row.session === "ses_gc_fresh", + ), + ).toBe(true); + } finally { + h.sidecars.close(); + } + }); + + it("gc --force without --session is refused", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_old", "the old session"); + ageAll(h.sidecars, "ses_gc_old", new Date(0)); + + const text = await run(gcCommand, h, "/lossless-gc --force --apply"); + expect(text).toBe("LCM gc: --force needs --session ."); + expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(true); + expect(h.records.length).toBe(0); + } finally { + h.sidecars.close(); + } + }); + + it("gc warns about candidates younger than 24 hours and omits the warning for older ones", async () => { + const young = harness(); + try { + seed(young.sidecars, SESSION, "the current session"); + seedDetached(young.sidecars, "ses_gc_young", "the young transcript"); + seedDetached(young.sidecars, "ses_gc_old", "the old transcript"); + ageAll(young.sidecars, "ses_gc_young", new Date(Date.now() - 5 * 3_600_000)); + ageAll(young.sidecars, "ses_gc_old", new Date(0)); + + const dry = await run(gcCommand, young, "/lossless-gc --older-than 0.01"); + expect(dry).toContain("younger than 24 hour(s)"); + expect(dry).toContain("ses_gc_young"); + } finally { + young.sidecars.close(); + } + const oldOnly = harness(); + try { + seed(oldOnly.sidecars, SESSION, "the current session"); + seedDetached(oldOnly.sidecars, "ses_gc_old", "the old transcript"); + ageAll(oldOnly.sidecars, "ses_gc_old", new Date(0)); + + const dry = await run(gcCommand, oldOnly, "/lossless-gc --older-than 30"); + expect(dry).not.toContain("younger than 24 hour(s)"); + } finally { + oldOnly.sidecars.close(); + } + }); + + it("gc skips future mtimes in sweeps and lists them as 0.0 days with --force", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_fut", "the future transcript"); + ageAll(h.sidecars, "ses_gc_fut", new Date(Date.now() + 3_600_000)); + + const dry = await run(gcCommand, h, "/lossless-gc --older-than 30"); + expect(dry).toContain("nothing eligible"); + expect(dry).toContain("Skipped 1 sidecar(s) active within the last 60 minute(s)"); + expect(dry).toContain("ses_gc_fut"); + expect(existsSync(h.sidecars.pathFor("ses_gc_fut"))).toBe(true); + expect( + h.records.some( + (row) => + row.kind === "gc" && + row.action === "skipped" && + row.session === "ses_gc_fut" && + row.reason === "recency-grace", + ), + ).toBe(true); + + const forced = await run(gcCommand, h, "/lossless-gc --session ses_gc_fut --force"); + expect(forced).toContain("ses_gc_fut"); + expect(forced).toContain("0.0 day(s) old"); + expect(forced).toContain("younger than 24 hour(s)"); + expect(existsSync(h.sidecars.pathFor("ses_gc_fut"))).toBe(true); + } finally { + h.sidecars.close(); + } + }); + + it("gc grace uses the newest of sqlite, wal, and shm", async () => { + const h = harness(); + try { + seed(h.sidecars, SESSION, "the current session"); + seedDetached(h.sidecars, "ses_gc_old", "the old alpha"); + seedDetached(h.sidecars, "ses_gc_wal", "the wal transcript"); + ageAll(h.sidecars, "ses_gc_old", new Date(0)); + ageAll(h.sidecars, "ses_gc_wal", new Date(0)); + writeFileSync(`${h.sidecars.pathFor("ses_gc_wal")}-wal`, "fresh wal bytes"); + + const dry = await run(gcCommand, h, "/lossless-gc --older-than 30"); + expect(dry).toContain("ses_gc_old"); + expect(dry).toContain("Skipped 1 sidecar(s) active within the last 60 minute(s)"); + expect(dry).toContain("ses_gc_wal"); + expect( + h.records.some( + (row) => + row.kind === "gc" && + row.action === "skipped" && + row.session === "ses_gc_wal" && + row.reason === "recency-grace", + ), + ).toBe(true); + + const applied = await run(gcCommand, h, "/lossless-gc --older-than 30 --apply"); + expect(applied).toContain("removed 1 of 1"); + expect(existsSync(h.sidecars.pathFor("ses_gc_wal"))).toBe(true); + expect(existsSync(h.sidecars.pathFor("ses_gc_old"))).toBe(false); + } finally { + h.sidecars.close(); + } + }); + + it("gc docs state the grace, the refusal, and the force rule", async () => { + const { readFileSync: read } = await import("node:fs"); + const { join: joinPath } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const doc = read( + joinPath( + fileURLToPath(new URL(".", import.meta.url)), + "../../../apps/docs/content/_snippets/reference/command/opencode.mdx", + ), + "utf8", + ); + expect(doc).toContain( + "It only excludes sidecars open in this process, so against the shared global directory", + ); + expect(doc).toContain("60 minutes"); + expect(doc).toContain("--older-than 0"); + expect(doc).toContain("--force"); + expect(doc).toContain("24 hours"); + expect(doc).toContain("Revisit these guards"); + }); });