fix(opencode): fix memory-inject race condition, empty-memory ban, add turn limit

Three critical fixes to the iai-mcp-memory-inject.js plugin:

- Race condition: inflight is now a Map<sessionID, Promise> so concurrent
  callers (warm-on-create event + first transform hook) await the same
  fetch instead of getting empty string. First turn now gets memory.
- Empty-memory ban: fetchMemory returns {ok, text} tuple. HTTP 200 with
  empty body is memoized as valid (new user, minimal mode). Only actual
  network errors count against the retry budget.
- Turn-limited injection: new INJECT_MAX_TURNS (default 3) stops injecting
  after N turns. Memory was already in system prompt; the model has seen
  it. Controls per-turn token cost that was 50x the old approach.

Also: README now documents memory-inject.js (not session-init), and
session-init.js is marked @deprecated.
This commit is contained in:
Apunkt 2026-06-03 16:47:55 +02:00
parent 4f6b91aef8
commit 7559bac57f
No known key found for this signature in database
3 changed files with 69 additions and 24 deletions

View file

@ -26,11 +26,13 @@ const HOME = process.env.HOME || process.cwd();
const PORT_FILE = `${HOME}/.iai-mcp/.http.port`;
const WAKE_DEPTH = process.env.IAI_MCP_WAKE_DEPTH || "standard";
const FETCH_TIMEOUT_MS = 10000; // a cold runtime-graph build can exceed 5s
const MAX_ATTEMPTS = 3; // give up after this many failed fetches per session
const MAX_ATTEMPTS = 3; // give up after this many consecutive failed fetches per session
const INJECT_MAX_TURNS = 3; // stop injecting after N turns (token-cost control)
const memo = new Map(); // sessionID -> injected text (cached on success)
const attempts = new Map(); // sessionID -> failed-fetch count
const inflight = new Set(); // sessionIDs with a fetch in flight (dedupe warm+inject)
const memo = new Map(); // sessionID -> injected text | null (null = permanent failure)
const inflight = new Map(); // sessionID -> Promise (await for concurrent callers)
const attempts = new Map(); // sessionID -> consecutive-failed-fetch count
const turns = new Map(); // sessionID -> number of times memory was injected
async function readPort() {
const fs = await import("node:fs");
@ -44,17 +46,19 @@ async function readPort() {
async function fetchMemory(sessionId) {
const port = await readPort();
if (!port) return "";
if (!port) return { ok: false, text: "" };
const url =
`http://127.0.0.1:${port}/memory/session-context` +
`?session_id=${encodeURIComponent(sessionId)}` +
`&wake_depth=${encodeURIComponent(WAKE_DEPTH)}&format=text`;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) return "";
return (await res.text()).trim();
const text = (await res.text()).trim();
// Distinguish network failure from valid-but-empty response.
// An empty body on HTTP 200 is a valid result (new user, minimal mode).
return { ok: res.ok, text };
} catch {
return "";
return { ok: false, text: "" };
}
}
@ -62,17 +66,41 @@ async function fetchMemory(sessionId) {
// transform hook share ONE in-flight fetch, one cache, and one attempt budget.
// This dedupes concurrent calls so a session never emits duplicate daemon-side
// session_started events.
//
// Fix: inflight stores a Promise so concurrent callers await the same fetch
// instead of getting "". Fix: memo stores null for permanent failure so we
// don't retry forever. Fix: distinguish {ok:true,text:""} (valid empty) from
// {ok:false} (network error).
async function ensureMemory(sessionId) {
// Already resolved — return cached value (may be "" or null).
const cached = memo.get(sessionId);
if (cached !== undefined) return cached;
if (inflight.has(sessionId)) return ""; // a fetch is already running
if ((attempts.get(sessionId) || 0) >= MAX_ATTEMPTS) return "";
inflight.add(sessionId);
try {
const text = await fetchMemory(sessionId);
if (text) memo.set(sessionId, text);
else attempts.set(sessionId, (attempts.get(sessionId) || 0) + 1);
// A fetch is already running — await it instead of returning "".
const existing = inflight.get(sessionId);
if (existing) return existing;
// Permanent failure — stop retrying.
if ((attempts.get(sessionId) || 0) >= MAX_ATTEMPTS) {
memo.set(sessionId, null);
return null;
}
const promise = (async () => {
const { ok, text } = await fetchMemory(sessionId);
if (ok) {
// HTTP 200 — memoize regardless of content (empty is valid).
memo.set(sessionId, text);
attempts.delete(sessionId); // reset failure counter on success
} else {
// Network error — increment counter for retry budget.
attempts.set(sessionId, (attempts.get(sessionId) || 0) + 1);
}
return text;
})();
inflight.set(sessionId, promise);
try {
return await promise;
} finally {
inflight.delete(sessionId);
}
@ -98,8 +126,16 @@ export const IaiMcpMemoryInject = async () => {
try {
const sid = input?.sessionID;
if (!sid || !output || !Array.isArray(output.system)) return;
// Turn-limited injection: stop after INJECT_MAX_TURNS to control token cost.
// Memory was already in the system prompt for the first N turns; the model
// has seen it and doesn't need repetition every turn.
const currentTurns = turns.get(sid) || 0;
if (currentTurns >= INJECT_MAX_TURNS) return;
const text = await ensureMemory(sid);
if (text) {
turns.set(sid, currentTurns + 1);
output.system.push(`# iai-mcp memory (session start)\n${text}`);
}
} catch (err) {

View file

@ -1,8 +1,17 @@
/**
* iai-mcp session-init plugin for OpenCode.
* @deprecated Use `iai-mcp-memory-inject.js` instead.
*
* Sends SDK prompt on session.updated to inject the system instruction.
* The model calls memory_session_context on every new session.
* This plugin forces a tool call via a phantom "INIT" user turn, which
* corrupts session title generation. The memory-inject plugin uses the
* system.transform hook to inject memory directly into the system prompt
* no phantom turn, clean titles, memory visible every turn.
*
* Retained temporarily for backward compatibility. Will be removed in a
* future release. Migrate to iai-mcp-memory-inject.js:
*
* cp deploy/opencode/iai-mcp-memory-inject.js ~/.config/opencode/plugins/
*
* Then replace this plugin reference in ~/.config/opencode/config.json.
*/
export const IaiMcpSessionInit = async ({