Skip to content

feat(plugin/codex): add lifecycle hooks (recall, capture, pre-compact) to codex-memory-plugin#1957

Merged
qin-ctx merged 12 commits into
volcengine:mainfrom
ZaynJarvis:feat/codex-memory-plugin-hooks
May 13, 2026
Merged

feat(plugin/codex): add lifecycle hooks (recall, capture, pre-compact) to codex-memory-plugin#1957
qin-ctx merged 12 commits into
volcengine:mainfrom
ZaynJarvis:feat/codex-memory-plugin-hooks

Conversation

@ZaynJarvis
Copy link
Copy Markdown
Collaborator

Summary

Brings examples/codex-memory-plugin to feature parity with examples/claude-code-memory-plugin by wiring all four Codex lifecycle hooks. Every Codex session now gets:

  • SessionStartbootstrap-runtime.mjsnpm ci --omit=dev into ${CODEX_PLUGIN_DATA}/runtime (or ~/.openviking/codex-memory-plugin/runtime), so MCP runtime deps install once and cache.
  • UserPromptSubmitauto-recall.mjs — searches viking://user/memories, viking://agent/memories, viking://agent/skills; ranks with leaf/preference/temporal/lexical boosts; reads top leaves; emits hookSpecificOutput.additionalContext.
  • Stopauto-capture.mjs — incremental transcript parsing + last_assistant_message capture; each capture creates an OV session, posts the text, and calls /api/v1/sessions/{id}/commit so the extraction pipeline lands persistent leaf memories under viking://user/<user>/memories/.
  • PreCompactpre-compact-capture.mjs — the new piece for Codex. Before Codex compacts the conversation, posts the entire transcript to one OV session and commits, so detail survives summarization.

The MCP server (openviking_recall / _store / _forget / _health) stays available for explicit operations and now reads ~/.openviking/ovcli.conf (the canonical client config) so the plugin works against hosted OpenViking deployments out of the box.

Codex vs Claude Code differences baked into the scripts

Aspect Claude Code Plugin Codex Plugin (this PR)
Plugin root env CLAUDE_PLUGIN_ROOT CODEX_PLUGIN_ROOT
Plugin data env CLAUDE_PLUGIN_DATA CODEX_PLUGIN_DATA
UserPromptSubmit injection decision: "approve" + hookSpecificOutput.additionalContext hookSpecificOutput.additionalContext only — approve is not a Codex output
Stop no-op decision: "approve" {} — only block is a valid Codex decision
Compaction hook n/a PreCompact (full-transcript commit)
Config section claude_code codex
Default config ~/.openviking/ov.conf ~/.openviking/ovcli.conf, falls back to ov.conf

These were derived from codex-rs/hooks/schema/generated/ — see the README for the full input/output schema breakdown.

What's in the diff

  • hooks/hooks.json (new)
  • scripts/{auto-recall,auto-capture,pre-compact-capture,bootstrap-runtime,start-memory-server,runtime-common,config,debug-log}.mjs (new)
  • servers/memory-server.js (compiled, checked in — same pattern as claude-code-memory-plugin)
  • src/memory-server.ts (refactored to read ovcli.conf + env-var-only mode)
  • .codex-plugin/plugin.json (adds hooks and mcpServers fields)
  • .mcp.json (uses ${CODEX_PLUGIN_ROOT}/scripts/start-memory-server.mjs so the MCP path also benefits from runtime bootstrap)
  • README.md (architecture diagram, full validation SOP, configuration reference, differences table)

Test plan

  • node --check on every scripts/*.mjs and tsc clean for src/memory-server.ts
  • Auto-recall hook: piped a synthetic UserPromptSubmit payload to auto-recall.mjs against a hosted OV (https://ov.zaynjarvis.com) — returned 6 ranked leaves and a well-formed hookSpecificOutput.additionalContext.
  • Auto-capture hook: piped a synthetic Stop payload — created OV session 5e39a84e-…, committed, and after ov wait the extraction landed events/2026/05/10/codex_plugin_smoke_test.md and preferences/.../coffee_preference.md.
  • Pre-compact hook: piped a synthetic 4-turn transcript with a PreCompact payload — created OV session b2954283-…, committed all 4 turns with archived=true, and the extractor landed entities/software/atlas_river.md, preferences/.../dev_tool_appearance.md, plus matching event memories.
  • Verified the canonical validation SOP end-to-end (in README): ov ls viking://session/<id>ov ls viking://session/<id>/history (archive_NNN appears) → ov waitov find <seed> returns leaf memories under viking://user/<user>/memories/.
  • Plugin installable through a local marketplace fixture (codex plugin marketplace add /tmp/ov-codex-mp) once [plugins."openviking-memory@<marketplace>"] is enabled in ~/.codex/config.toml.

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🏅 Score: 70
🧪 No relevant tests
🔒 No security concerns identified
✅ No TODO sections
🔀 Multiple PR themes

Sub-PR theme: Add runtime bootstrap and lifecycle hooks

Relevant files:

  • examples/codex-memory-plugin/scripts/bootstrap-runtime.mjs
  • examples/codex-memory-plugin/scripts/runtime-common.mjs

Sub-PR theme: Update MCP server config loading

Relevant files:

  • examples/codex-memory-plugin/src/memory-server.ts
  • examples/codex-memory-plugin/servers/memory-server.js

⚡ Recommended focus areas for review

Bug: Capture state updated even on failure

state.capturedTurnCount is set to allTurns.length even when captureToOpenViking fails, preventing retries for failed captures.

state.capturedTurnCount = allTurns.length;
Bug: Config inconsistencies between hooks and MCP server

account/user don't fall back to default_account/default_user; agentId doesn't check top-level agent_id/default_agent, leading to different values between hooks and MCP server.

const account = str(process.env.OPENVIKING_ACCOUNT, str(file.account, ""));
const user = str(process.env.OPENVIKING_USER, str(file.user, ""));

const cx = file.codex || {};

const debug = cx.debug === true || process.env.OPENVIKING_DEBUG === "1";
const defaultLogPath = join(homedir(), ".openviking", "logs", "codex-hooks.log");
const debugLogPath = str(process.env.OPENVIKING_DEBUG_LOG, defaultLogPath);

const timeoutMs = Math.max(1000, Math.floor(num(cx.timeoutMs, 15000)));
const captureTimeoutMs = Math.max(
  1000,
  Math.floor(num(cx.captureTimeoutMs, Math.max(timeoutMs * 2, 30000))),
);

return {
  configPath,
  baseUrl,
  apiKey,
  account,
  user,
  agentId: str(process.env.OPENVIKING_AGENT_ID, str(cx.agentId, "codex")),
Perf: Blocking spawnSync in async function

ensureRuntimeInstalled is async but uses blocking spawnSync, which could starve the event loop in async contexts.

const result = spawnSync(getNpmCommand(), installArgs(), {
Suggestion: Avoid committing compiled files

Committing compiled JS can lead to sync issues with TypeScript source; consider adding to .gitignore and building during plugin installation.

import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
function readJson(path) {
    return JSON.parse(readFileSync(path, "utf-8"));
}
function loadOvConf() {
    const defaultCli = join(homedir(), ".openviking", "ovcli.conf");
    const defaultServer = join(homedir(), ".openviking", "ov.conf");
    const explicit = process.env.OPENVIKING_CONFIG_FILE
        ? resolvePath(process.env.OPENVIKING_CONFIG_FILE.replace(/^~/, homedir()))
        : null;
    const candidates = explicit ? [explicit] : [defaultCli, defaultServer];
    for (const candidate of candidates) {
        if (!existsSync(candidate))
            continue;
        try {
            return { file: readJson(candidate), configPath: candidate };
        }
        catch {
            process.stderr.write(`[openviking-memory] Invalid config file: ${candidate}\n`);
            process.exit(1);
        }
    }
    // No config file. Allow env-var-only operation (cloud mode with OPENVIKING_URL).
    if (process.env.OPENVIKING_URL) {
        return { file: {}, configPath: explicit || defaultCli };
    }
    process.stderr.write(`[openviking-memory] Config file not found at ${defaultCli} or ${defaultServer}; set OPENVIKING_CONFIG_FILE or OPENVIKING_URL.\n`);
    process.exit(1);
}
function deriveBaseUrl(file) {
    const direct = str(file.url, "");
    if (direct)
        return direct.replace(/\/+$/, "");
    const server = (file.server ?? {});
    const host = str(server.host, "127.0.0.1").replace("0.0.0.0", "127.0.0.1");
    const port = Math.floor(num(server.port, 1933));
    return `http://${host}:${port}`;
}
function str(value, fallback) {
    if (typeof value === "string" && value.trim())
        return value.trim();
    return fallback;
}
function num(value, fallback) {
    if (typeof value === "number" && Number.isFinite(value))
        return value;
    if (typeof value === "string" && value.trim()) {
        const parsed = Number(value);
        if (Number.isFinite(parsed))
            return parsed;
    }
    return fallback;
}
function md5Short(value) {
    return createHash("md5").update(value).digest("hex").slice(0, 12);
}
function clampScore(value) {
    if (typeof value !== "number" || Number.isNaN(value))
        return 0;
    return Math.max(0, Math.min(1, value));
}
function isMemoryUri(uri) {
    return /^viking:\/\/(?:user|agent)\/[^/]+\/memories(?:\/|$)/.test(uri);
}
function totalCommitMemories(result) {
    return Object.values(result.memories_extracted ?? {}).reduce((sum, count) => sum + count, 0);
}
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
const { file: ovConf, configPath } = loadOvConf();
const serverConfig = (ovConf.server ?? {});
const baseUrlFromFile = deriveBaseUrl(ovConf);
const apiKeyFromFile = str(ovConf.api_key, "") || str(serverConfig.root_api_key, "");
const config = {
    configPath,
    baseUrl: str(process.env.OPENVIKING_URL, baseUrlFromFile).replace(/\/+$/, ""),
    apiKey: str(process.env.OPENVIKING_API_KEY, apiKeyFromFile),
    accountId: str(process.env.OPENVIKING_ACCOUNT, str(ovConf.account, str(ovConf.default_account, "default"))),
    userId: str(process.env.OPENVIKING_USER, str(ovConf.user, str(ovConf.default_user, "default"))),
    agentId: str(process.env.OPENVIKING_AGENT_ID, str(ovConf.agent_id, str(ovConf.default_agent, "codex"))),
    timeoutMs: Math.max(1000, Math.floor(num(process.env.OPENVIKING_TIMEOUT_MS, 15000))),
    recallLimit: Math.max(1, Math.floor(num(process.env.OPENVIKING_RECALL_LIMIT, 6))),
    scoreThreshold: Math.min(1, Math.max(0, num(process.env.OPENVIKING_SCORE_THRESHOLD, 0.01))),
};
class OpenVikingClient {
    baseUrl;
    apiKey;
    accountId;
    userId;
    agentId;
    timeoutMs;
    runtimeIdentity = null;
    constructor(baseUrl, apiKey, accountId, userId, agentId, timeoutMs) {
        this.baseUrl = baseUrl;
        this.apiKey = apiKey;
        this.accountId = accountId;
        this.userId = userId;
        this.agentId = agentId;
        this.timeoutMs = timeoutMs;
    }
    async request(path, init = {}) {
        const controller = new AbortController();
        const timer = setTimeout(() => controller.abort(), this.timeoutMs);
        try {
            const headers = new Headers(init.headers ?? {});
            if (this.apiKey)
                headers.set("X-API-Key", this.apiKey);
            if (this.accountId)
                headers.set("X-OpenViking-Account", this.accountId);
            if (this.userId)
                headers.set("X-OpenViking-User", this.userId);
            if (this.agentId)
                headers.set("X-OpenViking-Agent", this.agentId);
            if (init.body && !headers.has("Content-Type"))
                headers.set("Content-Type", "application/json");
            const response = await fetch(`${this.baseUrl}${path}`, {
                ...init,
                headers,
                signal: controller.signal,
            });
            const payload = (await response.json().catch(() => ({})));
            if (!response.ok || payload.status === "error") {
                const code = payload.error?.code ? ` [${payload.error.code}]` : "";
                const message = payload.error?.message ?? `HTTP ${response.status}`;
                throw new Error(`OpenViking request failed${code}: ${message}`);
            }
            return (payload.result ?? payload);
        }
        finally {
            clearTimeout(timer);
        }
    }
    async healthCheck() {
        try {
            await this.request("/health");
            return true;
        }
        catch {
            return false;
        }
    }
    async getRuntimeIdentity() {
        if (this.runtimeIdentity)
            return this.runtimeIdentity;
        const fallback = { userId: this.userId || "default", agentId: this.agentId || "default" };
        try {
            const status = await this.request("/api/v1/system/status");
            const userId = typeof status.user === "string" && status.user.trim() ? status.user.trim() : fallback.userId;
            this.runtimeIdentity = { userId, agentId: this.agentId || "default" };
            return this.runtimeIdentity;
        }
        catch {
            this.runtimeIdentity = fallback;
            return fallback;
        }
    }
    async normalizeMemoryTargetUri(targetUri) {
        const trimmed = targetUri.trim().replace(/\/+$/, "");
        const match = trimmed.match(/^viking:\/\/(user|agent)\/memories(?:\/(.*))?$/);
        if (!match)
            return trimmed;
        const scope = match[1];
        const rest = match[2] ? `/${match[2]}` : "";
        const identity = await this.getRuntimeIdentity();
        const space = scope === "user" ? identity.userId : md5Short(`${identity.userId}:${identity.agentId}`);
        return `viking://${scope}/${space}/memories${rest}`;
    }
    async find(query, targetUri, limit, scoreThreshold) {
        const normalizedTargetUri = await this.normalizeMemoryTargetUri(targetUri);
        return this.request("/api/v1/search/find", {
            method: "POST",
            body: JSON.stringify({
                query,
                target_uri: normalizedTargetUri,
                limit,
                score_threshold: scoreThreshold,
            }),
        });
    }
    async read(uri) {
        return this.request(`/api/v1/content/read?uri=${encodeURIComponent(uri)}`);
    }
    async createSession() {
        const result = await this.request("/api/v1/sessions", {
            method: "POST",
            body: JSON.stringify({}),
        });
        return result.session_id;
    }
    async addSessionMessage(sessionId, role, content) {
        await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/messages`, {
            method: "POST",
            body: JSON.stringify({ role, content }),
        });
    }
    async commitSession(sessionId) {
        const result = await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}/commit`, { method: "POST", body: JSON.stringify({}) });
        if (!result.task_id)
            return result;
        const deadline = Date.now() + Math.max(this.timeoutMs, 30000);
        while (Date.now() < deadline) {
            await sleep(500);
            const task = await this.getTask(result.task_id).catch(() => null);
            if (!task)
                break;
            if (task.status === "completed") {
                const taskResult = (task.result ?? {});
                return {
                    ...result,
                    status: "completed",
                    memories_extracted: (taskResult.memories_extracted ?? {}),
                };
            }
            if (task.status === "failed")
                return { ...result, status: "failed", error: task.error };
        }
        return { ...result, status: "timeout" };
    }
    async getTask(taskId) {
        return this.request(`/api/v1/tasks/${encodeURIComponent(taskId)}`, { method: "GET" });
    }
    async deleteSession(sessionId) {
        await this.request(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
    }
    async deleteUri(uri) {
        await this.request(`/api/v1/fs?uri=${encodeURIComponent(uri)}&recursive=false`, { method: "DELETE" });
    }
}
function formatMemoryResults(items) {
    return items
        .map((item, index) => {
        const summary = item.abstract?.trim() || item.overview?.trim() || item.uri;
        const score = Math.round(clampScore(item.score) * 100);
        return `${index + 1}. ${summary}\n   URI: ${item.uri}\n   Score: ${score}%`;
    })
        .join("\n\n");
}
const client = new OpenVikingClient(config.baseUrl, config.apiKey, config.accountId, config.userId, config.agentId, config.timeoutMs);
const server = new McpServer({ name: "openviking-memory-codex", version: "0.1.0" });
server.tool("find", "Find OpenViking long-term memory.", {
    query: z.string().describe("Find query"),
    target_uri: z.string().optional().describe("Find scope URI, default viking://user/memories"),
    limit: z.number().optional().describe("Max results, default 6"),
    score_threshold: z.number().optional().describe("Minimum relevance score 0-1, default 0.01"),
}, async ({ query, target_uri, limit, score_threshold }) => {
    const recallLimit = limit ?? config.recallLimit;
    const threshold = score_threshold ?? config.scoreThreshold;
    const result = await client.find(query, target_uri ?? "viking://user/memories", recallLimit, threshold);
    const items = [...(result.memories ?? []), ...(result.resources ?? []), ...(result.skills ?? [])]
        .filter((item) => clampScore(item.score) >= threshold)
        .sort((left, right) => clampScore(right.score) - clampScore(left.score))
        .slice(0, recallLimit);
    if (items.length === 0) {
        return { content: [{ type: "text", text: "No relevant OpenViking memories found." }] };
    }
    return { content: [{ type: "text", text: formatMemoryResults(items) }] };
});
server.tool("remember", "Store information in OpenViking long-term memory.", {
    text: z.string().describe("Information to store"),
    role: z.string().optional().describe("Message role, default user"),
}, async ({ text, role }) => {
    let sessionId;
    try {
        sessionId = await client.createSession();
        await client.addSessionMessage(sessionId, role || "user", text);
        const result = await client.commitSession(sessionId);
        const count = totalCommitMemories(result);
        if (result.status === "failed") {
            return { content: [{ type: "text", text: `Memory extraction failed: ${String(result.error)}` }] };
        }
        if (result.status === "timeout") {
            return {
                content: [{
                        type: "text",
                        text: `Memory extraction is still running (task_id=${result.task_id ?? "unknown"}).`,
                    }],
            };
        }
        if (count === 0) {
            return {
                content: [{
                        type: "text",
                        text: "Committed session, but OpenViking extracted 0 memory item(s).",
                    }],
            };
        }
        return { content: [{ type: "text", text: `Stored memory. Extracted ${count} item(s).` }] };
    }
    finally {
        if (sessionId)
            await client.deleteSession(sessionId).catch(() => { });
    }
});
server.tool("forget", "Delete an exact OpenViking memory URI. Use find first if you only have a query.", {
    uri: z.string().describe("Exact memory URI to delete"),
}, async ({ uri }) => {
    if (!isMemoryUri(uri)) {
        return { content: [{ type: "text", text: `Refusing to delete non-memory URI: ${uri}` }] };
    }
    await client.deleteUri(uri);
    return { content: [{ type: "text", text: `Deleted memory: ${uri}` }] };
});
server.tool("health", "Check whether the OpenViking server is reachable.", {}, async () => {
    const ok = await client.healthCheck();
    const text = ok
        ? `OpenViking is reachable at ${config.baseUrl}.`
        : `OpenViking is unreachable at ${config.baseUrl}.`;
    return { content: [{ type: "text", text }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);

@github-actions
Copy link
Copy Markdown

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Replace blocking spawnSync with async spawn in runtime bootstrap

Replace blocking spawnSync with async spawn to avoid blocking the event loop in
async functions. Collect stdout/stderr asynchronously and wait for the process to
exit.

examples/codex-memory-plugin/scripts/runtime-common.mjs [188-196]

-const result = spawnSync(getNpmCommand(), installArgs(), {
+async function spawnAsync(command, args, options) {
+  return new Promise((resolve, reject) => {
+    const child = spawn(command, args, { ...options, stdio: ["inherit", "pipe", "pipe"] });
+    const stdoutChunks = [];
+    const stderrChunks = [];
+    child.stdout?.on("data", (chunk) => stdoutChunks.push(chunk));
+    child.stderr?.on("data", (chunk) => stderrChunks.push(chunk));
+    child.on("error", reject);
+    child.on("close", (code) => {
+      resolve({
+        status: code,
+        stdout: Buffer.concat(stdoutChunks).toString(options.encoding || "utf8"),
+        stderr: Buffer.concat(stderrChunks).toString(options.encoding || "utf8"),
+      });
+    });
+  });
+}
+
+// ... later in ensureRuntimeInstalled:
+const result = await spawnAsync(getNpmCommand(), installArgs(), {
   cwd: paths.runtimeRoot,
   encoding: "utf8",
-  stdio: "pipe",
   shell: process.platform === "win32",
 });
 
-if (result.error) throw result.error;
 if (result.status !== 0) throw new Error(formatInstallFailure(result));
Suggestion importance[1-10]: 5

__

Why: Replacing spawnSync with async spawn avoids blocking the event loop in an async context, improving responsiveness. This is a valid moderate improvement.

Low

@ZaynJarvis
Copy link
Copy Markdown
Collaborator Author

Pushed v0.4.0 refactor on top of the original commit. New commits add a DESIGN.md (canonical decision tree), an active-window heuristic on SessionStart(source=clear|startup), an idle-TTL sweep at the SessionStart tail, atomic state writes, and commit-failure-preserves-state.

End-to-end verified against ov.zaynjarvis.com:

  • Stop append + idempotency + incremental ✅
  • SessionStart(source=resume){} no-op ✅
  • SessionStart(source=startup) 1 active stale → heuristic commit ✅
  • SessionStart(source=startup) 0 stale → no-op ✅
  • SessionStart(source=startup) 2 fresh + 1 old → heuristic skip + idle sweep tail commits the old one ✅
  • Post-compact transcript shrink → counter resets to 0 and re-captures correctly ✅

Verification SOP at examples/codex-memory-plugin/VERIFICATION.md (steps 6a–6e cover the new SessionStart paths).

🤖 Generated with Claude Code

@t0saki
Copy link
Copy Markdown
Contributor

t0saki commented May 13, 2026

Nice to see this land — env-var-only mode + ovcli.conf fallback unblock the cloud / daemon-spawn flow that the previous version couldn't do. A few suggestions that would round it out:

1. Honor OPENVIKING_CLI_CONFIG_FILE, and make env strictly highest priority

The default config path is now ovcli.conf, but the override env var is still OPENVIKING_CONFIG_FILE — which historically refers to ov.conf (the server config). Both ov CLI and claude-code-memory-plugin already use OPENVIKING_CLI_CONFIG_FILE for the client config (ovcli.conf), and external launchers (e.g. zouk-daemon) inject that variable. Without alignment, a daemon that exports OPENVIKING_CLI_CONFIG_FILE=/path/to/ovcli.conf silently falls back to ~/.openviking/ovcli.conf.

Concretely:

const explicit =
  process.env.OPENVIKING_CLI_CONFIG_FILE ??
  process.env.OPENVIKING_CONFIG_FILE ??
  null;

Same applies to the priority order for connection fields. The README states "env > ovcli.conf", but please make sure that holds for every field including baseUrl (currently OK: str(process.env.OPENVIKING_URL, baseUrlFromFile)), account, user, and agentIdOPENVIKING_* should always win over file values, with no exception. This matches the convention documented in claude-code-memory-plugin/README.md §Configuration.

2. Migrate auth header to Authorization: Bearer

Both memory-server.ts and the hooks currently send X-API-Key. OpenViking Cloud (https://api.vikingdb.cn-beijing.volces.com/openviking) only accepts Authorization: Bearer, and the open-source server is gradually phasing out X-API-Key — eventually it may be rejected outright. Suggest emitting both during a transition window, then dropping X-API-Key:

if (this.apiKey) {
  headers.set("Authorization", `Bearer ${this.apiKey}`);
  headers.set("X-API-Key", this.apiKey); // legacy, remove later
}

3. Unify hook & MCP-server config loading

scripts/config.mjs (hooks) and src/memory-server.ts (MCP) have diverged fallback chains for account / user / agentId — the PR-Agent comment above flagged this. In claude-code-memory-plugin the two surfaces share a single config.mjs loader, which keeps "what identity am I writing under" identical between auto-capture and explicit remember calls. Consolidating to one loader here would prevent silent-identity-mismatch bugs (e.g. hook writes as default/default while MCP writes as default/codex).

Happy to take items 1 & 2 in a follow-up PR if you'd prefer to keep this one focused on the hook surface.

@t0saki
Copy link
Copy Markdown
Contributor

t0saki commented May 13, 2026

FYI I went ahead and drafted a follow-up commit on top of your branch — couldn't push to your fork directly (403 from ZaynJarvis/OpenViking.git), so it's parked on mine: t0saki/OpenViking@32aa1d6e (branch: feat/codex-memory-plugin-hooks on my fork).

It covers all three items above:

  1. OPENVIKING_CLI_CONFIG_FILE as the ovcli.conf overridescripts/config.mjs now loads ovcli.conf + ov.conf as two separate sources with their own env-var overrides, matching the convention in ov CLI and claude-code-memory-plugin. Legacy OPENVIKING_CONFIG_FILE pointing at an ovcli-shaped file still works for backward compat.
  2. Strict env-first priority for every connection / identity field (baseUrl, apiKey, account, user, agentId).
  3. Auth header: emits Authorization: Bearer <key> (primary) + X-API-Key (legacy) at all six fetch sites — 4 hook scripts, src/memory-server.ts, and the compiled servers/memory-server.js.
  4. Hook/MCP config unification: src/memory-server.ts now imports loadConfig from scripts/config.mjs (module: ESNext + sibling servers/scripts/ keeps the relative path valid post-compile), so the divergent fallback chains the PR-Agent reviewer flagged are gone.

README rewritten to document the resolution chain + OPENVIKING_CLI_CONFIG_FILE + OPENVIKING_BEARER_TOKEN alias + the Bearer migration note.

Verified locally:

  • Env-only daemon scenario (only OPENVIKING_URL + _API_KEY + _ACCOUNT + _USER set, no config files) → all values resolve from env.
  • OPENVIKING_CLI_CONFIG_FILE pointing at a tmp ovcli.conf → file values used; setting OPENVIKING_URL/_API_KEY on top → env wins.
  • Legacy OPENVIKING_CONFIG_FILE=<ovcli-shaped> → treated as ovcli.conf, cliConfigPath reflects the actual loaded path.
  • npm run build clean, node servers/memory-server.js module loads without crash.

Feel free to cherry-pick the single commit into your branch (or pull-rebase from my fork) — happy to also open it as a follow-up PR after this one merges, whichever you prefer.

ZaynJarvis pushed a commit to ZaynJarvis/OpenViking that referenced this pull request May 13, 2026
Addresses three review points on PR volcengine#1957:

1. Honor OPENVIKING_CLI_CONFIG_FILE for the ovcli.conf override path
   (matches the convention used by `ov` CLI and claude-code-memory-plugin).
   OPENVIKING_CONFIG_FILE stays as the ov.conf override; for backward
   compat it still works when pointed at an ovcli-shaped file.

2. Strict env-first priority for every connection / identity field
   (baseUrl, apiKey, account, user, agentId). Env vars now win over
   ovcli.conf, which wins over ov.conf's codex.* block / server.*,
   which wins over built-in defaults.

3. Unify hook and MCP-server config loading: src/memory-server.ts now
   imports loadConfig from scripts/config.mjs (relative path stays
   valid post-compile because servers/ and scripts/ are siblings),
   eliminating the divergent account/user/agentId fallback chains
   the PR-Agent reviewer flagged.

Auth header: emit Authorization: Bearer (primary, required by OpenViking
Cloud) plus the legacy X-API-Key during the transition window. All six
fetch sites updated (4 hook scripts + memory-server.ts + compiled
servers/memory-server.js).

README: document the new resolution chain, OPENVIKING_CLI_CONFIG_FILE,
OPENVIKING_BEARER_TOKEN alias, and the Authorization: Bearer migration.
ZaynJarvis and others added 7 commits May 13, 2026 18:27
Brings the codex-memory-plugin to feature parity with the claude-code-memory-plugin
by wiring the four Codex lifecycle hooks via `hooks.json`:

- SessionStart  -> bootstrap-runtime.mjs (npm ci into ${CODEX_PLUGIN_DATA}/runtime)
- UserPromptSubmit -> auto-recall.mjs (search OV, inject via hookSpecificOutput.additionalContext)
- Stop -> auto-capture.mjs (incremental transcript capture + last_assistant_message commit)
- PreCompact -> pre-compact-capture.mjs (full transcript -> single OV session -> commit)

Differences from the Claude Code plugin baked into the scripts:

- Codex output schema does not allow `decision: "approve"`; no-op is `{}`
- Stop/PreCompact only support `systemMessage`, not `additionalContext`
- Plugin envs are CODEX_PLUGIN_ROOT / CODEX_PLUGIN_DATA
- Config section is `codex` (was `claude_code`); config file defaults to
  `~/.openviking/ovcli.conf`, falling back to legacy `~/.openviking/ov.conf`

Other changes:

- src/memory-server.ts now reads ovcli.conf-style configs (top-level `url`,
  `api_key`, `account`, `user`, `agent_id`) so the plugin works against
  hosted OpenViking deployments out of the box. Env-var-only operation
  (OPENVIKING_URL set, no config file) is also supported.
- .mcp.json points at scripts/start-memory-server.mjs, which boots the same
  runtime the hooks use, so the MCP path benefits from npm-ci bootstrap.
- README rewritten with architecture diagram, validation SOP, configuration
  reference, and a Codex-vs-Claude-Code differences table.

Validated end-to-end against an OpenViking deployment:

- Auto-recall returns ranked memories with full content and emits
  hookSpecificOutput.additionalContext.
- Auto-capture (last_assistant_message path) creates a session, commits, and
  the OV pipeline extracts events + preferences within ~60s.
- Pre-compact-capture posts a full 4-turn transcript to one OV session,
  commits with archived=true, and produces structured leaf memories
  (preferences, events, entities) under viking://user/<user>/memories/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…PreCompact=commit

Codex's `Stop` hook fires per turn, not at session end, so committing per-Stop
over-fragments memory extraction. And codex re-fires `SessionStart` on short
reconnects, so registering an `npm ci` bootstrap there reinstalls the runtime
unnecessarily.

This change keeps one long-lived OpenViking session per codex `session_id`
across all `Stop` invocations, and only triggers the OV memory extractor on
`PreCompact` (or via an idle-sweep best-effort commit when codex exits without
compacting).

- hooks.json: drop SessionStart entry; keep UserPromptSubmit/Stop/PreCompact
- scripts/session-state.mjs (new): per-codex-session state under
  ~/.openviking/codex-plugin-state/, tracks ovSessionId + capturedTurnCount
- scripts/auto-capture.mjs (Stop): incremental add_message only, idle-sweep at
  the tail to commit stale codex sessions (default IDLE_TTL=30 min, override
  with OPENVIKING_CODEX_IDLE_TTL_MS)
- scripts/pre-compact-capture.mjs (PreCompact): catch-up append + commit the
  long-lived OV session, then null out ovSessionId so the next Stop opens a
  fresh OV session for the post-compact half
- MCP runtime install stays lazy in start-memory-server.mjs (already there);
  no SessionStart hook means short reconnects don't re-trigger npm ci
- VERIFICATION.md: end-to-end SOP against a live OV server (~3 min)
- bump plugin to 0.3.0

Verified end-to-end against ov.zaynjarvis.com:
  Stop adds turns idempotently and incrementally; PreCompact commits to
  history/archive_001/ with extractor producing memories under
  viking://user/<user>/memories/profile.md after ~30 s; post-compact Stop
  opens a fresh OV session; idle-sweep commits stale state files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…lear) commit

Per Zayn's followup ("非必要不要加 idle commit"): drop the idle-sweep added
in the previous commit and use codex's actual context-disappearing signal —
SessionStart with source=clear — to commit orphaned sessions.

Codex hook signal map:
- /compact         → PreCompact      ✅ commit (already)
- /clear           → SessionStart(source=clear) for the NEW session_id;
                     the prior transcript is orphaned. Now committed.
- /new             → SessionStart(source=startup); ambiguous with fresh
                     codex startup, so we don't act on it.
- /resume / short reconnect → SessionStart(source=resume|startup); no-op
                     to avoid corrupting still-active sessions.
- SIGTERM/Ctrl+C/exit → no hook fires. Documented as a known gap; users
                     should /compact before /exit if they want commit.

Changes:
- new scripts/session-start-commit.mjs: gates internally on source=clear,
  iterates listStates(), and commits any state file whose codexSessionId
  != the new SessionStart session_id, then clears that state file
- hooks/hooks.json: re-register SessionStart pointing at the new script
  (timeout 30s)
- scripts/auto-capture.mjs: remove sweepIdleSessions() and
  IDLE_TTL_MS env handling; Stop is now strictly add_message
- README/VERIFICATION.md: update arch diagram, replace idle-sweep step
  with SessionStart(source=clear) verify (positive + negative paths),
  add "Known gap: SIGTERM/exit are silent" section
- bump to 0.3.1

Verified end-to-end against ov.zaynjarvis.com:
  Stop add+idempotent ✓
  SessionStart source=startup → {} ✓
  SessionStart source=resume → {} ✓
  SessionStart source=clear → committed prior OV session, history/archive_001/
  appeared, profile.md gained "Favorite snack: dark chocolate" within 30 s.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…r gate)

Codex's hooks dispatcher matches the SessionStart hook's `matcher` field
against the SessionStart `source` value. Setting matcher to "clear" means
codex won't even spawn our script on `source=startup` or `source=resume`
(short reconnects); we previously gated this in-script. The internal
source check in session-start-commit.mjs is kept as defense-in-depth.

Source: codex-rs/hooks/src/events/session_start.rs `select_handlers(...,
matcher_input: Some(request.source.as_str()))` and
codex-rs/hooks/src/events/common.rs `is_exact_matcher` — "clear" is
all-alphanumeric so it's matched as exact equality, not regex.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…essionStart (v0.4.0)

Source of truth: examples/codex-memory-plugin/DESIGN.md (added in this commit).

Behavioral changes:

- SessionStart matcher widens from `clear` to `clear|startup`. Both sources
  run the same active-window heuristic; `resume` is a hard no-op (still fires
  on short reconnects).
- Heuristic (DESIGN.md §3): count state files (excluding new session_id) within
  ACTIVE_WINDOW_MS (default 2 min). 0 → noop, 1 → commit it (just-ended
  session), ≥2 → skip and rely on idle TTL. Tunable via
  OPENVIKING_CODEX_ACTIVE_WINDOW_MS.
- Idle-TTL sweep returns at the tail of session-start-commit.mjs only (not
  every Stop). Default IDLE_TTL_MS = 30 min via OPENVIKING_CODEX_IDLE_TTL_MS.
  Catches SIGTERM/Ctrl+C/`/exit` orphans and the ≥2-active skip path.
- Stop hook deliberately does NOT sweep — state-write-on-every-turn already
  gives us the freshness signal. Marker comment added.
- Stop hook adds post-compact transcript-shrink defense: if
  allTurns.length < state.capturedTurnCount, reset capturedTurnCount = 0.
- Commit-on-failure preserves state everywhere (PreCompact, heuristic,
  idle sweep). A non-2xx /commit no longer clears ovSessionId; the next
  sweep retries.
- session-state.mjs saveState now uses atomic write (tmpfile + rename) for
  crash safety. listStates ignores the brief `<id>.json.tmp` window.

Bump: package.json + .codex-plugin/plugin.json → 0.4.0.

Docs: README "How It Works" gained a DESIGN.md pointer and rewrites the
SessionStart section to reflect heuristic + idle TTL. VERIFICATION.md step 6
now exercises all four heuristic branches (0/1/≥2 active, idle TTL, resume).

Phase-2 resume context inject documented in DESIGN.md but explicitly out of
scope here.

Verified locally with synthetic stdin tests against a fake OV server:
1-active commit, ≥2-active skip, idle TTL sweep, resume noop,
unreachable-server keeps state.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses three review points on PR volcengine#1957:

1. Honor OPENVIKING_CLI_CONFIG_FILE for the ovcli.conf override path
   (matches the convention used by `ov` CLI and claude-code-memory-plugin).
   OPENVIKING_CONFIG_FILE stays as the ov.conf override; for backward
   compat it still works when pointed at an ovcli-shaped file.

2. Strict env-first priority for every connection / identity field
   (baseUrl, apiKey, account, user, agentId). Env vars now win over
   ovcli.conf, which wins over ov.conf's codex.* block / server.*,
   which wins over built-in defaults.

3. Unify hook and MCP-server config loading: src/memory-server.ts now
   imports loadConfig from scripts/config.mjs (relative path stays
   valid post-compile because servers/ and scripts/ are siblings),
   eliminating the divergent account/user/agentId fallback chains
   the PR-Agent reviewer flagged.

Auth header: emit Authorization: Bearer (primary, required by OpenViking
Cloud) plus the legacy X-API-Key during the transition window. All six
fetch sites updated (4 hook scripts + memory-server.ts + compiled
servers/memory-server.js).

README: document the new resolution chain, OPENVIKING_CLI_CONFIG_FILE,
OPENVIKING_BEARER_TOKEN alias, and the Authorization: Bearer migration.
@ZaynJarvis ZaynJarvis force-pushed the feat/codex-memory-plugin-hooks branch from 01036e2 to 67c770c Compare May 13, 2026 10:28
@qin-ctx qin-ctx merged commit e92180a into volcengine:main May 13, 2026
7 checks passed
@github-project-automation github-project-automation Bot moved this from Backlog to Done in OpenViking project May 13, 2026
ZaynJarvis pushed a commit that referenced this pull request May 13, 2026
…CP startup (#2019)

* docs(plugin/codex): add dedicated agent-integrations page + fix MCP startup

Follow-up to #1957. Lifts Codex out of `04-other-plugins.md` into its own
`04-codex.md` (en + zh) with full install steps, configuration, hook
behavior, and troubleshooting — mirrors the shape of `02-claude-code.md`.

Renumbers `04-other-plugins.md` → `05-` and `05-langchain-langgraph.md`
→ `06-`. Overview tables in both locales updated; cross-refs fixed.

Also fixes two install/runtime bugs surfaced while validating the fresh
installer flow against the merged PR:

1. **Stale repo clone**: `setup-helper/install.sh` previously skipped the
   clone if `~/.openviking/openviking-repo` already existed, so a user
   who installed before #1957 merged ended up with a pre-PR plugin
   checkout (no `scripts/`, no `servers/memory-server.js`). The installer
   now `git fetch + reset --hard` an existing checkout to `$REPO_REF`
   (default `main`), matching the claude-code installer pattern.

2. **`${CODEX_PLUGIN_ROOT}` not expanded in `.mcp.json`**: Codex 0.130
   does not substitute env vars in `.mcp.json` `args`/`env` and does not
   always inject `CODEX_PLUGIN_ROOT` into MCP child env. The literal
   string `${CODEX_PLUGIN_ROOT}` was being passed to node, which then
   tried to resolve `${CODEX_PLUGIN_ROOT}/scripts/start-memory-server.mjs`
   against codex's cwd and failed with `MODULE_NOT_FOUND`. Fix:
   - `.mcp.json`: `args: ["scripts/start-memory-server.mjs"]` + `cwd: "."`
     (matches the syntax 0.1.0 used, which Codex does honor)
   - `scripts/runtime-common.mjs`: derive plugin root from
     `import.meta.url` as a fallback so the launcher works regardless of
     whether `CODEX_PLUGIN_ROOT` is set in the spawn env

Bumps plugin to 0.4.1 (package.json + plugin.json + lockfile) since the
runtime-common.mjs change invalidates the install-state hash and forces
a re-install of node_modules into the per-user runtime data root.

* fix(plugin/codex): hooks.json must use relative paths, not ${CODEX_PLUGIN_ROOT}

Same root cause as the .mcp.json fix in the previous commit: Codex 0.130
does not expand ${CODEX_PLUGIN_ROOT} in hooks.json `command` strings. The
shell that runs the hook sees the literal ${CODEX_PLUGIN_ROOT} and expands
it to "" (or leaves it literal), so node tries to load `/scripts/...mjs`
and exits 1.

Symptom in the chat UI:
  • SessionStart hook (failed)  error: hook exited with code 1
  • UserPromptSubmit hook (failed)
  • Stop hook (failed)

Fix: use `./scripts/<name>.mjs` paths, matching the pattern Codex's own
bundled plugins (e.g. figma) use. Codex's hook dispatcher resolves these
relative to the plugin root (where hooks.json lives).

The MCP launcher fix from the prior commit already handles the same class
of bug for .mcp.json; this catches the hooks path.

* fix(plugin/codex): hooks.json needs absolute paths rendered at install time

Previous fix (relative ./scripts/...) was based on the figma example but
empirically does not work on Codex 0.130: the hook subprocess runs with
cwd = user's cwd (not plugin root) and CODEX_PLUGIN_ROOT is NOT injected
into the env. So both ${CODEX_PLUGIN_ROOT}/scripts/foo.mjs and
./scripts/foo.mjs resolve to the wrong absolute path and node exits 1.

Verified with a probe shell script wired into hooks.json:
  argv: /tmp/codex-hook-probe.sh SessionStart
  cwd: /Users/<user>
  CODEX_PLUGIN_ROOT: <unset>
  CODEX_PLUGIN_DATA: <unset>

(The "Under-development features are incomplete" banner Codex prints when
plugin_hooks is enabled is real - the hook env wiring is unfinished in
0.130.)

Fix: keep the source hooks.json as a template (uses __OPENVIKING_PLUGIN_ROOT__
placeholder) and have install.sh sed-render the cache copy with the
absolute $CACHE_DIR path on every install. The cached hooks.json is now
fully self-contained absolute-path commands; the repo's checked-in copy
stays portable.

.mcp.json is unaffected: Codex 0.130 does honor the `cwd: "."` field for
MCP servers, so relative args resolve against plugin root there.

* fix(plugin/codex): bump UserPromptSubmit timeout to 15s

Empirically the auto-recall hook can take 0.8s–4s end-to-end (depending on
result count and remote OV latency), and Codex 0.130 sometimes adds 4-5s
of spawn overhead before our script even starts. The original 8s budget
was borderline and produced spurious "hook timed out after 8s" UI errors
on slow paths even when the recall would have succeeded.

15s matches the auto-recall internal timeoutMs default (config.mjs:186)
and gives enough headroom for spawn-time variance without holding the
user's input noticeably longer in the worst case.

* fix(plugin/codex): installer accepts OPENVIKING_REPO_BRANCH as alias

Per review feedback: the claude-code installer uses OPENVIKING_REPO_BRANCH
for the same purpose. Aliasing both names lets users reuse one env var
across installers without remembering which plugin uses which name.

Precedence: OPENVIKING_REPO_REF > OPENVIKING_REPO_BRANCH > "main".
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants