Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions examples/codex-memory-plugin/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions examples/codex-memory-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions examples/codex-memory-plugin/VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EOF
{"codexSessionId":"sess-aaa","ovSessionId":null,"capturedTurnCount":0,"createdAt":$OLD,"lastUpdatedAt":$OLD}
{"codexSessionId":"sess-aaa","ovSessionId":"cx-sess-aaa","capturedTurnCount":2,"createdAt":$OLD,"lastUpdatedAt":$OLD}
EOF

echo '{"session_id":"sess-ddd","source":"startup","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \
Expand All @@ -206,8 +206,9 @@ echo '{"session_id":"sess-ddd","source":"startup","cwd":"/tmp","model":"x","perm
node $PLUGIN/scripts/session-start-commit.mjs
```

Expect: log shows `idle_sweep` for `sess-aaa` (committed and cleared).
`sess-bbb.json` is still present (still fresh). `sess-aaa.json` is gone.
Expect: log shows `idle_sweep` for `sess-aaa` (committed).
`sess-bbb.json` is still present (still fresh). `sess-aaa.json` is also
present with `ovSessionId: null` and `capturedTurnCount: 2` for resume.
If `sess-bbb` was in `≥2 active` from 6c, the heuristic on this call sees
just `sess-bbb` (1 active) and commits it — that's expected and shows the
heuristic + sweep working together.
Expand Down
8 changes: 5 additions & 3 deletions examples/codex-memory-plugin/scripts/auto-capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -226,15 +226,17 @@ async function main() {

// Post-compact transcript-shrink defense: codex's /compact may rewrite or
// truncate transcript_path. If allTurns has fewer entries than we cached,
// our slice math would underflow and silently drop turns. Reset the
// counter so the next slice captures everything in the new transcript.
// our slice math would underflow and silently drop turns. Resume at the
// latest human user turn without replaying compacted history.
// See DESIGN.md "Post-compact transcript shrink".
if (allTurns.length < state.capturedTurnCount) {
log("transcript_shrink_detected", {
cached: state.capturedTurnCount,
observed: allTurns.length,
});
state.capturedTurnCount = 0;
state.capturedTurnCount = Math.max(0, allTurns.findLastIndex(
(turn) => turn.role === "user" && turn.parts?.some((part) => part.type === "text"),
));
}

const newTurns = allTurns.slice(state.capturedTurnCount);
Expand Down
78 changes: 78 additions & 0 deletions examples/codex-memory-plugin/scripts/auto-capture.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
17 changes: 9 additions & 8 deletions examples/codex-memory-plugin/scripts/session-start-commit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}
Expand Down