diff --git a/apps/memos-local-plugin/core/skill/crystallize.ts b/apps/memos-local-plugin/core/skill/crystallize.ts index c45c5453d..1a5206bb5 100644 --- a/apps/memos-local-plugin/core/skill/crystallize.ts +++ b/apps/memos-local-plugin/core/skill/crystallize.ts @@ -353,11 +353,18 @@ function normaliseDraft( const displayTitle = sanitizeDerivedText(raw.display_title ?? raw.displayTitle ?? input.policy.title ?? name) || name; - const summary = sanitizeDerivedText(raw.summary); const parameters = asArray(raw.parameters).map(coerceParameter).filter(Boolean) as SkillParameterDraft[]; const preconditions = sanitizeDerivedMarkdownList(asStringArray(raw.preconditions)); const steps = asArray(raw.steps).map(coerceStep).filter(Boolean) as SkillStepDraft[]; + const summarySource = + sanitizeDerivedText(raw.summary) || + sanitizeDerivedText(raw.retrieval_blurb) || + sanitizeDerivedText(raw.retrievalBlurb) || + sanitizeDerivedText(steps[0]?.body) || + sanitizeDerivedText(steps[0]?.title) || + displayTitle; + const summary = summarySource.slice(0, 200); const examples = asArray(raw.examples).map(coerceExample).filter(Boolean) as SkillExampleDraft[]; const tags = dedupeLc(sanitizeDerivedList(asStringArray(raw.tags))); // V7 §2.4.6 — coerce both `decision_guidance` (preferred LLM key) diff --git a/apps/memos-local-plugin/tests/unit/skill/crystallize.test.ts b/apps/memos-local-plugin/tests/unit/skill/crystallize.test.ts index a2e0e63eb..e923105b2 100644 --- a/apps/memos-local-plugin/tests/unit/skill/crystallize.test.ts +++ b/apps/memos-local-plugin/tests/unit/skill/crystallize.test.ts @@ -120,6 +120,64 @@ describe("skill/crystallize", () => { expect(r.draft.tools).toEqual(["shell", "pip.install"]); }); + it("derives a summary from the first step when the LLM omits it", async () => { + const draft = makeDraft({ + steps: [{ title: "Inspect failure", body: "Read the package install error." }], + }) as unknown as Record; + delete draft.summary; + const llm = fakeLlm({ + completeJson: { "skill.crystallize": draft }, + }); + + const r = await crystallizeDraft( + { policy: mkPolicy(), evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, + { llm, log, config: makeSkillConfig(), validate: defaultDraftValidator }, + ); + + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.draft.summary).toBe("Read the package install error."); + }); + + it("prefers a retrieval blurb when deriving a missing summary", async () => { + const draft = makeDraft({ + steps: [{ title: "Inspect failure", body: "Read the package install error." }], + }) as unknown as Record; + delete draft.summary; + draft.retrieval_blurb = "Use this skill for Alpine package installation failures."; + const llm = fakeLlm({ + completeJson: { "skill.crystallize": draft }, + }); + + const r = await crystallizeDraft( + { policy: mkPolicy(), evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, + { llm, log, config: makeSkillConfig(), validate: defaultDraftValidator }, + ); + + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.draft.summary).toBe( + "Use this skill for Alpine package installation failures.", + ); + }); + + it("caps an explicit summary at 200 characters", async () => { + const llm = fakeLlm({ + completeJson: { + "skill.crystallize": makeDraft({ summary: "x".repeat(250) }), + }, + }); + + const r = await crystallizeDraft( + { policy: mkPolicy(), evidence: [mkTrace("tr_1", "pip fails")], namingSpace: [] }, + { llm, log, config: makeSkillConfig(), validate: defaultDraftValidator }, + ); + + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(r.draft.summary).toBe("x".repeat(200)); + }); + it("cleans unsafe markup from LLM-derived skill fields", async () => { const policy = mkPolicy(); const llm = fakeLlm({