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
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,16 @@ export function repairDigestUris(digest, validUris = []) {
lines.push(line);
continue;
}
if (valid.some((uri) => line.includes(uri))) {
lines.push(line);
continue;
}
let dropped = false;
const repaired = line.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
if (validSet.has(uri)) return uri;
const nearest = nearestUri(uri, valid);
let decoded = uri;
try { decoded = decodeURI(uri); } catch { /* keep the original candidate */ }
if (validSet.has(decoded)) return decoded;
const nearest = nearestUri(decoded, valid);
if (nearest) return nearest;
dropped = true;
return uri;
Expand Down Expand Up @@ -171,7 +177,8 @@ export async function compressRecallContext({
});
const cached = await readCache(cachePath);
if (cached?.key === key && typeof cached.digest === "string") {
return { status: COMPRESS_OK, context: cached.digest };
const digest = normalizeCompressedContext(cached.digest, 4000, maxBullets);
if (digest) return { status: COMPRESS_OK, context: digest };
}

const prompt = buildRecallCompressionPrompt({
Expand All @@ -185,9 +192,10 @@ export async function compressRecallContext({
if (!normalized) return { status: COMPRESS_EMPTY, context: "" };

const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
const digest = repairDigestUris(normalized, validUris.length
const repaired = repairDigestUris(normalized, validUris.length
? validUris
: (input.match(/viking:\/\/[^\s<>"']+/g) || []));
const digest = normalizeCompressedContext(repaired, 4000, maxBullets);
if (!digest) return { status: COMPRESS_FAILED, context: "" };

await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
Expand Down
10 changes: 7 additions & 3 deletions examples/codex-memory-plugin/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ Env var overrides for tuning without rebuilding:
| `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` |
| `OPENVIKING_RECALL_COMPRESS_BASE_URL` | unset | custom API base URL for the nested `codex exec` compressor |
| `OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS` | `1500` | skip the nested compressor below this recalled-context size; `0` always compresses |
| `OPENVIKING_RECALL_COMPRESS_DETECT_ON_STARTUP` | `1` | recreate/cache compressor profile during every `SessionStart` |
| `OPENVIKING_RECALL_COMPRESS_DETECT_TIMEOUT_MS` | `15000` | per-candidate compressor probe timeout |
| `OPENVIKING_RECALL_COMPRESS_DETECT_TTL_MS` | `604800000` (7 days) | cache TTL used by `UserPromptSubmit` reads |
Expand Down Expand Up @@ -344,9 +345,12 @@ Model availability is re-probed at every `SessionStart`, not in every
`UserPromptSubmit`. Recreating the profile on each session start catches
cross-session env/config changes. The detector writes
`recall-compressor-profile.json` under `OPENVIKING_CODEX_STATE_DIR` and
auto-recall reads that cache. Cache misses in auto-recall use the first
candidate directly and fall back to deterministic digest if `codex exec`
fails.
auto-recall reads that cache. Before resolving a profile, auto-recall passes
the injection-ready context through the shared recall-compression core. The
shared core admits only blocks at or above the configured minimum and reuses a
digest for an identical query/context/URI set; only an eligible cache miss
launches `codex exec`. Compressor failures fall back to the deterministic
digest.

Fallback order:

Expand Down
7 changes: 4 additions & 3 deletions examples/codex-memory-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,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. By default, recalled context below `OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS` is injected directly; larger blocks pass through the shared relevance compressor, and an identical query/context pair reuses its cached 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 keep validated `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.

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 @@ -235,6 +235,7 @@ Config knobs:
| `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`. |
| `OPENVIKING_RECALL_COMPRESS_BASE_URL` | unset | Base URL for the nested compressor's provider. Use this when `--ignore-user-config` prevents the compressor from reading the main Codex provider configuration. |
| `OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS` | `1500` | Skip the nested compressor below this recalled-context size. Set `0` to compress every non-empty result. |
| `OPENVIKING_RECALL_COMPRESS_DETECT_ON_STARTUP` | `1` | Recreate/cache compressor profile in `SessionStart`. |
| `OPENVIKING_RECALL_COMPRESS_DETECT_TIMEOUT_MS` | `15000` | Per-candidate startup probe timeout. |
| `OPENVIKING_RECALL_COMPRESS_DETECT_TTL_MS` | `604800000` | Cache TTL used by `UserPromptSubmit` when reading the latest profile. |
Expand All @@ -251,8 +252,8 @@ unless explicitly configured, so the plugin follows the server instead of copyin
values such as `limit=10` or `max_tokens=1600`. An explicit legacy `recallLimit`
is converted to per-category coding quotas, not a final result cap. Values
from 1 through 5 therefore produce an effective total quota of 6, one retrieval
slot for each coding domain. Local `codex exec` compression is
unchanged and still runs on top of whichever path answered.
slot for each coding domain. Eligible cache misses still use local `codex exec`
compression on top of whichever path answered.

Client-side knobs can also live in `~/.openviking/ovcli.conf` under
`plugin` (shared) or `plugin.codex` (this harness only); resolution order is env
Expand Down
107 changes: 40 additions & 67 deletions examples/codex-memory-plugin/scripts/auto-recall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ import {
loadCachedRecallCompressorProfile,
markRecallCompressorRuntimeFailed,
} from "./recall-compressor-profile.mjs";
import { deriveOvSessionId } from "./session-state.mjs";
import { deriveOvSessionId, getStateDir } from "./session-state.mjs";
import {
buildRecallEndpointBody,
fetchAssembledContext,
normalizeContextEntry,
postRecall,
} from "./shared/recall-core.mjs";
import { compressRecallContext } from "./shared/recall-compress-core.mjs";
import { resolveEffectivePeerId } from "./shared/workspace-peer.mjs";

const cfg = loadConfig();
Expand All @@ -42,6 +43,7 @@ let emitted = false;
let activeCompressor = null;
let recallDeadline = null;
const DEFAULT_FINAL_RECALL_CHARS = 6500;
const RECALL_DIGEST_CACHE_PATH = join(getStateDir(), "recall-digest.json");

function output(obj, exitAfter = false) {
if (emitted) return;
Expand Down Expand Up @@ -370,19 +372,6 @@ function sanitizeInjectedText(text) {
.replace(/<\/?openviking-context\b[^>]*>/gi, "openviking context marker");
}

function isNoRelevantMemory(text) {
const value = String(text || "")
.trim()
.replace(/^openviking memory digest:\s*/i, "")
.trim();
return !value || /^NO_RELEVANT_MEMORY\.?$/i.test(value) || /^no (?:directly )?relevant memor(?:y|ies)\.?$/i.test(value);
}

function hasDigestSignal(text) {
const body = String(text || "").replace(/^openviking memory digest:\s*/i, "").trim();
return /(^|\n)\s*[-*]\s+\S/.test(body) || /\bviking:\/\//i.test(body);
}

function appendMcpRetrievalHint(text) {
const value = String(text || "").trim();
if (!/\bviking:\/\//i.test(value) || /OpenViking MCP/i.test(value)) return value;
Expand All @@ -397,19 +386,6 @@ function fallbackDigest(items) {
return lines.length > 0 ? appendMcpRetrievalHint(`OpenViking memory digest:\n${lines.join("\n")}`) : "";
}

function normalizeCompressedContext(text) {
let value = String(text || "").trim();
if (!value) return "";
value = value.replace(/^```(?:text|markdown)?\s*/i, "").replace(/\s*```$/i, "").trim();
value = sanitizeInjectedText(value);
if (isNoRelevantMemory(value)) return "";
if (!value.toLowerCase().startsWith("openviking memory digest:")) {
value = `OpenViking memory digest:\n${value}`;
}
if (!hasDigestSignal(value)) return "";
return truncateText(appendMcpRetrievalHint(value), 4000);
}

async function getRecallCompressorProfile() {
const cached = await loadCachedRecallCompressorProfile(cfg);
if (cached) return cached;
Expand Down Expand Up @@ -505,46 +481,42 @@ async function runCodexCompressor(prompt, profile) {
}
}

async function compressMemoryContext(userPrompt, items) {
async function compressMemoryContext(userPrompt, rendered, items) {
if (!cfg.recallCompress) return null;
const profile = await getRecallCompressorProfile();
if (!profile.enabled) {
log("compress_skip", { reason: "profile disabled", profile });
const input = String(rendered || "").trim() || fallbackDigest(items);
if (!input) return "";

let profile = null;
try {
const compression = await compressRecallContext({
query: userPrompt,
rendered: input,
entries: items,
cfg,
cachePath: RECALL_DIGEST_CACHE_PATH,
now: Date.now(),
// Short inputs and cache hits return before host model state is needed.
runCompressor: async (prompt) => {
profile = await getRecallCompressorProfile();
if (!profile.enabled) {
log("compress_skip", { reason: "profile disabled", profile });
return "";
}
return await runCodexCompressor(prompt, profile) ?? "";
},
});
log("compressed", {
status: compression.status,
inputCount: items.length,
chars: compression.context.length,
profile,
});
if (compression.status === "failed") return null;
return appendMcpRetrievalHint(compression.context);
} catch (err) {
logError("compress", err);
return null;
}
const perItemChars = Math.max(500, Math.floor(cfg.recallCompressMaxInputChars / Math.max(1, items.length)));
const payload = {
user_prompt: userPrompt,
max_bullets: cfg.recallCompressMaxBullets,
memories: items.map((item) => ({
uri: item.uri,
category: item.category || "memory",
score: item.score,
text: truncateText(item.text, perItemChars),
})),
};
const prompt = `You are a memory relevance compressor for a Codex UserPromptSubmit hook.

Task:
- Keep only memories directly useful for answering the user's current prompt.
- Drop stale, generic, duplicate, merely adjacent, or operationally unrelated memories.
- Compress to at most ${cfg.recallCompressMaxBullets} short bullets.
- Preserve concrete facts, dates, paths, repo names, commands, and user preferences.
- Include the source viking:// URI when the agent may need to inspect more detail.
- If the answer needs detail beyond the bullet, say to use OpenViking MCP read/search with the cited viking:// URI if needed.
- Do not include XML/HTML wrappers.
- Do not mention that you filtered memories.
- Output either "OpenViking memory digest:" followed by useful bullets, or exactly: NO_RELEVANT_MEMORY.
- If no memory is directly useful, output exactly: NO_RELEVANT_MEMORY.

Input JSON:
${JSON.stringify(payload, null, 2)}
`;
const raw = await runCodexCompressor(prompt, profile);
if (raw === null) return null;
const compressed = normalizeCompressedContext(raw);
log("compressed", { inputCount: items.length, chars: compressed.length, profile });
return compressed;
}

async function main() {
Expand Down Expand Up @@ -602,7 +574,7 @@ async function main() {
return;
}
const compressedContext = endpointRecall.items.length > 0
? await compressMemoryContext(userPrompt, endpointRecall.items)
? await compressMemoryContext(userPrompt, endpointRecall.context, endpointRecall.items)
: null;
const endpointFallback = cfg.recallCompress && endpointRecall.items.length > 0
? fallbackDigest(endpointRecall.items)
Expand Down Expand Up @@ -676,8 +648,9 @@ async function main() {
}),
);

const compressedContext = await compressMemoryContext(userPrompt, memoryItems);
const memoryContext = compressedContext === null ? fallbackDigest(memoryItems) : compressedContext;
const fallbackContext = fallbackDigest(memoryItems);
const compressedContext = await compressMemoryContext(userPrompt, fallbackContext, memoryItems);
const memoryContext = compressedContext === null ? fallbackContext : compressedContext;

emit(memoryContext);
}
Expand Down
56 changes: 52 additions & 4 deletions examples/codex-memory-plugin/scripts/auto-recall.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -160,8 +160,9 @@ async function runEndpointCompressionCase({
compressorOutput,
exitCode = 0,
extraEnv = {},
stateDir: providedStateDir = "",
}) {
const stateDir = await mkdtemp(join(tmpdir(), "ov-auto-recall-endpoint-compress-"));
const stateDir = providedStateDir || await mkdtemp(join(tmpdir(), "ov-auto-recall-endpoint-compress-"));
let requestBody = null;
try {
return await withFakeCodex(compressorOutput, async ({ callLog, argsLog, env }) => {
Expand Down Expand Up @@ -210,7 +211,7 @@ async function runEndpointCompressionCase({
};
}, { exitCode });
} finally {
await rm(stateDir, { recursive: true, force: true });
if (!providedStateDir) await rm(stateDir, { recursive: true, force: true });
}
}

Expand Down Expand Up @@ -381,13 +382,53 @@ test("auto-recall applies the relevance compressor to server recall entries", as
},
rendered: "<memory_group>Unrelated remembered detail</memory_group>",
compressorOutput: "NO_RELEVANT_MEMORY",
extraEnv: { OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS: "0" },
});

assert.deepEqual(result.output, {});
assert.equal(result.compressorCalls, 1);
assert.equal(result.requestBody.max_chars, 18000);
});

test("auto-recall skips the compressor for a bounded short recall", async () => {
const result = await runEndpointCompressionCase({
prompt: "Which editor do I prefer?",
entry: {
uri: "viking://user/zeus/memories/preferences/editor.md",
score: 0.91,
type: "preferences",
mode: "summary",
summary: "Use Vim",
},
rendered: '<memory uri="viking://user/zeus/memories/preferences/editor.md">Use Vim</memory>',
compressorOutput: "NO_RELEVANT_MEMORY",
});

assert.equal(result.compressorCalls, 0);
assert.match(result.output.hookSpecificOutput.additionalContext, /Use Vim/);
});

test("auto-recall reuses a cached digest for an identical recall", async () => {
const uri = "viking://user/zeus/memories/events/retry.md";
const stateDir = await mkdtemp(join(tmpdir(), "ov-auto-recall-cache-"));
const options = {
prompt: "How should retries work?",
entry: { uri, score: 0.91, type: "events", mode: "full", summary: "Retry with backoff" },
rendered: `<memory uri="${uri}">${"Retry with exponential backoff. ".repeat(80)}</memory>`,
compressorOutput: `- Retry with exponential backoff. source: ${uri}`,
stateDir,
};
try {
const first = await runEndpointCompressionCase(options);
const second = await runEndpointCompressionCase(options);
assert.equal(first.compressorCalls, 1);
assert.equal(second.compressorCalls, 0);
assert.match(second.output.hookSpecificOutput.additionalContext, /exponential backoff/);
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});

test("auto-recall passes the configured compressor base URL to Codex", async () => {
const result = await runEndpointCompressionCase({
prompt: "Explain HTTP 429",
Expand All @@ -400,7 +441,10 @@ test("auto-recall passes the configured compressor base URL to Codex", async ()
},
rendered: "<memory_group>Retry with backoff</memory_group>",
compressorOutput: "NO_RELEVANT_MEMORY",
extraEnv: { OPENVIKING_RECALL_COMPRESS_BASE_URL: "https://compressor.example/v1" },
extraEnv: {
OPENVIKING_RECALL_COMPRESS_BASE_URL: "https://compressor.example/v1",
OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS: "0",
},
});

assert.ok(result.compressorArgs.includes('model_provider="openviking_compressor"'));
Expand All @@ -421,6 +465,7 @@ test("auto-recall falls back to a bounded deterministic digest when endpoint com
rendered: "<memory_group>Use Vim</memory_group>",
compressorOutput: "",
exitCode: 1,
extraEnv: { OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS: "0" },
});

assert.match(result.output.hookSpecificOutput.additionalContext, /Use Vim/);
Expand Down Expand Up @@ -456,7 +501,10 @@ syncBuiltinESMExports();
},
rendered: "<memory_group>Use Vim</memory_group>",
compressorOutput: "unused",
extraEnv: { NODE_OPTIONS: `--require=${preloadPath}` },
extraEnv: {
NODE_OPTIONS: `--require=${preloadPath}`,
OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS: "0",
},
});

assert.match(result.output.hookSpecificOutput.additionalContext, /Use Vim/);
Expand Down
5 changes: 5 additions & 0 deletions examples/codex-memory-plugin/scripts/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
* OPENVIKING_RECALL_TIMEOUT_MS, OPENVIKING_RECALL_COMPRESS_TIMEOUT_MS
* OPENVIKING_RECALL_COMPRESS_MODEL, OPENVIKING_RECALL_COMPRESS_THINKING
* OPENVIKING_RECALL_COMPRESS_BASE_URL
* OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS
* OPENVIKING_RECALL_LIMIT, OPENVIKING_SCORE_THRESHOLD
* OPENVIKING_WORKSPACE_PEER, OPENVIKING_RECALL_PEER_SCOPE
* OPENVIKING_NO_AUTO_INJECT, OPENVIKING_PROFILE_TOKEN_BUDGET
Expand Down Expand Up @@ -204,6 +205,10 @@ export function loadConfig() {
process.env.OPENVIKING_RECALL_COMPRESS_DETECT_TTL_MS,
num(cx.recallCompressDetectTtlMs, 604800000),
))),
recallCompressMinInputChars: Math.max(0, Math.floor(num(
process.env.OPENVIKING_RECALL_COMPRESS_MIN_INPUT_CHARS,
num(cx.recallCompressMinInputChars, 1500),
))),
recallCompressMaxInputChars: Math.max(1000, Math.floor(num(
process.env.OPENVIKING_RECALL_COMPRESS_MAX_INPUT_CHARS,
num(cx.recallCompressMaxInputChars, 18000),
Expand Down
Loading