mirror of
https://github.com/volcengine/OpenViking.git
synced 2026-09-28 11:43:00 +08:00
fix(plugins): name the skill package a recall hit came from
Since a skill is indexed as a whole package, a hit can be any file inside it, not just the two index sidecars the old rewrite stripped. Derive the package root the way the server's skill_root_uri does, drop the internal update backups, and keep one entry per package at its best score. Claude-Session: https://claude.ai/code/session_01CrZadR75kCueyZyoiGUFBW
This commit is contained in:
@@ -31,13 +31,39 @@ export function skillEntryHint(text) {
|
||||
return /\btype="skills"/.test(value) || SKILL_URI_IN_TEXT_RE.test(value) ? SKILL_ENTRY_HINT : null;
|
||||
}
|
||||
|
||||
const SKILLS_ROOT_RE = /^viking:\/\/(?:~|user\/[^/]+|agent)\/skills(?:\/|$)/i;
|
||||
const SKILL_PACKAGE_RE = /^(viking:\/\/(?:~|user\/[^/]+|agent)\/skills\/([^/]+))(?:\/|$)/i;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A skill is indexed as a whole package, so a hit can be its .abstract.md, its
|
||||
* SKILL.md, or any file inside the directory. Name the package directory, the
|
||||
* way the server's skill_root_uri does, as the context face and the
|
||||
* session-start catalog do. A hit at a bare skills root, or under a package
|
||||
* whose name starts with "." (an internal update backup), is not an installed
|
||||
* skill and yields "". A URI outside a skills root is left alone.
|
||||
*/
|
||||
export function skillHitUri(uri) {
|
||||
return String(uri || "").replace(/\/\.(?:abstract|overview)\.md$/, "");
|
||||
const value = String(uri || "").replace(/\/+$/, "");
|
||||
const match = SKILL_PACKAGE_RE.exec(value);
|
||||
if (match) return match[2].startsWith(".") ? "" : match[1];
|
||||
return SKILLS_ROOT_RE.test(value) ? "" : value;
|
||||
}
|
||||
|
||||
/** One entry per skill package, keeping each package's highest-scoring hit. */
|
||||
export function dedupeSkillHits(items) {
|
||||
const best = new Map();
|
||||
const kept = [];
|
||||
for (const item of items) {
|
||||
const uri = item?.uri || "";
|
||||
const index = best.get(uri);
|
||||
if (index === undefined) {
|
||||
best.set(uri, kept.length);
|
||||
kept.push(item);
|
||||
} else if ((item?.score || 0) > (kept[index]?.score || 0)) {
|
||||
kept[index] = item;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
const DEFAULT_CONTEXT_LIMIT = 10;
|
||||
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
|
||||
@@ -341,11 +367,15 @@ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = ""
|
||||
}, { actorPeerId });
|
||||
if (!res.ok) return [];
|
||||
const items = res.result?.[source.bucket] || [];
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
...(source.type === "skill" ? { uri: skillHitUri(item.uri) } : {}),
|
||||
_sourceType: source.type,
|
||||
}));
|
||||
if (source.type !== "skill") {
|
||||
return items.map((item) => ({ ...item, _sourceType: source.type }));
|
||||
}
|
||||
const hits = [];
|
||||
for (const item of items) {
|
||||
const uri = skillHitUri(item.uri);
|
||||
if (uri) hits.push({ ...item, uri, _sourceType: source.type });
|
||||
}
|
||||
return dedupeSkillHits(hits);
|
||||
}
|
||||
|
||||
async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => {}) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { deriveOvSessionId, getStateDir } from "./session-state.mjs";
|
||||
import {
|
||||
buildRecallEndpointBody,
|
||||
dedupeSkillHits,
|
||||
fetchAssembledContext,
|
||||
normalizeContextEntry,
|
||||
postRecall,
|
||||
@@ -279,11 +280,11 @@ async function searchAll(query, limit, sessionId = null) {
|
||||
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) });
|
||||
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: "skills",
|
||||
}));
|
||||
const skills = dedupeSkillHits(
|
||||
[...userSkills, ...sharedSkills]
|
||||
.map((m) => ({ ...m, uri: skillHitUri(m.uri), category: "skills" }))
|
||||
.filter((m) => m.uri),
|
||||
);
|
||||
const all = [...userMems, ...skills];
|
||||
const seen = new Set();
|
||||
return all.filter((m) => {
|
||||
|
||||
+39
-9
@@ -31,13 +31,39 @@ export function skillEntryHint(text) {
|
||||
return /\btype="skills"/.test(value) || SKILL_URI_IN_TEXT_RE.test(value) ? SKILL_ENTRY_HINT : null;
|
||||
}
|
||||
|
||||
const SKILLS_ROOT_RE = /^viking:\/\/(?:~|user\/[^/]+|agent)\/skills(?:\/|$)/i;
|
||||
const SKILL_PACKAGE_RE = /^(viking:\/\/(?:~|user\/[^/]+|agent)\/skills\/([^/]+))(?:\/|$)/i;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A skill is indexed as a whole package, so a hit can be its .abstract.md, its
|
||||
* SKILL.md, or any file inside the directory. Name the package directory, the
|
||||
* way the server's skill_root_uri does, as the context face and the
|
||||
* session-start catalog do. A hit at a bare skills root, or under a package
|
||||
* whose name starts with "." (an internal update backup), is not an installed
|
||||
* skill and yields "". A URI outside a skills root is left alone.
|
||||
*/
|
||||
export function skillHitUri(uri) {
|
||||
return String(uri || "").replace(/\/\.(?:abstract|overview)\.md$/, "");
|
||||
const value = String(uri || "").replace(/\/+$/, "");
|
||||
const match = SKILL_PACKAGE_RE.exec(value);
|
||||
if (match) return match[2].startsWith(".") ? "" : match[1];
|
||||
return SKILLS_ROOT_RE.test(value) ? "" : value;
|
||||
}
|
||||
|
||||
/** One entry per skill package, keeping each package's highest-scoring hit. */
|
||||
export function dedupeSkillHits(items) {
|
||||
const best = new Map();
|
||||
const kept = [];
|
||||
for (const item of items) {
|
||||
const uri = item?.uri || "";
|
||||
const index = best.get(uri);
|
||||
if (index === undefined) {
|
||||
best.set(uri, kept.length);
|
||||
kept.push(item);
|
||||
} else if ((item?.score || 0) > (kept[index]?.score || 0)) {
|
||||
kept[index] = item;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
const DEFAULT_CONTEXT_LIMIT = 10;
|
||||
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
|
||||
@@ -341,11 +367,15 @@ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = ""
|
||||
}, { actorPeerId });
|
||||
if (!res.ok) return [];
|
||||
const items = res.result?.[source.bucket] || [];
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
...(source.type === "skill" ? { uri: skillHitUri(item.uri) } : {}),
|
||||
_sourceType: source.type,
|
||||
}));
|
||||
if (source.type !== "skill") {
|
||||
return items.map((item) => ({ ...item, _sourceType: source.type }));
|
||||
}
|
||||
const hits = [];
|
||||
for (const item of items) {
|
||||
const uri = skillHitUri(item.uri);
|
||||
if (uri) hits.push({ ...item, uri, _sourceType: source.type });
|
||||
}
|
||||
return dedupeSkillHits(hits);
|
||||
}
|
||||
|
||||
async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => {}) {
|
||||
|
||||
@@ -30,13 +30,39 @@ export function skillEntryHint(text) {
|
||||
return /\btype="skills"/.test(value) || SKILL_URI_IN_TEXT_RE.test(value) ? SKILL_ENTRY_HINT : null;
|
||||
}
|
||||
|
||||
const SKILLS_ROOT_RE = /^viking:\/\/(?:~|user\/[^/]+|agent)\/skills(?:\/|$)/i;
|
||||
const SKILL_PACKAGE_RE = /^(viking:\/\/(?:~|user\/[^/]+|agent)\/skills\/([^/]+))(?:\/|$)/i;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A skill is indexed as a whole package, so a hit can be its .abstract.md, its
|
||||
* SKILL.md, or any file inside the directory. Name the package directory, the
|
||||
* way the server's skill_root_uri does, as the context face and the
|
||||
* session-start catalog do. A hit at a bare skills root, or under a package
|
||||
* whose name starts with "." (an internal update backup), is not an installed
|
||||
* skill and yields "". A URI outside a skills root is left alone.
|
||||
*/
|
||||
export function skillHitUri(uri) {
|
||||
return String(uri || "").replace(/\/\.(?:abstract|overview)\.md$/, "");
|
||||
const value = String(uri || "").replace(/\/+$/, "");
|
||||
const match = SKILL_PACKAGE_RE.exec(value);
|
||||
if (match) return match[2].startsWith(".") ? "" : match[1];
|
||||
return SKILLS_ROOT_RE.test(value) ? "" : value;
|
||||
}
|
||||
|
||||
/** One entry per skill package, keeping each package's highest-scoring hit. */
|
||||
export function dedupeSkillHits(items) {
|
||||
const best = new Map();
|
||||
const kept = [];
|
||||
for (const item of items) {
|
||||
const uri = item?.uri || "";
|
||||
const index = best.get(uri);
|
||||
if (index === undefined) {
|
||||
best.set(uri, kept.length);
|
||||
kept.push(item);
|
||||
} else if ((item?.score || 0) > (kept[index]?.score || 0)) {
|
||||
kept[index] = item;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
const DEFAULT_CONTEXT_LIMIT = 10;
|
||||
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
|
||||
@@ -340,11 +366,15 @@ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = ""
|
||||
}, { actorPeerId });
|
||||
if (!res.ok) return [];
|
||||
const items = res.result?.[source.bucket] || [];
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
...(source.type === "skill" ? { uri: skillHitUri(item.uri) } : {}),
|
||||
_sourceType: source.type,
|
||||
}));
|
||||
if (source.type !== "skill") {
|
||||
return items.map((item) => ({ ...item, _sourceType: source.type }));
|
||||
}
|
||||
const hits = [];
|
||||
for (const item of items) {
|
||||
const uri = skillHitUri(item.uri);
|
||||
if (uri) hits.push({ ...item, uri, _sourceType: source.type });
|
||||
}
|
||||
return dedupeSkillHits(hits);
|
||||
}
|
||||
|
||||
async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => {}) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
isContextFaceLegacy,
|
||||
postRecall,
|
||||
readPeerScopeDowngrade,
|
||||
skillHitUri,
|
||||
} from "./lib/recall-core.mjs";
|
||||
|
||||
async function tempPath(name) {
|
||||
@@ -553,3 +554,55 @@ test("a skill hit competes with memory leaves on equal footing in the fallback",
|
||||
const picked = events.find((e) => e.event === "recall_picked").data.items.map((item) => item.uri);
|
||||
assert.ok(picked.includes("viking://user/alice/skills/pr-review"), picked.join("\n"));
|
||||
});
|
||||
|
||||
test("a skill hit names its package, whichever file inside it matched", () => {
|
||||
const cases = [
|
||||
["viking://agent/skills/deploy/.abstract.md", "viking://agent/skills/deploy"],
|
||||
["viking://agent/skills/deploy/.overview.md", "viking://agent/skills/deploy"],
|
||||
["viking://agent/skills/deploy/SKILL.md", "viking://agent/skills/deploy"],
|
||||
["viking://agent/skills/deploy/scripts/rollback.sh", "viking://agent/skills/deploy"],
|
||||
["viking://agent/skills/deploy/refs/.abstract.md", "viking://agent/skills/deploy"],
|
||||
["viking://user/alice/skills/pr-review/SKILL.md", "viking://user/alice/skills/pr-review"],
|
||||
["viking://user/alice/skills/pr-review/", "viking://user/alice/skills/pr-review"],
|
||||
// An internal update backup is not an installed skill, nor is a bare root.
|
||||
["viking://user/alice/skills/.pr-review.update-backup-ab12/SKILL.md", ""],
|
||||
["viking://agent/skills", ""],
|
||||
// Anything outside a skills root is left alone.
|
||||
["viking://user/alice/memories/events/e0.md", "viking://user/alice/memories/events/e0.md"],
|
||||
];
|
||||
for (const [uri, expected] of cases) assert.equal(skillHitUri(uri), expected, uri);
|
||||
});
|
||||
|
||||
test("several hits in one skill package become one entry at the best score", async () => {
|
||||
const legacyCachePath = await tempPath("context-face.json");
|
||||
const fetchJSON = async (path, init) => {
|
||||
if (path === "/api/v1/search/search") return { ok: false, status: 503 };
|
||||
if (path === "/api/v1/search/recall") return { ok: false, status: 404 };
|
||||
if (path === "/api/v1/search/find") {
|
||||
const body = JSON.parse(init.body);
|
||||
const skills = body.target_uri === "viking://agent/skills"
|
||||
? [
|
||||
{ uri: "viking://agent/skills/deploy/scripts/rollback.sh", score: 0.44, abstract: "roll back", level: 2 },
|
||||
{ uri: "viking://agent/skills/deploy/.abstract.md", score: 0.81, abstract: "name: deploy", level: 0 },
|
||||
{ uri: "viking://agent/skills/.deploy.update-backup-ab12/SKILL.md", score: 0.9, abstract: "stale", level: 2 },
|
||||
]
|
||||
: [];
|
||||
return { ok: true, result: { memories: [], skills } };
|
||||
}
|
||||
return { ok: false, status: 404 };
|
||||
};
|
||||
|
||||
const events = [];
|
||||
await buildRecallBlock(fetchJSON, {
|
||||
recallLimit: 5,
|
||||
recallPreferAbstract: true,
|
||||
scoreThreshold: 0.35,
|
||||
}, "how do we roll back a deploy", {
|
||||
legacyCachePath,
|
||||
log: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
const picked = events.find((e) => e.event === "recall_picked").data.items;
|
||||
assert.deepEqual(picked.map((item) => item.uri), ["viking://agent/skills/deploy"]);
|
||||
assert.equal(picked[0].score, 0.81);
|
||||
});
|
||||
|
||||
@@ -31,13 +31,39 @@ export function skillEntryHint(text) {
|
||||
return /\btype="skills"/.test(value) || SKILL_URI_IN_TEXT_RE.test(value) ? SKILL_ENTRY_HINT : null;
|
||||
}
|
||||
|
||||
const SKILLS_ROOT_RE = /^viking:\/\/(?:~|user\/[^/]+|agent)\/skills(?:\/|$)/i;
|
||||
const SKILL_PACKAGE_RE = /^(viking:\/\/(?:~|user\/[^/]+|agent)\/skills\/([^/]+))(?:\/|$)/i;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A skill is indexed as a whole package, so a hit can be its .abstract.md, its
|
||||
* SKILL.md, or any file inside the directory. Name the package directory, the
|
||||
* way the server's skill_root_uri does, as the context face and the
|
||||
* session-start catalog do. A hit at a bare skills root, or under a package
|
||||
* whose name starts with "." (an internal update backup), is not an installed
|
||||
* skill and yields "". A URI outside a skills root is left alone.
|
||||
*/
|
||||
export function skillHitUri(uri) {
|
||||
return String(uri || "").replace(/\/\.(?:abstract|overview)\.md$/, "");
|
||||
const value = String(uri || "").replace(/\/+$/, "");
|
||||
const match = SKILL_PACKAGE_RE.exec(value);
|
||||
if (match) return match[2].startsWith(".") ? "" : match[1];
|
||||
return SKILLS_ROOT_RE.test(value) ? "" : value;
|
||||
}
|
||||
|
||||
/** One entry per skill package, keeping each package's highest-scoring hit. */
|
||||
export function dedupeSkillHits(items) {
|
||||
const best = new Map();
|
||||
const kept = [];
|
||||
for (const item of items) {
|
||||
const uri = item?.uri || "";
|
||||
const index = best.get(uri);
|
||||
if (index === undefined) {
|
||||
best.set(uri, kept.length);
|
||||
kept.push(item);
|
||||
} else if ((item?.score || 0) > (kept[index]?.score || 0)) {
|
||||
kept[index] = item;
|
||||
}
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
const DEFAULT_CONTEXT_LIMIT = 10;
|
||||
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
|
||||
@@ -341,11 +367,15 @@ async function searchOneSource(fetchJSON, query, source, limit, actorPeerId = ""
|
||||
}, { actorPeerId });
|
||||
if (!res.ok) return [];
|
||||
const items = res.result?.[source.bucket] || [];
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
...(source.type === "skill" ? { uri: skillHitUri(item.uri) } : {}),
|
||||
_sourceType: source.type,
|
||||
}));
|
||||
if (source.type !== "skill") {
|
||||
return items.map((item) => ({ ...item, _sourceType: source.type }));
|
||||
}
|
||||
const hits = [];
|
||||
for (const item of items) {
|
||||
const uri = skillHitUri(item.uri);
|
||||
if (uri) hits.push({ ...item, uri, _sourceType: source.type });
|
||||
}
|
||||
return dedupeSkillHits(hits);
|
||||
}
|
||||
|
||||
async function searchAllSources(fetchJSON, query, perSourceLimit, actorPeerId = "", log = () => {}) {
|
||||
|
||||
Reference in New Issue
Block a user