mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-28 11:43:00 +08:00
* 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>
82 lines
3.0 KiB
JavaScript
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
|
|
}
|