feat: OpenCode context plugin (phase 2B) — pointer injection and attribution #2

Merged
troed merged 24 commits from feat/shared-context into main 2026-10-15 15:29:30 +02:00
Owner

What this is

Phase 2B of shared knowledge: the OpenCode plugin that makes a lesson learned in one project available to an agent working in another, without the agent having to ask.

Per prompt the plugin builds a query (prompt + project + working set), asks the hub once via POST /match under a hard timeout, dedupes the returned lesson ids per session, renders a byte-stable block of pointers, and appends it as the last system part of the model request. Attribution (inject) is reported fire-and-forget. Every failure path is fail-open.

Phase 2A — the hub-side matcher — is already on main; this branch adds the client half and no Python.

Runtime probe first

Spec §11 required the injection point be verified before anything was built, because two reference implementations disagreed. It was:

  • The running binary is opencode 2.0.29; the V2 plugin API is @opencode/plugin (not the stale @opencode-ai/plugin pinned in the global config).
  • ctx.session.hook("context", …) exists, with the shape { sessionID, model, system, messages, options, agent, tools }.
  • event.system.push({ type: "text", text }) reaches the outgoing request. No fallback path is needed.
  • context covers the agent loop only; auxiliary flows have their own hook kinds.
  • The injected line is byte-stable across identical prompts.
  • The plugin runs inside OpenCode's background service, not the CLI — so its environment must be set where the service starts. Documented, since it is the most likely operator mistake.

Design notes

  • Zero dependencies. TypeScript run directly by Node ≥ 22 via native type stripping; @opencode/plugin is a type-only import. Tests are node --test with no install step.
  • Fail-open throughout. /match is bounded by REFLECT_MATCH_TIMEOUT_MS (150 ms default) and never rejects; a dead, slow, 5xx or garbage hub injects nothing and the session proceeds. The hook body is wrapped so any throw leaves the request untouched.
  • File structure: src/config.ts, src/query.ts, src/block.ts, src/hub.ts, src/working-set.ts, src/plugin.ts, src/index.ts, with the package-root index.ts OpenCode actually resolves.
  • Registration: a bare directory in ~/.config/opencode/opencode.jsonc's plugins array.

Testing

  • 76 unit tests (cd plugin/opencode && node --test), fixtures captured by the probe rather than hand-built.
  • Host-side end-to-end against a mock OpenAI-compatible model server that records request bodies: a matching prompt produces the pointer; two identical prompts produce a byte-identical block; hub unreachable ⇒ nothing injected and the session proceeds; exactly one block per request. Exit 0, 4/4 assertions.
  • The full existing Python suite still passes (489 passed, 1 skipped).

Deferred

Deferred minors and every ruling are in the branch's SDD ledger under .superpowers/sdd/2026-10-15-shared-context/progress.md (git-ignored, present in the worktree). One parked item: the hanging-attribution test is self-fulfilling (it passes against the pre-fix code) — the implementation is correct by inspection, and a discriminating test is a small follow-up.

## What this is Phase 2B of **shared knowledge**: the OpenCode plugin that makes a lesson learned in one project available to an agent working in another, without the agent having to ask. Per prompt the plugin builds a query (prompt + project + working set), asks the hub once via `POST /match` under a hard timeout, dedupes the returned lesson ids per session, renders a byte-stable block of pointers, and appends it as the last system part of the model request. Attribution (`inject`) is reported fire-and-forget. Every failure path is fail-open. Phase 2A — the hub-side matcher — is already on `main`; this branch adds the client half and no Python. ## Runtime probe first Spec §11 required the injection point be verified before anything was built, because two reference implementations disagreed. It was: - The running binary is **opencode 2.0.29**; the V2 plugin API is `@opencode/plugin` (not the stale `@opencode-ai/plugin` pinned in the global config). - `ctx.session.hook("context", …)` exists, with the shape `{ sessionID, model, system, messages, options, agent, tools }`. - **`event.system.push({ type: "text", text })` reaches the outgoing request.** No fallback path is needed. - `context` covers the agent loop only; auxiliary flows have their own hook kinds. - The injected line is byte-stable across identical prompts. - The plugin runs inside OpenCode's **background service**, not the CLI — so its environment must be set where the service starts. Documented, since it is the most likely operator mistake. ## Design notes - **Zero dependencies.** TypeScript run directly by Node ≥ 22 via native type stripping; `@opencode/plugin` is a type-only import. Tests are `node --test` with no install step. - **Fail-open throughout.** `/match` is bounded by `REFLECT_MATCH_TIMEOUT_MS` (150 ms default) and never rejects; a dead, slow, 5xx or garbage hub injects nothing and the session proceeds. The hook body is wrapped so any throw leaves the request untouched. - **File structure:** `src/config.ts`, `src/query.ts`, `src/block.ts`, `src/hub.ts`, `src/working-set.ts`, `src/plugin.ts`, `src/index.ts`, with the package-root `index.ts` OpenCode actually resolves. - **Registration:** a bare directory in `~/.config/opencode/opencode.jsonc`'s `plugins` array. ## Testing - **76 unit tests** (`cd plugin/opencode && node --test`), fixtures captured by the probe rather than hand-built. - **Host-side end-to-end** against a mock OpenAI-compatible model server that records request bodies: a matching prompt produces the pointer; two identical prompts produce a byte-identical block; hub unreachable ⇒ nothing injected and the session proceeds; exactly one block per request. Exit 0, 4/4 assertions. - The full existing Python suite still passes (489 passed, 1 skipped). ## Deferred Deferred minors and every ruling are in the branch's SDD ledger under `.superpowers/sdd/2026-10-15-shared-context/progress.md` (git-ignored, present in the worktree). One parked item: the hanging-attribution test is self-fulfilling (it passes against the pre-fix code) — the implementation is correct by inspection, and a discriminating test is a small follow-up.
extractBlock previously returned header-to-end-of-text, so a block followed by
steer text (the committed request-injected.json fixture has ~591 chars after the
marker) was compared together with that trailing text, reopening the run-to-run
flakiness assertBlockStable exists to prevent. Bound it explicitly: by the part
boundary for the context-event shape, and by the header line plus contiguous
"- " entry lines for the flattened wire shape.
OpenCode's path-spec loader resolves a configured plugin directory at the
package root (<dir>/index.ts|js) and ignores package.json main; a directory
with no root entrypoint is skipped silently. plugin/opencode/ had none, so a
bare-directory registration loaded nothing and said nothing.

Add plugin/opencode/index.ts re-exporting the plugin from src/index.ts, point
package.json main at it, and correct the registration text in both READMEs.
Repoint the host-side e2e at the shipped directory instead of the synthetic
e2e/location/plugin loader (now removed), so the harness exercises the real
registration path that production uses.
#post had no timeout: a /attribution that accepted the connection and never
answered left #flushing true forever, so flush() short-circuited and #pending
grew without bound (an unbounded leak and permanent attribution loss).

Give #post the same bounded-abort pattern as matchLessons: an AbortController
armed with cfg.timeoutMs and cleared in finally. cfg.timeoutMs is already the
plugin's one network budget and a batch is no larger than a /match response, so
no second knob is warranted. A batch that hangs twice is dropped by the retry
rule, so the queue always drains. Pin it with a test that a hanging endpoint is
aborted and then delivered by the retry.
Two robustness gaps in the hook/setup path:

- The project label was env-only, so with REFLECT_PROJECT unset (the documented
  default) every inject row carried project: null even though SetupContext
  declares location.project.{id, canonical}. Resolve the fixed chain
  REFLECT_PROJECT -> ctx.location.project.canonical -> ctx.location.project.id
  -> null, log one line at setup naming the source, and pin the chain with a
  test. A blank value counts as absent at every step.

- The hook body guarded matchLessons but nothing else, so a throw from
  noteToolCalls, buildQuery, consider or event.system.push would reject the
  promise OpenCode awaits. Wrap the whole body in try/catch that logs and fails
  open; a test freezes the system array so the push throws and asserts the event
  is left untouched.
entryLine interpolated the hub title unescaped, so a title containing a newline
split one entry into extra lines inside a system-prompt block -- and hub-authored
titles cross into every project's session. Collapse runs of CR/LF to a single
space before capping by characters (the cap then counts what actually lands in
the block). The byte format for well-formed input is unchanged; add a
newline-title case and a cap-after-sanitise case.
Correct the registration paragraph (OpenCode ignores package.json main; the root
index.ts is the entrypoint), document the project-label chain and its
observability-only role, note that attribution attempts are bounded, and record
that titles collapse line breaks. Bump the test count to 76.
troed merged commit 6099bf72a8 into main 2026-10-15 15:29:30 +02:00
troed deleted branch feat/shared-context 2026-10-15 15:29:30 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
troed/reflect!2
No description provided.