Files
OpenViking/examples/codex-memory-plugin/scripts/session-start-commit.mjs
T
t0sakiand7487 a61c57bf59 fix(codex): preserve transcript cursors across commit and compaction (#4191)
* fix(#4058): [Bug]: Codex memory plugin replays historical turns after resume or transcript compaction

Fixes #4058

Ref: https://github.com/volcengine/OpenViking/issues/4058

* fix(codex): retire committed cursors and keep activity-based concurrency

Preserving the transcript cursor after a commit stops the replay, but it also
means nothing deletes state files any more: clearState() lost its last caller,
so every codex session — including ones that never captured a turn — leaves a
file behind, and listStates() reads all of them on every SessionStart.

The sweep now retires cursor-only states in the same pass: a real cursor is
kept for resume until OPENVIKING_CODEX_COMMITTED_TTL_MS (default 30 days, past
the life of the codex rollout it indexes), and a state that never captured
anything goes on the idle schedule, which is what the old sweep did with it.

Releasing ovSessionId also wrote lastUpdatedAt, making a committed session look
freshly active; saveState() takes touch:false so the field keeps meaning "last
transcript activity" for both the active window and retention.

Requiring a live ovSessionId to count as recently-active made the heuristic
miss sessions PreCompact had just committed, which can still be running: the
count is back on activity alone, and only a state with a live session is
committed.

Also name the shrink predicate: role === "user" covers tool results too
(normalizeCaptureRole maps them onto the user role), so findLastHumanTurnIndex
requires a text part, and the no-human-turn fallback to a full replay is now
visible in the log instead of silent.

---------

Co-authored-by: 7487 <1042653432@qq.com>
2026-08-26 11:44:40 +08:00

486 lines
17 KiB
JavaScript

#!/usr/bin/env node
/**
* SessionStart hook for Codex.
*
* Triggers (matcher = "clear|startup|resume" in hooks.json):
* - source=startup → fresh codex CLI / `/new` / zouk daemon spawn-without-sessionId
* - source=clear → `/clear` (orphans the current process's previous session)
* - source=resume → `/resume` or short reconnect (no commit/sweep;
* may inject latest archive summary if the live OV session was already committed)
*
* Every source injects the shared OpenViking profile block unless
* OPENVIKING_NO_AUTO_INJECT=1. The block contains profile.md plus
* abstract-annotated indexes of preferences/ and entities/, capped by
* OPENVIKING_PROFILE_TOKEN_BUDGET with the shared CJK-aware estimator.
*
* Behavior (see DESIGN.md §3 — "SessionStart source=startup, heuristic"):
* On `startup` or `clear`, run the active-window heuristic over state files
* excluding the new session_id:
* - 0 recently-active → no-op
* - 1 recently-active → commit it (the just-ended session)
* - ≥2 recently-active → skip; rely on idle TTL
* "Recently-active" means lastUpdatedAt within ACTIVE_WINDOW_MS (default 2 min).
* Concurrency is judged on activity alone; only a state that still has a live
* ovSessionId is actually committed.
*
* At the tail (regardless of which branch above ran), run an idle-TTL sweep:
* any live OV session state older than IDLE_TTL_MS (default 30 min) gets
* committed while retaining its transcript cursor. This catches
* SIGTERM/Ctrl+C/`/exit` exits and crashes that left sessions orphaned.
*
* The same pass retires cursor-only states (no live OV session): a cursor
* that was never used is dropped after IDLE_TTL_MS, and a real cursor is
* kept for resume until COMMITTED_TTL_MS (default 30 days). Without this the
* state directory grows one file per codex session forever and listStates()
* reads all of them on every SessionStart.
*
* Commit failure handling:
* On any /commit failure (OV unreachable, non-2xx, timeout) we keep the state
* file with ovSessionId still set so the next sweep retries. A transient OV
* outage shouldn't lose memory.
*
* Output may contain hookSpecificOutput.additionalContext for profile/archive
* injection and systemMessage for commit status at the same time.
*/
import { loadConfig } from "./config.mjs";
import { createLogger } from "./debug-log.mjs";
import { detectRecallCompressorProfile } from "./recall-compressor-profile.mjs";
import { clearState, deriveOvSessionId, listStates, loadState, saveState } from "./session-state.mjs";
import { buildProfileBlock } from "./shared/profile-inject.mjs";
import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs";
const cfg = loadConfig();
const { log, logError } = createLogger("session-start");
let activePeerId = cfg.peerId || "";
const ACTIVE_WINDOW_MS = (() => {
const v = Number(process.env.OPENVIKING_CODEX_ACTIVE_WINDOW_MS);
return Number.isFinite(v) && v > 0 ? Math.floor(v) : 120_000;
})();
const IDLE_TTL_MS = (() => {
const v = Number(process.env.OPENVIKING_CODEX_IDLE_TTL_MS);
return Number.isFinite(v) && v > 0 ? Math.floor(v) : 1_800_000;
})();
const COMMITTED_TTL_MS = (() => {
const v = Number(process.env.OPENVIKING_CODEX_COMMITTED_TTL_MS);
return Number.isFinite(v) && v > 0 ? Math.floor(v) : 2_592_000_000;
})();
function output(obj) {
process.stdout.write(JSON.stringify(obj) + "\n");
}
function noop(message) {
output(message ? { systemMessage: message } : {});
}
function emitSessionStartOutput({ contexts = [], systemMessage = "" } = {}) {
const additionalContext = contexts.filter(Boolean).join("\n\n");
const response = {};
if (additionalContext) {
response.hookSpecificOutput = {
hookEventName: "SessionStart",
additionalContext,
};
}
if (systemMessage) response.systemMessage = systemMessage;
output(response);
}
function responseTraceId(body) {
return body?.result?.trace_id || body?.error?.trace_id || body?.trace_id || undefined;
}
async function requestJSON(path, init = {}, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), cfg.captureTimeoutMs);
try {
const headers = { "Content-Type": "application/json" };
if (cfg.apiKey) {
headers["Authorization"] = `Bearer ${cfg.apiKey}`;
headers["X-API-Key"] = cfg.apiKey;
}
if (cfg.sendIdentityHeaders && cfg.account) headers["X-OpenViking-Account"] = cfg.account;
if (cfg.sendIdentityHeaders && cfg.user) headers["X-OpenViking-User"] = cfg.user;
const actorPeerId = options.actorPeerId ?? activePeerId;
if (actorPeerId) headers["X-OpenViking-Actor-Peer"] = actorPeerId;
if (cfg.userAgent) headers["User-Agent"] = cfg.userAgent;
const res = await fetch(`${cfg.baseUrl}${path}`, { ...init, headers, signal: controller.signal });
const body = await res.json().catch(() => null);
if (!body) return { ok: false, status: res.status };
const traceId = responseTraceId(body);
if (!res.ok || body.status === "error") {
return { ok: false, status: res.status, error: body.error || body, traceId };
}
return { ok: true, status: res.status, result: body.result ?? body, traceId };
} catch (error) {
return { ok: false, status: 0, error: { message: error?.message || String(error) } };
} finally {
clearTimeout(timer);
}
}
async function fetchJSON(path, init = {}, options = {}) {
const response = await requestJSON(path, init, options);
return response.ok ? response.result : null;
}
async function commitOvSession(ovSessionId) {
if (!ovSessionId) return null;
return requestJSON(
`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/commit`,
{ method: "POST", body: JSON.stringify({}) },
);
}
function truncateText(text, maxChars) {
const value = String(text || "").trim();
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(0, maxChars - 20)).trimEnd()}\n[truncated]`;
}
function formatResumeArchiveContext(ovSessionId, context) {
const overview = String(context?.latest_archive_overview || "").trim();
if (!overview) return "";
const archiveUri = `viking://~/sessions/${ovSessionId}/history/`;
const body = truncateText(overview, cfg.resumeArchiveMaxChars);
return [
"OpenViking session archive digest:",
`Latest committed archive for resumed Codex session ${ovSessionId}:`,
body,
"",
`More detail: use the OpenViking MCP read/search tools with ${archiveUri} if you need exact prior commands, files, tool outputs, or messages.`,
].join("\n");
}
function wrapResumeContext(additionalContext) {
const body = String(additionalContext || "")
.replace(/<\/?openviking-context\b[^>]*>/gi, "openviking context marker")
.trim();
if (!body) return "";
return [
'<openviking-context source="session-resume" format="archive-digest">',
body,
"</openviking-context>",
].join("\n");
}
function wrapProfileContext(profileBlock) {
if (!profileBlock) return "";
return [
'<openviking-context source="session-start">',
profileBlock,
"</openviking-context>",
].join("\n");
}
async function buildSessionProfileContext() {
if (cfg.noAutoInject) {
log("skip", { stage: "profile_inject", reason: "disabled" });
return "";
}
try {
const profile = await buildProfileBlock(
requestJSON,
cfg.profileTokenBudget,
activePeerId,
);
if (!profile?.block) {
log("skip", { stage: "profile_inject", reason: "no profile content" });
return "";
}
log("profile_inject", {
chars: profile.chars,
tokens: profile.tokens,
profileChars: profile.profileChars,
prefCount: profile.prefCount,
entCount: profile.entCount,
droppedPref: profile.droppedPref,
droppedEnt: profile.droppedEnt,
});
return wrapProfileContext(profile.block);
} catch (error) {
logError("profile_inject", error);
return "";
}
}
async function buildResumeArchiveContext(newSessionId) {
if (!cfg.resumeArchiveInject) {
log("skip", { stage: "resume_archive", reason: "disabled" });
return "";
}
const state = await loadState(newSessionId);
if (state.ovSessionId) {
log("skip", {
stage: "resume_archive",
reason: "live OV session still open",
ovSessionId: state.ovSessionId,
});
return "";
}
const ovSessionId = deriveOvSessionId(newSessionId);
const context = await fetchJSON(
`/api/v1/sessions/${encodeURIComponent(ovSessionId)}/context?token_budget=${cfg.resumeArchiveTokenBudget}`,
);
const additionalContext = formatResumeArchiveContext(ovSessionId, context);
if (!additionalContext) {
log("skip", { stage: "resume_archive", reason: "no archive overview", ovSessionId });
return "";
}
log("resume_archive_inject", {
ovSessionId,
chars: additionalContext.length,
tokenBudget: cfg.resumeArchiveTokenBudget,
});
return wrapResumeContext(additionalContext);
}
/**
* Commit a live OV session and release it, keeping the transcript cursor so a
* later resume appends instead of replaying (DESIGN.md "Commit-then-resume").
* On commit failure, keep the live session id so the next sweep retries.
*
* Returns { committed: bool, ovSessionId: string|null, traceId: string }.
*/
async function commitAndRelease(state, reason) {
const ovSessionId = state.ovSessionId;
const commit = await commitOvSession(ovSessionId);
if (!commit?.ok) {
log("commit", {
reason,
codexSessionId: state.codexSessionId,
ovSessionId,
ok: false,
status: commit?.status,
trace_id: commit?.traceId,
error: commit?.error?.message || commit?.error?.code,
});
return { committed: false, ovSessionId: null, traceId: commit?.traceId || "" };
}
const traceId = commit.traceId || commit.result?.trace_id || "";
log("commit", {
reason,
codexSessionId: state.codexSessionId,
ovSessionId,
archived: commit.result?.archived ?? false,
taskId: commit.result?.task_id,
status: commit.result?.status,
trace_id: traceId || undefined,
});
state.ovSessionId = null;
await saveState(state, { touch: false });
return { committed: true, ovSessionId, traceId };
}
/**
* Retire a state file with no live OV session. A cursor that never captured
* anything carries no resume value, so it goes on the idle schedule; a real
* cursor is kept until COMMITTED_TTL_MS in case the codex session is resumed.
*/
async function maybeRetireCursorState(state, ageMs) {
const hasCursor = Number(state.capturedTurnCount) > 0;
const ttl = hasCursor ? COMMITTED_TTL_MS : IDLE_TTL_MS;
if (ageMs <= ttl) return false;
log("state_retire", {
codexSessionId: state.codexSessionId,
capturedTurnCount: state.capturedTurnCount,
ageMs,
ttlMs: ttl,
});
await clearState(state.codexSessionId);
return true;
}
function describeCommittedSessions(commits) {
const traceIds = commits.map((item) => item.traceId).filter(Boolean);
if (commits.length === 1) {
return `OpenViking session ${commits[0].ovSessionId} is committed` +
(traceIds.length ? ` (trace_id=${traceIds[0]})` : "");
}
return `OpenViking sessions ${commits.map((item) => item.ovSessionId).join(", ")} are committed` +
(traceIds.length ? ` (trace_ids=${traceIds.join(",")})` : "");
}
async function main() {
let input;
try {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
input = JSON.parse(Buffer.concat(chunks).toString());
} catch {
log("skip", { stage: "stdin_parse", reason: "invalid input" });
noop();
return;
}
const source = input.source || "unknown";
const newSessionId = input.session_id || "unknown";
const cwd = typeof input.cwd === "string" && input.cwd.trim() ? input.cwd : process.cwd();
const effectivePeer = resolveEffectivePeerId({ cfg, cwd });
activePeerId = effectivePeer.peerId;
if (newSessionId !== "unknown") {
const state = await loadState(newSessionId);
await saveState({
...state,
workspacePeerId: effectivePeer.source === "workspace" ? effectivePeer.peerId : "",
});
}
log("start", {
source,
newSessionId,
activeWindowMs: ACTIVE_WINDOW_MS,
idleTtlMs: IDLE_TTL_MS,
peerSource: effectivePeer.source,
});
try {
await detectRecallCompressorProfile(cfg, { log, logError });
} catch (err) {
logError("compress_profile_detect_uncaught", err);
}
if (source === "resume") {
const health = await fetchJSON("/health");
if (!health) {
logError("health_check", "server unreachable; skipping profile + archive injection");
noop();
return;
}
const [profileContext, archiveContext] = await Promise.all([
buildSessionProfileContext(),
buildResumeArchiveContext(newSessionId),
]);
emitSessionStartOutput({ contexts: [profileContext, archiveContext] });
return;
}
// Other non-startup sources are hard no-ops. We don't sweep there, because
// reconnect-like sources may fire often and sweep should stay tied to a new
// session boundary.
if (source !== "startup" && source !== "clear") {
log("skip", { stage: "source_check", reason: `source=${source} (only startup|clear act)` });
noop();
return;
}
const health = await fetchJSON("/health");
if (!health) {
logError("health_check", "server unreachable; skipping profile injection + commit + sweep");
noop();
return;
}
const profileContext = await buildSessionProfileContext();
const now = Date.now();
const states = await listStates();
// -------------------------------------------------------------------------
// Active-window heuristic (DESIGN.md §3)
// -------------------------------------------------------------------------
const otherStates = states.filter(
(s) => s?.codexSessionId && s.codexSessionId !== newSessionId,
);
// Concurrency is judged on activity, not on whether an OV session is live:
// a session that PreCompact just committed is cursor-only yet may still be
// running, and counting it keeps the ≥2 branch from committing a sibling
// session mid-flight.
const recentlyActive = otherStates.filter(
(s) => typeof s.lastUpdatedAt === "number"
&& (now - s.lastUpdatedAt) <= ACTIVE_WINDOW_MS,
);
const heuristicCommits = [];
const skippedSessionIds = new Set();
if (recentlyActive.length === 0) {
log("heuristic", { branch: "0_active", action: "noop", otherStates: otherStates.length });
} else if (recentlyActive.length === 1) {
const target = recentlyActive[0];
if (!target.ovSessionId) {
log("heuristic", {
branch: "1_active",
action: "skip",
reason: "no live OV session",
codexSessionId: target.codexSessionId,
});
} else {
log("heuristic", {
branch: "1_active",
action: "commit",
codexSessionId: target.codexSessionId,
ovSessionId: target.ovSessionId,
});
const r = await commitAndRelease(target, "heuristic_1_active");
if (r.committed) heuristicCommits.push(r);
}
} else {
log("heuristic", {
branch: ">=2_active",
action: "skip; rely on idle TTL",
activeCount: recentlyActive.length,
activeIds: recentlyActive.map((s) => s.codexSessionId),
});
for (const s of recentlyActive) skippedSessionIds.add(s.codexSessionId);
}
// -------------------------------------------------------------------------
// Idle TTL sweep + cursor retention (tail) — applies to ALL state files
// including ones we just skipped above (≥2 active path). We re-list because
// the heuristic branch may have changed entries.
// -------------------------------------------------------------------------
const postHeuristic = await listStates();
const idleCommits = [];
let retired = 0;
for (const s of postHeuristic) {
if (!s?.codexSessionId) continue;
if (typeof s.lastUpdatedAt !== "number") continue;
const ageMs = now - s.lastUpdatedAt;
if (!s.ovSessionId) {
if (await maybeRetireCursorState(s, ageMs)) retired += 1;
continue;
}
if (ageMs <= IDLE_TTL_MS) continue;
log("idle_sweep", {
codexSessionId: s.codexSessionId,
ovSessionId: s.ovSessionId,
ageMs,
});
const r = await commitAndRelease(s, "idle_ttl");
if (r.committed) idleCommits.push(r);
}
const commits = [...heuristicCommits, ...idleCommits];
const ovSessionIds = commits.map((item) => item.ovSessionId);
log("done", {
source,
heuristicCommitted: heuristicCommits.length,
idleCommitted: idleCommits.length,
totalCommitted: commits.length,
retired,
ovSessionIds,
skipped: [...skippedSessionIds],
});
if (commits.length > 0) {
emitSessionStartOutput({
contexts: [profileContext],
systemMessage: describeCommittedSessions(commits),
});
} else {
emitSessionStartOutput({ contexts: [profileContext] });
}
}
main().catch((err) => { logError("uncaught", err); noop(); });