chapterhouse 0.3.26 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/api/server.js +12 -0
  2. package/dist/api/server.test.js +39 -0
  3. package/dist/config.js +70 -0
  4. package/dist/config.test.js +109 -0
  5. package/dist/copilot/agents.js +32 -6
  6. package/dist/copilot/agents.test.js +41 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +224 -3
  9. package/dist/copilot/orchestrator.test.js +380 -0
  10. package/dist/copilot/prompt-date.js +8 -0
  11. package/dist/copilot/system-message.js +8 -0
  12. package/dist/copilot/system-message.test.js +58 -0
  13. package/dist/copilot/tools.agent.test.js +24 -0
  14. package/dist/copilot/tools.js +351 -4
  15. package/dist/copilot/tools.memory.test.js +297 -0
  16. package/dist/copilot/turn-event-log-env.test.js +19 -0
  17. package/dist/copilot/turn-event-log.js +22 -23
  18. package/dist/copilot/turn-event-log.test.js +61 -2
  19. package/dist/memory/active-scope.js +69 -0
  20. package/dist/memory/active-scope.test.js +76 -0
  21. package/dist/memory/checkpoint-prompt.js +71 -0
  22. package/dist/memory/checkpoint.js +257 -0
  23. package/dist/memory/checkpoint.test.js +255 -0
  24. package/dist/memory/decisions.js +53 -0
  25. package/dist/memory/decisions.test.js +92 -0
  26. package/dist/memory/entities.js +59 -0
  27. package/dist/memory/entities.test.js +65 -0
  28. package/dist/memory/eot.js +219 -0
  29. package/dist/memory/eot.test.js +263 -0
  30. package/dist/memory/hot-tier.js +187 -0
  31. package/dist/memory/hot-tier.test.js +197 -0
  32. package/dist/memory/housekeeping.js +352 -0
  33. package/dist/memory/housekeeping.test.js +280 -0
  34. package/dist/memory/inbox.js +73 -0
  35. package/dist/memory/index.js +11 -0
  36. package/dist/memory/observations.js +46 -0
  37. package/dist/memory/observations.test.js +86 -0
  38. package/dist/memory/recall.js +210 -0
  39. package/dist/memory/recall.test.js +238 -0
  40. package/dist/memory/scopes.js +89 -0
  41. package/dist/memory/scopes.test.js +201 -0
  42. package/dist/memory/tiering.js +193 -0
  43. package/dist/memory/types.js +2 -0
  44. package/dist/paths.js +7 -1
  45. package/dist/store/db.js +412 -8
  46. package/dist/store/db.test.js +83 -0
  47. package/dist/test/setup-env.js +16 -0
  48. package/dist/test/setup-env.test.js +4 -0
  49. package/package.json +1 -1
  50. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  51. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  52. package/web/dist/index.html +1 -1
  53. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -0,0 +1,71 @@
1
+ function renderTurns(turns) {
2
+ return turns
3
+ .map((turn, index) => [
4
+ `Turn ${index + 1}`,
5
+ `User: ${turn.user}`,
6
+ `Assistant: ${turn.assistant}`,
7
+ ].join("\n"))
8
+ .join("\n\n");
9
+ }
10
+ function renderEntities(entities) {
11
+ if (entities.length === 0) {
12
+ return "- none";
13
+ }
14
+ return entities
15
+ .map((entity) => `- ${entity.name} (${entity.kind}): ${entity.summary ?? entity.name}`)
16
+ .join("\n");
17
+ }
18
+ function renderDecisions(decisions) {
19
+ if (decisions.length === 0) {
20
+ return "- none";
21
+ }
22
+ return decisions
23
+ .map((decision) => `- ${decision.title}: ${decision.rationale}`)
24
+ .join("\n");
25
+ }
26
+ export function buildCheckpointSystemPrompt() {
27
+ return [
28
+ "You extract durable agent memory from the most recent orchestrator turns.",
29
+ "Only return valid JSON. No prose, no markdown, no code fences.",
30
+ "Remember durable items only:",
31
+ "- decisions (architecture, process, user preferences)",
32
+ "- durable facts learned about code, tools, people, or infrastructure",
33
+ "- reusable gotchas or lessons learned",
34
+ "- named entities only when they are durable and worth remembering as an observation",
35
+ "Skip greetings, ephemeral progress updates, temporary branch names, one-off commit wording, and off-topic chatter.",
36
+ "Prefer concrete entries over vague summaries.",
37
+ "Use kind='decision' only when a real decision was made; otherwise use kind='observation'.",
38
+ "If nothing is worth remembering, return {\"proposals\":[]}.",
39
+ "JSON schema:",
40
+ "{\"proposals\":[{\"kind\":\"observation|decision\",\"title\":\"required for decision\",\"content\":\"string\",\"scope\":\"optional scope slug; omit to use active scope\",\"confidence\":0.0}]}",
41
+ "Example good output:",
42
+ "{\"proposals\":[{\"kind\":\"decision\",\"title\":\"Use SQLite FTS5 for recall\",\"content\":\"Chapterhouse uses SQLite FTS5 for scoped memory recall in v1.\",\"confidence\":0.93}]}",
43
+ "{\"proposals\":[{\"kind\":\"observation\",\"content\":\"Only orchestrator turns count toward memory checkpoints.\",\"confidence\":0.88}]}",
44
+ ].join("\n");
45
+ }
46
+ export function buildCheckpointUserPrompt(input) {
47
+ const scopeChangeBlock = input.scopeChangeContext
48
+ ? [
49
+ `Scope-change context: the user is moving from scope ${input.scopeChangeContext.from} to ${input.scopeChangeContext.to}.`,
50
+ `Extract everything worth remembering about scope ${input.scopeChangeContext.from} from the recent turns BEFORE the context shifts.`,
51
+ "",
52
+ ].join("\n")
53
+ : "";
54
+ return [
55
+ `Active scope: ${input.activeScope.slug} — ${input.activeScope.title}`,
56
+ `Scope description: ${input.activeScope.description}`,
57
+ "",
58
+ scopeChangeBlock,
59
+ "Known entities in the active scope:",
60
+ renderEntities(input.entities),
61
+ "",
62
+ "Known decisions in the active scope:",
63
+ renderDecisions(input.decisions),
64
+ "",
65
+ "Recent orchestrator turns:",
66
+ renderTurns(input.turns),
67
+ "",
68
+ "Return only the JSON object.",
69
+ ].join("\n");
70
+ }
71
+ //# sourceMappingURL=checkpoint-prompt.js.map
@@ -0,0 +1,257 @@
1
+ import { config } from "../config.js";
2
+ import { runOneShotPrompt } from "../copilot/oneshot.js";
3
+ import { childLogger } from "../util/logger.js";
4
+ import { recordDecision } from "./decisions.js";
5
+ import { listEntities } from "./entities.js";
6
+ import { recordObservation, listObservations } from "./observations.js";
7
+ import { getScope } from "./scopes.js";
8
+ import { listDecisions } from "./decisions.js";
9
+ import { buildCheckpointSystemPrompt, buildCheckpointUserPrompt, } from "./checkpoint-prompt.js";
10
+ const log = childLogger("memory.checkpoint");
11
+ const MAX_WRITES_PER_CHECKPOINT = 5;
12
+ const MIN_CONFIDENCE = 0.5;
13
+ const RECENT_MEMORY_LIMIT = 50;
14
+ const inFlightSessions = new Set();
15
+ function getInFlightKey(sessionKey) {
16
+ return sessionKey?.trim() || "__global__";
17
+ }
18
+ function normalizeText(value) {
19
+ return value
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9\s]/g, " ")
22
+ .replace(/\s+/g, " ")
23
+ .trim();
24
+ }
25
+ function calculateSimilarity(left, right) {
26
+ const normalizedLeft = normalizeText(left);
27
+ const normalizedRight = normalizeText(right);
28
+ if (!normalizedLeft || !normalizedRight) {
29
+ return 0;
30
+ }
31
+ if (normalizedLeft === normalizedRight) {
32
+ return 1;
33
+ }
34
+ if (normalizedLeft.includes(normalizedRight) || normalizedRight.includes(normalizedLeft)) {
35
+ return Math.min(normalizedLeft.length, normalizedRight.length) / Math.max(normalizedLeft.length, normalizedRight.length);
36
+ }
37
+ const leftTokens = new Set(normalizedLeft.split(" ").filter(Boolean));
38
+ const rightTokens = new Set(normalizedRight.split(" ").filter(Boolean));
39
+ if (leftTokens.size === 0 || rightTokens.size === 0) {
40
+ return 0;
41
+ }
42
+ let overlap = 0;
43
+ for (const token of leftTokens) {
44
+ if (rightTokens.has(token)) {
45
+ overlap++;
46
+ }
47
+ }
48
+ return overlap / new Set([...leftTokens, ...rightTokens]).size;
49
+ }
50
+ function parseProposals(raw) {
51
+ const parsed = JSON.parse(raw);
52
+ if (!Array.isArray(parsed.proposals)) {
53
+ return [];
54
+ }
55
+ return parsed.proposals.flatMap((proposal) => {
56
+ if (!proposal || typeof proposal !== "object") {
57
+ return [];
58
+ }
59
+ const candidate = proposal;
60
+ if ((candidate.kind !== "observation" && candidate.kind !== "decision")
61
+ || typeof candidate.content !== "string"
62
+ || typeof candidate.confidence !== "number") {
63
+ return [];
64
+ }
65
+ return [{
66
+ kind: candidate.kind,
67
+ title: typeof candidate.title === "string" ? candidate.title.trim() : undefined,
68
+ content: candidate.content.trim(),
69
+ scope: typeof candidate.scope === "string" ? candidate.scope.trim() : undefined,
70
+ confidence: candidate.confidence,
71
+ decided_at: typeof candidate.decided_at === "string" ? candidate.decided_at.trim() : undefined,
72
+ }];
73
+ });
74
+ }
75
+ function resolveProposalScope(proposal, activeScope) {
76
+ if (!proposal.scope || proposal.scope === activeScope.slug) {
77
+ return activeScope;
78
+ }
79
+ return getScope(proposal.scope) ?? null;
80
+ }
81
+ function isDuplicateObservation(content, scopeId, batchContents) {
82
+ const existing = listObservations({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
83
+ const combined = [...existing.map((observation) => observation.content), ...batchContents];
84
+ return combined.some((candidate) => calculateSimilarity(candidate, content) >= 0.85);
85
+ }
86
+ function isDuplicateDecision(proposal, scopeId) {
87
+ const existing = listDecisions({ scope_id: scopeId, limit: RECENT_MEMORY_LIMIT });
88
+ return existing.some((decision) => {
89
+ const titleSimilarity = proposal.title ? calculateSimilarity(decision.title, proposal.title) : 0;
90
+ const rationaleSimilarity = calculateSimilarity(decision.rationale, proposal.content);
91
+ return titleSimilarity >= 0.85 || rationaleSimilarity >= 0.85;
92
+ });
93
+ }
94
+ export class CheckpointTracker {
95
+ turnCountSinceLastFire = 0;
96
+ cadence;
97
+ enabled;
98
+ constructor(options = {}) {
99
+ const rawTurns = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS?.trim();
100
+ const envTurns = rawTurns && /^\d+$/.test(rawTurns) ? Number(rawTurns) : undefined;
101
+ const rawEnabled = process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED?.trim();
102
+ const envEnabled = rawEnabled === "0" ? false : rawEnabled === "1" ? true : undefined;
103
+ this.cadence = options.turns ?? envTurns ?? config.memoryCheckpointTurns;
104
+ this.enabled = options.enabled ?? envEnabled ?? config.memoryCheckpointEnabled;
105
+ }
106
+ tickOrchestratorTurn() {
107
+ this.turnCountSinceLastFire++;
108
+ }
109
+ shouldFire() {
110
+ return this.enabled && this.turnCountSinceLastFire >= this.cadence;
111
+ }
112
+ turnsSinceLastFire() {
113
+ return this.turnCountSinceLastFire;
114
+ }
115
+ markFired() {
116
+ this.turnCountSinceLastFire = 0;
117
+ }
118
+ markScopeChangeFire() {
119
+ this.turnCountSinceLastFire = 0;
120
+ }
121
+ reset() {
122
+ this.turnCountSinceLastFire = 0;
123
+ }
124
+ }
125
+ export function isCheckpointInFlight(sessionKey) {
126
+ if (sessionKey) {
127
+ return inFlightSessions.has(getInFlightKey(sessionKey));
128
+ }
129
+ return inFlightSessions.size > 0;
130
+ }
131
+ export async function runCheckpointExtraction(input) {
132
+ const model = input.model ?? config.copilotModel;
133
+ const inFlightKey = getInFlightKey(input.sessionKey);
134
+ const errors = [];
135
+ const trigger = input.trigger ?? "cadence";
136
+ if (!input.activeScope) {
137
+ log.info({ sessionKey: input.sessionKey ?? "default", reason: "no_scope" }, "memory.checkpoint.skip");
138
+ return { written: 0, skipped: 0, errors };
139
+ }
140
+ if (isCheckpointInFlight(input.sessionKey)) {
141
+ log.info({ sessionKey: input.sessionKey ?? "default" }, "memory.checkpoint.in_flight_skip");
142
+ return { written: 0, skipped: 0, errors };
143
+ }
144
+ inFlightSessions.add(inFlightKey);
145
+ try {
146
+ log.info({
147
+ turnCount: input.turns.length,
148
+ scope: input.activeScope.slug,
149
+ model,
150
+ sessionKey: input.sessionKey ?? "default",
151
+ trigger,
152
+ }, "memory.checkpoint.fire");
153
+ const entities = listEntities({ scope_id: input.activeScope.id, limit: 8 });
154
+ const decisions = listDecisions({ scope_id: input.activeScope.id, limit: 8 });
155
+ const system = buildCheckpointSystemPrompt();
156
+ const user = buildCheckpointUserPrompt({
157
+ turns: input.turns,
158
+ activeScope: input.activeScope,
159
+ entities,
160
+ decisions,
161
+ scopeChangeContext: input.scopeChangeContext ?? null,
162
+ });
163
+ const callLLM = input.callLLM
164
+ ?? (async ({ system: systemPrompt, user: userPrompt, model: chosenModel }) => {
165
+ const result = await runOneShotPrompt({
166
+ client: input.copilotClient,
167
+ model: chosenModel,
168
+ system: systemPrompt,
169
+ user: userPrompt,
170
+ expectJson: true,
171
+ });
172
+ return result.content;
173
+ });
174
+ const rawResponse = await callLLM({ system, user, model });
175
+ const proposals = parseProposals(rawResponse)
176
+ .filter((proposal) => proposal.content.length > 0)
177
+ .sort((left, right) => right.confidence - left.confidence);
178
+ log.info({
179
+ count: proposals.length,
180
+ scope: input.activeScope.slug,
181
+ sessionKey: input.sessionKey ?? "default",
182
+ }, "memory.checkpoint.proposal_count");
183
+ let written = 0;
184
+ let skipped = 0;
185
+ const pendingObservationContents = [];
186
+ for (const proposal of proposals) {
187
+ if (proposal.confidence < MIN_CONFIDENCE) {
188
+ skipped++;
189
+ log.info({ reason: "low_confidence", confidence: proposal.confidence, scope: input.activeScope.slug }, "memory.checkpoint.skip");
190
+ continue;
191
+ }
192
+ if (written >= MAX_WRITES_PER_CHECKPOINT) {
193
+ skipped++;
194
+ log.info({ reason: "cap_exceeded", scope: input.activeScope.slug }, "memory.checkpoint.skip");
195
+ continue;
196
+ }
197
+ const scope = resolveProposalScope(proposal, input.activeScope);
198
+ if (!scope) {
199
+ skipped++;
200
+ log.info({ reason: "no_scope", scope: proposal.scope ?? input.activeScope.slug }, "memory.checkpoint.skip");
201
+ continue;
202
+ }
203
+ if (proposal.kind === "observation" && isDuplicateObservation(proposal.content, scope.id, pendingObservationContents)) {
204
+ skipped++;
205
+ log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
206
+ continue;
207
+ }
208
+ if (proposal.kind === "decision") {
209
+ if (!proposal.title) {
210
+ skipped++;
211
+ log.info({ reason: "missing_title", scope_id: scope.id }, "memory.checkpoint.skip");
212
+ continue;
213
+ }
214
+ if (isDuplicateDecision(proposal, scope.id)) {
215
+ skipped++;
216
+ log.info({ reason: "duplicate", scope_id: scope.id }, "memory.checkpoint.skip");
217
+ continue;
218
+ }
219
+ const decision = recordDecision({
220
+ scope_id: scope.id,
221
+ title: proposal.title,
222
+ rationale: proposal.content,
223
+ decided_at: proposal.decided_at || new Date().toISOString().slice(0, 10),
224
+ tier: "warm",
225
+ });
226
+ written++;
227
+ log.info({ kind: "decision", scope_id: scope.id, id: decision.id }, "memory.checkpoint.write");
228
+ continue;
229
+ }
230
+ const observation = recordObservation({
231
+ scope_id: scope.id,
232
+ content: proposal.content,
233
+ source: "checkpoint:orchestrator",
234
+ tier: "warm",
235
+ confidence: proposal.confidence,
236
+ });
237
+ pendingObservationContents.push(proposal.content);
238
+ written++;
239
+ log.info({ kind: "observation", scope_id: scope.id, id: observation.id }, "memory.checkpoint.write");
240
+ }
241
+ return { written, skipped, errors };
242
+ }
243
+ catch (error) {
244
+ const message = error instanceof Error ? error.message : String(error);
245
+ errors.push(message);
246
+ log.error({
247
+ err: error,
248
+ sessionKey: input.sessionKey ?? "default",
249
+ scope: input.activeScope.slug,
250
+ }, "memory.checkpoint.error");
251
+ return { written: 0, skipped: 0, errors };
252
+ }
253
+ finally {
254
+ inFlightSessions.delete(inFlightKey);
255
+ }
256
+ }
257
+ //# sourceMappingURL=checkpoint.js.map
@@ -0,0 +1,255 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const sandboxRoot = join(repoRoot, ".test-work", `memory-checkpoint-${process.pid}`);
7
+ const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
8
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
9
+ function resetSandbox() {
10
+ mkdirSync(join(repoRoot, ".test-work"), { recursive: true });
11
+ rmSync(sandboxRoot, { recursive: true, force: true });
12
+ mkdirSync(chapterhouseHome, { recursive: true });
13
+ }
14
+ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
17
+ const checkpointModule = await import(new URL(`./checkpoint.js?case=${cacheBust}`, import.meta.url).href);
18
+ const checkpointPromptModule = await import(new URL(`./checkpoint-prompt.js?case=${cacheBust}`, import.meta.url).href);
19
+ return { dbModule, memoryModule, checkpointModule, checkpointPromptModule };
20
+ }
21
+ function getFunction(module, name) {
22
+ const value = module[name];
23
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
24
+ return value;
25
+ }
26
+ function getConstructor(module, name) {
27
+ const value = module[name];
28
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
29
+ return value;
30
+ }
31
+ test.beforeEach(async () => {
32
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED;
33
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE;
34
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE;
35
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
36
+ dbModule.closeDb();
37
+ resetSandbox();
38
+ });
39
+ test.after(async () => {
40
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED;
41
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE;
42
+ delete process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE;
43
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
44
+ dbModule.closeDb();
45
+ rmSync(sandboxRoot, { recursive: true, force: true });
46
+ });
47
+ test("CheckpointTracker increments on orchestrator turns, fires at cadence, and resets after markFired", async () => {
48
+ const { checkpointModule } = await loadModules();
49
+ const CheckpointTracker = getConstructor(checkpointModule, "CheckpointTracker");
50
+ const tracker = new CheckpointTracker({ turns: 3, enabled: true });
51
+ assert.equal(tracker.turnsSinceLastFire(), 0);
52
+ assert.equal(tracker.shouldFire(), false);
53
+ tracker.tickOrchestratorTurn();
54
+ assert.equal(tracker.turnsSinceLastFire(), 1);
55
+ assert.equal(tracker.shouldFire(), false);
56
+ tracker.tickOrchestratorTurn();
57
+ assert.equal(tracker.turnsSinceLastFire(), 2);
58
+ assert.equal(tracker.shouldFire(), false);
59
+ tracker.tickOrchestratorTurn();
60
+ assert.equal(tracker.turnsSinceLastFire(), 3);
61
+ assert.equal(tracker.shouldFire(), true);
62
+ tracker.markFired();
63
+ assert.equal(tracker.turnsSinceLastFire(), 0);
64
+ assert.equal(tracker.shouldFire(), false);
65
+ tracker.tickOrchestratorTurn();
66
+ assert.equal(tracker.turnsSinceLastFire(), 1);
67
+ assert.equal(tracker.shouldFire(), false);
68
+ tracker.markScopeChangeFire();
69
+ assert.equal(tracker.turnsSinceLastFire(), 0);
70
+ tracker.reset();
71
+ assert.equal(tracker.turnsSinceLastFire(), 0);
72
+ assert.equal(tracker.shouldFire(), false);
73
+ });
74
+ test("CheckpointTracker stays disabled when CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED=0", async () => {
75
+ process.env.CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED = "0";
76
+ const { checkpointModule } = await loadModules("disabled");
77
+ const CheckpointTracker = getConstructor(checkpointModule, "CheckpointTracker");
78
+ const tracker = new CheckpointTracker();
79
+ for (let index = 0; index < 10; index++) {
80
+ tracker.tickOrchestratorTurn();
81
+ }
82
+ assert.equal(tracker.shouldFire(), false);
83
+ });
84
+ test("runCheckpointExtraction writes high-confidence memories, skips low-confidence duplicates, and caps at five writes", async () => {
85
+ const { dbModule, memoryModule, checkpointModule } = await loadModules();
86
+ dbModule.getDb();
87
+ const getScope = getFunction(memoryModule, "getScope");
88
+ const recordObservation = getFunction(memoryModule, "recordObservation");
89
+ const listObservations = getFunction(memoryModule, "listObservations");
90
+ const listDecisions = getFunction(memoryModule, "listDecisions");
91
+ const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
92
+ const chapterhouse = getScope("chapterhouse");
93
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
94
+ recordObservation({
95
+ scope_id: chapterhouse.id,
96
+ content: "Base new memory implementation branches from origin/main after dependent PRs merge.",
97
+ source: "agent:test",
98
+ });
99
+ const result = await runCheckpointExtraction({
100
+ turns: [
101
+ {
102
+ user: "Implement checkpoint extraction after the response is committed.",
103
+ assistant: "I will add an async checkpoint pass with a five-turn cadence.",
104
+ },
105
+ ],
106
+ activeScope: chapterhouse,
107
+ copilotClient: {},
108
+ callLLM: async () => JSON.stringify({
109
+ proposals: [
110
+ {
111
+ kind: "observation",
112
+ content: "Base new memory implementation branches from origin/main after dependent PRs merge.",
113
+ confidence: 0.99,
114
+ },
115
+ {
116
+ kind: "decision",
117
+ title: "Run checkpoint extraction asynchronously",
118
+ content: "Checkpoint extraction runs in the background after the assistant response completes so the next user turn is never blocked.",
119
+ confidence: 0.98,
120
+ },
121
+ {
122
+ kind: "observation",
123
+ content: "Only orchestrator turns count toward the checkpoint cadence.",
124
+ confidence: 0.97,
125
+ },
126
+ {
127
+ kind: "observation",
128
+ content: "Checkpoint extraction skips overlapping runs instead of queueing them.",
129
+ confidence: 0.96,
130
+ },
131
+ {
132
+ kind: "observation",
133
+ content: "Checkpoint extraction defaults to the active scope when writing remembered items.",
134
+ confidence: 0.95,
135
+ },
136
+ {
137
+ kind: "observation",
138
+ content: "Checkpoint prompts include recent durable decisions already recorded in the active scope to avoid duplicates.",
139
+ confidence: 0.94,
140
+ },
141
+ {
142
+ kind: "observation",
143
+ content: "Checkpoint prompts stay under a tight token budget.",
144
+ confidence: 0.93,
145
+ },
146
+ {
147
+ kind: "observation",
148
+ content: "Low-confidence chatter about ephemeral commit messages should not be remembered.",
149
+ confidence: 0.49,
150
+ },
151
+ ],
152
+ }),
153
+ });
154
+ assert.deepEqual(result.errors, []);
155
+ assert.equal(result.written, 5);
156
+ assert.equal(result.skipped, 3);
157
+ assert.equal(listDecisions({ scope_id: chapterhouse.id }).some((row) => row.title === "Run checkpoint extraction asynchronously"), true);
158
+ assert.equal(listObservations({ scope_id: chapterhouse.id, limit: 20 }).filter((row) => row.content === "Base new memory implementation branches from origin/main after dependent PRs merge.").length, 1);
159
+ });
160
+ test("runCheckpointExtraction handles malformed JSON responses without crashing", async () => {
161
+ const { dbModule, memoryModule, checkpointModule } = await loadModules();
162
+ dbModule.getDb();
163
+ const getScope = getFunction(memoryModule, "getScope");
164
+ const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
165
+ const chapterhouse = getScope("chapterhouse");
166
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
167
+ const result = await runCheckpointExtraction({
168
+ turns: [{ user: "Remember this.", assistant: "Okay." }],
169
+ activeScope: chapterhouse,
170
+ copilotClient: {},
171
+ callLLM: async () => "{ definitely-not-json",
172
+ });
173
+ assert.equal(result.written, 0);
174
+ assert.ok(result.errors.length >= 1);
175
+ });
176
+ test("runCheckpointExtraction prevents overlapping executions with the in-flight guard", async () => {
177
+ const { dbModule, memoryModule, checkpointModule } = await loadModules();
178
+ dbModule.getDb();
179
+ const getScope = getFunction(memoryModule, "getScope");
180
+ const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
181
+ const isCheckpointInFlight = getFunction(checkpointModule, "isCheckpointInFlight");
182
+ const chapterhouse = getScope("chapterhouse");
183
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
184
+ let resolveFirst;
185
+ let llmCalls = 0;
186
+ const pendingResponse = new Promise((resolve) => {
187
+ resolveFirst = resolve;
188
+ });
189
+ const firstRun = runCheckpointExtraction({
190
+ turns: [{ user: "First", assistant: "Turn" }],
191
+ activeScope: chapterhouse,
192
+ copilotClient: {},
193
+ callLLM: async () => {
194
+ llmCalls++;
195
+ return await pendingResponse;
196
+ },
197
+ });
198
+ assert.equal(isCheckpointInFlight(), true);
199
+ const secondRun = await runCheckpointExtraction({
200
+ turns: [{ user: "Second", assistant: "Turn" }],
201
+ activeScope: chapterhouse,
202
+ copilotClient: {},
203
+ callLLM: async () => {
204
+ llmCalls++;
205
+ return JSON.stringify({ proposals: [] });
206
+ },
207
+ });
208
+ assert.equal(secondRun.written, 0);
209
+ assert.equal(llmCalls, 1);
210
+ resolveFirst(JSON.stringify({ proposals: [] }));
211
+ await firstRun;
212
+ assert.equal(isCheckpointInFlight(), false);
213
+ });
214
+ test("buildCheckpointUserPrompt adds scope-change instructions when context is provided", async () => {
215
+ const { checkpointPromptModule } = await loadModules("scope-change-prompt");
216
+ const buildCheckpointUserPrompt = getFunction(checkpointPromptModule, "buildCheckpointUserPrompt");
217
+ const prompt = buildCheckpointUserPrompt({
218
+ turns: [{ user: "Wrap PR 4.5", assistant: "Switching to wiki lint next." }],
219
+ activeScope: {
220
+ id: 1,
221
+ slug: "chapterhouse",
222
+ title: "Chapterhouse",
223
+ description: "Core Chapterhouse work.",
224
+ },
225
+ entities: [],
226
+ decisions: [],
227
+ scopeChangeContext: { from: "chapterhouse", to: "wiki" },
228
+ });
229
+ assert.match(prompt, /moving from scope chapterhouse to wiki/i);
230
+ assert.match(prompt, /extract everything worth remembering about scope chapterhouse/i);
231
+ });
232
+ test("runCheckpointExtraction passes scope-change context through to the prompt", async () => {
233
+ const { dbModule, memoryModule, checkpointModule } = await loadModules("scope-change-context");
234
+ dbModule.getDb();
235
+ const getScope = getFunction(memoryModule, "getScope");
236
+ const runCheckpointExtraction = getFunction(checkpointModule, "runCheckpointExtraction");
237
+ const chapterhouse = getScope("chapterhouse");
238
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
239
+ let capturedUserPrompt = "";
240
+ const result = await runCheckpointExtraction({
241
+ turns: [{ user: "Finish memory work", assistant: "Next I will move to wiki cleanup." }],
242
+ activeScope: chapterhouse,
243
+ copilotClient: {},
244
+ trigger: "scope_change",
245
+ scopeChangeContext: { from: "chapterhouse", to: "wiki" },
246
+ callLLM: async ({ user }) => {
247
+ capturedUserPrompt = user;
248
+ return JSON.stringify({ proposals: [] });
249
+ },
250
+ });
251
+ assert.deepEqual(result.errors, []);
252
+ assert.match(capturedUserPrompt, /moving from scope chapterhouse to wiki/i);
253
+ assert.match(capturedUserPrompt, /before the context shifts/i);
254
+ });
255
+ //# sourceMappingURL=checkpoint.test.js.map
@@ -0,0 +1,53 @@
1
+ import { getDb } from "../store/db.js";
2
+ function toDecision(row) {
3
+ return {
4
+ id: row.id,
5
+ scopeId: row.scope_id,
6
+ entityId: row.entity_id ?? undefined,
7
+ title: row.title,
8
+ rationale: row.rationale,
9
+ decidedAt: row.decided_at,
10
+ tier: row.tier,
11
+ supersededBy: row.superseded_by ?? undefined,
12
+ archivedAt: row.archived_at ?? undefined,
13
+ createdAt: row.created_at,
14
+ };
15
+ }
16
+ export function recordDecision(input) {
17
+ const result = getDb().prepare(`
18
+ INSERT INTO mem_decisions (scope_id, entity_id, title, rationale, decided_at, tier, created_at)
19
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
20
+ `).run(input.scope_id, input.entity_id ?? null, input.title, input.rationale, input.decided_at ?? new Date().toISOString().slice(0, 10), input.tier ?? "warm");
21
+ return getDecision(Number(result.lastInsertRowid));
22
+ }
23
+ export function getDecision(id) {
24
+ const row = getDb().prepare(`
25
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
26
+ FROM mem_decisions
27
+ WHERE id = ?
28
+ `).get(id);
29
+ return row ? toDecision(row) : undefined;
30
+ }
31
+ export function listDecisions(input = {}) {
32
+ const rows = getDb().prepare(`
33
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
34
+ FROM mem_decisions
35
+ WHERE (? IS NULL OR scope_id = ?)
36
+ AND (? IS NULL OR entity_id = ?)
37
+ ORDER BY decided_at DESC, id DESC
38
+ LIMIT ? OFFSET ?
39
+ `).all(input.scope_id ?? null, input.scope_id ?? null, input.entity_id ?? null, input.entity_id ?? null, input.limit ?? 50, input.offset ?? 0);
40
+ return rows.map(toDecision);
41
+ }
42
+ export function supersedeDecision(id, supersededByDecisionId) {
43
+ const result = getDb().prepare(`
44
+ UPDATE mem_decisions
45
+ SET superseded_by = ?
46
+ WHERE id = ?
47
+ `).run(supersededByDecisionId, id);
48
+ if (result.changes === 0) {
49
+ throw new Error(`Unknown decision id '${id}'.`);
50
+ }
51
+ return getDecision(id);
52
+ }
53
+ //# sourceMappingURL=decisions.js.map