Skip to content

Commit b992c2e

Browse files
author
Tehan
committed
feat(skill-memory): embeddings + intent-scoped recall (P2)
Upgrade recall from flat recency×hit to a multi-rung cascade: intent + model-matched embeddings → cosine blend across intent_embedding + delta_embedding (relevance/recency/hit weights tunable per skill via ranking_* frontmatter); intent + no model match → FTS5 fallback over the content-linked skill_memory_fts vtable; empty → flat fallback. - migration: delta_embedding + recall_count columns + skill_memory_fts FTS5 vtable. - embed-on-write in insertSkillMemoryNote; delta-only semantic dedup. - programmatic, no-LLM reembed pre-step for the distill-skill-memory dreamer task. - read-side recall_count (distinct from write-side hit_count). - canonical vector serde + dedup/ranking/FTS query helpers.
1 parent 2a39c7a commit b992c2e

21 files changed

Lines changed: 2510 additions & 104 deletions

packages/plugin/src/features/magic-context/dreamer/runner.ts

Lines changed: 1138 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -377,47 +377,32 @@ const STRUCTURE_TEMPLATE = `
377377
// ── Distill Skill Memory ───────────────────────────────────────────────────
378378

379379
function buildDistillSkillMemoryPrompt(projectPath: string): string {
380-
return `## Task: Distill Skill Memory
380+
return `## Task: Distill Skill Memory (P2 — read-only health report)
381381
382382
**Project:** ${projectPath}
383383
384-
### Goal
385-
Maintain the skill_memory table: merge near-duplicate notes, prune stale low-hit notes,
386-
promote recurring gotchas to pinned, enforce per-skill note caps.
384+
### Important context
385+
- Embedding refresh for NULL/stale vectors already ran programmatically BEFORE this prompt — no need to re-embed.
386+
- Merge (action="distill" + merge), prune, and promote are P3 / NOT YET IMPLEMENTED. Do NOT call ctx_skill_note with action="distill".
387387
388-
### Process
389-
1. Query skill_memory for skills with note_count > 20 (the distill threshold):
388+
### Your task: produce a short read-only summary of skill-memory corpus health
389+
1. Query aggregate counts and flag obvious issues:
390390
\`\`\`sql
391-
SELECT skill_id, tier, project_identity, COUNT(*) as note_count
391+
SELECT skill_id, tier, COUNT(*) as note_count,
392+
SUM(CASE WHEN pinned = 1 THEN 1 ELSE 0 END) as pinned_count,
393+
SUM(CASE WHEN intent_embedding IS NULL OR delta_embedding IS NULL THEN 1 ELSE 0 END) as missing_embedding_count
392394
FROM skill_memory
393395
WHERE project_identity = (SELECT project_identity FROM skill_memory LIMIT 1)
394-
GROUP BY skill_id, tier, project_identity
395-
HAVING note_count > 20
396+
GROUP BY skill_id, tier
396397
ORDER BY note_count DESC
397-
LIMIT 5;
398+
LIMIT 20;
398399
\`\`\`
399-
2. For each qualifying skill:
400-
a. List notes ordered by hit_count DESC, created_at DESC.
401-
b. Merge near-duplicate notes (same kind, similar delta — use judgment).
402-
Use ctx_skill_note with action="distill" and merge: [id, id].
403-
c. Prune notes with hit_count=0 AND created_at < now-30d (stale, never recalled).
404-
Use ctx_skill_note with action="distill" and prune: id.
405-
d. Promote notes with hit_count >= 5 to pinned=1 if not already pinned.
406-
Use ctx_skill_note with action="distill" and promote: id.
407-
e. If note count > 100 after pruning, archive oldest low-hit unpinned notes.
408-
3. Log a quality alert if >30% of kind='gotcha' notes appear to be general observations
409-
(not skill-specific). Use ctx_memory to record the alert.
410-
4. Process at most 5 skill groups per run (rotating by last_distilled_at).
411-
412-
### Tools available
413-
- ctx_skill_note (with action="distill" — dreamer-only action for merge/prune/promote)
414-
- Read, bash (for verification queries)
400+
2. Note any skills with >100 notes, >30% gotcha-kind notes, or obvious near-duplicates (same skill + kind + very similar delta text).
401+
3. Report findings as a short summary — no tool calls beyond read-only SQL queries.
415402
416403
### Success criteria
417-
- No skill has >100 notes in the project tier.
418-
- Pinned notes reflect genuinely recurring gotchas (hit_count >= 5).
419-
- Stale zero-hit notes older than 30 days are pruned.
420-
- Quality alert logged if >30% of gotcha notes are general-quality.`;
404+
- A concise health summary is logged (via ctx_memory) for the project maintainer to review.
405+
- No tool calls to unimplemented actions.`;
421406
}
422407

423408
// ── Dispatcher ─────────────────────────────────────────────────────────────
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { float32ArrayToBlob, toFloat32Array } from "./storage-memory-embeddings";
3+
4+
describe("vector serde round-trip", () => {
5+
test("Float32Array → blob → Float32Array preserves values", () => {
6+
const vec = new Float32Array([0.1, -0.5, 0.99, 0.0]);
7+
const blob = float32ArrayToBlob(vec);
8+
const back = toFloat32Array(blob);
9+
expect(Array.from(back)).toEqual(Array.from(vec));
10+
});
11+
});

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ function isEmbeddingRow(row: unknown): row is EmbeddingRow {
3737
);
3838
}
3939

40-
function toFloat32Array(blob: Uint8Array | ArrayBuffer): Float32Array {
40+
export function toFloat32Array(blob: Uint8Array | ArrayBuffer): Float32Array {
4141
if (blob instanceof Uint8Array) {
4242
const buffer = blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength);
4343
return new Float32Array(buffer);
@@ -46,6 +46,11 @@ function toFloat32Array(blob: Uint8Array | ArrayBuffer): Float32Array {
4646
return new Float32Array(blob.slice(0));
4747
}
4848

49+
/** Serialize a Float32Array to a SQLite BLOB (Buffer). Canonical — reuse, do not duplicate. */
50+
export function float32ArrayToBlob(vector: Float32Array): Buffer {
51+
return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
52+
}
53+
4954
function getSaveEmbeddingStatement(db: Database): PreparedStatement {
5055
let stmt = saveEmbeddingStatements.get(db);
5156
if (!stmt) {

packages/plugin/src/features/magic-context/migrations.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,12 @@ function assertForeignKeyIntegrity(db: Database, table?: string): void {
4747
}
4848
}
4949

50-
const MIGRATIONS: Migration[] = [
50+
function columnExists(db: Database, table: string, column: string): boolean {
51+
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name?: string }>;
52+
return rows.some((row) => row.name === column);
53+
}
54+
55+
export const MIGRATIONS: Migration[] = [
5156
{
5257
version: 1,
5358
description: "Merge session_notes + smart_notes into unified notes table",
@@ -1918,6 +1923,58 @@ const MIGRATIONS: Migration[] = [
19181923
`);
19191924
},
19201925
},
1926+
1927+
{
1928+
// Skill-memory P2: was v39/v43 across earlier rebases; renumbered to v51
1929+
// after upstream v0.27 took v42–v49 (skill-P1 is now v50).
1930+
version: 51,
1931+
description:
1932+
"Skill-memory P2: delta_embedding + recall_count columns + skill_memory_fts FTS5 vtable",
1933+
up: (db: Database) => {
1934+
// skill_memory is migration-only (created by v50); ALTER is safe here.
1935+
if (!columnExists(db, "skill_memory", "delta_embedding")) {
1936+
db.exec(`ALTER TABLE skill_memory ADD COLUMN delta_embedding BLOB;`);
1937+
}
1938+
1939+
// recall_count: read-side usage counter, bumped each time a note is surfaced
1940+
// in a recall block (distinct from hit_count, which is write-side re-record salience).
1941+
// Answers "which notes are recalled most". NOT_NULL+DEFAULT is valid in ALTER ADD COLUMN.
1942+
if (!columnExists(db, "skill_memory", "recall_count")) {
1943+
db.exec(
1944+
`ALTER TABLE skill_memory ADD COLUMN recall_count INTEGER NOT NULL DEFAULT 0;`,
1945+
);
1946+
}
1947+
1948+
// FTS5 over (intent, delta), content-linked to skill_memory — mirrors memories_fts.
1949+
db.exec(`
1950+
CREATE VIRTUAL TABLE IF NOT EXISTS skill_memory_fts USING fts5(
1951+
intent,
1952+
delta,
1953+
content='skill_memory',
1954+
content_rowid='id',
1955+
tokenize='porter unicode61'
1956+
);
1957+
1958+
CREATE TRIGGER IF NOT EXISTS skill_memory_ai AFTER INSERT ON skill_memory BEGIN
1959+
INSERT INTO skill_memory_fts(rowid, intent, delta) VALUES (new.id, new.intent, new.delta);
1960+
END;
1961+
1962+
CREATE TRIGGER IF NOT EXISTS skill_memory_ad AFTER DELETE ON skill_memory BEGIN
1963+
INSERT INTO skill_memory_fts(skill_memory_fts, rowid, intent, delta) VALUES ('delete', old.id, old.intent, old.delta);
1964+
END;
1965+
1966+
CREATE TRIGGER IF NOT EXISTS skill_memory_au AFTER UPDATE ON skill_memory BEGIN
1967+
INSERT INTO skill_memory_fts(skill_memory_fts, rowid, intent, delta) VALUES ('delete', old.id, old.intent, old.delta);
1968+
INSERT INTO skill_memory_fts(rowid, intent, delta) VALUES (new.id, new.intent, new.delta);
1969+
END;
1970+
`);
1971+
1972+
// Backfill the FTS index for any existing skill_memory rows. External-content FTS5 tables
1973+
// expose content rowids immediately, so a `NOT IN (SELECT rowid FROM …_fts)` guard is a no-op;
1974+
// the 'rebuild' command is the correct way to (re)populate an external-content index.
1975+
db.exec(`INSERT INTO skill_memory_fts(skill_memory_fts) VALUES('rebuild');`);
1976+
},
1977+
},
19211978
];
19221979

19231980
/**

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,29 @@ describe("parseFrontmatterConfig", () => {
4444
const content = `---\nskill-memory: true\n---\n# Skill`;
4545
expect(parseFrontmatterConfig(content)).toBeNull();
4646
});
47+
48+
test("parses flat ranking_* keys as numbers", () => {
49+
const md = `---
50+
skill-memory:
51+
enabled: true
52+
ranking_relevance: 0.7
53+
ranking_recency: 0.2
54+
ranking_hit: 0.1
55+
---
56+
body`;
57+
const cfg = parseFrontmatterConfig(md);
58+
expect(cfg?.ranking_relevance).toBe(0.7);
59+
expect(cfg?.ranking_recency).toBe(0.2);
60+
expect(cfg?.ranking_hit).toBe(0.1);
61+
});
62+
63+
test("ranking_* default to undefined when omitted (recall applies defaults)", () => {
64+
const md = `---
65+
skill-memory:
66+
enabled: true
67+
---
68+
body`;
69+
const cfg = parseFrontmatterConfig(md);
70+
expect(cfg?.ranking_relevance).toBeUndefined();
71+
});
4772
});

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export interface SkillMemoryConfig {
1111
max_tokens: number;
1212
max_pinned_tokens: number;
1313
dedup_threshold: number;
14+
ranking_relevance?: number;
15+
ranking_recency?: number;
16+
ranking_hit?: number;
1417
}
1518

1619
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/m;
@@ -32,6 +35,9 @@ export function parseFrontmatterConfig(content: string): SkillMemoryConfig | nul
3235
max_tokens: toNumber(skillMemoryBlock.max_tokens, 1500),
3336
max_pinned_tokens: toNumber(skillMemoryBlock.max_pinned_tokens, 4000),
3437
dedup_threshold: toNumber(skillMemoryBlock.dedup_threshold, 0.92),
38+
ranking_relevance: toOptionalNumber(skillMemoryBlock.ranking_relevance),
39+
ranking_recency: toOptionalNumber(skillMemoryBlock.ranking_recency),
40+
ranking_hit: toOptionalNumber(skillMemoryBlock.ranking_hit),
3541
};
3642
} catch {
3743
// Non-choke: malformed config = inert
@@ -48,6 +54,15 @@ function toNumber(value: unknown, defaultValue: number): number {
4854
return defaultValue;
4955
}
5056

57+
function toOptionalNumber(value: unknown): number | undefined {
58+
if (typeof value === "number" && Number.isFinite(value)) return value;
59+
if (typeof value === "string") {
60+
const parsed = Number(value);
61+
if (Number.isFinite(parsed)) return parsed;
62+
}
63+
return undefined;
64+
}
65+
5166
/**
5267
* Extract the `skill-memory:` sub-block from YAML frontmatter text.
5368
* Returns a flat key→value map of the block's immediate children.

0 commit comments

Comments
 (0)