Skip to content

Commit dccab5e

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 8c81c5a commit dccab5e

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
@@ -396,6 +396,10 @@
396396
"refresh-primers": {
397397
"schedule": "0 3 * * *",
398398
"timeout_minutes": 20
399+
},
400+
"distill-skill-memory": {
401+
"schedule": "",
402+
"timeout_minutes": 20
399403
}
400404
},
401405
"type": "object",
@@ -963,6 +967,56 @@
963967
"minimum": 5
964968
}
965969
}
970+
},
971+
"distill-skill-memory": {
972+
"default": {
973+
"schedule": "",
974+
"timeout_minutes": 20
975+
},
976+
"type": "object",
977+
"properties": {
978+
"schedule": {
979+
"default": "",
980+
"type": "string",
981+
"description": "5-field cron schedule (e.g. \"0 3 * * *\"), or \"\" to disable this task."
982+
},
983+
"model": {
984+
"description": "Per-task model override (inherits dreamer.model)",
985+
"type": "string"
986+
},
987+
"fallback_models": {
988+
"description": "Per-task fallback chain (inherits dreamer.fallback_models)",
989+
"anyOf": [
990+
{
991+
"type": "string"
992+
},
993+
{
994+
"type": "array",
995+
"items": {
996+
"type": "string"
997+
}
998+
}
999+
]
1000+
},
1001+
"thinking_level": {
1002+
"description": "Pi only: per-task thinking level",
1003+
"type": "string",
1004+
"enum": [
1005+
"off",
1006+
"minimal",
1007+
"low",
1008+
"medium",
1009+
"high",
1010+
"xhigh"
1011+
]
1012+
},
1013+
"timeout_minutes": {
1014+
"default": 20,
1015+
"description": "Minutes allowed for this task before it is aborted",
1016+
"type": "number",
1017+
"minimum": 5
1018+
}
1019+
}
9661020
}
9671021
},
9681022
"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
@@ -199,6 +199,11 @@ Off-hours maintenance (Dreamer) and on-demand prompt augmentation (Sidekick).
199199
| `dreamer.tasks.refresh-primers.fallback_models` | string \\| string[] || Per-task fallback chain (inherits dreamer.fallback_models) |
200200
| `dreamer.tasks.refresh-primers.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` || Pi only: per-task thinking level |
201201
| `dreamer.tasks.refresh-primers.timeout_minutes` | number (5–) | `20` | Minutes allowed for this task before it is aborted |
202+
| `dreamer.tasks.distill-skill-memory.schedule` | string | `""` | 5-field cron schedule (e.g. "0 3 * * *"), or "" to disable this task. |
203+
| `dreamer.tasks.distill-skill-memory.model` | string || Per-task model override (inherits dreamer.model) |
204+
| `dreamer.tasks.distill-skill-memory.fallback_models` | string \\| string[] || Per-task fallback chain (inherits dreamer.fallback_models) |
205+
| `dreamer.tasks.distill-skill-memory.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` || Pi only: per-task thinking level |
206+
| `dreamer.tasks.distill-skill-memory.timeout_minutes` | number (5–) | `20` | Minutes allowed for this task before it is aborted |
202207
| `dreamer.inject_docs` | boolean | `true` | Inject ARCHITECTURE.md and STRUCTURE.md into system prompt |
203208
| `dreamer.thinking_level` | `"off"` \\| `"minimal"` \\| `"low"` \\| `"medium"` \\| `"high"` \\| `"xhigh"` || Pi only: default thinking level for dreamer subagent invocations. See historian.thinking_level. |
204209
| `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
@@ -52,6 +52,7 @@ import {
5252
} from "@magic-context/core/features/magic-context/memory";
5353
import { resolveProjectIdentity } from "@magic-context/core/features/magic-context/memory/project-identity";
5454
import { getMemoriesByProject } from "@magic-context/core/features/magic-context/memory/storage-memory";
55+
import { promoteSkillObservations } from "@magic-context/core/features/magic-context/skill-memory/promote";
5556
import {
5657
clearEmergencyDrainLatch,
5758
clearEmergencyRecovery,
@@ -1114,6 +1115,28 @@ export async function runPiHistorian(deps: PiHistorianDeps): Promise<void> {
11141115
}
11151116
}
11161117

1118+
// Skill-memory historian extraction (Pi mirror of OpenCode): promote
1119+
// <skill_observations> into per-skill notes. Same promotionActive +
1120+
// !discardedLast gate as facts/primers so a provisional tail does not
1121+
// double-emit.
1122+
if (
1123+
promotionActive &&
1124+
!discardedLast &&
1125+
validatedPass.skillObservations &&
1126+
validatedPass.skillObservations.length > 0
1127+
) {
1128+
try {
1129+
const written = promoteSkillObservations(
1130+
db,
1131+
projectPath,
1132+
validatedPass.skillObservations,
1133+
);
1134+
sessionLog(sessionId, `promoted ${written} skill observation(s)`);
1135+
} catch (error) {
1136+
sessionLog(sessionId, "failed to promote skill observations:", error);
1137+
}
1138+
}
1139+
11171140
// Raw chunk embeddings: the ctx_search semantic substrate over session
11181141
// history. Fire-and-forget, best-effort, memory-gated.
11191142
if (embeddingActive) {
@@ -1275,6 +1298,13 @@ type ValidationOutcome =
12751298
? P
12761299
: never
12771300
: never;
1301+
skillObservations?: ReturnType<
1302+
typeof validateHistorianOutput
1303+
> extends infer T
1304+
? T extends { ok: true; skillObservations?: infer S }
1305+
? S
1306+
: never
1307+
: never;
12781308
events?: ReturnType<typeof validateHistorianOutput> extends infer T
12791309
? T extends { ok: true; events?: infer E }
12801310
? E
@@ -1317,6 +1347,7 @@ async function validateHistorianResult(
13171347
facts: validation.facts,
13181348
userObservations: validation.userObservations,
13191349
primerCandidates: validation.primerCandidates,
1350+
skillObservations: validation.skillObservations,
13201351
events: validation.events,
13211352
};
13221353
}
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)