diff --git a/examples/codex-memory-plugin/DESIGN.md b/examples/codex-memory-plugin/DESIGN.md index dccd9c128c..1437d1cc22 100644 --- a/examples/codex-memory-plugin/DESIGN.md +++ b/examples/codex-memory-plugin/DESIGN.md @@ -120,11 +120,12 @@ transcript. ### 5. Idle TTL sweep — fallback -State files whose `lastUpdatedAt` is older than `IDLE_TTL_MS` (default 30 -min) get committed and cleared. Mental model: a session not touched for -30 min is "temporarily concluded"; if the user resumes later, subsequent -turns append under the same deterministic OV session id, and the next -commit creates another archive there. +State files with a live `ovSessionId` whose `lastUpdatedAt` is older than +`IDLE_TTL_MS` (default 30 min) get committed. Their transcript cursor is +preserved while `ovSessionId` is cleared. Mental model: a session not touched +for 30 min is "temporarily concluded"; if the user resumes later, subsequent +turns append under the same deterministic OV session id, and the next commit +creates another archive there. This covers: - SIGTERM / Ctrl+C / `/exit` (no hook fires; state file rots) @@ -194,9 +195,9 @@ compatibility fallbacks. Codex's `/compact` may rewrite or truncate `transcript_path`. After compaction, if `allTurns.length < state.capturedTurnCount`, our slice -math underflows and we silently drop new turns. Defensive fix: when this -inequality is detected on `Stop`, reset `capturedTurnCount = 0` so the -next slice captures everything in the new transcript. +math underflows and we silently drop new turns. When this inequality is +detected on `Stop`, move `capturedTurnCount` to the latest human user turn +so the current interaction is captured without replaying compacted history. ### Commit failure diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index e180652c54..358bf8909b 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -191,11 +191,11 @@ On all three sources, the hook uses the same shared `buildProfileBlock()` implem On `startup` or `clear`, the script: -1. Counts state files (excluding the new session_id) whose `lastUpdatedAt` is within `OPENVIKING_CODEX_ACTIVE_WINDOW_MS` (default 2 min) of "now": +1. Counts state files with a live `ovSessionId` (excluding the new session_id) whose `lastUpdatedAt` is within `OPENVIKING_CODEX_ACTIVE_WINDOW_MS` (default 2 min) of "now": - **0 active** → no-op (no orphan to commit) - **1 active** → commit it (the just-ended session) - **≥2 active** → skip; rely on idle TTL (we can't tell which one ended) -2. **Idle-TTL sweep at the tail**: any state file (regardless of session_id) older than `OPENVIKING_CODEX_IDLE_TTL_MS` (default 30 min) gets committed and cleared. +2. **Idle-TTL sweep at the tail**: any live session state older than `OPENVIKING_CODEX_IDLE_TTL_MS` (default 30 min) gets committed while preserving its transcript cursor for resume. On any /commit failure (OV unreachable, non-2xx, timeout) we **preserve state** (don't `clearState`) so the next sweep can retry. diff --git a/examples/codex-memory-plugin/VERIFICATION.md b/examples/codex-memory-plugin/VERIFICATION.md index 22f3cccd5d..8add0ca02c 100644 --- a/examples/codex-memory-plugin/VERIFICATION.md +++ b/examples/codex-memory-plugin/VERIFICATION.md @@ -196,7 +196,7 @@ files are still present — the skip path does not clear them. # Backdate one of the state files to be older than IDLE_TTL_MS (default 30 min). OLD=$(node -e 'console.log(Date.now() - 60*60*1000)') # 1 hour ago cat > "$STATE_DIR/state/sess-aaa.json" < turn.role === "user" && turn.parts?.some((part) => part.type === "text"), + )); } const newTurns = allTurns.slice(state.capturedTurnCount); diff --git a/examples/codex-memory-plugin/scripts/auto-capture.test.mjs b/examples/codex-memory-plugin/scripts/auto-capture.test.mjs index 0e1ac0de48..8aee2c2834 100644 --- a/examples/codex-memory-plugin/scripts/auto-capture.test.mjs +++ b/examples/codex-memory-plugin/scripts/auto-capture.test.mjs @@ -385,3 +385,81 @@ test("auto-capture logs a commit error trace_id", async () => { await rm(stateDir, { recursive: true, force: true }); } }); + +test("auto-capture skips compacted history after transcript shrink", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "ov-auto-capture-compact-")); + const transcriptPath = join(stateDir, "transcript.jsonl"); + const batches = []; + const now = Date.now(); + + try { + await writeFile(join(stateDir, "compaction.json"), JSON.stringify({ + codexSessionId: "compaction", + ovSessionId: "cx-compaction", + capturedTurnCount: 8, + createdAt: now - 1000, + lastUpdatedAt: now, + })); + await writeFile( + transcriptPath, + [ + { payload: { message: { role: "user", content: "compacted historical summary" } } }, + { payload: { message: { role: "assistant", content: "prior assistant tail" } } }, + { payload: { message: { role: "user", content: "current user request" } } }, + { payload: { type: "function_call", id: "call-1", name: "shell", arguments: "{}" } }, + { payload: { type: "function_call_output", call_id: "call-1", output: "tool result" } }, + { payload: { message: { role: "assistant", content: "current assistant response" } } }, + ].map((entry) => JSON.stringify(entry)).join("\n"), + ); + + await withMockOpenViking(async (req, res) => { + const url = new URL(req.url, "http://127.0.0.1"); + if (req.method === "GET" && url.pathname === "/health") { + writeJson(res, { status: "ok", result: { ok: true } }); + return; + } + if (req.method === "POST" && url.pathname.endsWith("/messages/batch")) { + batches.push(await readRequestBody(req)); + writeJson(res, { status: "ok", result: { ok: true } }); + return; + } + if (req.method === "GET" && url.pathname === "/api/v1/sessions/cx-compaction") { + writeJson(res, { + status: "ok", + result: { pending_tokens: 0, commit_count: 0, total_message_count: 4 }, + }); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "error", error: "not found" })); + }, async (baseUrl) => { + await runAutoCapture( + { session_id: "compaction", transcript_path: transcriptPath }, + { + OPENVIKING_AUTO_CAPTURE: "1", + OPENVIKING_CAPTURE_ASSISTANT_TURNS: "1", + OPENVIKING_CODEX_STATE_DIR: stateDir, + OPENVIKING_CONFIG_FILE: join(stateDir, "missing-ov.conf"), + OPENVIKING_CLI_CONFIG_FILE: join(stateDir, "missing-ovcli.conf"), + OPENVIKING_CREDENTIAL_SOURCE: "env", + OPENVIKING_MIN_QUERY_LENGTH: "1", + OPENVIKING_WRITE_PATH_ASYNC: "0", + OPENVIKING_TIMEOUT_MS: "5000", + OPENVIKING_URL: baseUrl, + }, + ); + }); + + const messages = batches.flatMap((batch) => batch.messages || []); + assert.equal(messages.length, 4); + assert.equal(messages[0].parts[0].text, "current user request"); + assert.equal( + messages.some((message) => + message.parts?.some((part) => part.text === "compacted historical summary") + ), + false, + ); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } +}); diff --git a/examples/codex-memory-plugin/scripts/session-start-commit.mjs b/examples/codex-memory-plugin/scripts/session-start-commit.mjs index 7e16344555..f468f3c212 100644 --- a/examples/codex-memory-plugin/scripts/session-start-commit.mjs +++ b/examples/codex-memory-plugin/scripts/session-start-commit.mjs @@ -23,10 +23,9 @@ * "Recently-active" means lastUpdatedAt within ACTIVE_WINDOW_MS (default 2 min). * * At the tail (regardless of which branch above ran), run an idle-TTL sweep: - * any state file (including the new session_id, but in practice it's just - * been created and is fresh) older than IDLE_TTL_MS (default 30 min) gets - * committed and cleared. This catches SIGTERM/Ctrl+C/`/exit` exits and - * crashes that left state files orphaned. + * 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. * * Commit failure handling: * On any /commit failure (OV unreachable, non-2xx, timeout) we DO NOT call @@ -232,8 +231,8 @@ async function buildResumeArchiveContext(newSessionId) { } /** - * Commit and clear a single state file. On commit failure, preserve state - * (don't call clearState) so the next sweep retries. + * Commit a live OV session and preserve its transcript cursor. On commit + * failure, keep the live session id so the next sweep retries. * * Returns { committed: bool, ovSessionId: string|null }. */ @@ -263,7 +262,8 @@ async function commitAndClear(state, reason) { status: commit.result?.status, trace_id: traceId || undefined, }); - await clearState(state.codexSessionId); + state.ovSessionId = null; + await saveState(state); return { committed: true, ovSessionId, traceId }; } // No OV session attached — nothing to commit on the server, but the local @@ -364,7 +364,7 @@ async function main() { // Active-window heuristic (DESIGN.md §3) // ------------------------------------------------------------------------- const otherStates = states.filter( - (s) => s?.codexSessionId && s.codexSessionId !== newSessionId, + (s) => s?.codexSessionId && s.codexSessionId !== newSessionId && s.ovSessionId, ); const recentlyActive = otherStates.filter( @@ -412,6 +412,7 @@ async function main() { for (const s of postHeuristic) { if (!s?.codexSessionId) continue; + if (!s.ovSessionId) continue; if (typeof s.lastUpdatedAt !== "number") continue; if ((now - s.lastUpdatedAt) <= IDLE_TTL_MS) continue; log("idle_sweep", { diff --git a/examples/codex-memory-plugin/scripts/session-start-commit.test.mjs b/examples/codex-memory-plugin/scripts/session-start-commit.test.mjs index 2ae20f4757..642219699b 100644 --- a/examples/codex-memory-plugin/scripts/session-start-commit.test.mjs +++ b/examples/codex-memory-plugin/scripts/session-start-commit.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import http from "node:http"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -224,6 +224,54 @@ test("startup preserves the existing commit systemMessage alongside profile cont request.method === "POST" && request.path === "/api/v1/sessions/cx-old-session/commit" )); + const state = JSON.parse(await readFile(join(stateDir, "old-session.json"), "utf-8")); + assert.equal(state.ovSessionId, null); + assert.equal(state.capturedTurnCount, 2); + } finally { + await rm(stateDir, { recursive: true, force: true }); + } +}); + +test("startup ignores committed cursor-only states", async () => { + const stateDir = await mkdtemp(join(tmpdir(), "ov-codex-cursor-only-")); + const requests = []; + try { + const now = Date.now(); + await Promise.all([ + writeFile(join(stateDir, "recent.json"), JSON.stringify({ + codexSessionId: "recent", + ovSessionId: null, + capturedTurnCount: 4, + createdAt: now - 500, + lastUpdatedAt: now, + })), + writeFile(join(stateDir, "stale.json"), JSON.stringify({ + codexSessionId: "stale", + ovSessionId: null, + capturedTurnCount: 6, + createdAt: now - 20_000, + lastUpdatedAt: now - 10_000, + })), + ]); + + await withMockOpenViking(profileHandler(requests), async (baseUrl) => { + await runSessionStart( + { session_id: "new-session", source: "startup", cwd: "/tmp/codex-cursor-only" }, + { + ...baseEnv(baseUrl, stateDir), + OPENVIKING_CODEX_ACTIVE_WINDOW_MS: "1000", + OPENVIKING_CODEX_IDLE_TTL_MS: "5000", + }, + ); + }); + + const [recent, stale] = await Promise.all([ + readFile(join(stateDir, "recent.json"), "utf-8"), + readFile(join(stateDir, "stale.json"), "utf-8"), + ]); + assert.equal(JSON.parse(recent).capturedTurnCount, 4); + assert.equal(JSON.parse(stale).capturedTurnCount, 6); + assert.equal(requests.some((request) => request.path.endsWith("/commit")), false); } finally { await rm(stateDir, { recursive: true, force: true }); }