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
8 changes: 8 additions & 0 deletions examples/codex-memory-plugin/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ model turn. Transcript capture may later see that injected context
adjacent to the prompt, so plugin-generated recall and resume context are
wrapped in a deterministic boundary:

Before the health check or any search request, strict control-only prompts are
short-circuited to `{}`. This covers bare acknowledgements, continuations,
status checks, and fingerprinted approvals. The classifier is
deliberately exact: prompts with task nouns, constraints, or implementation
details continue through normal recall. Set
`OPENVIKING_RECALL_CONTROL_PROMPT_SHORT_CIRCUIT=0` to disable it.

```text
<openviking-context source="auto-recall" format="digest">
OpenViking memory digest:
Expand Down Expand Up @@ -250,6 +257,7 @@ Env var overrides for tuning without rebuilding:
| `OPENVIKING_CODEX_ACTIVE_WINDOW_MS` | `120000` (2 min) | rule-3 active window |
| `OPENVIKING_CODEX_IDLE_TTL_MS` | `1800000` (30 min) | idle sweep TTL |
| `OPENVIKING_RECALL_TIMEOUT_MS` | `120000` (2 min) | whole UserPromptSubmit auto-recall deadline |
| `OPENVIKING_RECALL_CONTROL_PROMPT_SHORT_CIRCUIT` | `1` | skip external recall for strict control-only prompts; set `0` to disable |
| `OPENVIKING_RECALL_COMPRESS` | `1` | set `0` / `off` to skip `codex exec` compression |
| `OPENVIKING_RECALL_COMPRESS_MODEL` | unset | custom first-choice compressor model; `off` disables compression |
| `OPENVIKING_RECALL_COMPRESS_THINKING` | unset | custom `model_reasoning_effort`; `default` means omit override; alias `OPENVIKING_RECALL_COMPRESS_REASONING_EFFORT` |
Expand Down
3 changes: 2 additions & 1 deletion examples/codex-memory-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ On `resume`, the script skips commit/sweep. It still injects the profile block.
{ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": "<openviking-context source=\"auto-recall\" format=\"digest\">\nOpenViking memory digest:\n- ...\n</openviking-context>" } }
```

Codex injects `additionalContext` into the model turn, so memories arrive without an extra tool call. By default the hook runs a Codex compression pass over recalled candidates before injection, dropping weakly-related memories and preserving only a short digest. If the compressor returns `NO_RELEVANT_MEMORY`, empty text, or non-digest chatter, the hook emits `{}` and injects nothing. The whole hook has its own `OPENVIKING_RECALL_TIMEOUT_MS` deadline (default 120s); the bundled `hooks.json` gives Codex 130s so the script can return `{}` before Codex kills it. Digests may keep `viking://` source URIs and point the model at the OpenViking MCP `read`/`search` tools for details when the inline bullet is intentionally short. The outer `<openviking-context ...>` wrapper is deterministic, not compressor-generated; capture strips it to distinguish recalled context from the user's prompt. Set `OPENVIKING_RECALL_COMPRESS=0` to fall back to deterministic short formatting.
Codex injects `additionalContext` into the model turn, so memories arrive without an extra tool call. Before contacting OpenViking, the hook skips strict control-only prompts such as a bare acknowledgement, continuation, status request, or fingerprinted approval. Prompts containing an extra task noun, constraint, or implementation detail still take the normal recall path. By default the hook runs a Codex compression pass over recalled candidates before injection, dropping weakly-related memories and preserving only a short digest. If the compressor returns `NO_RELEVANT_MEMORY`, empty text, or non-digest chatter, the hook emits `{}` and injects nothing. The whole hook has its own `OPENVIKING_RECALL_TIMEOUT_MS` deadline (default 120s); the bundled `hooks.json` gives Codex 130s so the script can return `{}` before Codex kills it. Digests may keep `viking://` source URIs and point the model at the OpenViking MCP `read`/`search` tools for details when the inline bullet is intentionally short. The outer `<openviking-context ...>` wrapper is deterministic, not compressor-generated; capture strips it to distinguish recalled context from the user's prompt. Set `OPENVIKING_RECALL_CONTROL_PROMPT_SHORT_CIRCUIT=0` to disable the control-prompt shortcut, or `OPENVIKING_RECALL_COMPRESS=0` to fall back to deterministic short formatting.

The compressor profile is recreated on every `SessionStart` and cached under `OPENVIKING_CODEX_STATE_DIR` so cross-session config changes are picked up but each `UserPromptSubmit` does not probe models. Default fallback order:

Expand All @@ -216,6 +216,7 @@ Config knobs:
| Env var | Default | Meaning |
|---|---|---|
| `OPENVIKING_RECALL_LIMIT` | `10` | Legacy quota-scaling input; explicit values are converted to six coding quotas, not enforced as a final result cap. |
| `OPENVIKING_RECALL_CONTROL_PROMPT_SHORT_CIRCUIT` | `1` | Skip external recall for strict control-only prompts; set `0` to disable. |
| `OPENVIKING_RECALL_COMPRESS` | `1` | Set `0` / `off` to disable `codex exec` compression. |
| `OPENVIKING_RECALL_COMPRESS_MODEL` | unset | Custom first-choice compressor model. Set `off` to disable compression. |
| `OPENVIKING_RECALL_COMPRESS_THINKING` | unset | Custom `model_reasoning_effort`; `default` omits the Codex config override. Alias: `OPENVIKING_RECALL_COMPRESS_REASONING_EFFORT`. |
Expand Down
10 changes: 10 additions & 0 deletions examples/codex-memory-plugin/scripts/auto-recall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadConfig } from "./config.mjs";
import { classifyControlPrompt } from "./control-prompt.mjs";
import { trySpawnCodex } from "./codex-launch.mjs";
import { createLogger } from "./debug-log.mjs";
import {
Expand Down Expand Up @@ -587,6 +588,15 @@ async function main() {
return;
}

const controlPromptKind = cfg.recallControlPromptShortCircuit
? classifyControlPrompt(userPrompt)
: null;
if (controlPromptKind) {
log("skip", { stage: "query_check", reason: "control prompt", kind: controlPromptKind });
emit();
return;
}

const health = await fetchJSON("/health");
if (!health.ok) {
logError("health_check", "server unreachable or unhealthy");
Expand Down
77 changes: 77 additions & 0 deletions examples/codex-memory-plugin/scripts/auto-recall.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { resolveCodexLaunch, trySpawnCodex } from "./codex-launch.mjs";
import { classifyControlPrompt } from "./control-prompt.mjs";

const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));

Expand Down Expand Up @@ -200,6 +201,82 @@ async function runEndpointCompressionCase({
}
}

test("control prompt classifier is strict about prompts with task details", () => {
const controls = [
["确认", "acknowledgement"],
["继续执行", "continuation"],
["现在什么进度?", "status"],
["批准 PUBLISH_APPROVED,指纹 66a86081040c620b9ec92e52b22ca7a74d62f634fc921264030414943b1b2eae", "approval"],
];
for (const [prompt, kind] of controls) assert.equal(classifyControlPrompt(prompt), kind);

for (const prompt of [
"继续优化 OpenViking 的召回算法",
"批准预算为 100 美元并导入历史记录",
"现在 OpenViking 的召回机制是什么",
"please continue the migration but keep Python 3.10 compatibility",
"这个 PR 完成了吗?如果没有请修复 CI",
]) {
assert.equal(classifyControlPrompt(prompt), null, prompt);
}
});

test("auto-recall short-circuits control prompts before any HTTP request", async () => {
let requestCount = 0;
await withMockOpenViking(async (_req, res) => {
requestCount += 1;
writeJson(res, { status: "ok", result: { ok: true } });
}, async (baseUrl) => {
const result = await runAutoRecall(
{ prompt: "继续执行", session_id: "codex:control" },
{
OPENVIKING_AUTO_RECALL: "1",
OPENVIKING_CREDENTIAL_SOURCE: "env",
OPENVIKING_MIN_QUERY_LENGTH: "1",
OPENVIKING_RECALL_TIMEOUT_MS: "10000",
OPENVIKING_URL: baseUrl,
},
);
assert.deepEqual(JSON.parse(result.stdout.trim()), {});
});
assert.equal(requestCount, 0);
});

test("control prompt short-circuit can be disabled", async () => {
let requestCount = 0;
await withMockOpenViking(async (req, res) => {
requestCount += 1;
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 === "/api/v1/search/search") {
writeJson(res, { status: "ok", result: { entries: [], rendered: "" } });
return;
}
if (req.method === "POST" && url.pathname === "/api/v1/search/recall") {
writeJson(res, { status: "ok", result: { entries: [], rendered: "" } });
return;
}
writeStatusJson(res, 404, { status: "error", error: "not found" });
}, async (baseUrl) => {
const result = await runAutoRecall(
{ prompt: "继续执行", session_id: "codex:control-disabled" },
{
OPENVIKING_AUTO_RECALL: "1",
OPENVIKING_CREDENTIAL_SOURCE: "env",
OPENVIKING_MIN_QUERY_LENGTH: "1",
OPENVIKING_RECALL_CONTROL_PROMPT_SHORT_CIRCUIT: "0",
OPENVIKING_RECALL_TIMEOUT_MS: "10000",
OPENVIKING_URL: baseUrl,
},
);
assert.deepEqual(JSON.parse(result.stdout.trim()), {});
});
assert.ok(requestCount > 0);
});

test("auto-recall asks the context face with the derived OpenViking session id", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "ov-auto-recall-state-"));
const requests = [];
Expand Down
4 changes: 4 additions & 0 deletions examples/codex-memory-plugin/scripts/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
* OPENVIKING_RECALL_TIMEOUT_MS, OPENVIKING_RECALL_COMPRESS_TIMEOUT_MS
* OPENVIKING_RECALL_COMPRESS_MODEL, OPENVIKING_RECALL_COMPRESS_THINKING
* OPENVIKING_RECALL_LIMIT, OPENVIKING_SCORE_THRESHOLD
* OPENVIKING_RECALL_CONTROL_PROMPT_SHORT_CIRCUIT
* OPENVIKING_WORKSPACE_PEER, OPENVIKING_RECALL_PEER_SCOPE
* OPENVIKING_NO_AUTO_INJECT, OPENVIKING_PROFILE_TOKEN_BUDGET
* OPENVIKING_DEBUG=1, OPENVIKING_DEBUG_LOG
Expand Down Expand Up @@ -180,6 +181,9 @@ export function loadConfig() {
process.env.OPENVIKING_MIN_QUERY_LENGTH,
num(cx.minQueryLength, 3),
))),
recallControlPromptShortCircuit:
envBool("OPENVIKING_RECALL_CONTROL_PROMPT_SHORT_CIRCUIT") ??
configBool(cx.recallControlPromptShortCircuit, true),
logRankingDetails: envBool("OPENVIKING_LOG_RANKING_DETAILS") ?? (cx.logRankingDetails === true),
recallPeerScope,
recallCompress: envBool("OPENVIKING_RECALL_COMPRESS") ?? configBool(cx.recallCompress, true),
Expand Down
24 changes: 24 additions & 0 deletions examples/codex-memory-plugin/scripts/control-prompt.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const APPROVAL_FINGERPRINT_RE = /(?:指纹|fingerprint)\s*[::]?\s*[a-f0-9]{64}\b/i;
const APPROVAL_PREFIX_RE = /^(?:批准|同意|approve(?:d)?)(?:\s|[,:,:])/iu;

const ACKNOWLEDGEMENT_RE = /^(?:ok(?:ay)?|yes|yep|sure|confirmed?|approved?|sounds good|lgtm|好|好的|可以|没问题|确认|同意|批准|行|收到|就这样)[。.!!]?$/iu;
const CONTINUATION_RE = /^(?:continue|proceed|go ahead|keep going|do it|start|继续|继续执行|接着做|开始吧|执行吧|按计划继续|就按这个做|按这个做)[。.!!]?$/iu;
const STATUS_RE = /^(?:status|progress|(?:any|status) update|what(?:'s| is) the (?:current )?(?:status|progress)|is it done|are we done|进度|现在进度怎么样|进度怎么样|什么进度|现在什么进度|汇报(?:一下)?进度|完成了吗|整体完成了吗|现在整体完成了吗)[??。.!!]?$/iu;

/**
* Classify prompts whose meaning is entirely carried by the active turn state.
* These prompts should not trigger external memory retrieval: the model already
* has the approval, continuation, or status request in the conversation.
*
* The match is intentionally strict. Any extra task noun, constraint, or
* implementation detail falls through to normal recall.
*/
export function classifyControlPrompt(prompt) {
const text = String(prompt || "").normalize("NFKC").replace(/\s+/g, " ").trim();
if (!text || text.length > 512) return null;
if (APPROVAL_PREFIX_RE.test(text) && APPROVAL_FINGERPRINT_RE.test(text)) return "approval";
if (ACKNOWLEDGEMENT_RE.test(text)) return "acknowledgement";
if (CONTINUATION_RE.test(text)) return "continuation";
if (STATUS_RE.test(text)) return "status";
return null;
}