Skip to content

Commit 5cc2f7c

Browse files
author
Tehan
committed
feat(skill-memory): historian auto-extraction pipeline (P3b)
Close the loop so the historian writes skill notes during compaction without an agent volunteering ctx_skill_note. - historian prompt emits a <skill_observations> block; parser extracts it; threaded through the validated historian result. - both runners (OpenCode + Pi) promote skill observations post-commit via the shared promoteSkillObservations helper, gated by promotionActive && !discardedLast, writing global '*' notes with source_type='historian'. - self-heal net: initializeDatabase re-creates skill_memory + ensureColumn so an upgraded DB recovers even if a migration row is lost.
1 parent ffb9414 commit 5cc2f7c

27 files changed

Lines changed: 833 additions & 181 deletions

packages/docs/src/content/docs/reference/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Off-hours maintenance (Dreamer) and on-demand prompt augmentation (Sidekick).
141141
| `dreamer.fallback_models` | string \\| string[] || Fallback model IDs if primary is unavailable |
142142
| `dreamer.schedule` | string | `"02:00-06:00"` | Scheduled window for overnight dreaming (e.g. '02:00-06:00') |
143143
| `dreamer.max_runtime_minutes` | number (10–) | `120` | Maximum runtime per dream session in minutes |
144-
| `dreamer.tasks` | `"consolidate"` \\| `"verify"` \\| `"archive-stale"` \\| `"improve"` \\| `"maintain-docs"`[] | `["consolidate","verify","archive-stale","improve"]` | Tasks to run during dreaming, in order |
144+
| `dreamer.tasks` | `"consolidate"` \\| `"verify"` \\| `"archive-stale"` \\| `"improve"` \\| `"maintain-docs"` \\| `"distill-skill-memory"`[] | `["consolidate","verify","archive-stale","improve"]` | Tasks to run during dreaming, in order |
145145
| `dreamer.task_timeout_minutes` | number (5–) | `20` | Minutes allocated per task before moving to next |
146146
| `dreamer.inject_docs` | boolean | `true` | Inject ARCHITECTURE.md and STRUCTURE.md into system prompt |
147147
| `dreamer.user_memories` | object || User memory pipeline: historian extracts behavior observations from each compartment run; dreamer reviews recurring patterns and promotes them to stable user memories injected into all sessions as <user-profile>. Requires dreamer to not be disabled for promotion to actually happen. Graduated from experimental in v0.14. Default: enabled. |

packages/pi-plugin/src/pi-historian-runner.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,25 @@ describe("runPiHistorian", () => {
393393
closeQuietly(db);
394394
}
395395
});
396+
it("promotes skillObservations as global '*' notes", async () => {
397+
const xml = `${successXml()}\n<skill_observations>\n* council | gotcha | fast aggregator\n</skill_observations>`;
398+
const { db } = await runHistorianWith({
399+
outputs: [xml],
400+
memoryEnabled: true,
401+
autoPromote: true,
402+
});
403+
try {
404+
const row = db
405+
.prepare(
406+
"SELECT project_identity, source_type FROM skill_memory WHERE skill_id='council'",
407+
)
408+
.get() as { project_identity: string; source_type: string } | undefined;
409+
expect(row?.project_identity).toBe("*");
410+
expect(row?.source_type).toBe("historian");
411+
} finally {
412+
closeQuietly(db);
413+
}
414+
});
396415
it("runs the Pi subagent, parses output, and publishes compartments and facts", async () => {
397416
const { db, runner } = await runHistorianWith({ outputs: [successXml()] });
398417
try {

packages/pi-plugin/src/pi-historian-runner.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import {
5151
} from "@magic-context/core/features/magic-context/memory";
5252
import { resolveProjectIdentity } from "@magic-context/core/features/magic-context/memory/project-identity";
5353
import { getMemoriesByProject } from "@magic-context/core/features/magic-context/memory/storage-memory";
54+
import { promoteSkillObservations } from "@magic-context/core/features/magic-context/skill-memory/promote";
5455
import {
5556
clearEmergencyDrainLatch,
5657
clearEmergencyRecovery,
@@ -1031,6 +1032,24 @@ export async function runPiHistorian(deps: PiHistorianDeps): Promise<void> {
10311032
}
10321033
}
10331034

1035+
if (
1036+
promotionActive &&
1037+
!discardedLast &&
1038+
validatedPass.skillObservations &&
1039+
validatedPass.skillObservations.length > 0
1040+
) {
1041+
try {
1042+
const written = promoteSkillObservations(
1043+
db,
1044+
projectPath,
1045+
validatedPass.skillObservations,
1046+
);
1047+
sessionLog(sessionId, `promoted ${written} skill observation(s)`);
1048+
} catch (error) {
1049+
sessionLog(sessionId, "failed to promote skill observations:", error);
1050+
}
1051+
}
1052+
10341053
// Raw chunk embeddings: the ctx_search semantic substrate over session
10351054
// history. Fire-and-forget, best-effort, memory-gated.
10361055
if (embeddingActive) {
@@ -1185,6 +1204,13 @@ type ValidationOutcome =
11851204
: never
11861205
: never;
11871206
userObservations?: string[];
1207+
skillObservations?: ReturnType<
1208+
typeof validateHistorianOutput
1209+
> extends infer T
1210+
? T extends { ok: true; skillObservations?: infer S }
1211+
? S
1212+
: never
1213+
: never;
11881214
events?: ReturnType<typeof validateHistorianOutput> extends infer T
11891215
? T extends { ok: true; events?: infer E }
11901216
? E
@@ -1226,6 +1252,7 @@ async function validateHistorianResult(
12261252
compartments: validation.compartments,
12271253
facts: validation.facts,
12281254
userObservations: validation.userObservations,
1255+
skillObservations: validation.skillObservations,
12291256
events: validation.events,
12301257
};
12311258
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
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, runMigrations } from "./migrations";
5+
import { initializeDatabase, LATEST_SUPPORTED_VERSION } from "./storage-db"; // ESM import (not require) — matches codebase pattern
6+
7+
function columnNames(db: Database, table: string): string[] {
8+
return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map(
9+
(c) => c.name,
10+
);
11+
}
12+
13+
function tableExists(db: Database, name: string): boolean {
14+
return Boolean(
15+
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name),
16+
);
17+
}
18+
19+
describe("migration v42 — skill_memory table", () => {
20+
test("creates skill_memory table with correct columns on fresh DB, idempotently", () => {
21+
const db = new Database(":memory:");
22+
try {
23+
initializeDatabase(db);
24+
runMigrations(db);
25+
runMigrations(db); // idempotency check
26+
27+
expect(tableExists(db, "skill_memory")).toBe(true);
28+
29+
const cols = columnNames(db, "skill_memory");
30+
expect(cols).toContain("id");
31+
expect(cols).toContain("skill_id");
32+
expect(cols).toContain("resolved_path");
33+
expect(cols).toContain("tier");
34+
expect(cols).toContain("skill_source");
35+
expect(cols).toContain("project_identity");
36+
expect(cols).toContain("intent");
37+
expect(cols).toContain("intent_embedding");
38+
expect(cols).toContain("embedding_model_version");
39+
expect(cols).toContain("kind");
40+
expect(cols).toContain("delta");
41+
expect(cols).toContain("tags");
42+
expect(cols).toContain("hit_count");
43+
expect(cols).toContain("pinned");
44+
expect(cols).toContain("normalized_hash");
45+
expect(cols).toContain("created_at");
46+
expect(cols).toContain("last_used_at");
47+
48+
expect(
49+
db
50+
.prepare("SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1")
51+
.get(),
52+
).toEqual({ version: LATEST_MIGRATION_VERSION });
53+
} finally {
54+
closeQuietly(db);
55+
}
56+
});
57+
58+
test("skill_memory CHECK constraints reject invalid tier and kind values", () => {
59+
const db = new Database(":memory:");
60+
try {
61+
initializeDatabase(db);
62+
runMigrations(db);
63+
64+
const insert = db.prepare(`
65+
INSERT INTO skill_memory
66+
(skill_id, resolved_path, tier, project_identity, intent, kind, delta, normalized_hash, hit_count, pinned, created_at)
67+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?)
68+
`);
69+
70+
// Valid row
71+
expect(() =>
72+
insert.run(
73+
"test-skill",
74+
"/path/SKILL.md",
75+
"project",
76+
"git:abc123",
77+
"test intent",
78+
"gotcha",
79+
"test delta",
80+
"hash1",
81+
Date.now(),
82+
),
83+
).not.toThrow();
84+
85+
// Invalid tier
86+
expect(() =>
87+
insert.run(
88+
"test-skill",
89+
"/path/SKILL.md",
90+
"invalid-tier",
91+
"git:abc123",
92+
"test intent",
93+
"gotcha",
94+
"test delta",
95+
"hash2",
96+
Date.now(),
97+
),
98+
).toThrow();
99+
100+
// Invalid kind
101+
expect(() =>
102+
insert.run(
103+
"test-skill",
104+
"/path/SKILL.md",
105+
"project",
106+
"git:abc123",
107+
"test intent",
108+
"general",
109+
"test delta",
110+
"hash3",
111+
Date.now(),
112+
),
113+
).toThrow();
114+
} finally {
115+
closeQuietly(db);
116+
}
117+
});
118+
119+
test("LATEST_SUPPORTED_VERSION equals LATEST_MIGRATION_VERSION after v42", () => {
120+
// This test will fail until storage-db.ts is bumped to 39.
121+
// Belt-and-braces: mirrors schema-version-fence.test.ts but is co-located with the migration.
122+
// If this feels redundant, keep it with this comment — co-location aids discoverability.
123+
// NOTE: use ESM import at the top of the file (not require()) to match codebase pattern.
124+
expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION);
125+
});
126+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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 columnNames(db: Database, table: string): string[] {
8+
return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map(
9+
(c) => c.name,
10+
);
11+
}
12+
function tableExists(db: Database, name: string): boolean {
13+
return Boolean(
14+
db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?").get(name),
15+
);
16+
}
17+
18+
describe("migration v43 — skill_memory embeddings + FTS", () => {
19+
test("fresh DB: delta_embedding column and skill_memory_fts exist, no throw", () => {
20+
const db = new Database(":memory:");
21+
try {
22+
initializeDatabase(db);
23+
runMigrations(db);
24+
runMigrations(db); // idempotency
25+
26+
expect(columnNames(db, "skill_memory")).toContain("delta_embedding");
27+
expect(tableExists(db, "skill_memory_fts")).toBe(true);
28+
} finally {
29+
closeQuietly(db);
30+
}
31+
});
32+
33+
test("FTS triggers keep skill_memory_fts in sync with skill_memory", () => {
34+
const db = new Database(":memory:");
35+
try {
36+
initializeDatabase(db);
37+
runMigrations(db);
38+
db.prepare(
39+
`INSERT INTO skill_memory
40+
(skill_id, resolved_path, tier, project_identity, intent, kind, delta, normalized_hash, hit_count, pinned, created_at)
41+
VALUES (?,?,?,?,?,?,?,?,0,0,?)`,
42+
).run(
43+
"s1",
44+
"/p/SKILL.md",
45+
"global",
46+
"git:abc",
47+
"fix a flaky auth test",
48+
"fix",
49+
"mock Date.now in auth tests",
50+
"h1",
51+
Date.now(),
52+
);
53+
54+
const hit = db
55+
.prepare(
56+
`SELECT m.id FROM skill_memory_fts f JOIN skill_memory m ON m.id = f.rowid
57+
WHERE skill_memory_fts MATCH ?`,
58+
)
59+
.get('"auth"');
60+
expect(hit).toBeTruthy();
61+
} finally {
62+
closeQuietly(db);
63+
}
64+
});
65+
66+
// requires `import { MIGRATIONS } from "./migrations";` (added above)
67+
test("v43 migration backfills FTS for rows that pre-existed v40", () => {
68+
const db = new Database(":memory:");
69+
try {
70+
initializeDatabase(db);
71+
// Build a PRE-v40 schema: apply every migration BELOW v40 directly via up() (runMigrations has no
72+
// target-version param). skill_memory is created at v39; the FTS table + delta_embedding do NOT exist yet.
73+
for (const m of MIGRATIONS.filter((x) => x.version < 43).sort(
74+
(a, b) => a.version - b.version,
75+
)) {
76+
m.up(db);
77+
}
78+
// Insert a row under the pre-v43 schema — no FTS table yet, so no AFTER-INSERT trigger indexes it.
79+
db.prepare(
80+
`INSERT INTO skill_memory
81+
(skill_id, resolved_path, tier, project_identity, intent, kind, delta, normalized_hash, hit_count, pinned, created_at)
82+
VALUES (?,?,?,?,?,?,?,?,0,0,?)`,
83+
).run(
84+
"s2",
85+
"/p/SKILL.md",
86+
"global",
87+
"git:abc",
88+
"handle oauth refresh",
89+
"fix",
90+
"rotate the token early",
91+
"h2",
92+
Date.now(),
93+
);
94+
// Apply ONLY v40's up() — its body must ALTER + create the FTS table + BACKFILL the pre-existing row.
95+
const v43 = MIGRATIONS.find((m) => m.version === 43);
96+
if (!v43) throw new Error("v40 migration not found");
97+
v43.up(db);
98+
const hit = db
99+
.prepare(
100+
`SELECT m.id FROM skill_memory_fts f JOIN skill_memory m ON m.id = f.rowid WHERE skill_memory_fts MATCH ?`,
101+
)
102+
.get('"oauth"');
103+
expect(hit).toBeTruthy();
104+
} finally {
105+
closeQuietly(db);
106+
}
107+
});
108+
109+
test("LATEST_SUPPORTED_VERSION equals LATEST_MIGRATION_VERSION after v43", () => {
110+
expect(LATEST_SUPPORTED_VERSION).toBe(LATEST_MIGRATION_VERSION);
111+
expect(LATEST_SUPPORTED_VERSION).toBe(44);
112+
});
113+
});

0 commit comments

Comments
 (0)