Files
OpenViking/examples/zcode-memory-plugin/scripts/zcode-hook.mjs
T
t0saki 674f5e6039 fix(retrieval): honor context tier ceilings and stop cooling unserved recalls (#3746)
* fix(retrieval): honor tier ceilings and stop cooling unserved recalls

Follow-up to #3534, from its post-merge review round.

- The abstract-to-overview substitute now applies only to categories whose
  stored abstract is the whole file body. A resource or skill whose abstract is
  missing (`processing_mode=vectors_only`) or over the per-entry cap read its
  body and returned an overview instead, which for a short file is the body
  almost verbatim — crossing the opt-in deepening boundary those categories are
  documented to have, and doing it even under an explicit `detail="abstract"`.
  They now degrade to a bare URI and their body is never read.
- A digest reporting `no_relevant` blanks `rendered`, so the client injects
  nothing, yet those URIs still entered the dedup ledger and were cooled for
  `dedup_turns` turns. That contradicted the ledger's own bare-URI grace rule
  and held memories back from the later turn they were relevant to.
- Flat retrieval reaches built-in memory types outside the four named ones
  (`cases`, `patterns`, `tools`, `trajectories`, skill-usage memories) and
  reported them as an undeclared `memories` category that no tier or penalty
  table covered, so other-peer hits skipped the score penalty and callers could
  not pin their tier. The catch-all is now a declared category with both; it
  stays out of `quotas`, whose buckets it would overlap. Skill-usage memories
  also stop being misread as the `skills` category.
- ZCode, OpenCode and pi own an OV session id but did not forward it, so their
  recalls silently ran without query expansion or cross-turn dedup.
- The context-request deadline covered only the server's 30s rewrite fuse, but
  the pipeline is serial: expansion, retrieval and budgeting all precede it.
  45s covers both fuses and the work between them.
- `plugin` config scope and the `/recall` successor example now match what the
  code actually does.

* fix(retrieval): make the context deadline and expansion opt-out reachable

Forwarding a session id turns on server-side query expansion, an LLM call with
its own 5s fuse, but neither the deadline that was supposed to cover it nor the
switch that turns it off reached the two harnesses this PR newly enabled it for.

- `contextRequestTimeoutMs()` now derives the deadline from the request body
  rather than from `cfg` plus a rewrite flag. The body is what states which
  server stages will run: a session takes the expansion fuse, `rewrite` takes
  the digest fuse, and a bare retrieval takes neither and keeps the caller's own
  budget. Reading `cfg` alone could not tell those apart.
- OpenCode pinned `timeoutMs: 5000` after spreading the helper's options and pi
  ignored them entirely, so the helper's deadline was dead code in both. Their
  own budgets are now defaults rather than ceilings. OpenCode's 5s in particular
  was shorter than the expansion fuse it had just enabled, so a legal request
  would have been aborted client-side and dropped back to the path with neither
  dedup nor expansion.
- OpenCode and pi read `OPENVIKING_RECALL_QUERY_EXPANSION` (and
  `recallQueryExpansion` in their own config files) and set the `configured`
  flag the shared body builder requires, so the documented opt-out exists where
  the cost was introduced.
- The integration overview no longer implies every harness reads the same
  environment knobs, and describes the deadline as per-stage rather than
  rewrite-only.
2026-08-05 23:52:15 +08:00

194 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
/**
* ZCode hook dispatcher.
*
* Mirrors the TRAE adapter pattern: a single entry point branched on
* OPENVIKING_HOOK_EVENT. Four thin shim scripts set the event env var and
* import this module.
*
* Output contract: ZCode parses hook stdout as strict JSON — any unrecognized
* key causes the entire output to be silently discarded. Therefore we NEVER
* emit Claude-Code-isms like { "decision": "approve" }. Instead:
* - Context injection: { hookSpecificOutput: { hookEventName, additionalContext } }
* - Pass-through: empty output (stdout = "") + exit 0
*
* The deny path (PreToolUse) is handled separately by uri-guard.mjs, which
* emits { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", ... } }.
*/
import {
addAgentMessages,
buildAgentProfile,
commitAgentSession,
createAgentLogger,
deriveAgentSessionId,
loadAgentHookConfig,
makeAgentFetchJSON,
readHookState,
recallForPrompt,
replayAgentPending,
resolveAgentCwd,
resolveNativeSessionId,
shouldBypassAgent,
stableHash,
withAgentHookLock,
writeHookState,
} from "./shared/agent-hook-runtime.mjs";
import { maybeDetach, readHookStdin } from "./shared/async-writer.mjs";
import { applyZcodeCaptureResult, buildZcodeCapturePlan } from "./zcode-capture.mjs";
import { buildZcodeTurns, cleanZcodeText } from "./zcode-turns.mjs";
const eventName = process.env.OPENVIKING_HOOK_EVENT || process.argv[2] || "";
const cfg = loadAgentHookConfig("zcode");
const { log, logError } = createAgentLogger("zcode", eventName, cfg);
/**
* Emit context injection output for SessionStart / UserPromptSubmit.
* Uses ONLY ZCode-recognized keys: hookSpecificOutput with hookEventName + additionalContext.
* No `decision` field — that is a Claude-Code-ism that ZCode's strict schema rejects.
*/
function outputContext(additionalContext, hookEventName) {
if (!additionalContext) {
// Pass-through: empty output + implicit exit 0
return;
}
process.stdout.write(
JSON.stringify({
hookSpecificOutput: {
hookEventName,
additionalContext,
},
}) + "\n",
);
}
let input = {};
let nativeSessionId = "";
let sessionId = "";
let cwd = "";
let fetchJSON;
async function main() {
if (!cfg.enabled || shouldBypassAgent(cfg, input)) {
return;
}
let state = await readHookState("zcode", nativeSessionId);
// --- SessionStart: inject user profile + replay pending ---
if (eventName === "session-start") {
const profile = await withAgentHookLock("zcode", nativeSessionId, async () => {
state = await readHookState("zcode", nativeSessionId);
const now = Date.now();
if (now - Number(state.lastSessionStartAt || 0) < 2000) return null;
state = { ...state, lastSessionStartAt: now };
await writeHookState("zcode", nativeSessionId, state);
await replayAgentPending(fetchJSON, log).catch((error) => logError("pending", error));
return buildAgentProfile(fetchJSON, cfg, cwd).catch((error) => {
logError("profile", error);
return null;
});
});
outputContext(
profile ? `<openviking-context source="session-start">\n${profile}\n</openviking-context>` : "",
"SessionStart",
);
return;
}
// --- UserPromptSubmit: recall relevant memories ---
if (eventName === "user-prompt-submit") {
const prompt = cleanZcodeText(
input.prompt || input.user_prompt || input.userMessage || input.user_message || input.message || "",
);
if (!prompt) return;
const recallBlock = await withAgentHookLock("zcode", nativeSessionId, async () => {
state = await readHookState("zcode", nativeSessionId);
const promptHash = stableHash(prompt);
const now = Date.now();
const promptEventId =
input.generation_id || input.request_id || input.message_id || input.prompt_id || "";
const duplicateEvent = promptEventId
? state.promptEventId === promptEventId
: state.promptHash === promptHash && now - Number(state.promptAt || 0) < 500;
if (duplicateEvent) return null;
const block =
state.promptHash === promptHash && state.recallBlock
? state.recallBlock
: await recallForPrompt(fetchJSON, cfg, prompt, cwd, log, { sessionId }).catch((error) => {
logError("recall", error);
return null;
});
await writeHookState("zcode", nativeSessionId, {
...state,
promptHash,
promptEventId,
promptAt: now,
recallBlock: block,
pendingPrompt: { prompt, hash: promptHash, at: now },
});
return block;
});
outputContext(recallBlock || "", "UserPromptSubmit");
return;
}
// --- Stop: capture incremental turns + commit ---
if (eventName === "stop") {
if (!cfg.autoCapture) return;
await withAgentHookLock("zcode", nativeSessionId, async () => {
state = await readHookState("zcode", nativeSessionId);
const plan = buildZcodeCapturePlan(buildZcodeTurns(input, state), state);
// Fail-closed: if no turns and no dedup keys, skip silently (not an error —
// could be a Stop with no new content, or a race with UserPromptSubmit).
if (plan.toSend.length === 0) return;
const result = await addAgentMessages(fetchJSON, sessionId, plan.payloads);
const { captured, ...nextState } = applyZcodeCaptureResult(state, plan, result);
let nextCount = Number(state.capturedSinceCommit || 0) + captured;
if (captured > 0) {
const committed = await commitAgentSession(fetchJSON, sessionId);
if (committed.ok) nextCount = 0;
}
await writeHookState("zcode", nativeSessionId, {
...nextState,
capturedSinceCommit: nextCount,
});
});
// Stop: no output needed (pass-through)
return;
}
// Unknown event: pass-through silently
}
async function run() {
if (eventName === "stop" && cfg.enabled && cfg.autoCapture) {
const detached = await maybeDetach(cfg, { approve: () => {} });
if (detached) return;
}
try {
input = JSON.parse(await readHookStdin());
} catch {
input = {};
}
// ZCode may pass sessionId in camelCase or snake_case. Ensure both are present
// so resolveNativeSessionId() finds it via the direct lookup path — avoids the
// cwd fallback that would collide for two windows in the same directory.
if (!input.session_id && input.sessionId) input.session_id = input.sessionId;
nativeSessionId = resolveNativeSessionId(input);
sessionId = deriveAgentSessionId("zc-", input);
cwd = resolveAgentCwd(input);
({ fetchJSON } = makeAgentFetchJSON(cfg, cwd));
await main();
}
run().catch((error) => {
logError("uncaught", error);
// Pass-through on error — never block the session
});