Skip to content

Commit 4034019

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 563d0a2 commit 4034019

40 files changed

Lines changed: 972 additions & 1338 deletions

ARCHITECTURE.md

Lines changed: 8 additions & 5 deletions
Large diffs are not rendered by default.

assets/magic-context.schema.json

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,10 @@
392392
"refresh-primers": {
393393
"schedule": "0 3 * * *",
394394
"timeout_minutes": 20
395+
},
396+
"distill-skill-memory": {
397+
"schedule": "",
398+
"timeout_minutes": 20
395399
}
396400
},
397401
"type": "object",
@@ -959,6 +963,56 @@
959963
"minimum": 5
960964
}
961965
}
966+
},
967+
"distill-skill-memory": {
968+
"default": {
969+
"schedule": "",
970+
"timeout_minutes": 20
971+
},
972+
"type": "object",
973+
"properties": {
974+
"schedule": {
975+
"default": "",
976+
"type": "string",
977+
"description": "5-field cron schedule (e.g. \"0 3 * * *\"), or \"\" to disable this task."
978+
},
979+
"model": {
980+
"description": "Per-task model override (inherits dreamer.model)",
981+
"type": "string"
982+
},
983+
"fallback_models": {
984+
"description": "Per-task fallback chain (inherits dreamer.fallback_models)",
985+
"anyOf": [
986+
{
987+
"type": "string"
988+
},
989+
{
990+
"type": "array",
991+
"items": {
992+
"type": "string"
993+
}
994+
}
995+
]
996+
},
997+
"thinking_level": {
998+
"description": "Pi only: per-task thinking level",
999+
"type": "string",
1000+
"enum": [
1001+
"off",
1002+
"minimal",
1003+
"low",
1004+
"medium",
1005+
"high",
1006+
"xhigh"
1007+
]
1008+
},
1009+
"timeout_minutes": {
1010+
"default": 20,
1011+
"description": "Minutes allowed for this task before it is aborted",
1012+
"type": "number",
1013+
"minimum": 5
1014+
}
1015+
}
9621016
}
9631017
},
9641018
"description": "Per-task scheduling + model config. Each task has its own cron schedule and may override the dreamer-level model."

packages/cli/src/lib/dreamer-setup.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ describe("runDreamerSetup", () => {
7777
const prompts = new MockPrompts({
7878
confirms: [false],
7979
autos: ["x/y"],
80-
selects: Array(11).fill("cron:0 3 * * *"),
80+
selects: Array(12).fill("cron:0 3 * * *"),
8181
});
8282
const result = await runDreamerSetup(prompts, ["x/y"]);
8383
expect(result.tasks).toBeDefined();
84-
expect(Object.keys(result.tasks ?? {}).length).toBe(11);
84+
expect(Object.keys(result.tasks ?? {}).length).toBe(12);
8585
expect(result.tasks?.verify.schedule).toBe("0 3 * * *");
8686
expect(result.tasks?.curate.schedule).toBe("0 3 * * *");
8787
expect(result.tasks?.["classify-memories"].schedule).toBe("0 3 * * *");

packages/cli/src/lib/dreamer-setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const TASK_DESCRIPTIONS: Record<DreamTaskName, string> = {
3535
"review-user-memories": "Promote recurring behaviors into your user profile",
3636
"promote-primers": "Promote recurring project questions into Primers",
3737
"refresh-primers": "Refresh answers for active project Primers",
38+
"distill-skill-memory": "Opt-in: distills per-skill memory (merge/prune/promote)",
3839
};
3940

4041
/** v1-behavior-preserving default schedules (must match the Zod schema defaults). */
@@ -50,6 +51,7 @@ const DEFAULT_TASK_SCHEDULES: Record<DreamTaskName, string> = {
5051
"review-user-memories": "0 3 * * *",
5152
"promote-primers": "0 3 * * *",
5253
"refresh-primers": "0 3 * * *",
54+
"distill-skill-memory": "",
5355
};
5456

5557
const PRESET_CUSTOM = "__custom__";

packages/dashboard/src-tauri/src/config.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ pub fn resolve_project_config_path(project_path: &str) -> PathBuf {
2828
/// frontend DreamerTasksField list). The dashboard renders this fixed set so
2929
/// every project shows the same tasks regardless of its (possibly stale) per-
3030
/// project scheduler snapshot in task_schedule_state.
31-
pub const CANONICAL_DREAM_TASKS: [&str; 11] = [
31+
pub const CANONICAL_DREAM_TASKS: [&str; 12] = [
3232
"map-memories",
3333
"verify",
3434
"verify-broad",
@@ -40,6 +40,7 @@ pub const CANONICAL_DREAM_TASKS: [&str; 11] = [
4040
"review-user-memories",
4141
"promote-primers",
4242
"refresh-primers",
43+
"distill-skill-memory",
4344
];
4445

4546
/// Default cron per task (mirrors DEFAULT_TASK_SCHEDULES in the plugin schema and
@@ -58,6 +59,7 @@ pub fn default_task_schedule(task: &str) -> &'static str {
5859
"review-user-memories" => "0 3 * * *",
5960
"promote-primers" => "0 3 * * *",
6061
"refresh-primers" => "0 3 * * *",
62+
"distill-skill-memory" => "",
6163
_ => "",
6264
}
6365
}

packages/dashboard/src/components/ConfigEditor/DreamerTasksField.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,12 @@ export const TASKS: TaskMeta[] = [
100100
description: "Refresh answers for active project Primers",
101101
defaultSchedule: "0 3 * * *",
102102
},
103+
{
104+
name: "distill-skill-memory",
105+
label: "Distill skill memory",
106+
description: "Opt-in: distills per-skill memory (merge/prune/promote)",
107+
defaultSchedule: "",
108+
},
103109
];
104110

105111
const PRESETS: { label: string; cron: string }[] = [

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,11 @@ Off-hours maintenance (Dreamer) and on-demand prompt augmentation (Sidekick).
198198
| `dreamer.tasks.refresh-primers.fallback_models` | string \\| string[] || Per-task fallback chain (inherits dreamer.fallback_models) |
199199
| `dreamer.tasks.refresh-primers.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` || Pi only: per-task thinking level |
200200
| `dreamer.tasks.refresh-primers.timeout_minutes` | number (5–) | `20` | Minutes allowed for this task before it is aborted |
201+
| `dreamer.tasks.distill-skill-memory.schedule` | string | `""` | 5-field cron schedule (e.g. "0 3 * * *"), or "" to disable this task. |
202+
| `dreamer.tasks.distill-skill-memory.model` | string || Per-task model override (inherits dreamer.model) |
203+
| `dreamer.tasks.distill-skill-memory.fallback_models` | string \\| string[] || Per-task fallback chain (inherits dreamer.fallback_models) |
204+
| `dreamer.tasks.distill-skill-memory.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` || Pi only: per-task thinking level |
205+
| `dreamer.tasks.distill-skill-memory.timeout_minutes` | number (5–) | `20` | Minutes allowed for this task before it is aborted |
201206
| `dreamer.inject_docs` | boolean | `true` | Inject ARCHITECTURE.md and STRUCTURE.md into system prompt |
202207
| `dreamer.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` || Pi only: default thinking level for dreamer subagent invocations. See historian.thinking_level. |
203208
| `sidekick` | object || Optional sidekick agent configuration for session-start memory retrieval |

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: 31 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,
@@ -1101,6 +1102,28 @@ export async function runPiHistorian(deps: PiHistorianDeps): Promise<void> {
11011102
}
11021103
}
11031104

1105+
// Skill-memory historian extraction (Pi mirror of OpenCode): promote
1106+
// <skill_observations> into per-skill notes. Same promotionActive +
1107+
// !discardedLast gate as facts/primers so a provisional tail does not
1108+
// double-emit.
1109+
if (
1110+
promotionActive &&
1111+
!discardedLast &&
1112+
validatedPass.skillObservations &&
1113+
validatedPass.skillObservations.length > 0
1114+
) {
1115+
try {
1116+
const written = promoteSkillObservations(
1117+
db,
1118+
projectPath,
1119+
validatedPass.skillObservations,
1120+
);
1121+
sessionLog(sessionId, `promoted ${written} skill observation(s)`);
1122+
} catch (error) {
1123+
sessionLog(sessionId, "failed to promote skill observations:", error);
1124+
}
1125+
}
1126+
11041127
// Raw chunk embeddings: the ctx_search semantic substrate over session
11051128
// history. Fire-and-forget, best-effort, memory-gated.
11061129
if (embeddingActive) {
@@ -1262,6 +1285,13 @@ type ValidationOutcome =
12621285
? P
12631286
: never
12641287
: never;
1288+
skillObservations?: ReturnType<
1289+
typeof validateHistorianOutput
1290+
> extends infer T
1291+
? T extends { ok: true; skillObservations?: infer S }
1292+
? S
1293+
: never
1294+
: never;
12651295
events?: ReturnType<typeof validateHistorianOutput> extends infer T
12661296
? T extends { ok: true; events?: infer E }
12671297
? E
@@ -1304,6 +1334,7 @@ async function validateHistorianResult(
13041334
facts: validation.facts,
13051335
userObservations: validation.userObservations,
13061336
primerCandidates: validation.primerCandidates,
1337+
skillObservations: validation.skillObservations,
13071338
events: validation.events,
13081339
};
13091340
}
Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
11
import { describe, expect, test } from "bun:test";
2-
import { DEFAULT_DREAMER_TASKS, DREAMER_TASKS, DreamingTaskSchema } from "./magic-context";
2+
import {
3+
AGENTIC_DREAM_TASKS,
4+
CANONICAL_DREAM_TASKS,
5+
} from "../../features/magic-context/dreamer/task-registry";
6+
import { DREAMER_TASKS, DreamingTaskSchema, DreamTasksSchema } from "./magic-context";
37

48
describe("distill-skill-memory dreamer task", () => {
5-
test("distill-skill-memory is in DREAMER_TASKS enum", () => {
6-
expect(DREAMER_TASKS).toContain("distill-skill-memory");
9+
test("distill-skill-memory is a canonical dream task", () => {
10+
expect(CANONICAL_DREAM_TASKS).toContain("distill-skill-memory");
711
});
812

9-
test("distill-skill-memory is NOT in DEFAULT_DREAMER_TASKS (opt-in)", () => {
10-
expect(DEFAULT_DREAMER_TASKS).not.toContain("distill-skill-memory");
13+
test("distill-skill-memory is agentic (prompt-driven)", () => {
14+
expect(AGENTIC_DREAM_TASKS).toContain("distill-skill-memory");
15+
// DREAMER_TASKS (the schema enum) re-exports AGENTIC_DREAM_TASKS.
16+
expect(DREAMER_TASKS).toContain("distill-skill-memory");
1117
});
1218

1319
test("DreamingTaskSchema accepts distill-skill-memory", () => {
1420
expect(() => DreamingTaskSchema.parse("distill-skill-memory")).not.toThrow();
1521
});
1622

17-
test("maintain-docs is also not in DEFAULT_DREAMER_TASKS (precedent)", () => {
18-
// Verify the existing asymmetry pattern we're following
19-
expect(DEFAULT_DREAMER_TASKS).not.toContain("maintain-docs");
20-
expect(DREAMER_TASKS).toContain("maintain-docs");
23+
test("distill-skill-memory defaults to OFF (empty schedule = opt-in)", () => {
24+
// v2 model: a task is opt-in when its default schedule is "" (disabled).
25+
// Parse an explicit tasks object so the per-key defaults fire.
26+
const parsed = DreamTasksSchema.parse({});
27+
expect(parsed["distill-skill-memory"].schedule).toBe("");
28+
});
29+
30+
test("maintain-docs is also opt-in (empty default schedule) — precedent", () => {
31+
const parsed = DreamTasksSchema.parse({});
32+
expect(parsed["maintain-docs"].schedule).toBe("");
2133
});
2234
});

0 commit comments

Comments
 (0)