From 6e84a86d1a5831838c834cdb0949f5d402ac1d32 Mon Sep 17 00:00:00 2001 From: ZaynJarvis Date: Wed, 13 May 2026 20:01:07 +0800 Subject: [PATCH] fix(plugin/codex): capture stop assistant message --- examples/codex-memory-plugin/DESIGN.md | 16 ++++-- examples/codex-memory-plugin/README.md | 4 +- examples/codex-memory-plugin/VERIFICATION.md | 50 +++++++++---------- .../scripts/auto-capture.mjs | 49 +++++++++++++++++- .../scripts/session-state.mjs | 1 + 5 files changed, 87 insertions(+), 33 deletions(-) diff --git a/examples/codex-memory-plugin/DESIGN.md b/examples/codex-memory-plugin/DESIGN.md index 6ba56ebc2..2adc698ba 100644 --- a/examples/codex-memory-plugin/DESIGN.md +++ b/examples/codex-memory-plugin/DESIGN.md @@ -15,7 +15,7 @@ events imply "context for a particular codex `session_id` is gone". `session_id`, append messages on every `Stop`, and commit it (which triggers OV's memory extractor) at session-end-equivalent moments. - **State file** — `~/.openviking/codex-plugin-state/.json`, - shape `{ codexSessionId, ovSessionId, capturedTurnCount, createdAt, lastUpdatedAt }`. + shape `{ codexSessionId, ovSessionId, capturedTurnCount, lastAssistantMessageHash, createdAt, lastUpdatedAt }`. - **Active window** — state files whose `lastUpdatedAt` is within `ACTIVE_WINDOW_MS` (default 2 min) of "now". Used to detect "the codex session that just ended". @@ -124,9 +124,14 @@ forever. Accepted. Future work could add an MCP tool ## Stop hook — append only, no commit Every `Stop` reads `transcript_path`, slices to `[capturedTurnCount, end)`, -and appends each new user/assistant turn to the OV session for this codex -`session_id` (creating one on first append). State is updated: -`{ovSessionId, capturedTurnCount, lastUpdatedAt: now}`. Never commits. +and appends each new capture-eligible transcript turn to the OV session for +this codex `session_id` (creating one on first append). By default those are +user turns; assistant transcript turns are included only when +`captureAssistantTurns=true`. The current `last_assistant_message` is appended +separately when `captureLastAssistantOnStop=true` and its hash was not already +captured. State is updated: +`{ovSessionId, capturedTurnCount, lastAssistantMessageHash, lastUpdatedAt: now}`. +Never commits. ## Edge cases handled @@ -167,7 +172,8 @@ gets extracted independently. Acceptable. { "codexSessionId": "0193af...", // codex thread id "ovSessionId": "uuid-or-null", // null means "committed, awaiting next Stop" - "capturedTurnCount": 7, // turns from transcript already appended + "capturedTurnCount": 7, // capture-eligible transcript turns already appended + "lastAssistantMessageHash": "...", // last Stop assistant payload captured separately "createdAt": 1715000000000, "lastUpdatedAt": 1715000300000 } diff --git a/examples/codex-memory-plugin/README.md b/examples/codex-memory-plugin/README.md index 232bd7eb4..960e11eca 100644 --- a/examples/codex-memory-plugin/README.md +++ b/examples/codex-memory-plugin/README.md @@ -5,7 +5,7 @@ Long-term semantic memory for [Codex](https://developers.openai.com/codex), powe This is the Codex counterpart to [`claude-code-memory-plugin`](../claude-code-memory-plugin). It hooks Codex's lifecycle to: - **Auto-recall** relevant memories on every `UserPromptSubmit` and inject them via `hookSpecificOutput.additionalContext` -- **Incremental capture on `Stop`** (turn end): append the new user/assistant turns to a single long-lived OpenViking session keyed by Codex `session_id`. No commit per turn. +- **Incremental capture on `Stop`** (turn end): append new transcript turns plus Codex's `last_assistant_message` to a single long-lived OpenViking session keyed by Codex `session_id`. No commit per turn. - **Commit on `PreCompact`**: trigger OpenViking's memory extractor on the full pre-compact transcript before Codex summarizes it. - **Commit on `SessionStart` (source=startup|clear)**: active-window heuristic — if exactly one *other* state file was touched within the last 2 min, commit it (the just-ended session). On `≥2`, defer to idle-TTL sweep at the tail. `source=resume` is a hard no-op (short reconnects re-fire `resume` and we don't want to commit a still-active session). See `DESIGN.md` for the full decision tree. - **MCP runtime bootstrap is lazy**: the MCP launcher (`start-memory-server.mjs`) installs runtime deps on first MCP invocation, not in a hook. @@ -213,7 +213,7 @@ Codex injects `additionalContext` into the model turn, so memories arrive withou ### Stop (turn end → `add_message`, NOT `commit`) -Codex's `Stop` fires per turn, not at session end. So `auto-capture.mjs` keeps **one** long-lived OpenViking session per Codex `session_id` and incrementally appends every new user/assistant turn from the rollout JSONL via `/api/v1/sessions/{id}/messages`. Per-codex-session state lives at `~/.openviking/codex-plugin-state/.json` and tracks `{ ovSessionId, capturedTurnCount, lastUpdatedAt }`. +Codex's `Stop` fires per turn, not at session end. So `auto-capture.mjs` keeps **one** long-lived OpenViking session per Codex `session_id` and incrementally appends new capture-eligible transcript turns via `/api/v1/sessions/{id}/messages`. By default that means user turns from the rollout JSONL plus the current `last_assistant_message`; set `captureAssistantTurns=true` only if you also want assistant turns from the transcript stream itself. Per-codex-session state lives at `~/.openviking/codex-plugin-state/.json` and tracks `{ ovSessionId, capturedTurnCount, lastAssistantMessageHash, lastUpdatedAt }`. We do **not** call `/commit` per turn — committing extracts memories, and per-turn extraction would over-fragment the memory tree and waste OV's extractor. diff --git a/examples/codex-memory-plugin/VERIFICATION.md b/examples/codex-memory-plugin/VERIFICATION.md index 6ec3a4ff5..70ec2cccb 100644 --- a/examples/codex-memory-plugin/VERIFICATION.md +++ b/examples/codex-memory-plugin/VERIFICATION.md @@ -27,11 +27,11 @@ cat > "$STATE_DIR/transcript.jsonl" <<'EOF' {"payload":{"role":"assistant","content":"Got it — fuchsia noted."}} EOF -OPENVIKING_CONFIG_FILE=$OV_CONF \ -OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ -CODEX_PLUGIN_ROOT=$PLUGIN \ -echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl"}' \ - | node $PLUGIN/scripts/auto-capture.mjs +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl","last_assistant_message":"Got it — fuchsia noted."}' \ + | OPENVIKING_CLI_CONFIG_FILE=$OV_CONF \ + OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ + CODEX_PLUGIN_ROOT=$PLUGIN \ + node $PLUGIN/scripts/auto-capture.mjs ``` Expect: `{"systemMessage":"appended 2 turn(s) to OpenViking session "}`. @@ -39,26 +39,26 @@ Expect: `{"systemMessage":"appended 2 turn(s) to OpenViking session "}`. State file: ```bash cat $STATE_DIR/state/verify-sess.json -# {"codexSessionId":"verify-sess","ovSessionId":"","capturedTurnCount":2,...} +# {"codexSessionId":"verify-sess","ovSessionId":"","capturedTurnCount":1,"lastAssistantMessageHash":"...",...} ``` OV side: ```bash -OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://session//messages.jsonl +OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov read viking://session//messages.jsonl # 2 JSONL records: user "fuchsia", assistant "noted" ``` ## 2. Stop hook idempotency — re-run without changes is a no-op ```bash -echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl"}' \ - | OPENVIKING_CONFIG_FILE=$OV_CONF \ +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl","last_assistant_message":"Got it — fuchsia noted."}' \ + | OPENVIKING_CLI_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ node $PLUGIN/scripts/auto-capture.mjs ``` -Expect: `{}` (no new turns). `capturedTurnCount` still 2. +Expect: `{}` (no new turns). `capturedTurnCount` still 1. ## 3. Stop hook — incremental append @@ -70,8 +70,8 @@ cat >> "$STATE_DIR/transcript.jsonl" <<'EOF' {"payload":{"role":"assistant","content":"Updated to mint green."}} EOF -echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl"}' \ - | OPENVIKING_CONFIG_FILE=$OV_CONF \ +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl","last_assistant_message":"Updated to mint green."}' \ + | OPENVIKING_CLI_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ node $PLUGIN/scripts/auto-capture.mjs @@ -84,7 +84,7 @@ Expect: `appended 2 turn(s)` (only the new ones). Re-read ```bash echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl","trigger":"manual"}' \ - | OPENVIKING_CONFIG_FILE=$OV_CONF \ + | OPENVIKING_CLI_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ node $PLUGIN/scripts/pre-compact-capture.mjs @@ -92,14 +92,14 @@ echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.j Expect: `pre-compact commit: → N memory item(s) extracted (archived)`. -State file: `ovSessionId` is now `null`, `capturedTurnCount` stays at 4. +State file: `ovSessionId` is now `null`, `capturedTurnCount` stays at 2. OV side: ```bash -OPENVIKING_CONFIG_FILE=$OV_CONF ov ls viking://session/ +OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov ls viking://session/ # messages.jsonl is now size 0 (archived) # history/archive_001/ exists with the committed messages -OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://session//history/archive_001/messages.jsonl +OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov read viking://session//history/archive_001/messages.jsonl ``` ## 5. Post-compact Stop — fresh OV session @@ -112,8 +112,8 @@ cat >> "$STATE_DIR/transcript.jsonl" <<'EOF' {"payload":{"role":"assistant","content":"Noted serif preference."}} EOF -echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl"}' \ - | OPENVIKING_CONFIG_FILE=$OV_CONF \ +echo '{"session_id":"verify-sess","transcript_path":"'"$STATE_DIR"'/transcript.jsonl","last_assistant_message":"Noted serif preference."}' \ + | OPENVIKING_CLI_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ node $PLUGIN/scripts/auto-capture.mjs @@ -136,7 +136,7 @@ Heuristic should commit it. ```bash echo '{"session_id":"new-after-verify","source":"startup","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ - | OPENVIKING_CONFIG_FILE=$OV_CONF \ + | OPENVIKING_CLI_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ node $PLUGIN/scripts/session-start-commit.mjs @@ -150,7 +150,7 @@ After this `verify-sess.json` is gone from `$STATE_DIR/state`. ```bash # State dir empty (after 6a). Fire SessionStart-startup again. echo '{"session_id":"another-fresh","source":"startup","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ - | OPENVIKING_CONFIG_FILE=$OV_CONF \ + | OPENVIKING_CLI_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ node $PLUGIN/scripts/session-start-commit.mjs @@ -173,7 +173,7 @@ EOF OPENVIKING_DEBUG=1 \ echo '{"session_id":"sess-ccc","source":"startup","cwd":"/tmp","model":"x","permission_mode":"default","transcript_path":null,"hook_event_name":"SessionStart"}' \ - | OPENVIKING_CONFIG_FILE=$OV_CONF \ + | OPENVIKING_CLI_CONFIG_FILE=$OV_CONF \ OPENVIKING_CODEX_STATE_DIR=$STATE_DIR/state \ CODEX_PLUGIN_ROOT=$PLUGIN \ OPENVIKING_DEBUG=1 \ @@ -194,7 +194,7 @@ cat > "$STATE_DIR/state/sess-aaa.json" </memories/ -OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://user//memories/profile.md +OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov ls viking://user//memories/ +OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov read viking://user//memories/profile.md ``` Expect new entries describing the captured preferences (favorite color, diff --git a/examples/codex-memory-plugin/scripts/auto-capture.mjs b/examples/codex-memory-plugin/scripts/auto-capture.mjs index ece49a6fe..fb9ef1402 100644 --- a/examples/codex-memory-plugin/scripts/auto-capture.mjs +++ b/examples/codex-memory-plugin/scripts/auto-capture.mjs @@ -24,6 +24,7 @@ * cadence. See DESIGN.md §5 ("Sweep trigger"). */ +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { loadConfig } from "./config.mjs"; import { createLogger } from "./debug-log.mjs"; @@ -80,6 +81,16 @@ function extractTextFromContent(content) { return ""; } +function normalizeLastAssistantMessage(value) { + const text = extractTextFromContent(value).trim(); + if (!text) return ""; + return text.length > cfg.captureMaxLength ? text.slice(0, cfg.captureMaxLength) : text; +} + +function hashText(text) { + return createHash("sha256").update(text).digest("hex"); +} + function parseTranscript(content) { try { const data = JSON.parse(content); @@ -209,7 +220,12 @@ async function main() { const sessionId = input.session_id || "unknown"; const transcriptPath = input.transcript_path || null; - log("start", { sessionId, transcriptPath }); + const lastAssistantMessage = normalizeLastAssistantMessage(input.last_assistant_message); + log("start", { + sessionId, + transcriptPath, + hasLastAssistantMessage: Boolean(lastAssistantMessage), + }); const health = await fetchJSON("/health"); if (!health) { @@ -261,6 +277,37 @@ async function main() { } } + if (cfg.captureLastAssistantOnStop && lastAssistantMessage) { + const hash = hashText(lastAssistantMessage); + const allNewTranscriptTurnsAppended = added >= newTurns.length; + const alreadyCapturedInTranscript = newTurns.some( + (turn) => turn.role === "assistant" && hashText(turn.text) === hash, + ) && allNewTranscriptTurnsAppended; + + if (alreadyCapturedInTranscript) { + state.lastAssistantMessageHash = hash; + log("last_assistant_skip", { reason: "captured_in_transcript" }); + } else if (state.lastAssistantMessageHash === hash) { + log("last_assistant_skip", { reason: "duplicate_hash" }); + } else { + const ovSessionId = await ensureOvSession(state); + if (!ovSessionId) { + logError("ensure_ov_session", "failed to create OV session for last_assistant_message"); + } else { + const assistantAdded = await appendTurns(ovSessionId, [ + { role: "assistant", text: lastAssistantMessage }, + ]); + if (assistantAdded > 0) { + added += assistantAdded; + state.lastAssistantMessageHash = hash; + log("appended_last_assistant", { ovSessionId, added: assistantAdded }); + } else { + logError("append_last_assistant", { ovSessionId, attempted: 1, added: assistantAdded }); + } + } + } + } + await saveState(state); // could also sweep here, deliberately not — see header comment + DESIGN.md §5. diff --git a/examples/codex-memory-plugin/scripts/session-state.mjs b/examples/codex-memory-plugin/scripts/session-state.mjs index da4d89fc5..64cd6df0e 100644 --- a/examples/codex-memory-plugin/scripts/session-state.mjs +++ b/examples/codex-memory-plugin/scripts/session-state.mjs @@ -33,6 +33,7 @@ function defaultState(codexSessionId) { codexSessionId, ovSessionId: null, capturedTurnCount: 0, + lastAssistantMessageHash: null, createdAt: now, lastUpdatedAt: now, };