Skip to content

Commit 942cd32

Browse files
author
Tehan
committed
fix(skill-memory): address council findings (disabled-path crash + 2 routing/parser gaps)
Council review (deepseek/sonnet/gpt-5.5) of PR #181: - Must (consensus rev-2+rev-3): the ctx_skill_note fail-loud guard threw during plugin init when the plugin is disabled (enabled:false OR conflict-disabled) — createSessionHooks returns {magicContext:null} by design, so the unconditional guard crashed the entry module on the disabled path. Gate it on pluginConfig.enabled; pass a throwaway Map to createToolRegistry (which early-returns {} when disabled and never reads it). - Must (rev-3): singular ~/.config/opencode/skill/ global path was misclassified as project tier (opencode's pattern is {skill,skills}/**/SKILL.md) — fixed in deriveSkillTier/deriveSkillSource + the ctx_skill_recall cold-start search list. - Must (rev-3): the frontmatter parser rejected the inline flow-mapping form 'skill-memory: { enabled: true }' — the EXACT form the ctx_skill_recall remediation message and ARCHITECTURE/CONFIGURATION/README advertise. Added inline-mapping parsing so guidance and parser agree. - Should (consensus rev-1+rev-3): recallSkillMemoryBlock swallowed all errors silently — added a log() so FTS/blob corruption is diagnosable (still no-throw). Regression tests: inline frontmatter form (3 cases), singular skill/ global path (2 cases).
1 parent 0bf4f52 commit 942cd32

7 files changed

Lines changed: 123 additions & 4 deletions

File tree

packages/plugin/src/features/magic-context/skill-memory/frontmatter.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,30 @@ body`;
9191
expect(parseFrontmatterConfig(md)?.enabled).toBe(true);
9292
});
9393

94+
test("parses the inline flow-mapping form the docs/remediation advertise", () => {
95+
// ctx_skill_recall remediation + ARCHITECTURE/CONFIGURATION/README all tell
96+
// users to add `skill-memory: { enabled: true }`. The parser MUST accept it
97+
// or the guidance is dead-on-arrival.
98+
const cfg = parseFrontmatterConfig("---\nskill-memory: { enabled: true }\n---\n# Skill");
99+
expect(cfg).not.toBeNull();
100+
expect(cfg!.enabled).toBe(true);
101+
});
102+
103+
test("parses inline flow-mapping with multiple keys", () => {
104+
const cfg = parseFrontmatterConfig(
105+
"---\nskill-memory: { enabled: true, max_tokens: 2000, dedup_threshold: 0.8 }\n---\nbody",
106+
);
107+
expect(cfg!.enabled).toBe(true);
108+
expect(cfg!.max_tokens).toBe(2000);
109+
expect(cfg!.dedup_threshold).toBe(0.8);
110+
});
111+
112+
test("inline form with enabled: false stays inert", () => {
113+
expect(
114+
parseFrontmatterConfig("---\nskill-memory: { enabled: false }\n---\nbody"),
115+
).toBeNull();
116+
});
117+
94118
test("a plain quoted scalar still enables", () => {
95119
const md = `---\nskill-memory:\n enabled: "true"\n---\nbody`;
96120
expect(parseFrontmatterConfig(md)?.enabled).toBe(true);

packages/plugin/src/features/magic-context/skill-memory/frontmatter.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,25 @@ function extractSkillMemoryBlock(fmText: string): Record<string, unknown> | null
8383

8484
for (const line of lines) {
8585
if (!inSkillMemory) {
86+
// Inline flow-mapping form on the header line:
87+
// `skill-memory: { enabled: true, max_tokens: 2000 }`
88+
// This is the form the ctx_skill_recall remediation message and the
89+
// root docs advertise, so it MUST parse — otherwise a user following
90+
// the guidance silently gets an inert config. Parse the {...} body
91+
// into the same flat map the block form produces, then stop (a flow
92+
// mapping is self-contained on one line).
93+
const inlineMatch = line.match(/^skill-memory:\s*\{(.*)\}\s*(#.*)?$/);
94+
if (inlineMatch) {
95+
found = true;
96+
for (const pair of splitFlowEntries(inlineMatch[1])) {
97+
const sep = pair.indexOf(":");
98+
if (sep < 0) continue;
99+
const key = pair.slice(0, sep).trim();
100+
if (!/^\w+$/.test(key)) continue;
101+
result[key] = parseYamlScalar(pair.slice(sep + 1).trim());
102+
}
103+
break;
104+
}
86105
// Tolerate a trailing inline comment after the block header
87106
// (`skill-memory: # motor memory`), which is valid YAML.
88107
if (/^skill-memory:\s*(#.*)?$/.test(line)) {
@@ -105,6 +124,37 @@ function extractSkillMemoryBlock(fmText: string): Record<string, unknown> | null
105124
return found ? result : null;
106125
}
107126

127+
/**
128+
* Split a YAML flow-mapping body (the text inside `{...}`) on top-level commas,
129+
* leaving quoted segments intact. Minimal — the skill-memory config is a flat
130+
* map of scalar values, so we don't need nested {}/[] handling.
131+
*/
132+
function splitFlowEntries(body: string): string[] {
133+
const entries: string[] = [];
134+
let current = "";
135+
let quote: '"' | "'" | null = null;
136+
for (const ch of body) {
137+
if (quote) {
138+
current += ch;
139+
if (ch === quote) quote = null;
140+
continue;
141+
}
142+
if (ch === '"' || ch === "'") {
143+
quote = ch;
144+
current += ch;
145+
continue;
146+
}
147+
if (ch === ",") {
148+
entries.push(current);
149+
current = "";
150+
continue;
151+
}
152+
current += ch;
153+
}
154+
if (current.trim()) entries.push(current);
155+
return entries;
156+
}
157+
108158
function parseYamlScalar(raw: string): unknown {
109159
// Strip an inline `# comment` for UNQUOTED scalars (YAML requires whitespace
110160
// before the `#`). Quoted values keep their content verbatim so a literal

packages/plugin/src/features/magic-context/skill-memory/provenance.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,22 @@ describe("parseSkillProvenance", () => {
5151
expect(result!.tier).toBe("project");
5252
expect(result!.skillSource).toBe("opencode-project");
5353
});
54+
55+
test("handles the SINGULAR ~/.config/opencode/skill/ global path (OPENCODE_SKILL_PATTERN covers both)", () => {
56+
// Regression: opencode's pattern is {skill,skills}/**/SKILL.md, so the
57+
// global config dir resolves under singular `skill/` too. Before the fix,
58+
// deriveSkillTier classified it as project → notes written to the wrong
59+
// partition + cold recall couldn't find SKILL.md.
60+
const output = `Base directory for this skill: file://${HOME}/.config/opencode/skill/my-skill`;
61+
const result = parseSkillProvenance(output, "my-skill");
62+
expect(result!.tier).toBe("global");
63+
expect(result!.skillSource).toBe("opencode-global");
64+
});
65+
66+
test("handles the plural ~/.config/opencode/skills/ global path", () => {
67+
const output = `Base directory for this skill: file://${HOME}/.config/opencode/skills/my-skill`;
68+
const result = parseSkillProvenance(output, "my-skill");
69+
expect(result!.tier).toBe("global");
70+
expect(result!.skillSource).toBe("opencode-global");
71+
});
5472
});

packages/plugin/src/features/magic-context/skill-memory/provenance.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ export function deriveSkillTier(absDir: string): "project" | "global" {
4242
// ~/.claude/skills/ — via CLAUDE_EXTERNAL_DIR + skills/**/SKILL.md
4343
const home = (process.env.HOME ?? process.env.USERPROFILE ?? "").replace(/\\/g, "/");
4444
if (
45+
// opencode's OPENCODE_SKILL_PATTERN is `{skill,skills}/**/SKILL.md`, so
46+
// the global config dir resolves under BOTH singular and plural.
4547
absDir.startsWith(`${home}/.config/opencode/skills/`) ||
48+
absDir.startsWith(`${home}/.config/opencode/skill/`) ||
4649
absDir.startsWith(`${home}/.agents/skills/`) ||
4750
absDir.startsWith(`${home}/.claude/skills/`)
4851
) {
@@ -55,7 +58,11 @@ export function deriveSkillSource(
5558
absDir: string,
5659
): "opencode-project" | "opencode-global" | "claude-skills" | "agents-skills" {
5760
const home = (process.env.HOME ?? process.env.USERPROFILE ?? "").replace(/\\/g, "/");
58-
if (absDir.startsWith(`${home}/.config/opencode/skills/`)) return "opencode-global";
61+
if (
62+
absDir.startsWith(`${home}/.config/opencode/skills/`) ||
63+
absDir.startsWith(`${home}/.config/opencode/skill/`)
64+
)
65+
return "opencode-global";
5966
if (absDir.startsWith(`${home}/.claude/skills/`)) return "claude-skills";
6067
if (absDir.includes("/.agents/skills/")) return "agents-skills";
6168
// Both singular .opencode/skill/ and plural .opencode/skills/ are valid —

packages/plugin/src/features/magic-context/skill-memory/recall.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { log } from "../../../shared/logger";
12
import type { Database } from "../../../shared/sqlite";
23
import { cosineSimilarity } from "../memory/cosine-similarity";
34
import { embedTextForProject } from "../memory/embedding";
@@ -277,7 +278,14 @@ export async function recallSkillMemoryBlock(
277278
);
278279
const selected = budgetFill(ordered, maxTokens, maxPinned);
279280
return finalize("fts5-fallback", selected);
280-
} catch {
281+
} catch (err) {
282+
// Cache-safe + non-choking: never throw from recall (a thrown error here
283+
// would surface in the skill tool result). But log it — a broken FTS
284+
// table or bad vector blob would otherwise look identical to "no notes",
285+
// making durable skill-memory data loss undiagnosable.
286+
log(
287+
`[skill-memory] recallSkillMemoryBlock failed for skill "${opts.skill}": ${err instanceof Error ? err.message : String(err)}`,
288+
);
281289
return "";
282290
}
283291
}

packages/plugin/src/index.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,14 @@ const server: Plugin = async (ctx) => {
157157
// every note would return "No recent skill load found" — the exact
158158
// opposite of "fail loud". Catch a wiring regression at startup, not
159159
// at the first ctx_skill_note call from an agent.
160-
if (!hooks.magicContext?.skillLoadRegistry) {
160+
//
161+
// ONLY when the plugin is enabled: when disabled by config (`enabled: false`)
162+
// or by a detected conflict (sets enabled=false above), createSessionHooks
163+
// returns `{ magicContext: null }` by design and no tools are exposed, so a
164+
// missing registry is expected — throwing here would crash plugin init on
165+
// the disabled path (an entry-module throw — the exact load-crash class this
166+
// plugin must avoid).
167+
if (pluginConfig.enabled && !hooks.magicContext?.skillLoadRegistry) {
161168
throw new Error(
162169
"[magic-context] ctx_skill_note registration failed: " +
163170
"hooks.magicContext.skillLoadRegistry is missing. " +
@@ -167,7 +174,11 @@ const server: Plugin = async (ctx) => {
167174
const tools = createToolRegistry({
168175
ctx,
169176
pluginConfig,
170-
skillLoadRegistry: hooks.magicContext.skillLoadRegistry,
177+
// Disabled path: magicContext is null and createToolRegistry early-returns
178+
// {} without using the registry. Pass a throwaway Map so the argument
179+
// expression never dereferences null (createToolRegistry never reads it
180+
// when disabled).
181+
skillLoadRegistry: hooks.magicContext?.skillLoadRegistry ?? new Map(),
171182
});
172183

173184
// v22 deferred legacy-memory identity backfill. createSessionHooks() opens

packages/plugin/src/tools/ctx-skill-recall/tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ export function createCtxSkillRecallTool(deps: CtxSkillRecallToolDeps): ToolDefi
128128
`${projectDirectory}/.claude/skills/${args.skill}`,
129129
// Global dirs second
130130
`${home}/.config/opencode/skills/${args.skill}`, // via Global.Path.config + {skill,skills}/**/SKILL.md
131+
`${home}/.config/opencode/skill/${args.skill}`, // singular — OPENCODE_SKILL_PATTERN covers both
131132
`${home}/.agents/skills/${args.skill}`, // via AGENTS_EXTERNAL_DIR
132133
`${home}/.claude/skills/${args.skill}`, // via CLAUDE_EXTERNAL_DIR
133134
];

0 commit comments

Comments
 (0)