Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/gc-soft-guards.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/opencode-sidecar-global-default.md
Original file line number Diff line number Diff line change
@@ -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 `<home>/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.
4 changes: 2 additions & 2 deletions apps/docs/components/seo/lcm-faqs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand Down Expand Up @@ -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,
{
Expand Down
6 changes: 3 additions & 3 deletions apps/docs/components/seo/opencode-faqs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?",
Expand All @@ -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,
{
Expand Down
10 changes: 8 additions & 2 deletions apps/docs/content/_snippets/guides/configuration/opencode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<home>/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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
34 changes: 22 additions & 12 deletions apps/docs/content/_snippets/reference/command/opencode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,38 +8,38 @@
/lossless-import <path>
/lossless-diagnose [--save <path>] [--repair]
/lossless-migrate [--apply]
/lossless-gc [--older-than <days>] [--session <id>] [--apply]
/lossless-gc [--older-than <days>] [--session <id>] [--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

```text
/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

```text
/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

```text
/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

Expand All @@ -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

Expand All @@ -65,24 +65,34 @@ Copies this session's sidecar to `<sidecar>.backup-<timestamp>`, 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

```text
/lossless-import <path>
```

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 <id> --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 <id>` 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

Expand All @@ -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

Expand All @@ -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.
Loading
Loading