Files
t0sakiandTrent Telfer 12076389da feat(opencode-plugin): support OpenCode v2 (#5341)
* feat(opencode-plugin): support the OpenCode v2 plugin API

OpenCode 2 does not run v1 hook plugins. Keep the existing server()
entrypoint and add setup() so one package serves both, adapting MCP,
recall, capture, and lifecycle events to the v2 shapes.

Refs #5226

* fix(opencode-plugin): align v2 lifecycle handling

* docs(opencode-plugin): document v2 support

* fix(opencode-plugin): preserve capture across v2 compaction

* fix(opencode-plugin): drop reasoning and keep state on failed v2 executions

v2 capture turned reasoning blocks into assistant text, so chain-of-thought
reached memory extraction and inflated pending tokens. v1 parts and the shared
capture filter drop reasoning; v2 now does the same.

A failed v2 execution ends one turn, not the session. Mapping it to v1's
session.error committed and deleted the session state, so a later capture
without a cursor resent every earlier turn. Failed executions now take the
same idle path as succeeded and interrupted ones.

* fix(opencode-plugin): scope v2 capture to the plugin location and persist its cursor

One OpenCode v2 service runs a plugin instance per location, and the plugin
event stream carries every location's events. Each instance therefore
captured and committed every session, storing each message once per open
project. Handle only lifecycle events whose session belongs to this
location, resolved from session.created or ctx.session.get because execution
events carry no envelope location.

The capture cursor lived only in memory, and the shared session state file
is rewritten by every instance, so a reloaded or evicted instance could
resend the transcript since the last compaction. Keep the cursor in plugin
storage and remove it when the session is deleted.

* docs(opencode-plugin): describe v2 commit points and location scoping

---------

Co-authored-by: Trent Telfer <4094016+ttelfer@users.noreply.github.com>
2026-09-24 15:35:07 +08:00

82 lines
3.0 KiB
JavaScript

import { buildRecallBlock, isRecallEnabled } from "./shared/recall-core.mjs"
import { isBypassed } from "./shared/session-model.mjs"
import { effectivePeerId, fetchJSON, log } from "./utils.mjs"
export function createMemoryRecall({ config, sessionManager }) {
async function buildRelevantMemories(input, parts) {
if (!isRecallEnabled(config)) return
const query = extractCurrentUserText(parts ?? [])
if (!query) return
if (query.length < config.minQueryLength) return
const sessionID = input.sessionID
if (isBypassed(config, {
sessionId: sessionID,
cwd: input.directory ?? input.cwd,
})) return
const health = await fetchJSON(config, "/health", {}, { timeoutMs: 5000 })
if (!health.ok) return
const block = await buildRecallBlock(
// 5s is this hook's own budget for a bare retrieval; when the request
// also spends a server fuse the helper hands down a longer deadline, and
// overriding it here would abort a request the server was still inside.
(path, init = {}, options = {}) =>
fetchJSON(config, path, init, { ...options, timeoutMs: options.timeoutMs ?? 5000 }),
config,
query,
{
actorPeerId: effectivePeerId(config),
legacyPeerId: config.effectivePeer?.legacyPeerId ?? "",
// The mapped OV session is what turns on server-side query expansion
// and the cross-turn dedup ledger.
sessionId: sessionID ? sessionManager.getMappedSessionId(sessionID) : "",
log: (stage, data) => log("DEBUG", "recall", stage, data),
},
)
return block || undefined
}
async function injectRelevantMemories(input, output) {
const block = await buildRelevantMemories({
...input,
sessionID: input.sessionID ?? output.message?.sessionID,
}, output.parts)
if (block && prependSyntheticRecallPart(input, output, block)) {
log("INFO", "recall", "Injected OpenViking context")
}
}
return { buildRelevantMemories, injectRelevantMemories }
}
export function extractCurrentUserText(parts) {
const texts = []
for (const part of parts) {
if (part.type !== "text" || typeof part.text !== "string") continue
if (part.synthetic || part.ignored) continue
if (part.text.includes("<openviking-context")) return null
texts.push(part.text)
}
const joined = texts.join(" ").trim()
return joined || null
}
function prependSyntheticRecallPart(input, output, injection) {
const sessionID = input.sessionID ?? output.message?.sessionID
// messageID is optional in opencode's chat.message API (messageID?: string).
// When absent, generate a fallback so recall context is always injected.
const messageID = input.messageID ?? output.message?.id ?? `ov-recall-${Date.now()}`
if (!sessionID) return false
output.parts.unshift({
id: `prt-ov-recall-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: "text",
text: injection,
synthetic: true,
sessionID,
messageID,
})
return true
}