chapterhouse 0.3.26 → 0.4.0

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 (52) 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 +27 -4
  6. package/dist/copilot/agents.test.js +7 -0
  7. package/dist/copilot/oneshot.js +54 -0
  8. package/dist/copilot/orchestrator.js +227 -3
  9. package/dist/copilot/orchestrator.test.js +372 -0
  10. package/dist/copilot/system-message.js +4 -0
  11. package/dist/copilot/system-message.test.js +24 -0
  12. package/dist/copilot/tools.agent.test.js +23 -0
  13. package/dist/copilot/tools.js +350 -4
  14. package/dist/copilot/tools.memory.test.js +248 -0
  15. package/dist/copilot/turn-event-log-env.test.js +19 -0
  16. package/dist/copilot/turn-event-log.js +22 -23
  17. package/dist/copilot/turn-event-log.test.js +61 -2
  18. package/dist/memory/active-scope.js +69 -0
  19. package/dist/memory/active-scope.test.js +76 -0
  20. package/dist/memory/checkpoint-prompt.js +71 -0
  21. package/dist/memory/checkpoint.js +257 -0
  22. package/dist/memory/checkpoint.test.js +255 -0
  23. package/dist/memory/decisions.js +53 -0
  24. package/dist/memory/decisions.test.js +92 -0
  25. package/dist/memory/entities.js +59 -0
  26. package/dist/memory/entities.test.js +65 -0
  27. package/dist/memory/eot.js +219 -0
  28. package/dist/memory/eot.test.js +263 -0
  29. package/dist/memory/hot-tier.js +187 -0
  30. package/dist/memory/hot-tier.test.js +197 -0
  31. package/dist/memory/housekeeping.js +352 -0
  32. package/dist/memory/housekeeping.test.js +280 -0
  33. package/dist/memory/inbox.js +73 -0
  34. package/dist/memory/index.js +11 -0
  35. package/dist/memory/observations.js +46 -0
  36. package/dist/memory/observations.test.js +86 -0
  37. package/dist/memory/recall.js +197 -0
  38. package/dist/memory/recall.test.js +196 -0
  39. package/dist/memory/scopes.js +89 -0
  40. package/dist/memory/scopes.test.js +201 -0
  41. package/dist/memory/tiering.js +193 -0
  42. package/dist/memory/types.js +2 -0
  43. package/dist/paths.js +7 -1
  44. package/dist/store/db.js +412 -8
  45. package/dist/store/db.test.js +83 -0
  46. package/dist/test/setup-env.js +16 -0
  47. package/dist/test/setup-env.test.js +4 -0
  48. package/package.json +1 -1
  49. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  50. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  51. package/web/dist/index.html +1 -1
  52. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * Architecture decisions (from Mal #129):
5
5
  * - Events accumulate in a per-turn ring buffer (200 events) during the turn.
6
- * - A parallel per-session ring buffer (200 events) feeds the SSE reconnect path.
7
- * - On turn completion, events are persisted to the `turn_events` SQLite table.
6
+ * - A parallel per-session ring buffer feeds the hot SSE reconnect path.
7
+ * - Events are persisted to the `turn_events` SQLite table as they are emitted.
8
8
  * - The per-turn buffer is cleared after a 30 s grace window so late reconnects
9
9
  * can still replay from memory before SQLite is needed.
10
10
  * - After the grace window, SSE replay falls back to SQLite.
@@ -13,7 +13,7 @@
13
13
  * - `emitTurnEvent(sessionKey, event)` — emit an event (called from orchestrator.ts)
14
14
  * - `subscribeTurn(turnId, listener)` — per-turn subscribe with immediate replay
15
15
  * - `subscribeSession(sessionKey, listener, afterSeq?)` — session SSE subscribe + replay
16
- * - `persistTurnEvents(turnId, sessionKey)` — write ring buffer to SQLite
16
+ * - `persistTurnEvents(turnId, sessionKey)` — compatibility no-op after eager persistence
17
17
  * - `scheduleClearTurnLog(turnId)` — schedule buffer clear after grace window
18
18
  * - `getSessionEventsFromDb(sessionKey, afterSeq)` — SQLite fallback for completed turns
19
19
  *
@@ -21,6 +21,7 @@
21
21
  */
22
22
  import { childLogger } from "../util/logger.js";
23
23
  import { getDb } from "../store/db.js";
24
+ import { config } from "../config.js";
24
25
  import { RingBuffer } from "./ring-buffer.js";
25
26
  const log = childLogger("turn-event-log");
26
27
  // ---------------------------------------------------------------------------
@@ -29,7 +30,9 @@ const log = childLogger("turn-event-log");
29
30
  /** Events retained per turn in memory (covers long turns with many tool calls). */
30
31
  export const TURN_BUFFER_CAPACITY = 200;
31
32
  /** Recent events retained per session for SSE reconnect replay. */
32
- export const SESSION_BUFFER_CAPACITY = 200;
33
+ export const SESSION_BUFFER_CAPACITY = config.sseBufferCapacity;
34
+ /** Maximum SQLite replay events returned for one session reconnect. */
35
+ export const SESSION_REPLAY_LIMIT = config.sseReplayLimit;
33
36
  /** Grace window before per-turn buffer is cleared after turn completion (ms). */
34
37
  export const TURN_CLEAR_GRACE_MS = 30_000;
35
38
  // ---------------------------------------------------------------------------
@@ -58,6 +61,7 @@ let globalSeq = 0;
58
61
  */
59
62
  export function emitTurnEvent(sessionKey, event) {
60
63
  const indexed = { ...event, _seq: ++globalSeq, _ts: Date.now() };
64
+ persistIndexedTurnEvent(sessionKey, indexed);
61
65
  // Per-turn buffer --------------------------------------------------------
62
66
  const turnId = event.turnId;
63
67
  let entry = turnBuffers.get(turnId);
@@ -182,31 +186,26 @@ export function subscribeSession(sessionKey, listener, afterSeq) {
182
186
  // Persistence
183
187
  // ---------------------------------------------------------------------------
184
188
  /**
185
- * Persist the turn's buffered events to the `turn_events` SQLite table.
186
- * Called on `turn:complete` and `turn:error` before scheduling the clear.
187
- * No-ops silently if the turn has no buffered events.
189
+ * Persist one indexed event to the `turn_events` SQLite table synchronously.
190
+ * SQLite is the replay source of truth; the in-memory rings are hot caches.
188
191
  */
189
- export function persistTurnEvents(turnId, sessionKey) {
190
- const entry = turnBuffers.get(turnId);
191
- if (!entry)
192
- return;
193
- const events = entry.buf.getAll();
194
- if (events.length === 0)
195
- return;
192
+ function persistIndexedTurnEvent(sessionKey, event) {
196
193
  try {
197
194
  const db = getDb();
198
- const stmt = db.prepare(`INSERT OR IGNORE INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
195
+ const stmt = db.prepare(`INSERT INTO turn_events (turn_id, session_key, seq, ts, event_type, payload)
199
196
  VALUES (?, ?, ?, ?, ?, ?)`);
200
- db.transaction(() => {
201
- for (const e of events) {
202
- stmt.run(turnId, sessionKey, e._seq, e._ts, e.type, JSON.stringify(e));
203
- }
204
- })();
197
+ stmt.run(event.turnId, sessionKey, event._seq, event._ts, event.type, JSON.stringify(event));
205
198
  }
206
199
  catch (err) {
207
- log.warn({ err: err instanceof Error ? err.message : err, turnId }, "turn-event-log: SQLite persist failed");
200
+ log.warn({ err: err instanceof Error ? err.message : err, turnId: event.turnId }, "turn-event-log: SQLite persist failed");
208
201
  }
209
202
  }
203
+ /**
204
+ * Compatibility no-op for callers that still finalize turns through this API.
205
+ * Events are now persisted eagerly by emitTurnEvent(), including terminal events.
206
+ */
207
+ export function persistTurnEvents(_turnId, _sessionKey) {
208
+ }
210
209
  // ---------------------------------------------------------------------------
211
210
  // Lifecycle — clear
212
211
  // ---------------------------------------------------------------------------
@@ -272,8 +271,8 @@ export function getSessionEventsFromDb(sessionKey, afterSeq = 0) {
272
271
  .prepare(`SELECT payload FROM turn_events
273
272
  WHERE session_key = ? AND seq > ?
274
273
  ORDER BY seq ASC
275
- LIMIT 500`)
276
- .all(sessionKey, afterSeq);
274
+ LIMIT ?`)
275
+ .all(sessionKey, afterSeq, SESSION_REPLAY_LIMIT);
277
276
  return rows.map((r) => JSON.parse(r.payload));
278
277
  }
279
278
  catch (err) {
@@ -25,17 +25,21 @@
25
25
  import { describe, it, afterEach } from "node:test";
26
26
  import assert from "node:assert/strict";
27
27
  import { setTimeout as setTimeoutPromise } from "node:timers/promises";
28
- import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, } from "./turn-event-log.js";
28
+ import { emitTurnEvent, subscribeTurn, subscribeSession, clearTurnLog, scheduleClearTurnLog, turnLogSize, oldestSessionSeq, persistTurnEvents, getSessionEventsFromDb, TURN_BUFFER_CAPACITY, SESSION_BUFFER_CAPACITY, } from "./turn-event-log.js";
29
+ import { getDb } from "../store/db.js";
29
30
  // ---------------------------------------------------------------------------
30
31
  // Helpers
31
32
  // ---------------------------------------------------------------------------
32
33
  let turnCounter = 0;
33
34
  let sessionCounter = 0;
35
+ const usedSessionKeys = [];
34
36
  function freshTurnId() {
35
37
  return `turn-test-${++turnCounter}-${Date.now()}`;
36
38
  }
37
39
  function freshSessionKey() {
38
- return `session-test-${++sessionCounter}-${Date.now()}`;
40
+ const sessionKey = `session-test-${++sessionCounter}-${Date.now()}`;
41
+ usedSessionKeys.push(sessionKey);
42
+ return sessionKey;
39
43
  }
40
44
  function makeStarted(turnId, sessionKey) {
41
45
  return { type: "turn:started", turnId, sessionKey, prompt: "hello" };
@@ -63,6 +67,14 @@ afterEach(() => {
63
67
  for (const id of usedTurnIds.splice(0)) {
64
68
  clearTurnLog(id);
65
69
  }
70
+ const sessions = usedSessionKeys.splice(0);
71
+ if (sessions.length > 0) {
72
+ const db = getDb();
73
+ const deleteSessionEvents = db.prepare("DELETE FROM turn_events WHERE session_key = ?");
74
+ for (const sessionKey of sessions) {
75
+ deleteSessionEvents.run(sessionKey);
76
+ }
77
+ }
66
78
  });
67
79
  function trackTurn(turnId) {
68
80
  usedTurnIds.push(turnId);
@@ -98,6 +110,15 @@ describe("turn-event-log", () => {
98
110
  const after = Date.now();
99
111
  assert.ok(collected[0]._ts >= before && collected[0]._ts <= after);
100
112
  });
113
+ it("persists each event to SQLite immediately", () => {
114
+ const session = freshSessionKey();
115
+ const turnId = trackTurn(freshTurnId());
116
+ emitTurnEvent(session, makeStarted(turnId, session));
117
+ const persisted = getSessionEventsFromDb(session);
118
+ assert.equal(persisted.length, 1);
119
+ assert.equal(persisted[0].type, "turn:started");
120
+ assert.equal(persisted[0].turnId, turnId);
121
+ });
101
122
  });
102
123
  describe("subscribeTurn", () => {
103
124
  it("replays existing buffered events immediately on subscribe", () => {
@@ -224,6 +245,16 @@ describe("turn-event-log", () => {
224
245
  assert.equal(recv1[0].turnId, turn1);
225
246
  assert.equal(recv2[0].turnId, turn2);
226
247
  });
248
+ it("SQLite fallback returns events from an in-flight turn", () => {
249
+ const session = freshSessionKey();
250
+ const turnId = trackTurn(freshTurnId());
251
+ emitTurnEvent(session, makeStarted(turnId, session));
252
+ emitTurnEvent(session, makeDelta(turnId, session, "still-running"));
253
+ const persisted = getSessionEventsFromDb(session);
254
+ assert.equal(persisted.length, 2);
255
+ assert.deepEqual(persisted.map((event) => event.type), ["turn:started", "turn:delta"]);
256
+ assert.equal(persisted[1].part.text, "still-running");
257
+ });
227
258
  });
228
259
  describe("clearTurnLog", () => {
229
260
  it("removes the per-turn buffer immediately", () => {
@@ -321,6 +352,34 @@ describe("turn-event-log", () => {
321
352
  trackUnsub(subscribeTurn(turnId, (e) => received.push(e)));
322
353
  assert.equal(received.length, TURN_BUFFER_CAPACITY);
323
354
  });
355
+ it("SQLite replay keeps events that were evicted from the session ring buffer", () => {
356
+ const session = freshSessionKey();
357
+ const turnId = trackTurn(freshTurnId());
358
+ const totalEvents = SESSION_BUFFER_CAPACITY + 3;
359
+ for (let i = 0; i < totalEvents; i++) {
360
+ emitTurnEvent(session, makeDelta(turnId, session, `chunk-${i}`));
361
+ }
362
+ const fromBuffer = [];
363
+ const unsubscribe = subscribeSession(session, (event) => fromBuffer.push(event), 0);
364
+ unsubscribe();
365
+ assert.equal(fromBuffer.length, SESSION_BUFFER_CAPACITY);
366
+ const fromDb = getSessionEventsFromDb(session);
367
+ assert.equal(fromDb.length, totalEvents);
368
+ assert.equal(fromDb[0].part.text, "chunk-0");
369
+ });
370
+ });
371
+ describe("persistTurnEvents", () => {
372
+ it("does not double-write events that were eagerly persisted by turn completion", () => {
373
+ const session = freshSessionKey();
374
+ const turnId = trackTurn(freshTurnId());
375
+ emitTurnEvent(session, makeStarted(turnId, session));
376
+ emitTurnEvent(session, makeComplete(turnId, session));
377
+ assert.equal(getSessionEventsFromDb(session).length, 2);
378
+ persistTurnEvents(turnId, session);
379
+ const persisted = getSessionEventsFromDb(session);
380
+ assert.equal(persisted.length, 2);
381
+ assert.deepEqual(persisted.map((event) => event.type), ["turn:started", "turn:complete"]);
382
+ });
324
383
  });
325
384
  });
326
385
  //# sourceMappingURL=turn-event-log.test.js.map
@@ -0,0 +1,69 @@
1
+ import { getDb } from "../store/db.js";
2
+ import { getScope, listScopes } from "./scopes.js";
3
+ const ACTIVE_SCOPE_KEY = "current_scope_slug";
4
+ function setSetting(key, value) {
5
+ getDb().prepare(`
6
+ INSERT INTO mem_settings (key, value)
7
+ VALUES (?, ?)
8
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
9
+ `).run(key, value);
10
+ }
11
+ function getSetting(key) {
12
+ const row = getDb().prepare(`
13
+ SELECT value
14
+ FROM mem_settings
15
+ WHERE key = ?
16
+ `).get(key);
17
+ return row?.value;
18
+ }
19
+ function clearSetting(key) {
20
+ getDb().prepare(`DELETE FROM mem_settings WHERE key = ?`).run(key);
21
+ }
22
+ export function getActiveScope() {
23
+ const slug = getSetting(ACTIVE_SCOPE_KEY);
24
+ if (!slug)
25
+ return null;
26
+ const scope = getScope(slug);
27
+ if (!scope) {
28
+ clearSetting(ACTIVE_SCOPE_KEY);
29
+ return null;
30
+ }
31
+ return scope;
32
+ }
33
+ export function setActiveScope(slug) {
34
+ if (slug === null) {
35
+ clearSetting(ACTIVE_SCOPE_KEY);
36
+ return null;
37
+ }
38
+ const scope = getScope(slug);
39
+ if (!scope) {
40
+ throw new Error(`Unknown memory scope '${slug}'.`);
41
+ }
42
+ setSetting(ACTIVE_SCOPE_KEY, scope.slug);
43
+ return scope;
44
+ }
45
+ export function inferScopeFromText(text) {
46
+ const lowered = text.toLowerCase();
47
+ const ranked = listScopes()
48
+ .filter((scope) => scope.active)
49
+ .map((scope) => {
50
+ const matchedKeywords = scope.keywords.filter((keyword, index, keywords) => lowered.includes(keyword.toLowerCase()) && keywords.indexOf(keyword) === index);
51
+ return { scope, matchedKeywords };
52
+ })
53
+ .filter((entry) => entry.matchedKeywords.length > 0)
54
+ .sort((a, b) => {
55
+ if (b.matchedKeywords.length !== a.matchedKeywords.length) {
56
+ return b.matchedKeywords.length - a.matchedKeywords.length;
57
+ }
58
+ return a.scope.slug.localeCompare(b.scope.slug);
59
+ });
60
+ const winner = ranked[0];
61
+ if (!winner)
62
+ return null;
63
+ return {
64
+ scope_id: winner.scope.id,
65
+ confidence: winner.matchedKeywords.length / Math.max(winner.scope.keywords.length, 1),
66
+ matched_keywords: winner.matchedKeywords,
67
+ };
68
+ }
69
+ //# sourceMappingURL=active-scope.js.map
@@ -0,0 +1,76 @@
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-active-scope-${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() {
15
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
16
+ const memoryModule = await import(new URL("./index.js", import.meta.url).href);
17
+ return { dbModule, memoryModule };
18
+ }
19
+ function getFunction(module, name) {
20
+ const value = module[name];
21
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
22
+ return value;
23
+ }
24
+ test.beforeEach(async () => {
25
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
26
+ dbModule.closeDb();
27
+ resetSandbox();
28
+ });
29
+ test.after(async () => {
30
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
31
+ dbModule.closeDb();
32
+ rmSync(sandboxRoot, { recursive: true, force: true });
33
+ });
34
+ test("active scope can be set, read, and cleared without changing scope activation status", async () => {
35
+ const { dbModule, memoryModule } = await loadModules();
36
+ dbModule.getDb();
37
+ const getScope = getFunction(memoryModule, "getScope");
38
+ const getActiveScope = getFunction(memoryModule, "getActiveScope");
39
+ const setActiveScope = getFunction(memoryModule, "setActiveScope");
40
+ const deactivateScope = getFunction(memoryModule, "deactivateScope");
41
+ assert.equal(getActiveScope(), null);
42
+ assert.equal(setActiveScope("chapterhouse")?.slug, "chapterhouse");
43
+ assert.equal(getActiveScope()?.slug, "chapterhouse");
44
+ const team = getScope("team");
45
+ assert.ok(team);
46
+ const deactivated = deactivateScope(team.id);
47
+ assert.equal(deactivated.active, false);
48
+ assert.equal(getActiveScope()?.slug, "chapterhouse");
49
+ assert.equal(setActiveScope(null), null);
50
+ assert.equal(getActiveScope(), null);
51
+ });
52
+ test("inferScopeFromText prefers the highest keyword match count and breaks ties deterministically", async () => {
53
+ const { dbModule, memoryModule } = await loadModules();
54
+ dbModule.getDb();
55
+ const createScope = getFunction(memoryModule, "createScope");
56
+ const inferScopeFromText = getFunction(memoryModule, "inferScopeFromText");
57
+ const alpha = createScope({
58
+ slug: "alpha-release",
59
+ title: "Alpha Release",
60
+ description: "Alpha release work",
61
+ keywords: ["release"],
62
+ });
63
+ const beta = createScope({
64
+ slug: "beta-deploy",
65
+ title: "Beta Deploy",
66
+ description: "Deployment work",
67
+ keywords: ["deploy", "release"],
68
+ });
69
+ const bestMatch = inferScopeFromText("Please deploy the release workflow today.");
70
+ assert.equal(bestMatch?.scope_id, beta.id);
71
+ assert.deepEqual(bestMatch?.matched_keywords.sort(), ["deploy", "release"]);
72
+ const tie = inferScopeFromText("This release needs triage.");
73
+ assert.equal(tie?.scope_id, alpha.id);
74
+ assert.deepEqual(tie?.matched_keywords, ["release"]);
75
+ });
76
+ //# sourceMappingURL=active-scope.test.js.map
@@ -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