Skip to content

Commit 782b11b

Browse files
author
Tehan
committed
fix(skill-memory): address second review round (greptile P1 + cubic P2/P3)
- P1: recall now unions the skill's own partition with the global '*' partition (recallPartitionPredicate helper) so a PROJECT-LOCAL skill surfaces historian-written global notes — previously orphaned (tier='project' query never matched tier='global'/'*'). Write/dedup paths stay exact-partition. - Escape apostrophes in projectPath before SQL string interpolation in the distill prompt template. - Frontmatter regex tolerates a leading UTF-8 BOM / whitespace (still start-anchored). - TC: skill(<name>) marker emits the name VERBATIM when marker-safe, else drops it — never mutates the identity key (recall keys on raw input.name). - Fix misleading frontmatter test: now actually exercises a '#' inside a quoted scalar (preserved) vs unquoted (comment-stripped). Regression tests: project-local skill recalls a global historian note; agent project note + historian global note both surface for the same skill.
1 parent 30399d1 commit 782b11b

6 files changed

Lines changed: 130 additions & 21 deletions

File tree

packages/plugin/src/features/magic-context/dreamer/task-prompts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,7 @@ function buildDistillSkillMemoryPrompt(projectPath: string): string {
393393
SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) as pinned_count,
394394
SUM(CASE WHEN intent_embedding IS NULL OR delta_embedding IS NULL THEN 1 ELSE 0 END) as missing_embedding_count
395395
FROM skill_memory
396-
WHERE project_identity IN ('${projectPath}', '*')
396+
WHERE project_identity IN ('${projectPath.replace(/'/g, "''")}', '*')
397397
GROUP BY skill_id, tier
398398
ORDER BY note_count DESC
399399
LIMIT 20;

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

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

94-
test("does not strip a '#' inside a quoted scalar", () => {
94+
test("a plain quoted scalar still enables", () => {
9595
const md = `---\nskill-memory:\n enabled: "true"\n---\nbody`;
96-
// "true" (quoted) still enables; the quote-strip path runs after the
97-
// unquoted-only comment strip, so quoted values are untouched.
9896
expect(parseFrontmatterConfig(md)?.enabled).toBe(true);
9997
});
98+
99+
test("does NOT strip a '#' inside a quoted scalar (comment-strip is unquoted-only)", () => {
100+
// Quoted value containing a '#': the inline-comment strip must NOT fire,
101+
// so the value stays the literal "true # x" (≠ "true") and the config is
102+
// inert. If the '#' WERE wrongly stripped, it would collapse to "true"
103+
// and enable — so a passing assertion proves the '#' was preserved.
104+
const quoted = `---\nskill-memory:\n enabled: "true # x"\n---\nbody`;
105+
expect(parseFrontmatterConfig(quoted)).toBeNull();
106+
// Contrast: the SAME text UNquoted is a real inline comment → stripped → enables.
107+
const unquoted = `---\nskill-memory:\n enabled: true # x\n---\nbody`;
108+
expect(parseFrontmatterConfig(unquoted)?.enabled).toBe(true);
109+
});
100110
});

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ export interface SkillMemoryConfig {
1919
// Anchored to the very start of the file (NO `m` flag): frontmatter is only
2020
// valid as the first bytes of the document. With `m`, `^` matches any line
2121
// start, so a later `--- ... ---` block (e.g. a markdown horizontal rule) could
22-
// be misparsed as config.
23-
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/;
22+
// be misparsed as config. A leading UTF-8 BOM and leading whitespace/blank
23+
// lines are tolerated (`\uFEFF?\s*`) so an editor-saved SKILL.md with a BOM or a
24+
// stray blank first line still parses; this stays start-anchored because `\s*`
25+
// only spans leading whitespace before the first `---`, never a mid-document rule.
26+
const FRONTMATTER_REGEX = /^\uFEFF?\s*---\r?\n([\s\S]*?)\r?\n---/;
2427

2528
export function parseFrontmatterConfig(content: string): SkillMemoryConfig | null {
2629
try {

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

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,66 @@ describe("cross-project global recall", () => {
427427
closeQuietly(db);
428428
}
429429
});
430+
431+
test("a PROJECT-LOCAL skill surfaces a historian-written global note (P1 regression)", async () => {
432+
// Regression: promoteSkillObservations always writes tier='global'/'*',
433+
// but recall for a project-local skill uses scope='project'. Before the
434+
// union-on-recall fix, the project-tier query never matched the global
435+
// '*' rows, so historian notes were orphaned for project-local skills.
436+
const db = makeDb();
437+
try {
438+
promoteSkillObservations(db, "git:repoA", [
439+
{
440+
skillId: "tdd",
441+
kind: "discovery",
442+
lesson: "spike the parser before writing the plan",
443+
},
444+
]);
445+
// Recall as a PROJECT-tier skill (the failing case pre-fix).
446+
const block = await recallSkillMemoryBlock(db, {
447+
skill: "tdd",
448+
scope: "project",
449+
projectIdentity: "git:repoA",
450+
frontmatterConfig: cfg,
451+
});
452+
expect(block).toContain("spike the parser before writing the plan");
453+
} finally {
454+
closeQuietly(db);
455+
}
456+
});
457+
458+
test("a project-local AGENT note and a global HISTORIAN note both surface for the same skill", async () => {
459+
const db = makeDb();
460+
try {
461+
// Agent-written, project-tier note.
462+
insertSkillMemoryNote(db, {
463+
skillId: "tdd",
464+
resolvedPath: "/repo/.opencode/skills/tdd/SKILL.md",
465+
tier: "project",
466+
skillSource: "opencode-project",
467+
projectIdentity: "git:repoLocal",
468+
intent: "local lesson",
469+
kind: "gotcha",
470+
delta: "project-local agent note",
471+
normalizedHash: "pl1",
472+
createdAt: Date.now(),
473+
});
474+
// Historian-written, global note for the same skill.
475+
promoteSkillObservations(db, "git:repoLocal", [
476+
{ skillId: "tdd", kind: "discovery", lesson: "global historian note" },
477+
]);
478+
const block = await recallSkillMemoryBlock(db, {
479+
skill: "tdd",
480+
scope: "project",
481+
projectIdentity: "git:repoLocal",
482+
frontmatterConfig: cfg,
483+
});
484+
expect(block).toContain("project-local agent note");
485+
expect(block).toContain("global historian note");
486+
} finally {
487+
closeQuietly(db);
488+
}
489+
});
430490
});
431491

432492
describe("recallSkillMemoryBlock bumps recall_count for surfaced notes", () => {

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

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,37 @@ export function partitionKey(tier: "project" | "global", projectIdentity: string
1010
return tier === "global" ? "*" : projectIdentity;
1111
}
1212

13+
/**
14+
* RECALL-path partition predicate: a note is recallable for (tier, projectIdentity)
15+
* when it is either in the skill's OWN partition OR in the cross-project global
16+
* '*' partition (where historian-extracted lessons live). This is why a
17+
* project-local skill still surfaces global historian notes — without the global
18+
* branch, recall for a project-tier skill (`tier='project'`) would never match the
19+
* `tier='global'/project_identity='*'` rows that promoteSkillObservations writes.
20+
*
21+
* For a global-tier skill the two branches are identical (both `tier='global' AND
22+
* project_identity='*'`), so the OR is a harmless no-op — a row still matches once
23+
* (it's a row filter, not a join, so no duplication).
24+
*
25+
* WRITE/dedup paths (insert, findExistingNote, bumpHitCount, getDedupCandidates)
26+
* deliberately do NOT use this — they must target one exact partition.
27+
*
28+
* Returns `{ clause, binds }`; callers splice `clause` into the WHERE and spread
29+
* `binds` (tier, ownPartition) at the matching `?` positions.
30+
*/
31+
function recallPartitionPredicate(
32+
tier: "project" | "global",
33+
projectIdentity: string,
34+
columnPrefix = "",
35+
): { clause: string; binds: [string, string] } {
36+
const t = `${columnPrefix}tier`;
37+
const p = `${columnPrefix}project_identity`;
38+
return {
39+
clause: `((${t} = ? AND ${p} = ?) OR (${t} = 'global' AND ${p} = '*'))`,
40+
binds: [tier, partitionKey(tier, projectIdentity)],
41+
};
42+
}
43+
1344
export interface SkillMemoryNote {
1445
id: number;
1546
skill_id: string;
@@ -123,11 +154,12 @@ export function getSkillMemoryNotes(
123154
projectIdentity: string,
124155
limit: number,
125156
): SkillMemoryNote[] {
157+
const { clause, binds } = recallPartitionPredicate(tier, projectIdentity);
126158
return db
127159
.prepare(
128160
`SELECT *
129161
FROM skill_memory
130-
WHERE skill_id = ? AND tier = ? AND project_identity = ?
162+
WHERE skill_id = ? AND ${clause}
131163
ORDER BY
132164
pinned DESC,
133165
(
@@ -145,7 +177,7 @@ export function getSkillMemoryNotes(
145177
created_at DESC
146178
LIMIT ?`,
147179
)
148-
.all(skillId, tier, partitionKey(tier, projectIdentity), limit) as SkillMemoryNote[];
180+
.all(skillId, ...binds, limit) as SkillMemoryNote[];
149181
}
150182

151183
/**
@@ -249,13 +281,14 @@ export function getRankingCandidates(
249281
projectIdentity: string,
250282
limit: number,
251283
): SkillMemoryNote[] {
284+
const { clause, binds } = recallPartitionPredicate(tier, projectIdentity);
252285
return db
253286
.prepare(
254287
`SELECT * FROM skill_memory
255-
WHERE skill_id=? AND tier=? AND project_identity=?
288+
WHERE skill_id=? AND ${clause}
256289
ORDER BY COALESCE(last_used_at, created_at) DESC LIMIT ?`,
257290
)
258-
.all(skillId, tier, partitionKey(tier, projectIdentity), limit) as SkillMemoryNote[];
291+
.all(skillId, ...binds, limit) as SkillMemoryNote[];
259292
}
260293

261294
export function searchSkillMemoryFts(
@@ -271,15 +304,14 @@ export function searchSkillMemoryFts(
271304
`SELECT m.* FROM skill_memory_fts f
272305
JOIN skill_memory m ON m.id = f.rowid
273306
WHERE skill_memory_fts MATCH ?
274-
AND m.skill_id=? AND m.tier=? AND m.project_identity=?
307+
AND m.skill_id=? AND ${recallPartitionPredicate(tier, projectIdentity, "m.").clause}
275308
ORDER BY bm25(skill_memory_fts) ASC, COALESCE(m.last_used_at, m.created_at) DESC
276309
LIMIT ?`,
277310
)
278311
.all(
279312
matchQuery,
280313
skillId,
281-
tier,
282-
partitionKey(tier, projectIdentity),
314+
...recallPartitionPredicate(tier, projectIdentity, "m.").binds,
283315
limit,
284316
) as SkillMemoryNote[];
285317
}
@@ -290,13 +322,14 @@ export function getPinnedNotes(
290322
tier: "project" | "global",
291323
projectIdentity: string,
292324
): SkillMemoryNote[] {
325+
const { clause, binds } = recallPartitionPredicate(tier, projectIdentity);
293326
return db
294327
.prepare(
295328
`SELECT * FROM skill_memory
296-
WHERE skill_id=? AND tier=? AND project_identity=? AND pinned=1
329+
WHERE skill_id=? AND ${clause} AND pinned=1
297330
ORDER BY COALESCE(last_used_at, created_at) DESC`,
298331
)
299-
.all(skillId, tier, partitionKey(tier, projectIdentity)) as SkillMemoryNote[];
332+
.all(skillId, ...binds) as SkillMemoryNote[];
300333
}
301334

302335
export function getSkillMemoryStats(

packages/plugin/src/hooks/magic-context/read-session-formatting.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,17 @@ export function extractToolCallSummaries(parts: unknown[]): string[] {
7272

7373
// Skill tool: surface the skill name (input.name) before the description
7474
// fallback, which would otherwise mask it if metadata.description exists.
75-
// The name is an IDENTITY key (the historian extracts skill-id from this
76-
// marker), so do NOT truncate it. Sanitize newlines/control chars and a
77-
// stray ")" so the single-line `TC: skill(<name>)` marker can't be
78-
// corrupted — skill names are normally slugs, this is defensive only.
75+
// The name is an IDENTITY key — the historian extracts the skill-id from
76+
// this marker and recall keys on the raw input.name, so the marker name
77+
// MUST equal the raw name (no truncation, no mutation) or the stored id
78+
// won't match recall. Emit it VERBATIM when marker-safe; if it contains a
79+
// marker-breaking char (CR/LF/tab/")") — which a real skill directory name
80+
// never does — drop the name (`TC: skill`) rather than emit a corrupted or
81+
// mutated identity.
7982
if (p.tool === "skill") {
8083
const rawName = input && typeof input.name === "string" ? input.name : "";
81-
const name = rawName.replace(/[\r\n\t)]/g, " ").trim();
82-
summaries.push(name ? `TC: skill(${name})` : "TC: skill");
84+
const markerSafe = rawName !== "" && !/[\r\n\t)]/.test(rawName);
85+
summaries.push(markerSafe ? `TC: skill(${rawName})` : "TC: skill");
8386
continue;
8487
}
8588

0 commit comments

Comments
 (0)