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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ via the drift script before proposing structural changes.
first, followed only by the built-in opencode file tools it requires. Never
allow shell, network, task delegation, IDE, or MCP tools; that is the sandbox
(D2), and `tests/agents.test.ts` enforces it.
- opencode2 support lives in `src/v2/` and reuses the V1 pipeline unchanged
via a V1-client shim (`src/v2/shim.ts`); see `docs/opencode2.md` for the
deliberate V2 adaptations. Rules: never edit V1 behavior for V2 needs (adapt
in `src/v2/`), the `agents` map in `opencode.json` follows the same
allowlist rule as `agent` (V2 action names: `edit` covers write/patch;
`tests/v2-agents.test.ts` enforces it), and `bun run contract:v2` must pass
alongside `bun run contract`. The `./tui` entry (`src/v2/tui.tsx`, RPC in
`src/v2/status-rpc.ts`) adds the sidebar section: `setup()` must only claim
slots (Solid-scoped APIs like `keymap.layer` belong in slot components),
the TUI bundle may import only `@opencode/plugin/tui` + `solid-js` +
`@opentui/solid`, and `bun run build` compiles the JSX via
`scripts/build-tui.ts`.

## Packaging & releases

Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,20 @@ learning starts immediately.
Requires OpenCode 1.18 or newer. Models and other options: see
[Configuration](#configuration).

**OpenCode 2:** the same package works on opencode2 — install it with the
V2 plugin syntax:

```jsonc
{
"plugins": [{ "package": "[email protected]" }],
}
```

Behavior is identical; see [docs/opencode2.md](./docs/opencode2.md) for the
few platform adaptations and current limitations. On opencode2 you also get a
**Memory** section in the session sidebar plus a `/memory-status` command,
served live from the same state as `memory_inspect`.

### Installation hints

To bump pins, copy
Expand Down
791 changes: 788 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

117 changes: 117 additions & 0 deletions docs/opencode2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# opencode2 support

This plugin runs on both hosts from a single package entry:

- opencode 1.x reads `server()` (V1 hooks, `opencode.json` → `agent` map).
- opencode2 reads `id` + `setup()` (V2 context API, `src/v2/*` adapter).

Install for opencode2:

```jsonc
// opencode.jsonc
{
"$schema": "https://opencode.ai/config.json",
"plugins": [
{ "package": "[email protected]", "options": { "min_rollout_idle_hours": 1 } },
],
}
```

All V1 plugin options (`generate_memories`, `use_memories`,
`dedicated_tools`, `disable_on_external_context`, `extract_model`,
`consolidation_model`, numeric clamps, `codex_interop`, `claude_import`)
apply unchanged — option parsing is shared (`applyPluginOptions`).

## How it works

The entire memory pipeline (extraction → consolidation → injection →
citation feedback against the global `~/.local/share/opencode` workspace)
runs byte-identical on both hosts. `src/v2/shim.ts` presents a V1-shaped
client façade over the V2 plugin context, so `phase1/phase2/capture/llm`
execute the same code paths. Only genuinely missing V2 surfaces are
adapted; everything else is hook translation (`src/v2/plugin.ts`):

| V1 | V2 |
| --- | --- |
| `config` hook agent injection | `agent.transform` ensure (update creates) |
| returned `tool` map | `tool.transform` (same `tools/*` logic via adapter) |
| `chat.message` pump | `prompt` hook |
| `system.transform` injection | `context` hook (`system.push({type:"text",…})`) |
| `text.complete` + `messages.transform` citations | `session.text.ended` durable accounting + `context` reconciliation/strip |
| `tool.execute.before` pollution | `tool.execute.before` (same hook name) |
| `session.status idle` / `session.idle` pump | `session.execution.succeeded` event |
| `session.deleted` cleanup | public `session.remove` with liveness fallback |
| `experimental/session` global discovery | authenticated public `session.list` with cursor pagination |
| V1 `session.messages` | authenticated public `message.list` (full persisted history) |

## Deliberate V2 differences

- **Registered service is required for global reads.** V2 discovers the
local service with `Service.discover()`, preserves its auth headers, and
accepts it only when `/health` reports the plugin host's own PID. It never
starts a service with `Service.ensure()`. If no matching service is
registered, global discovery reports a clear unavailable error.
- **Global discovery is complete.** The adapter follows public
`session.list` cursors and uses public `message.list` for full persisted
history. Helper sessions are excluded by durable metadata and the cleanup
sweep reclaims them after a restart; `session.context` is not used for
transcript capture.
- **Agents are location-scoped.** V2 provisions the agents in the active
plugin location and creates helper sessions in that same location. The
consolidation agent's read/edit/search/glob permissions are allowlisted
only under the memory workspace; all other actions remain denied. A global
OpenCode plugin install therefore provisions the agents as each active
location loads the plugin.
- **Citations are accounted durably.** `session.text.ended` is the primary
hook because it contains the completed text after durable commit. A SQLite
reconciliation table deduplicates `(assistant message, cited session)`
pairs across duplicate events, context calls, and process restarts. The
context hook strips citation markup before the next model call; retained
markup in persisted history is harmless and can be rendered by the TUI.
- **Config and models are explicit.** V2 adapts public config documents for
the shared resolver. V2 configs do not provide V1's `small_model` field;
unset `extract_model` uses the session default, while
`consolidation_model` uses the configured `model` when present. Set both
plugin options explicitly for deterministic routing. Cancellation is
verified through request signals plus interrupt-and-wait cleanup.
- **Both agents ship; only `memorize` works.** Extraction runs sessionless
through `generate.text`, so `memorize-extract` is provisioned hidden and
unused (V1 likewise skips injecting unused agents).

## Sidebar status

The package's `./tui` entry adds a **Memory** section to the session sidebar.
OpenCode2 loads it automatically alongside the server plugin. `/memory-status`
(also **Show memory status** in the command palette) opens effective models,
read/write settings, import status, retry eligibility, and warnings.

- Status is global, using the same job snapshots as `memory_inspect`.
- The UI refreshes on pipeline events and reconciles every five seconds while
loaded, including changes made by another worker; viewing it never starts jobs.
- A disconnected or unavailable server shows **Unavailable**, not stale **Idle**.
- **Last success** is shown only when the latest recorded consolidation attempt
succeeded; `—` means no clean success timestamp is available for that attempt.
- TUI dependencies are optional peers supplied by OpenCode2; V1 loads only the
`./server` entry and does not import V2 runtime dependencies. The build
compiles Solid JSX and ships the result under `dist/`.
- TUI rules learned the hard way: `setup()` must only claim slots —
`keymap.layer` throws outside a Solid component scope, so it lives in an
`app`-slot component; never render `<Show>` (or any conditional) with element
children directly under `<box>` — its empty placeholder is a bare text node
and the renderer rejects it. Use unconditional lines with placeholders.
- The TUI bundle must import only `@opencode/plugin/tui`, `solid-js`, and
`@opentui/solid`: the CLI sandbox does not resolve `zod` or
`@opencode/plugin/rpc`, so the status contract (`src/v2/status-rpc.ts`) is
plain JSON Schema with a hand-written guard.

For a local wrapper, add `tui.ts` beside its `index.ts`, re-exporting the built
`dist/src/v2/tui.js` default export, then run `bun run build` in this repository.

## Verify

```bash
bun run typecheck && bun test && bun run build
bun run smoke # V1 entry
bun run contract # V1 host surface
bun run contract:v2 # V2 host surface (needs the opencode2 service)
```
2 changes: 1 addition & 1 deletion opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@
}
}
}
}
}
38 changes: 36 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
"import": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
},
"./server": {
"import": "./dist/src/index.js",
"types": "./dist/src/index.d.ts"
},
"./v2": {
"import": "./dist/src/v2/index.js",
"types": "./dist/src/v2/index.d.ts"
},
"./tui": {
"types": "./dist/src/v2/tui.d.ts",
"import": "./dist/src/v2/tui.js"
},
"./tools/*": {
"import": "./dist/tools/*.js",
"types": "./dist/tools/*.d.ts"
Expand All @@ -29,9 +41,10 @@
},
"scripts": {
"dev": "bun --watch src/index.ts",
"build": "tsc && rm -rf dist/src/templates && cp -R src/templates dist/src/templates && cp opencode.json dist/opencode.json",
"build": "tsc && bun scripts/build-tui.ts && rm -rf dist/src/templates && cp -R src/templates dist/src/templates && cp opencode.json dist/opencode.json",
"smoke": "bun scripts/smoke.ts",
"contract": "bun scripts/check-opencode-contract.ts",
"contract:v2": "bun scripts/check-opencode2-contract.ts",
"live:read": "bun scripts/live-readpath.ts",
"live:e2e": "bun scripts/live-e2e.ts",
"prepack": "npm run build && npm run smoke",
Expand All @@ -51,12 +64,33 @@
"@opencode-ai/plugin": "^1.18.0",
"diff": "^9.0.0",
"isomorphic-git": "^1.38.6",
"xdg-basedir": "^5.1.0"
"xdg-basedir": "^5.1.0",
"zod": "4.1.8"
},
"devDependencies": {
"@opencode/client": "2.0.3",
"@opencode/plugin": "2.0.3",
"@opencode/theme": "2.0.3",
"@opentui/core": "0.5.10",
"@opentui/solid": "0.5.10",
"solid-js": "1.9.15",
"typescript": "^5.5.0",
"@types/bun": "^1.1.0"
},
"peerDependencies": {
"@opencode/client": ">=2.0.0",
"@opencode/plugin": ">=2.0.0",
"@opentui/core": ">=0.5.10",
"@opentui/solid": ">=0.5.10",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"@opencode/client": { "optional": true },
"@opencode/plugin": { "optional": true },
"@opentui/core": { "optional": true },
"@opentui/solid": { "optional": true },
"solid-js": { "optional": true }
},
"engines": {
"bun": ">=1.1.0"
}
Expand Down
13 changes: 13 additions & 0 deletions scripts/build-tui.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import solid from "@opentui/solid/bun-plugin"

const result = await Bun.build({
entrypoints: ["./src/v2/tui.tsx"],
outdir: "./dist/src/v2",
target: "bun",
format: "esm",
packages: "external",
plugins: [solid],
})
if (!result.success) throw new AggregateError(result.logs, "TUI build failed")
// tsc emits preserved JSX for declarations; only the Solid-compiled JS ships.
await Bun.file("./dist/src/v2/tui.jsx").delete()
16 changes: 11 additions & 5 deletions scripts/check-opencode-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,17 @@ async function main() {
const healthVer = (health.json as { version?: string } | null)?.version
if (healthVer) note(healthVer === version, `health.version ${healthVer} matches CLI ${version}`)

const docRes = await api(serve, sandbox, "GET", "/doc")
note(docRes.status === 200, `GET /doc → ${docRes.status}`)
const doc = docRes.json as OpenAPI
if (!doc?.paths) {
note(false, "/doc missing paths")
if (version.startsWith("2.")) {
// OpenCode 2 moved its machine-readable contract to /openapi.json and
// is checked by contract:v2; /doc is the V1-only surface.
log("skip", "/doc is a V1-only surface on OpenCode 2; see contract:v2")
} else {
const docRes = await api(serve, sandbox, "GET", "/doc")
note(docRes.status === 200, `GET /doc → ${docRes.status}`)
const doc = docRes.json as OpenAPI
if (!doc?.paths) {
note(false, "/doc missing paths")
} else {
const listOp = getPathOp(doc, "/session", "get")
note(!!listOp, "GET /session present")
if (listOp) {
Expand Down Expand Up @@ -192,6 +197,7 @@ async function main() {
note(names.has("cursor"), "experimental.session.list query param 'cursor'")
note(names.has("search"), "experimental.session.list query param 'search'")
}
}
}
} finally {
if (serve) await serve.stop()
Expand Down
Loading