mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-28 11:43:00 +08:00
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:
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user