feat(codex): recall shared skills and flag skill entries in Codex too

Codex builds its recall block itself instead of through recall-core's
wrappers, so the previous commit's changes never reached it: its raw
search still skipped viking://agent/skills, its digest carried no skill
hint, and its post-processing kept only level-2 leaves, which silently
dropped every skill hit (skills are found through their directory's
level-0 abstract), including the viking://~/skills search it already ran.

recall-core now exports skillEntryHint() and skillHitUri(). The hint is
added when a block carries a type="skills" entry or cites any skill URI,
which also covers digests that only keep URIs. Codex searches the shared
skill root as a third bucket, labels skill hits "skills" under their
directory URI, lets them through post-processing, and adds the same hint
line to its envelope.

Claude-Session: https://claude.ai/code/session_01ECVkFufAU2LKe4g83cxt28
This commit is contained in:
zhengxiao.wu
2026-09-18 15:31:19 +08:00
parent ec90494c3b
commit 553416200e
7 changed files with 173 additions and 34 deletions
@@ -20,6 +20,25 @@ const SOURCES = [
];
const SKILL_ENTRY_HINT =
"Skill entries are OpenViking skills: read SKILL.md under the entry's URI before following one.";
const SKILL_URI_IN_TEXT_RE = /\bviking:\/\/(?:~|user\/[^/\s"'<>()]+|agent)\/skills\/[^\s"'<>()]/;
/**
* The header line for a recall block that carries a skill — an assembled
* type="skills" entry, or any skill URI a digest cites — else null.
*/
export function skillEntryHint(text) {
const value = String(text || "");
return /\btype="skills"/.test(value) || SKILL_URI_IN_TEXT_RE.test(value) ? SKILL_ENTRY_HINT : null;
}
/**
* A skill search hit is its directory's .abstract.md (or .overview.md); name
* the skill directory instead, as the context face and the session-start
* catalog do.
*/
export function skillHitUri(uri) {
return String(uri || "").replace(/\/\.(?:abstract|overview)\.md$/, "");
}
const DEFAULT_CONTEXT_LIMIT = 10;
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
const DEFAULT_REWRITE_MAX_BULLETS = 6;
@@ -323,11 +342,7 @@ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = ""
const items = res.result?.[source.bucket] || [];
return items.map((item) => ({
...item,
// A skill hit is its directory's .abstract.md; name the skill directory, as
// the context face and the session-start catalog do.
...(source.type === "skill" && typeof item.uri === "string"
? { uri: item.uri.replace(/\/\.(?:abstract|overview)\.md$/, "") }
: {}),
...(source.type === "skill" ? { uri: skillHitUri(item.uri) } : {}),
_sourceType: source.type,
}));
}
@@ -484,7 +499,7 @@ function wrapContext(body) {
return [
"<openviking-context>",
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
...(/\btype="skills"/.test(body) ? [SKILL_ENTRY_HINT] : []),
...(skillEntryHint(body) ? [SKILL_ENTRY_HINT] : []),
body,
"</openviking-context>",
].join("\n");
@@ -31,6 +31,8 @@ import {
fetchAssembledContext,
normalizeContextEntry,
postRecall,
skillEntryHint,
skillHitUri,
} from "./shared/recall-core.mjs";
import { runHookStage } from "./shared/agent-hook-runtime.mjs";
import { createOvHttp } from "./shared/ov-http.mjs";
@@ -63,8 +65,10 @@ function output(obj, exitAfter = false) {
function wrapRecallContext(additionalContext) {
const body = sanitizeInjectedText(additionalContext).trim();
if (!body) return "";
const skillHint = skillEntryHint(body);
return [
'<openviking-context source="auto-recall" format="digest">',
...(skillHint ? [skillHint] : []),
body,
"</openviking-context>",
].join("\n");
@@ -201,7 +205,8 @@ function postProcess(items, limit, threshold) {
const sorted = [...items].sort((a, b) => clampScore(b.score) - clampScore(a.score));
const result = [];
for (const item of sorted) {
if (item.level !== 2) continue;
// Memories are leaves; a skill is found through its directory's abstract.
if (item.level !== 2 && item.category !== "skills") continue;
if (clampScore(item.score) < threshold) continue;
const cat = (item.category || "").toLowerCase() || "unknown";
const abs = (item.abstract || item.overview || "").trim().toLowerCase();
@@ -261,13 +266,20 @@ async function searchBucket(query, targetUris, limit, bucket, sessionId = null)
}
async function searchAll(query, limit, sessionId = null) {
const [userMems, userSkills] = await Promise.all([
const [userMems, userSkills, sharedSkills] = await Promise.all([
searchBucket(query, userScopedTargets("memories"), limit, "memories", sessionId),
searchBucket(query, userScopedTargets("skills"), limit, "skills", sessionId),
searchBucket(query, ["viking://agent/skills"], limit, "skills", sessionId),
]);
log("search_complete", { scope: "user", rawCount: userMems.length, topScores: userMems.slice(0, 3).map((m) => m.score) });
log("search_complete", { scope: "skills", rawCount: userSkills.length, topScores: userSkills.slice(0, 3).map((m) => m.score) });
const all = [...userMems, ...userSkills];
log("search_complete", { scope: "shared_skills", rawCount: sharedSkills.length, topScores: sharedSkills.slice(0, 3).map((m) => m.score) });
const skills = [...userSkills, ...sharedSkills].map((m) => ({
...m,
uri: skillHitUri(m.uri),
category: m.category || "skills",
}));
const all = [...userMems, ...skills];
const seen = new Set();
return all.filter((m) => {
if (seen.has(m.uri)) return false;
@@ -683,6 +683,72 @@ test("auto-recall expands configured user in memory search target", async () =>
}
});
test("the raw-search fallback also searches shared skills and flags them", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "ov-auto-recall-shared-skills-"));
const targets = [];
try {
await withMockOpenViking(async (req, res) => {
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") {
const body = await readRequestBody(req);
if (body.mode === "context") {
writeStatusJson(res, 400, { status: "error", error: "Extra inputs are not permitted: mode" });
return;
}
targets.push(body.target_uri);
const skills = body.target_uri === "viking://agent/skills"
? [{
uri: "viking://agent/skills/deploy-runbook/.abstract.md",
level: 0,
score: 0.9,
abstract: "name: deploy-runbook description: Shared deployment runbook",
}]
: [];
writeJson(res, { status: "ok", result: { memories: [], skills } });
return;
}
if (req.method === "POST" && url.pathname === "/api/v1/search/recall") {
writeStatusJson(res, 404, { status: "error", error: "not found" });
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "error", error: "not found" }));
}, async (baseUrl) => {
const result = await runAutoRecall(
{ prompt: "how do we roll back the payments deploy", session_id: "codex:789" },
{
OPENVIKING_AUTO_RECALL: "1",
OPENVIKING_CODEX_STATE_DIR: stateDir,
OPENVIKING_STATE_DIR: stateDir,
OPENVIKING_CONFIG_FILE: join(stateDir, "missing-ov.conf"),
OPENVIKING_CLI_CONFIG_FILE: join(stateDir, "missing-ovcli.conf"),
OPENVIKING_CREDENTIAL_SOURCE: "env",
OPENVIKING_RECALL_COMPRESS: "0",
OPENVIKING_RECALL_LIMIT: "1",
OPENVIKING_RECALL_TIMEOUT_MS: "10000",
OPENVIKING_MIN_QUERY_LENGTH: "1",
OPENVIKING_SCORE_THRESHOLD: "0",
OPENVIKING_TIMEOUT_MS: "5000",
OPENVIKING_URL: baseUrl,
},
);
const context = JSON.parse(result.stdout.trim()).hookSpecificOutput.additionalContext;
assert.match(context, /Skill entries are OpenViking skills/);
assert.match(context, /\[skills\] name: deploy-runbook .*\(viking:\/\/agent\/skills\/deploy-runbook\)/);
});
assert.ok(targets.includes("viking://agent/skills"), `searched ${targets.join(", ")}`);
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
test("auto-recall preserves explicit default user memory target", async () => {
const stateDir = await mkdtemp(join(tmpdir(), "ov-auto-recall-default-user-"));
const requests = [];
+21 -6
View File
@@ -20,6 +20,25 @@ const SOURCES = [
];
const SKILL_ENTRY_HINT =
"Skill entries are OpenViking skills: read SKILL.md under the entry's URI before following one.";
const SKILL_URI_IN_TEXT_RE = /\bviking:\/\/(?:~|user\/[^/\s"'<>()]+|agent)\/skills\/[^\s"'<>()]/;
/**
* The header line for a recall block that carries a skill — an assembled
* type="skills" entry, or any skill URI a digest cites — else null.
*/
export function skillEntryHint(text) {
const value = String(text || "");
return /\btype="skills"/.test(value) || SKILL_URI_IN_TEXT_RE.test(value) ? SKILL_ENTRY_HINT : null;
}
/**
* A skill search hit is its directory's .abstract.md (or .overview.md); name
* the skill directory instead, as the context face and the session-start
* catalog do.
*/
export function skillHitUri(uri) {
return String(uri || "").replace(/\/\.(?:abstract|overview)\.md$/, "");
}
const DEFAULT_CONTEXT_LIMIT = 10;
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
const DEFAULT_REWRITE_MAX_BULLETS = 6;
@@ -323,11 +342,7 @@ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = ""
const items = res.result?.[source.bucket] || [];
return items.map((item) => ({
...item,
// A skill hit is its directory's .abstract.md; name the skill directory, as
// the context face and the session-start catalog do.
...(source.type === "skill" && typeof item.uri === "string"
? { uri: item.uri.replace(/\/\.(?:abstract|overview)\.md$/, "") }
: {}),
...(source.type === "skill" ? { uri: skillHitUri(item.uri) } : {}),
_sourceType: source.type,
}));
}
@@ -484,7 +499,7 @@ function wrapContext(body) {
return [
"<openviking-context>",
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
...(/\btype="skills"/.test(body) ? [SKILL_ENTRY_HINT] : []),
...(skillEntryHint(body) ? [SKILL_ENTRY_HINT] : []),
body,
"</openviking-context>",
].join("\n");
@@ -19,6 +19,25 @@ const SOURCES = [
];
const SKILL_ENTRY_HINT =
"Skill entries are OpenViking skills: read SKILL.md under the entry's URI before following one.";
const SKILL_URI_IN_TEXT_RE = /\bviking:\/\/(?:~|user\/[^/\s"'<>()]+|agent)\/skills\/[^\s"'<>()]/;
/**
* The header line for a recall block that carries a skill — an assembled
* type="skills" entry, or any skill URI a digest cites — else null.
*/
export function skillEntryHint(text) {
const value = String(text || "");
return /\btype="skills"/.test(value) || SKILL_URI_IN_TEXT_RE.test(value) ? SKILL_ENTRY_HINT : null;
}
/**
* A skill search hit is its directory's .abstract.md (or .overview.md); name
* the skill directory instead, as the context face and the session-start
* catalog do.
*/
export function skillHitUri(uri) {
return String(uri || "").replace(/\/\.(?:abstract|overview)\.md$/, "");
}
const DEFAULT_CONTEXT_LIMIT = 10;
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
const DEFAULT_REWRITE_MAX_BULLETS = 6;
@@ -322,11 +341,7 @@ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = ""
const items = res.result?.[source.bucket] || [];
return items.map((item) => ({
...item,
// A skill hit is its directory's .abstract.md; name the skill directory, as
// the context face and the session-start catalog do.
...(source.type === "skill" && typeof item.uri === "string"
? { uri: item.uri.replace(/\/\.(?:abstract|overview)\.md$/, "") }
: {}),
...(source.type === "skill" ? { uri: skillHitUri(item.uri) } : {}),
_sourceType: source.type,
}));
}
@@ -483,7 +498,7 @@ function wrapContext(body) {
return [
"<openviking-context>",
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
...(/\btype="skills"/.test(body) ? [SKILL_ENTRY_HINT] : []),
...(skillEntryHint(body) ? [SKILL_ENTRY_HINT] : []),
body,
"</openviking-context>",
].join("\n");
@@ -506,15 +506,16 @@ test("fallback recall also searches the shared skill root and flags skill entrie
});
test("the skill hint appears only when the assembled context carries a skill", async () => {
for (const [type, expected] of [["skills", true], ["events", false]]) {
const cases = [
['<memory uri="viking://agent/skills/deploy-runbook" type="skills">body</memory>', true],
["- Roll back with the shared runbook (viking://user/alice/skills/deploy-runbook)", true],
['<memory uri="viking://user/alice/memories/events/deploy.md" type="events">body</memory>', false],
];
for (const [rendered, expected] of cases) {
const block = await buildRecallBlock(async () => ({
ok: true,
result: {
rendered: `<memory uri="viking://agent/skills/deploy-runbook" type="${type}">body</memory>`,
entries: [],
stats: { rewrite: "off" },
},
result: { rendered, entries: [], stats: { rewrite: "off" } },
}), {}, "deploy the payments service", { legacyCachePath: await tempPath("context-face.json") });
assert.equal(/Skill entries are OpenViking skills/.test(block), expected, type);
assert.equal(/Skill entries are OpenViking skills/.test(block), expected, rendered);
}
});
@@ -20,6 +20,25 @@ const SOURCES = [
];
const SKILL_ENTRY_HINT =
"Skill entries are OpenViking skills: read SKILL.md under the entry's URI before following one.";
const SKILL_URI_IN_TEXT_RE = /\bviking:\/\/(?:~|user\/[^/\s"'<>()]+|agent)\/skills\/[^\s"'<>()]/;
/**
* The header line for a recall block that carries a skill — an assembled
* type="skills" entry, or any skill URI a digest cites — else null.
*/
export function skillEntryHint(text) {
const value = String(text || "");
return /\btype="skills"/.test(value) || SKILL_URI_IN_TEXT_RE.test(value) ? SKILL_ENTRY_HINT : null;
}
/**
* A skill search hit is its directory's .abstract.md (or .overview.md); name
* the skill directory instead, as the context face and the session-start
* catalog do.
*/
export function skillHitUri(uri) {
return String(uri || "").replace(/\/\.(?:abstract|overview)\.md$/, "");
}
const DEFAULT_CONTEXT_LIMIT = 10;
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
const DEFAULT_REWRITE_MAX_BULLETS = 6;
@@ -323,11 +342,7 @@ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = ""
const items = res.result?.[source.bucket] || [];
return items.map((item) => ({
...item,
// A skill hit is its directory's .abstract.md; name the skill directory, as
// the context face and the session-start catalog do.
...(source.type === "skill" && typeof item.uri === "string"
? { uri: item.uri.replace(/\/\.(?:abstract|overview)\.md$/, "") }
: {}),
...(source.type === "skill" ? { uri: skillHitUri(item.uri) } : {}),
_sourceType: source.type,
}));
}
@@ -484,7 +499,7 @@ function wrapContext(body) {
return [
"<openviking-context>",
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
...(/\btype="skills"/.test(body) ? [SKILL_ENTRY_HINT] : []),
...(skillEntryHint(body) ? [SKILL_ENTRY_HINT] : []),
body,
"</openviking-context>",
].join("\n");