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:
zhengxiao.wu
2026-09-21 13:07:29 +08:00
parent e6469610a6
commit 62a456c082
6 changed files with 215 additions and 41 deletions
@@ -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
View File
@@ -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 = () => {}) {