From 2bb903dca796201585556bb63e46f87f2bc28587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=B3=84=E9=B1=BC?= Date: Wed, 28 Oct 2026 17:43:57 +0800 Subject: [PATCH] fix(memory): select meaningful skill injection descriptions (MEMMY-814) --- .../service/retrieval/retrieval-service.ts | 3 +- .../retrieval/skill-injection-description.ts | 64 +++++++++++++++++++ .../retrieval/skill-description.test.ts | 43 +++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 Memory/src/service/retrieval/skill-injection-description.ts create mode 100644 Memory/tests/service/retrieval/skill-description.test.ts diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index 2a0e9624b..8fd2bf06a 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -1,3 +1,4 @@ +import { skillInjectionDescription } from "./skill-injection-description.js"; import type { RetrievalQueryExtract, RetrievalResult @@ -870,7 +871,7 @@ function renderInjectedSnippet( "", ...labeledInjectedBlock("Name", name), "", - ...labeledInjectedBlock("Description", firstLineSummary(guide, summaryChars) || "(not provided)") + ...labeledInjectedBlock("Description", skillInjectionDescription({ name, guide, retrievalBlurb: skill?.retrievalBlurb, triggerContext: skill?.triggerContext, maxChars: summaryChars })) ]; return { refKind: "skill", diff --git a/Memory/src/service/retrieval/skill-injection-description.ts b/Memory/src/service/retrieval/skill-injection-description.ts new file mode 100644 index 000000000..d51395547 --- /dev/null +++ b/Memory/src/service/retrieval/skill-injection-description.ts @@ -0,0 +1,64 @@ +import { parse as parseYaml } from "yaml"; +import { isRecord } from "../../utils/json.js"; + +/** Select a useful summary without treating the guide title or YAML as prose. */ +export function skillInjectionDescription(input: { + name: string; + guide: string; + retrievalBlurb?: string; + triggerContext?: string; + maxChars: number; +}): string { + const normalize = (value: string) => value.replace(/\s+/gu, " ").trim(); + const usable = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; + const text = normalize(value); + return text && text !== normalize(input.name) ? text : undefined; + }; + let description = usable(input.retrievalBlurb) ?? usable(input.triggerContext); + let body = input.guide.replace(/^\uFEFF/u, "").trim(); + if (!description) { + const frontmatter = body.match(/^---\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)(?:\r?\n|$)/u); + if (frontmatter) { + body = body.slice(frontmatter[0].length).trim(); + try { + const parsed: unknown = parseYaml(frontmatter[1]!, { maxAliasCount: 0 }); + if (isRecord(parsed)) description = usable(parsed.description); + } catch { + // A malformed header must not become the description or hide valid prose. + } + } else if (/^---(?:\r?\n|$)/u.test(body)) { + // An unterminated header has no safe prose boundary. + body = ""; + } + } + if (!description) { + const lines = body.split(/\r?\n/u); + const paragraph: string[] = []; + let fence: string | undefined; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]!.trim(); + const marker = line.match(/^(`{3,}|~{3,})/u)?.[1]; + if (marker) { + if (!fence) fence = marker[0]; + else if (marker[0] === fence) fence = undefined; + continue; + } + if (fence) continue; + const isHeading = /^#{1,6}(?:\s|$)/u.test(line) + || /^(?:=+|-+)\s*$/u.test(lines[index + 1]?.trim() ?? "") + || /^(?:=+|-+)$/u.test(line) + || /^\*\*[^*]+\*\*:?$/u.test(line); + if (!line || isHeading) { + if (paragraph.length) break; + continue; + } + if (!paragraph.length && !usable(line)) continue; + paragraph.push(line); + } + description = usable(paragraph.join(" ")); + } + if (!description) return "(not provided)"; + const limit = Math.max(1, Math.floor(input.maxChars)); + return description.length <= limit ? description : `${description.slice(0, limit - 1)}…`; +} diff --git a/Memory/tests/service/retrieval/skill-description.test.ts b/Memory/tests/service/retrieval/skill-description.test.ts new file mode 100644 index 000000000..66191431a --- /dev/null +++ b/Memory/tests/service/retrieval/skill-description.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { buildInjectedContext } from "../../../src/service/retrieval/retrieval-service.js"; +import type { MemoryRow, RecallHit } from "../../../src/types.js"; + +function skill(id: string, guide: string, procedure: Record = {}) { + const hit: RecallHit = { id, kind: "skill", memoryLayer: "Skill", status: "activated", title: id, + snippet: guide, score: 1, tags: [], source: "skill" }; + const memory: MemoryRow = { id, timeline: "2026-10-28T00:00:00Z", userId: "test", memoryType: "SkillMemory", + status: "activated", visibility: "private", memoryKey: id, memoryValue: guide, tags: [], info: {}, + properties: { internal_info: { memory_layer: "Skill", memory_kind: "skill", + skill: { name: id, invocation_guide: guide, procedure_json: procedure } } }, + memoryLayer: "Skill", version: 1, createdAt: "2026-10-28T00:00:00Z", updatedAt: "2026-10-28T00:00:00Z" }; + return { hit, memory }; +} + +describe("Skill injection descriptions", () => { + it.each([ + ["generated", "# report-writer\n\nWrite a repair report.", {}, "Write a repair report."], + ["blurb priority", "# report-writer\n\nBody fallback", { retrievalBlurb: "Preferred summary", triggerContext: "Trigger fallback" }, "Preferred summary"], + ["trigger fallback", "# report-writer\n\nBody fallback", { retrievalBlurb: " ", triggerContext: "Trigger summary" }, "Trigger summary"], + ["frontmatter", "---\nname: report-writer\ndescription: Write a repair report.\n---\n# report-writer", {}, "Write a repair report."], + ["multiline YAML", "---\r\nname: report-writer\r\ndescription: >-\r\n Write a report\r\n with evidence.\r\n---\r\n# report-writer", {}, "Write a report with evidence."], + ["skip title and name", "# report-writer\n\nreport-writer\n\n## Usage\nExplain verified changes.", {}, "Explain verified changes."], + ["missing", "# report-writer\n\nreport-writer", {}, "(not provided)"], + ["invalid YAML", "---\nname: [broken\n---\n# report-writer\n\nBody survives.", {}, "Body survives."] + ])("uses the expected description for %s", (_label, guide, procedure, expected) => { + const { hit, memory } = skill("report-writer", guide, procedure); + const packet = buildInjectedContext([hit], 10000, [memory]); + const content = packet.injectedContext.sections[0]!.content; + expect(content).toContain(`Description:\n${expected}`); + expect(packet.sourceMemoryIds).toEqual([hit.id]); + }); + + it("keeps full guides and applies the summary length limit", () => { + const guide = "# report-writer\n\nComplete procedure text."; + const { hit, memory } = skill("report-writer", guide, { retrievalBlurb: "x".repeat(400) }); + const full = buildInjectedContext([hit], 10000, [memory], "search", undefined, undefined, { skillInjectionMode: "full" }); + expect(full.injectedContext.sections[0]!.content).toContain(guide); + expect(full.injectedContext.sections[0]!.content).not.toContain("x".repeat(20)); + const summary = buildInjectedContext([hit], 10000, [memory], "search", undefined, undefined, { skillSummaryChars: 20 }); + expect(summary.injectedContext.sections[0]!.content).toContain("x".repeat(19) + "…"); + }); +});