Skip to content
Closed
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
16 changes: 11 additions & 5 deletions examples/codex-memory-plugin/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<safe-codex-session-id>.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".
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
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 @@ -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.
Expand Down Expand Up @@ -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/<safe-session-id>.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/<safe-session-id>.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.

Expand Down
50 changes: 25 additions & 25 deletions examples/codex-memory-plugin/VERIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,38 +27,38 @@ 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 <UUID>"}`.

State file:
```bash
cat $STATE_DIR/state/verify-sess.json
# {"codexSessionId":"verify-sess","ovSessionId":"<UUID>","capturedTurnCount":2,...}
# {"codexSessionId":"verify-sess","ovSessionId":"<UUID>","capturedTurnCount":1,"lastAssistantMessageHash":"...",...}
```

OV side:
```bash
OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://session/<UUID>/messages.jsonl
OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov read viking://session/<UUID>/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

Expand All @@ -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
Expand All @@ -84,22 +84,22 @@ 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
```

Expect: `pre-compact commit: <UUID> → 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/<UUID>
OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov ls viking://session/<UUID>
# messages.jsonl is now size 0 (archived)
# history/archive_001/ exists with the committed messages
OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://session/<UUID>/history/archive_001/messages.jsonl
OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov read viking://session/<UUID>/history/archive_001/messages.jsonl
```

## 5. Post-compact Stop — fresh OV session
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 \
Expand All @@ -194,7 +194,7 @@ cat > "$STATE_DIR/state/sess-aaa.json" <<EOF
EOF

echo '{"session_id":"sess-ddd","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
Expand All @@ -210,7 +210,7 @@ heuristic + sweep working together.

```bash
echo '{"session_id":"any","source":"resume","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
Expand All @@ -222,8 +222,8 @@ echo '{"session_id":"any","source":"resume","cwd":"/tmp","model":"x","permission
Wait ~60 s for OV's extractor, then:

```bash
OPENVIKING_CONFIG_FILE=$OV_CONF ov ls viking://user/<your-user>/memories/
OPENVIKING_CONFIG_FILE=$OV_CONF ov read viking://user/<your-user>/memories/profile.md
OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov ls viking://user/<your-user>/memories/
OPENVIKING_CLI_CONFIG_FILE=$OV_CONF ov read viking://user/<your-user>/memories/profile.md
```

Expect new entries describing the captured preferences (favorite color,
Expand Down
49 changes: 48 additions & 1 deletion examples/codex-memory-plugin/scripts/auto-capture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions examples/codex-memory-plugin/scripts/session-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ function defaultState(codexSessionId) {
codexSessionId,
ovSessionId: null,
capturedTurnCount: 0,
lastAssistantMessageHash: null,
createdAt: now,
lastUpdatedAt: now,
};
Expand Down