Skip to content

Commit 8c81c5a

Browse files
author
Tehan
committed
feat(skill-memory): historian-extraction foundation — global '*' unification (P3a)
Foundation for the historian to auto-capture skill notes cross-project. - surface the skill name in the historian chunk as a `TC: skill(<name>)` marker (the keystone — the tool input name was previously dropped). - migration: origin_project + source_type columns; unify global-tier notes under project_identity='*' (collision-merge) so a global note is one row recallable from any repo. - partitionKey helper routes global write/recall/reembed/stats through '*'; recall reads global-tier from '*' (cross-project); reembed sweeps '*'.
1 parent b992c2e commit 8c81c5a

15 files changed

Lines changed: 665 additions & 67 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { Database } from "../../shared/sqlite";
3+
import { closeQuietly } from "../../shared/sqlite-helpers";
4+
import { LATEST_MIGRATION_VERSION, MIGRATIONS, runMigrations } from "./migrations";
5+
import { initializeDatabase, LATEST_SUPPORTED_VERSION } from "./storage-db";
6+
7+
function migratedDb(): Database {
8+
const db = new Database(":memory:");
9+
initializeDatabase(db);
10+
runMigrations(db);
11+
return db;
12+
}
13+
14+
function insertGlobal(
15+
db: Database,
16+
skillId: string,
17+
projectIdentity: string,
18+
hash: string,
19+
opts: { hit?: number; recall?: number; lastUsed?: number | null; createdAt?: number } = {},
20+
): void {
21+
db.prepare(
22+
`INSERT INTO skill_memory (skill_id, resolved_path, tier, project_identity, intent, kind, delta, hit_count, recall_count, pinned, normalized_hash, created_at, last_used_at)
23+
VALUES (?, '/p', 'global', ?, 'i', 'fix', 'd-' || ?, ?, ?, 0, ?, ?, ?)`,
24+
).run(
25+
skillId,
26+
projectIdentity,
27+
hash,
28+
opts.hit ?? 0,
29+
opts.recall ?? 0,
30+
hash,
31+
opts.createdAt ?? Date.now(),
32+
opts.lastUsed ?? null,
33+
);
34+
}
35+
36+
describe("migration v41 — origin_project + source_type + global '*' unification", () => {
37+
test("LATEST_SUPPORTED_VERSION equals LATEST_MIGRATION_VERSION after v41", () => {
38+
expect(LATEST_SUPPORTED_VERSION).toBe(41);
39+
expect(LATEST_MIGRATION_VERSION).toBe(41);
40+
});
41+
42+
test("fresh DB has origin_project + source_type columns", () => {
43+
const db = migratedDb();
44+
try {
45+
const cols = (
46+
db.prepare("PRAGMA table_info(skill_memory)").all() as Array<{ name: string }>
47+
).map((r) => r.name);
48+
expect(cols).toContain("origin_project");
49+
expect(cols).toContain("source_type");
50+
} finally {
51+
closeQuietly(db);
52+
}
53+
});
54+
55+
test("singleton global note rewritten to '*' with origin_project preserved", () => {
56+
const db = new Database(":memory:");
57+
try {
58+
initializeDatabase(db);
59+
for (const m of MIGRATIONS.filter((m) => m.version <= 40)) m.up(db);
60+
insertGlobal(db, "council", "git:repoA", "h1");
61+
62+
const v41 = MIGRATIONS.find((m) => m.version === 41);
63+
expect(v41).toBeDefined();
64+
v41?.up(db);
65+
66+
const row = db
67+
.prepare(
68+
"SELECT project_identity, origin_project FROM skill_memory WHERE normalized_hash='h1'",
69+
)
70+
.get() as { project_identity: string; origin_project: string };
71+
expect(row.project_identity).toBe("*");
72+
expect(row.origin_project).toBe("git:repoA");
73+
} finally {
74+
closeQuietly(db);
75+
}
76+
});
77+
78+
test("collision-merge: same lesson in 2 repos → one '*' row, summed counters, MAX(last_used_at)", () => {
79+
const db = new Database(":memory:");
80+
try {
81+
initializeDatabase(db);
82+
for (const m of MIGRATIONS.filter((m) => m.version <= 40)) m.up(db);
83+
insertGlobal(db, "council", "git:repoA", "dup", {
84+
hit: 2,
85+
recall: 3,
86+
lastUsed: 1000,
87+
createdAt: 500,
88+
});
89+
insertGlobal(db, "council", "git:repoB", "dup", {
90+
hit: 5,
91+
recall: 1,
92+
lastUsed: 9000,
93+
createdAt: 800,
94+
});
95+
96+
MIGRATIONS.find((m) => m.version === 41)?.up(db);
97+
98+
const rows = db
99+
.prepare(
100+
"SELECT project_identity, hit_count, recall_count, last_used_at, created_at FROM skill_memory WHERE normalized_hash='dup'",
101+
)
102+
.all() as Array<{
103+
project_identity: string;
104+
hit_count: number;
105+
recall_count: number;
106+
last_used_at: number;
107+
created_at: number;
108+
}>;
109+
expect(rows.length).toBe(1);
110+
expect(rows[0].project_identity).toBe("*");
111+
expect(rows[0].hit_count).toBe(7);
112+
expect(rows[0].recall_count).toBe(4);
113+
expect(rows[0].last_used_at).toBe(9000);
114+
expect(rows[0].created_at).toBe(500);
115+
} finally {
116+
closeQuietly(db);
117+
}
118+
});
119+
120+
test("idempotent: re-running v41 up() does not double-process '*' rows", () => {
121+
const db = new Database(":memory:");
122+
try {
123+
initializeDatabase(db);
124+
for (const m of MIGRATIONS.filter((m) => m.version <= 40)) m.up(db);
125+
insertGlobal(db, "council", "git:repoA", "h1", { hit: 1 });
126+
127+
const v41 = MIGRATIONS.find((m) => m.version === 41);
128+
v41?.up(db);
129+
v41?.up(db);
130+
131+
const rows = db
132+
.prepare("SELECT hit_count FROM skill_memory WHERE normalized_hash='h1'")
133+
.all() as Array<{ hit_count: number }>;
134+
expect(rows.length).toBe(1);
135+
expect(rows[0].hit_count).toBe(1);
136+
} finally {
137+
closeQuietly(db);
138+
}
139+
});
140+
141+
test("FTS index consistent after collision-merge (no orphans)", () => {
142+
const db = new Database(":memory:");
143+
try {
144+
initializeDatabase(db);
145+
for (const m of MIGRATIONS.filter((m) => m.version <= 40)) m.up(db);
146+
insertGlobal(db, "council", "git:repoA", "dup");
147+
insertGlobal(db, "council", "git:repoB", "dup");
148+
149+
MIGRATIONS.find((m) => m.version === 41)?.up(db);
150+
151+
const ftsCount = db.prepare("SELECT COUNT(*) AS n FROM skill_memory_fts").get() as {
152+
n: number;
153+
};
154+
const rowCount = db.prepare("SELECT COUNT(*) AS n FROM skill_memory").get() as {
155+
n: number;
156+
};
157+
// Prove the merge actually happened (2 dup rows → 1) so the parity
158+
// assertion below isn't trivially true on a no-op merge.
159+
expect(rowCount.n).toBe(1);
160+
expect(ftsCount.n).toBe(rowCount.n);
161+
} finally {
162+
closeQuietly(db);
163+
}
164+
});
165+
166+
test("project-tier rows untouched", () => {
167+
const db = new Database(":memory:");
168+
try {
169+
initializeDatabase(db);
170+
for (const m of MIGRATIONS.filter((m) => m.version <= 40)) m.up(db);
171+
db.prepare(
172+
`INSERT INTO skill_memory (skill_id, resolved_path, tier, project_identity, intent, kind, delta, normalized_hash, created_at) VALUES ('s', '/p', 'project', 'git:repoA', 'i', 'fix', 'd', 'ph', 1)`,
173+
).run();
174+
175+
MIGRATIONS.find((m) => m.version === 41)?.up(db);
176+
177+
const row = db
178+
.prepare("SELECT project_identity FROM skill_memory WHERE normalized_hash='ph'")
179+
.get() as { project_identity: string };
180+
expect(row.project_identity).toBe("git:repoA");
181+
} finally {
182+
closeQuietly(db);
183+
}
184+
});
185+
});

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1975,6 +1975,78 @@ export const MIGRATIONS: Migration[] = [
19751975
db.exec(`INSERT INTO skill_memory_fts(skill_memory_fts) VALUES('rebuild');`);
19761976
},
19771977
},
1978+
{
1979+
// Skill-memory historian extraction: was v41/v44 across earlier rebases;
1980+
// renumbered to v52 after upstream v0.27 took v42–v49 (skill is now v50/51).
1981+
version: 52,
1982+
description:
1983+
"Skill-memory historian extraction: origin_project + source_type columns; unify global-tier notes under project_identity='*' (collision-merge)",
1984+
up: (db: Database) => {
1985+
db.transaction(() => {
1986+
if (!columnExists(db, "skill_memory", "origin_project")) {
1987+
db.exec(`ALTER TABLE skill_memory ADD COLUMN origin_project TEXT;`);
1988+
}
1989+
if (!columnExists(db, "skill_memory", "source_type")) {
1990+
db.exec(`ALTER TABLE skill_memory ADD COLUMN source_type TEXT;`);
1991+
}
1992+
1993+
// resolved_path stays TEXT NOT NULL; historian writes the '' sentinel
1994+
// (handled in storage layer, not here).
1995+
const groups = db
1996+
.prepare(
1997+
`SELECT skill_id, normalized_hash, COUNT(*) AS n, MIN(created_at) AS min_created,
1998+
SUM(hit_count) AS sum_hit, SUM(recall_count) AS sum_recall, MAX(last_used_at) AS max_used
1999+
FROM skill_memory
2000+
WHERE tier='global' AND project_identity != '*'
2001+
GROUP BY skill_id, normalized_hash HAVING COUNT(*) > 1`,
2002+
)
2003+
.all() as Array<{
2004+
skill_id: string;
2005+
normalized_hash: string;
2006+
n: number;
2007+
min_created: number;
2008+
sum_hit: number;
2009+
sum_recall: number;
2010+
max_used: number | null;
2011+
}>;
2012+
for (const g of groups) {
2013+
const survivor = db
2014+
.prepare(
2015+
`SELECT id, project_identity FROM skill_memory
2016+
WHERE skill_id=? AND normalized_hash=? AND tier='global' AND project_identity != '*'
2017+
ORDER BY created_at ASC, id ASC LIMIT 1`,
2018+
)
2019+
.get(g.skill_id, g.normalized_hash) as {
2020+
id: number;
2021+
project_identity: string;
2022+
};
2023+
db.prepare(
2024+
`DELETE FROM skill_memory WHERE skill_id=? AND normalized_hash=? AND tier='global' AND project_identity != '*' AND id != ?`,
2025+
).run(g.skill_id, g.normalized_hash, survivor.id);
2026+
db.prepare(
2027+
`UPDATE skill_memory SET hit_count=?, recall_count=?, last_used_at=?, origin_project=?, project_identity='*' WHERE id=?`,
2028+
).run(g.sum_hit, g.sum_recall, g.max_used, survivor.project_identity, survivor.id);
2029+
}
2030+
2031+
// Defensive (S4): drop any pre-'*' row whose (skill_id, normalized_hash)
2032+
// already has a '*' sibling. Dead code in normal flow — v41 is the only
2033+
// writer of '*' rows and runs atomically, so a pre-'*' row can't coexist
2034+
// with a '*' sibling after a clean run. It only fires if a prior v41 run
2035+
// was interrupted after creating some '*' rows but before finishing; in
2036+
// that case the '*' row is canonical and the leftover pre-'*' row is
2037+
// dropped rather than colliding on the singleton UPDATE below.
2038+
db.prepare(
2039+
`DELETE FROM skill_memory AS s
2040+
WHERE s.tier='global' AND s.project_identity != '*'
2041+
AND EXISTS (SELECT 1 FROM skill_memory g WHERE g.tier='global' AND g.project_identity='*' AND g.skill_id=s.skill_id AND g.normalized_hash=s.normalized_hash)`,
2042+
).run();
2043+
2044+
db.prepare(
2045+
`UPDATE skill_memory SET origin_project = project_identity, project_identity = '*' WHERE tier='global' AND project_identity != '*'`,
2046+
).run();
2047+
})();
2048+
},
2049+
},
19782050
];
19792051

19802052
/**
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { Database } from "../../../shared/sqlite";
3+
import { closeQuietly } from "../../../shared/sqlite-helpers";
4+
import { runMigrations } from "../migrations";
5+
import { initializeDatabase } from "../storage-db";
6+
import { promoteSkillObservations } from "./promote";
7+
8+
function makeDb(): Database {
9+
const db = new Database(":memory:");
10+
initializeDatabase(db);
11+
runMigrations(db);
12+
return db;
13+
}
14+
15+
describe("promoteSkillObservations", () => {
16+
test("direct-writes a global '*' note with historian provenance", () => {
17+
const db = makeDb();
18+
try {
19+
const n = promoteSkillObservations(db, "git:repoA", [
20+
{ skillId: "council", kind: "gotcha", lesson: "aggregator needs a fast model" },
21+
]);
22+
expect(n).toBe(1);
23+
const row = db.prepare("SELECT tier, project_identity, origin_project, source_type, resolved_path, kind FROM skill_memory").get() as Record<
24+
string,
25+
string
26+
>;
27+
expect(row.tier).toBe("global");
28+
expect(row.project_identity).toBe("*");
29+
expect(row.origin_project).toBe("git:repoA");
30+
expect(row.source_type).toBe("historian");
31+
expect(row.resolved_path).toBe("");
32+
expect(row.kind).toBe("gotcha");
33+
} finally {
34+
closeQuietly(db);
35+
}
36+
});
37+
38+
test("exact-hash duplicate bumps hit_count instead of inserting", () => {
39+
const db = makeDb();
40+
try {
41+
promoteSkillObservations(db, "git:repoA", [{ skillId: "council", kind: "fix", lesson: "same lesson" }]);
42+
const n = promoteSkillObservations(db, "git:repoB", [{ skillId: "council", kind: "fix", lesson: "same lesson" }]);
43+
expect(n).toBe(0);
44+
const rows = db.prepare("SELECT hit_count FROM skill_memory").all() as Array<{ hit_count: number }>;
45+
expect(rows.length).toBe(1);
46+
expect(rows[0].hit_count).toBe(1);
47+
} finally {
48+
closeQuietly(db);
49+
}
50+
});
51+
52+
test("rejects kind='general'", () => {
53+
const db = makeDb();
54+
try {
55+
const n = promoteSkillObservations(db, "git:repoA", [{ skillId: "council", kind: "general" as never, lesson: "x" }]);
56+
expect(n).toBe(0);
57+
} finally {
58+
closeQuietly(db);
59+
}
60+
});
61+
});
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { Database } from "../../../shared/sqlite";
2+
import { computeNormalizedHash } from "../memory/normalize-hash";
3+
import { bumpHitCount, findExistingNote, insertSkillMemoryNote, partitionKey } from "./storage";
4+
5+
const VALID_KINDS = new Set(["gotcha", "discovery", "fix", "workflow"]);
6+
7+
export interface SkillObservation {
8+
skillId: string;
9+
kind: "gotcha" | "discovery" | "fix" | "workflow";
10+
lesson: string;
11+
}
12+
13+
/**
14+
* Direct-write historian-extracted skill observations as GLOBAL-tier notes under
15+
* the '*' partition (source_type='historian', resolved_path='' sentinel). Hash-dedup:
16+
* an exact-hash match bumps hit_count instead of inserting. Returns the number of
17+
* NEW notes written (dups excluded). Best-effort per item: never throws.
18+
*/
19+
export function promoteSkillObservations(
20+
db: Database,
21+
originProject: string,
22+
observations: SkillObservation[],
23+
): number {
24+
let written = 0;
25+
const tier = "global" as const;
26+
const part = partitionKey(tier, originProject);
27+
28+
for (const obs of observations) {
29+
if (!obs.skillId || !obs.lesson || !VALID_KINDS.has(obs.kind)) continue;
30+
31+
try {
32+
const normalizedHash = computeNormalizedHash(obs.lesson);
33+
const existing = findExistingNote(db, obs.skillId, tier, part, normalizedHash);
34+
if (existing) {
35+
bumpHitCount(db, obs.skillId, tier, part, normalizedHash);
36+
continue;
37+
}
38+
39+
const id = insertSkillMemoryNote(db, {
40+
skillId: obs.skillId,
41+
resolvedPath: "",
42+
tier,
43+
skillSource: null,
44+
projectIdentity: part,
45+
originProject,
46+
sourceType: "historian",
47+
intent: obs.lesson,
48+
kind: obs.kind,
49+
delta: obs.lesson,
50+
normalizedHash,
51+
createdAt: Date.now(),
52+
});
53+
if (id !== null) written++;
54+
} catch {
55+
// Best-effort: one bad observation must not block the publish.
56+
}
57+
}
58+
59+
return written;
60+
}

0 commit comments

Comments
 (0)