Skip to content
Closed
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
838 changes: 835 additions & 3 deletions bun.lock

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions docs/opencode2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# 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 | `context` hook record + strip |
| `tool.execute.before` pollution | `tool.execute.before` (same hook name) |
| `session.status idle` / `session.idle` pump | `session.execution.succeeded` event |
| `session.deleted` cleanup | liveness (`session.get` → NotFound) in phase 2 |
| `experimental/session` global discovery | process-local session registry |

## Deliberate V2 differences

- **No session deletion.** V2 has no remove API. Helper sessions are
interrupted and released; their rows remain as inert, clearly titled
(`codex-memory-*`) history. Extraction uses `generate.text` and creates
no session at all.
- **Registry-based discovery.** `ctx` exposes no session list and raw HTTP
is unauthenticated from plugins, so discovery reads sessions observed via
`session.created` + the prompt hook (with `parentID` backfill to keep
excluding subagent children). A cold boot sees sessions from admission
on; prior extraction rows still drive consolidation.
- **Citations stripped from model context only.** V2 has no pre-persist
hook, so `<memory-citation>` markup stays in stored history (visible in
the UI) while the context hook removes it before every model call.
Usage counts are exact within a process (per-message dedupe).
- **No `small_model`/`model` config defaults.** V2 exposes no config API
to plugins, so unset `extract_model`/`consolidation_model` fall back to
the session default. Set them explicitly to mirror V1 model routing.
- **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. 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)
```
23 changes: 22 additions & 1 deletion opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,26 @@
"StructuredOutput": "allow"
}
}
},
"agents": {
"memorize": {
"description": "Memory consolidation agent (opencode-codex-memory)",
"mode": "subagent",
"system": "You are a memory consolidation agent. Read the workspace diff file and update MEMORY.md, memory_summary.md, and skills/ under the memory workspace only. Do not read or edit project source files outside that memory root. Keep memory_summary.md under 10000 chars (2500 tokens). Prune stale entries. Do not access the network.",
"permissions": [
{ "action": "*", "resource": "*", "effect": "deny" },
{ "action": "read", "resource": "*", "effect": "allow" },
{ "action": "edit", "resource": "*", "effect": "allow" },
{ "action": "glob", "resource": "*", "effect": "allow" },
{ "action": "grep", "resource": "*", "effect": "allow" }
]
},
"memorize-extract": {
"description": "Memory extraction agent (opencode-codex-memory)",
"mode": "subagent",
"hidden": true,
"system": "You are a memory extraction agent. The session transcript is provided inline in the prompt. Extract raw_memory, rollout_summary, and rollout_slug as JSON. Exclude AGENTS.md/instruction content. Redact secrets.",
"permissions": [{ "action": "*", "resource": "*", "effect": "deny" }]
}
}
}
}
29 changes: 27 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@
"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 +37,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 @@ -49,14 +58,30 @@
"license": "Apache-2.0",
"dependencies": {
"@opencode-ai/plugin": "^1.18.0",
"@opencode/plugin": "0.0.0-beta-19296",
"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/theme": "0.0.0-beta-19296",
"@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": {
"@opentui/core": ">=0.5.10",
"@opentui/solid": ">=0.5.10",
"solid-js": ">=1.9.0"
},
"peerDependenciesMeta": {
"@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()
151 changes: 151 additions & 0 deletions scripts/check-opencode2-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* V2 contract check for the opencode2 host adapter (src/v2/*).
*
* No LLM, no auth, no Docker. Safe for every PR / pre-release ritual.
*
* Checks:
* 1. opencode2 binary present; version ≥ floor (OPENCODE2_MIN_VERSION,
* default 0.0.0-beta-19296 — the beta this adapter was verified against)
* 2. Live OpenAPI (/openapi.json via the background service): every
* operation the shim + setup depend on is present
* 3. Built plugin dual-exports V1 server() and V2 setup()
* 4. V2 memorize agent satisfies the deny-first allowlist shape
* 5. V2 tool registration yields the expected 7/3 tool sets
*
* Exit 0 = aligned. Exit 1 = contract break. Exit 2 = setup error.
*/
import fs from "fs"
import os from "os"
import path from "path"
import { $ } from "bun"

const MIN_VERSION = process.env.OPENCODE2_MIN_VERSION?.trim() || "0.0.0-beta-19296"

let failed = 0
function log(kind: string, msg: string): void {
console.log(`[${kind}] ${msg}`)
}
function note(ok: boolean, msg: string): void {
if (ok) log("ok", msg)
else {
console.error(`[fail] ${msg}`)
failed++
}
}
function failSetup(msg: string): never {
console.error(`[setup] ${msg}`)
process.exit(2)
}

function betaNum(v: string): number | null {
const m = v.trim().match(/beta-(\d+)/)
return m ? Number(m[1]) : null
}

// Semver-ish compare with beta-suffix awareness for 0.0.0-beta-NNN.
function versionGte(a: string, b: string): boolean {
const an = betaNum(a)
const bn = betaNum(b)
if (an !== null && bn !== null) {
const [acore] = a.split("-beta-")
const [bcore] = b.split("-beta-")
if (acore !== bcore) return acore > bcore
return an >= bn
}
return a >= b
}

const REQUIRED_OPS = [
"v2.session.create",
"v2.session.get",
"v2.session.prompt",
"v2.session.wait",
"v2.session.context",
"v2.session.generate",
"v2.session.switchAgent",
"v2.session.switchModel",
"v2.session.interrupt",
"v2.generate.text",
"v2.mcp.list",
"v2.agent.get",
"v2.event.subscribe",
"v2.model.list",
] as const

async function main(): Promise<void> {
// --- binary ---
let version = ""
try {
version = (await $`opencode2 --version`.text()).trim()
} catch {
failSetup("opencode2 binary not found on PATH")
}
log("bin", `opencode2 @ ${version}`)
note(versionGte(version, MIN_VERSION), `opencode2 ${version} ≥ ${MIN_VERSION}`)

// --- OpenAPI via the background service (no auth needed for /openapi.json) ---
let doc: { paths?: Record<string, Record<string, { operationId?: string }>> }
try {
const raw = await $`opencode2 api get /openapi.json`.text()
doc = JSON.parse(raw)
} catch (e) {
failSetup(`could not fetch /openapi.json: ${e instanceof Error ? e.message : String(e)}`)
}
const ops = new Set<string>()
for (const methods of Object.values(doc.paths ?? {})) {
for (const spec of Object.values(methods)) {
if (spec?.operationId) ops.add(spec.operationId)
}
}
note(ops.size > 0, `openapi has ${ops.size} operations`)
for (const op of REQUIRED_OPS) {
note(ops.has(op), `operation ${op} present`)
}

// --- built plugin dual export ---
const root = path.resolve(import.meta.dirname, "..")
const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ocm-contract2-plugin-"))
process.env.OPENCODE_CODEX_MEMORY_TEST_ROOT = testRoot
try {
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"))
const entry = path.resolve(root, pkg.main)
if (!fs.existsSync(entry)) failSetup(`main entry ${pkg.main} missing — run build first`)
const mod = await import(entry + `?t=${Date.now()}`)
note(typeof mod.default?.server === "function", "default export keeps V1 server()")
note(typeof mod.default?.setup === "function", "default export adds V2 setup()")
note(mod.default?.id === "opencode-codex-memory", "plugin id unchanged")

const v2entry = path.resolve(root, pkg.exports?.["./v2"]?.import ?? "./dist/src/v2/index.js")
if (!fs.existsSync(v2entry)) failSetup(`v2 entry missing — run build first`)
const v2mod = await import(v2entry + `?t=${Date.now()}`)
note(typeof v2mod.default?.setup === "function", "./v2 entry exports setup()")

// --- V2 agent shape (D2 allowlist, V2 action names) ---
const agents = (await import(path.join(root, "dist", "src", "v2", "agents.js") + `?t=${Date.now()}`)) as typeof import("../src/v2/agents.js")
const def = agents.buildMemorizeAgent()
note(def.mode === "subagent", "memorize mode is subagent")
const rules = def.permissions
note(rules[0]?.action === "*" && rules[0]?.effect === "deny", "wildcard deny is first")
const allows = new Set(rules.filter((r) => r.effect === "allow").map((r) => r.action))
const safeAllows = new Set(["read", "edit", "glob", "grep", "external_directory"])
note([...allows].every((a) => safeAllows.has(a)), `allows ⊆ read/edit/glob/grep/external_directory (got ${[...allows].join(",")})`)
for (const t of ["read", "edit", "glob", "grep"]) note(allows.has(t), `allows ${t}`)

// --- V2 tool sets ---
const tools = (await import(path.join(root, "dist", "src", "v2", "tools.js") + `?t=${Date.now()}`)) as typeof import("../src/v2/tools.js")
const names = tools.buildV2Tools().map((t) => t.name).sort()
for (const t of ["memory_read", "memory_search", "memory_list", "memory_add_note", "memory_reset", "memory_inspect", "memory_mode"]) {
note(names.includes(t), `v2 tool ${t} registered`)
}
} finally {
fs.rmSync(testRoot, { recursive: true, force: true })
}

if (failed > 0) {
console.error(`contract2: FAIL — ${failed} check(s) broken`)
process.exit(1)
}
console.log("contract2: OK — v2 host surface aligned")
}

await main()
Loading
Loading