chapterhouse 0.3.25 → 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 (57) hide show
  1. package/dist/api/server-runtime.js +1 -1
  2. package/dist/api/server.js +13 -1
  3. package/dist/api/server.test.js +68 -54
  4. package/dist/api/sse.integration.test.js +4 -46
  5. package/dist/api/turn-sse.integration.test.js +20 -47
  6. package/dist/config.js +81 -1
  7. package/dist/config.test.js +123 -0
  8. package/dist/copilot/agents.js +27 -4
  9. package/dist/copilot/agents.test.js +7 -0
  10. package/dist/copilot/oneshot.js +54 -0
  11. package/dist/copilot/orchestrator.js +228 -4
  12. package/dist/copilot/orchestrator.test.js +373 -1
  13. package/dist/copilot/system-message.js +4 -0
  14. package/dist/copilot/system-message.test.js +24 -0
  15. package/dist/copilot/tools.agent.test.js +23 -0
  16. package/dist/copilot/tools.js +350 -4
  17. package/dist/copilot/tools.memory.test.js +248 -0
  18. package/dist/copilot/turn-event-log-env.test.js +19 -0
  19. package/dist/copilot/turn-event-log.js +22 -23
  20. package/dist/copilot/turn-event-log.test.js +61 -2
  21. package/dist/memory/active-scope.js +69 -0
  22. package/dist/memory/active-scope.test.js +76 -0
  23. package/dist/memory/checkpoint-prompt.js +71 -0
  24. package/dist/memory/checkpoint.js +257 -0
  25. package/dist/memory/checkpoint.test.js +255 -0
  26. package/dist/memory/decisions.js +53 -0
  27. package/dist/memory/decisions.test.js +92 -0
  28. package/dist/memory/entities.js +59 -0
  29. package/dist/memory/entities.test.js +65 -0
  30. package/dist/memory/eot.js +219 -0
  31. package/dist/memory/eot.test.js +263 -0
  32. package/dist/memory/hot-tier.js +187 -0
  33. package/dist/memory/hot-tier.test.js +197 -0
  34. package/dist/memory/housekeeping.js +352 -0
  35. package/dist/memory/housekeeping.test.js +280 -0
  36. package/dist/memory/inbox.js +73 -0
  37. package/dist/memory/index.js +11 -0
  38. package/dist/memory/observations.js +46 -0
  39. package/dist/memory/observations.test.js +86 -0
  40. package/dist/memory/recall.js +197 -0
  41. package/dist/memory/recall.test.js +196 -0
  42. package/dist/memory/scopes.js +89 -0
  43. package/dist/memory/scopes.test.js +201 -0
  44. package/dist/memory/tiering.js +193 -0
  45. package/dist/memory/types.js +2 -0
  46. package/dist/paths.js +7 -1
  47. package/dist/store/db.js +423 -17
  48. package/dist/store/db.test.js +94 -7
  49. package/dist/test/api-server.js +50 -0
  50. package/dist/test/api-server.test.js +57 -0
  51. package/dist/test/setup-env.js +25 -0
  52. package/dist/test/setup-env.test.js +38 -0
  53. package/package.json +1 -1
  54. package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
  55. package/web/dist/assets/index-DmYLALt0.js.map +1 -0
  56. package/web/dist/index.html +1 -1
  57. package/web/dist/assets/index-BRPJa1DK.js.map +0 -1
package/dist/store/db.js CHANGED
@@ -1,13 +1,220 @@
1
1
  import Database from "better-sqlite3";
2
- import { DB_PATH, ensureChapterhouseHome } from "../paths.js";
2
+ import { ensureChapterhouseHome, getDbPath } from "../paths.js";
3
3
  let db;
4
4
  let logInsertCount = 0;
5
5
  let fts5Available = false;
6
+ function hasColumn(database, table, column) {
7
+ return database.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column);
8
+ }
9
+ function memoryTierCase(tableAlias = "") {
10
+ const prefix = tableAlias ? `${tableAlias}.` : "";
11
+ return `
12
+ CASE
13
+ WHEN ${prefix}archived_at IS NOT NULL OR ${prefix}superseded_by IS NOT NULL THEN 'cold'
14
+ WHEN ${prefix}tier = 'glacier' THEN 'cold'
15
+ WHEN ${prefix}tier IN ('hot', 'warm', 'cold') THEN ${prefix}tier
16
+ ELSE 'warm'
17
+ END
18
+ `;
19
+ }
20
+ function entityTierCase(tableAlias = "") {
21
+ const prefix = tableAlias ? `${tableAlias}.` : "";
22
+ return `
23
+ CASE
24
+ WHEN ${prefix}tier = 'glacier' THEN 'cold'
25
+ WHEN ${prefix}tier IN ('hot', 'warm', 'cold') THEN ${prefix}tier
26
+ ELSE 'warm'
27
+ END
28
+ `;
29
+ }
30
+ function tableCreateSql(database, table) {
31
+ const row = database.prepare(`
32
+ SELECT sql
33
+ FROM sqlite_master
34
+ WHERE type = 'table' AND name = ?
35
+ `).get(table);
36
+ return row?.sql ?? "";
37
+ }
38
+ function rebuildMemoryTierTables(database) {
39
+ const needsRebuild = ["mem_entities", "mem_observations", "mem_decisions"]
40
+ .some((table) => tableCreateSql(database, table).includes("'glacier'"));
41
+ if (!needsRebuild) {
42
+ return;
43
+ }
44
+ database.pragma("foreign_keys = OFF");
45
+ try {
46
+ database.transaction(() => {
47
+ database.exec(`ALTER TABLE mem_entities RENAME TO mem_entities_legacy_tier`);
48
+ database.exec(`
49
+ CREATE TABLE mem_entities (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
52
+ kind TEXT NOT NULL,
53
+ name TEXT NOT NULL,
54
+ summary TEXT,
55
+ tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
56
+ confidence REAL NOT NULL DEFAULT 1.0,
57
+ tier_pinned_at DATETIME,
58
+ tier_reason TEXT,
59
+ last_recalled_at DATETIME,
60
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
61
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
62
+ )
63
+ `);
64
+ database.exec(`
65
+ INSERT INTO mem_entities (id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at)
66
+ SELECT id, scope_id, kind, name, summary, ${entityTierCase()}, confidence, created_at, updated_at
67
+ FROM mem_entities_legacy_tier
68
+ `);
69
+ database.exec(`DROP TABLE mem_entities_legacy_tier`);
70
+ database.exec(`ALTER TABLE mem_observations RENAME TO mem_observations_legacy_tier`);
71
+ database.exec(`
72
+ CREATE TABLE mem_observations (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
75
+ entity_id INTEGER REFERENCES mem_entities(id),
76
+ content TEXT NOT NULL,
77
+ source TEXT NOT NULL,
78
+ tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
79
+ confidence REAL NOT NULL DEFAULT 1.0,
80
+ embedding BLOB,
81
+ superseded_by INTEGER REFERENCES mem_observations(id) ON DELETE SET NULL,
82
+ archived_at DATETIME,
83
+ tier_pinned_at DATETIME,
84
+ tier_reason TEXT,
85
+ last_recalled_at DATETIME,
86
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
87
+ )
88
+ `);
89
+ database.exec(`
90
+ INSERT INTO mem_observations (
91
+ id, scope_id, entity_id, content, source, tier, confidence, embedding, superseded_by, archived_at, created_at
92
+ )
93
+ SELECT id, scope_id, entity_id, content, source, ${memoryTierCase()}, confidence, embedding, superseded_by, archived_at, created_at
94
+ FROM mem_observations_legacy_tier
95
+ `);
96
+ database.exec(`DROP TABLE mem_observations_legacy_tier`);
97
+ database.exec(`ALTER TABLE mem_decisions RENAME TO mem_decisions_legacy_tier`);
98
+ database.exec(`
99
+ CREATE TABLE mem_decisions (
100
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
101
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
102
+ entity_id INTEGER REFERENCES mem_entities(id),
103
+ title TEXT NOT NULL,
104
+ rationale TEXT NOT NULL,
105
+ decided_at TEXT NOT NULL,
106
+ tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
107
+ superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL,
108
+ archived_at DATETIME,
109
+ tier_pinned_at DATETIME,
110
+ tier_reason TEXT,
111
+ last_recalled_at DATETIME,
112
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
113
+ )
114
+ `);
115
+ database.exec(`
116
+ INSERT INTO mem_decisions (
117
+ id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
118
+ )
119
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, ${memoryTierCase()}, superseded_by, archived_at, created_at
120
+ FROM mem_decisions_legacy_tier
121
+ `);
122
+ database.exec(`DROP TABLE mem_decisions_legacy_tier`);
123
+ })();
124
+ }
125
+ finally {
126
+ database.pragma("foreign_keys = ON");
127
+ }
128
+ }
129
+ function ensureMemoryTierColumns(database) {
130
+ for (const table of ["mem_entities", "mem_observations", "mem_decisions"]) {
131
+ if (!hasColumn(database, table, "tier")) {
132
+ database.exec(`ALTER TABLE ${table} ADD COLUMN tier TEXT DEFAULT 'warm'`);
133
+ }
134
+ if (!hasColumn(database, table, "tier_pinned_at")) {
135
+ database.exec(`ALTER TABLE ${table} ADD COLUMN tier_pinned_at DATETIME`);
136
+ }
137
+ if (!hasColumn(database, table, "tier_reason")) {
138
+ database.exec(`ALTER TABLE ${table} ADD COLUMN tier_reason TEXT`);
139
+ }
140
+ if (!hasColumn(database, table, "last_recalled_at")) {
141
+ database.exec(`ALTER TABLE ${table} ADD COLUMN last_recalled_at DATETIME`);
142
+ }
143
+ }
144
+ database.exec(`
145
+ UPDATE mem_entities
146
+ SET tier = CASE
147
+ WHEN tier = 'glacier' THEN 'cold'
148
+ WHEN tier IN ('hot', 'warm', 'cold') THEN tier
149
+ WHEN confidence > 0.7 AND datetime(updated_at) >= datetime('now', '-30 days') THEN 'hot'
150
+ ELSE 'warm'
151
+ END
152
+ `);
153
+ database.exec(`
154
+ UPDATE mem_observations
155
+ SET tier = CASE
156
+ WHEN archived_at IS NOT NULL OR superseded_by IS NOT NULL THEN 'cold'
157
+ WHEN tier = 'glacier' THEN 'cold'
158
+ WHEN tier IN ('hot', 'warm', 'cold') THEN tier
159
+ WHEN confidence > 0.7 AND datetime(created_at) >= datetime('now', '-30 days') THEN 'hot'
160
+ ELSE 'warm'
161
+ END
162
+ `);
163
+ database.exec(`
164
+ UPDATE mem_decisions
165
+ SET tier = CASE
166
+ WHEN archived_at IS NOT NULL OR superseded_by IS NOT NULL THEN 'cold'
167
+ WHEN tier = 'glacier' THEN 'cold'
168
+ WHEN tier IN ('hot', 'warm', 'cold') THEN tier
169
+ WHEN datetime(COALESCE(decided_at, created_at)) >= datetime('now', '-30 days') THEN 'hot'
170
+ ELSE 'warm'
171
+ END
172
+ `);
173
+ }
174
+ function ensureMemoryIndexes(database) {
175
+ database.exec(`CREATE INDEX IF NOT EXISTS mem_entities_scope_kind_idx ON mem_entities(scope_id, kind)`);
176
+ database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_kind_name_idx ON mem_entities(scope_id, kind, name)`);
177
+ database.exec(`CREATE INDEX IF NOT EXISTS mem_observations_scope_idx ON mem_observations(scope_id)`);
178
+ database.exec(`CREATE INDEX IF NOT EXISTS mem_decisions_scope_idx ON mem_decisions(scope_id)`);
179
+ }
180
+ const MEMORY_SCOPE_SEEDS = [
181
+ {
182
+ slug: "global",
183
+ title: "Global",
184
+ description: "Cross-cutting facts that apply everywhere",
185
+ keywords: ["everywhere", "general"],
186
+ },
187
+ {
188
+ slug: "chapterhouse",
189
+ title: "Chapterhouse",
190
+ description: "Chapterhouse codebase, conventions, decisions, gotchas",
191
+ keywords: ["chapterhouse", "this repo", "this project", "the daemon"],
192
+ },
193
+ {
194
+ slug: "infra",
195
+ title: "Infra",
196
+ description: "Infrastructure, hosting, deployment, CI/CD",
197
+ keywords: ["infra"],
198
+ },
199
+ {
200
+ slug: "team",
201
+ title: "Team",
202
+ description: "Team processes, rituals, OKRs",
203
+ keywords: ["team"],
204
+ },
205
+ {
206
+ slug: "brian",
207
+ title: "Brian",
208
+ description: "Brian's preferences, context, working style",
209
+ keywords: ["brian"],
210
+ },
211
+ ];
6
212
  export function getDb() {
7
213
  if (!db) {
8
214
  ensureChapterhouseHome();
9
- db = new Database(DB_PATH);
215
+ db = new Database(getDbPath());
10
216
  db.pragma("journal_mode = WAL");
217
+ db.pragma("foreign_keys = ON");
11
218
  db.exec(`
12
219
  CREATE TABLE IF NOT EXISTS worker_sessions (
13
220
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -62,7 +269,7 @@ export function getDb() {
62
269
  db.exec(`
63
270
  CREATE TABLE IF NOT EXISTS conversation_log (
64
271
  id INTEGER PRIMARY KEY AUTOINCREMENT,
65
- role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
272
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
66
273
  content TEXT NOT NULL,
67
274
  source TEXT NOT NULL DEFAULT 'unknown',
68
275
  ts DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -80,16 +287,16 @@ export function getDb() {
80
287
  `);
81
288
  // Migrate: if the table already existed with a stricter CHECK, recreate it
82
289
  try {
83
- db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('system', '__migration_test__', 'test')`).run();
290
+ db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES ('agent_completion', '__migration_test__', 'test')`).run();
84
291
  db.prepare(`DELETE FROM conversation_log WHERE content = '__migration_test__'`).run();
85
292
  }
86
293
  catch {
87
- // CHECK constraint doesn't allow 'system' — recreate table preserving data
294
+ // CHECK constraint doesn't allow current synthetic roles — recreate table preserving data
88
295
  db.exec(`ALTER TABLE conversation_log RENAME TO conversation_log_old`);
89
296
  db.exec(`
90
297
  CREATE TABLE conversation_log (
91
298
  id INTEGER PRIMARY KEY AUTOINCREMENT,
92
- role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
299
+ role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
93
300
  content TEXT NOT NULL,
94
301
  source TEXT NOT NULL DEFAULT 'unknown',
95
302
  ts DATETIME DEFAULT CURRENT_TIMESTAMP
@@ -171,7 +378,7 @@ export function getDb() {
171
378
  db.exec(`ALTER TABLE agent_tasks ADD COLUMN event_seq INTEGER NOT NULL DEFAULT 0`);
172
379
  }
173
380
  // turn_events: append-only per-turn event log for the SSE chat channel (#130).
174
- // Events are written on turn completion; ring buffer serves live/recent replay.
381
+ // Events are written eagerly; ring buffer serves live/recent hot replay.
175
382
  db.exec(`
176
383
  CREATE TABLE IF NOT EXISTS turn_events (
177
384
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -185,6 +392,133 @@ export function getDb() {
185
392
  `);
186
393
  db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_events_turn_id ON turn_events(turn_id, seq)`);
187
394
  db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_events_session_key ON turn_events(session_key, seq)`);
395
+ db.exec(`
396
+ CREATE TABLE IF NOT EXISTS mem_scopes (
397
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
398
+ slug TEXT NOT NULL,
399
+ title TEXT NOT NULL,
400
+ description TEXT NOT NULL,
401
+ keywords TEXT NOT NULL DEFAULT '[]',
402
+ active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0, 1)),
403
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
404
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
405
+ )
406
+ `);
407
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_scopes_slug_idx ON mem_scopes(slug)`);
408
+ db.exec(`
409
+ CREATE TABLE IF NOT EXISTS mem_entities (
410
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
411
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
412
+ kind TEXT NOT NULL,
413
+ name TEXT NOT NULL,
414
+ summary TEXT,
415
+ tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
416
+ confidence REAL NOT NULL DEFAULT 1.0,
417
+ tier_pinned_at DATETIME,
418
+ tier_reason TEXT,
419
+ last_recalled_at DATETIME,
420
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
421
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
422
+ )
423
+ `);
424
+ db.exec(`
425
+ DELETE FROM mem_entities
426
+ WHERE id NOT IN (
427
+ SELECT MIN(id)
428
+ FROM mem_entities
429
+ GROUP BY scope_id, kind, name
430
+ )
431
+ `);
432
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_kind_name_idx ON mem_entities(scope_id, kind, name)`);
433
+ db.exec(`
434
+ CREATE TABLE IF NOT EXISTS mem_observations (
435
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
436
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
437
+ entity_id INTEGER REFERENCES mem_entities(id),
438
+ content TEXT NOT NULL,
439
+ source TEXT NOT NULL,
440
+ tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
441
+ confidence REAL NOT NULL DEFAULT 1.0,
442
+ embedding BLOB,
443
+ superseded_by INTEGER REFERENCES mem_observations(id) ON DELETE SET NULL,
444
+ archived_at DATETIME,
445
+ tier_pinned_at DATETIME,
446
+ tier_reason TEXT,
447
+ last_recalled_at DATETIME,
448
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
449
+ )
450
+ `);
451
+ const observationCols = db.prepare(`PRAGMA table_info(mem_observations)`).all();
452
+ if (!observationCols.some((column) => column.name === "superseded_by")) {
453
+ db.exec(`ALTER TABLE mem_observations ADD COLUMN superseded_by INTEGER REFERENCES mem_observations(id) ON DELETE SET NULL`);
454
+ }
455
+ if (!observationCols.some((column) => column.name === "archived_at")) {
456
+ db.exec(`ALTER TABLE mem_observations ADD COLUMN archived_at DATETIME`);
457
+ }
458
+ db.exec(`
459
+ CREATE TABLE IF NOT EXISTS mem_decisions (
460
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
461
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
462
+ entity_id INTEGER REFERENCES mem_entities(id),
463
+ title TEXT NOT NULL,
464
+ rationale TEXT NOT NULL,
465
+ decided_at TEXT NOT NULL,
466
+ tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
467
+ superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL,
468
+ archived_at DATETIME,
469
+ tier_pinned_at DATETIME,
470
+ tier_reason TEXT,
471
+ last_recalled_at DATETIME,
472
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
473
+ )
474
+ `);
475
+ const decisionCols = db.prepare(`PRAGMA table_info(mem_decisions)`).all();
476
+ if (!decisionCols.some((column) => column.name === "superseded_by")) {
477
+ db.exec(`ALTER TABLE mem_decisions ADD COLUMN superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL`);
478
+ }
479
+ if (!decisionCols.some((column) => column.name === "archived_at")) {
480
+ db.exec(`ALTER TABLE mem_decisions ADD COLUMN archived_at DATETIME`);
481
+ }
482
+ rebuildMemoryTierTables(db);
483
+ ensureMemoryTierColumns(db);
484
+ ensureMemoryIndexes(db);
485
+ db.exec(`
486
+ CREATE TABLE IF NOT EXISTS mem_inbox (
487
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
488
+ scope_id INTEGER REFERENCES mem_scopes(id),
489
+ kind TEXT NOT NULL,
490
+ payload TEXT NOT NULL,
491
+ source_agent TEXT NOT NULL,
492
+ source_task_id TEXT,
493
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'accepted', 'rejected', 'edited')),
494
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
495
+ resolved_at TEXT,
496
+ resolution_reason TEXT
497
+ )
498
+ `);
499
+ db.exec(`CREATE INDEX IF NOT EXISTS mem_inbox_status_idx ON mem_inbox(status)`);
500
+ db.exec(`CREATE INDEX IF NOT EXISTS mem_inbox_task_status_idx ON mem_inbox(source_task_id, status, kind)`);
501
+ const inboxCols = db.prepare(`PRAGMA table_info(mem_inbox)`).all();
502
+ if (!inboxCols.some((column) => column.name === "resolution_reason")) {
503
+ db.exec(`ALTER TABLE mem_inbox ADD COLUMN resolution_reason TEXT`);
504
+ }
505
+ db.exec(`
506
+ CREATE TABLE IF NOT EXISTS mem_settings (
507
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
508
+ key TEXT NOT NULL UNIQUE,
509
+ value TEXT NOT NULL
510
+ )
511
+ `);
512
+ const seedMemoryScopes = db.transaction(() => {
513
+ const insert = db.prepare(`
514
+ INSERT OR IGNORE INTO mem_scopes (slug, title, description, keywords, active, created_at, updated_at)
515
+ VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
516
+ `);
517
+ for (const scope of MEMORY_SCOPE_SEEDS) {
518
+ insert.run(scope.slug, scope.title, scope.description, JSON.stringify(scope.keywords));
519
+ }
520
+ });
521
+ seedMemoryScopes();
188
522
  // Prune conversation log at startup — keep more history for better recovery
189
523
  db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
190
524
  // Set up FTS5 for memory search (graceful fallback if not available)
@@ -194,23 +528,79 @@ export function getDb() {
194
528
  content,
195
529
  content_rowid='id'
196
530
  )
531
+ `);
532
+ db.exec(`
533
+ CREATE VIRTUAL TABLE IF NOT EXISTS mem_observations_fts USING fts5(
534
+ content,
535
+ content_rowid='id'
536
+ )
537
+ `);
538
+ db.exec(`
539
+ CREATE VIRTUAL TABLE IF NOT EXISTS mem_decisions_fts USING fts5(
540
+ title,
541
+ rationale,
542
+ content_rowid='id'
543
+ )
197
544
  `);
198
545
  // Sync triggers
546
+ db.exec(`DROP TRIGGER IF EXISTS memories_ai`);
547
+ db.exec(`DROP TRIGGER IF EXISTS memories_ad`);
548
+ db.exec(`DROP TRIGGER IF EXISTS memories_au`);
549
+ db.exec(`DROP TRIGGER IF EXISTS mem_observations_ai`);
550
+ db.exec(`DROP TRIGGER IF EXISTS mem_observations_ad`);
551
+ db.exec(`DROP TRIGGER IF EXISTS mem_observations_au`);
552
+ db.exec(`DROP TRIGGER IF EXISTS mem_decisions_ai`);
553
+ db.exec(`DROP TRIGGER IF EXISTS mem_decisions_ad`);
554
+ db.exec(`DROP TRIGGER IF EXISTS mem_decisions_au`);
199
555
  db.exec(`
200
- CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
556
+ CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
201
557
  INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
202
558
  END
203
559
  `);
204
560
  db.exec(`
205
- CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
206
- INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
561
+ CREATE TRIGGER memories_ad AFTER DELETE ON memories BEGIN
562
+ DELETE FROM memories_fts WHERE rowid = old.id;
207
563
  END
208
564
  `);
209
565
  db.exec(`
210
- CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
211
- INSERT INTO memories_fts(memories_fts, rowid, content) VALUES ('delete', old.id, old.content);
566
+ CREATE TRIGGER memories_au AFTER UPDATE ON memories BEGIN
567
+ DELETE FROM memories_fts WHERE rowid = old.id;
212
568
  INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
213
569
  END
570
+ `);
571
+ db.exec(`
572
+ CREATE TRIGGER mem_observations_ai AFTER INSERT ON mem_observations BEGIN
573
+ INSERT INTO mem_observations_fts(rowid, content) VALUES (new.id, new.content);
574
+ END
575
+ `);
576
+ db.exec(`
577
+ CREATE TRIGGER mem_observations_ad AFTER DELETE ON mem_observations BEGIN
578
+ DELETE FROM mem_observations_fts WHERE rowid = old.id;
579
+ END
580
+ `);
581
+ db.exec(`
582
+ CREATE TRIGGER mem_observations_au AFTER UPDATE ON mem_observations BEGIN
583
+ DELETE FROM mem_observations_fts WHERE rowid = old.id;
584
+ INSERT INTO mem_observations_fts(rowid, content) VALUES (new.id, new.content);
585
+ END
586
+ `);
587
+ db.exec(`
588
+ CREATE TRIGGER mem_decisions_ai AFTER INSERT ON mem_decisions BEGIN
589
+ INSERT INTO mem_decisions_fts(rowid, title, rationale)
590
+ VALUES (new.id, new.title, new.rationale);
591
+ END
592
+ `);
593
+ db.exec(`
594
+ CREATE TRIGGER mem_decisions_ad AFTER DELETE ON mem_decisions BEGIN
595
+ DELETE FROM mem_decisions_fts WHERE rowid = old.id;
596
+ END
597
+ `);
598
+ db.exec(`
599
+ CREATE TRIGGER mem_decisions_au AFTER UPDATE ON mem_decisions BEGIN
600
+ DELETE FROM mem_decisions_fts WHERE rowid = old.id;
601
+ INSERT INTO mem_decisions_fts(rowid, title, rationale)
602
+ VALUES (new.id, new.title, new.rationale);
603
+ END
214
604
  `);
215
605
  // Backfill: check if FTS is in sync by comparing row counts
216
606
  const memCount = db.prepare(`SELECT COUNT(*) as c FROM memories`).get().c;
@@ -218,6 +608,16 @@ export function getDb() {
218
608
  if (memCount > 0 && ftsCount < memCount) {
219
609
  db.exec(`INSERT INTO memories_fts(memories_fts) VALUES ('rebuild')`);
220
610
  }
611
+ const observationCount = db.prepare(`SELECT COUNT(*) as c FROM mem_observations`).get().c;
612
+ const observationFtsCount = db.prepare(`SELECT COUNT(*) as c FROM mem_observations_fts`).get().c;
613
+ if (observationCount > 0 && observationFtsCount < observationCount) {
614
+ db.exec(`INSERT INTO mem_observations_fts(mem_observations_fts) VALUES ('rebuild')`);
615
+ }
616
+ const decisionCount = db.prepare(`SELECT COUNT(*) as c FROM mem_decisions`).get().c;
617
+ const decisionFtsCount = db.prepare(`SELECT COUNT(*) as c FROM mem_decisions_fts`).get().c;
618
+ if (decisionCount > 0 && decisionFtsCount < decisionCount) {
619
+ db.exec(`INSERT INTO mem_decisions_fts(mem_decisions_fts) VALUES ('rebuild')`);
620
+ }
221
621
  fts5Available = true;
222
622
  }
223
623
  catch {
@@ -227,6 +627,10 @@ export function getDb() {
227
627
  }
228
628
  return db;
229
629
  }
630
+ export function isFts5Available() {
631
+ getDb();
632
+ return fts5Available;
633
+ }
230
634
  export function getState(key) {
231
635
  const db = getDb();
232
636
  const row = db.prepare(`SELECT value FROM max_state WHERE key = ?`).get(key);
@@ -309,7 +713,8 @@ export function getRecentConversation(limit, sessionKey) {
309
713
  return rows.map((r) => {
310
714
  const tag = r.role === "user" ? `[${r.source}] User`
311
715
  : r.role === "system" ? `[${r.source}] System`
312
- : "Chapterhouse";
716
+ : r.role === "agent_completion" ? `[${r.source}] Agent completion`
717
+ : "Chapterhouse";
313
718
  // Truncate long messages to keep context manageable
314
719
  const content = r.content.length > 1500 ? r.content.slice(0, 1500) + "…" : r.content;
315
720
  return `${tag}: ${content}`;
@@ -344,21 +749,22 @@ export function normalizeSqliteTsToIso(ts) {
344
749
  * suitable for seeding the frontend Zustand store on mount.
345
750
  *
346
751
  * Unlike `getRecentConversation()`, this returns structured objects (not a
347
- * formatted string) and omits system messages (role = 'system') because the
348
- * UI only renders user/assistant turns.
752
+ * formatted string) and omits internal system messages. Synthetic background
753
+ * completion notices are included and mapped to assistant-style turns so reload
754
+ * matches the live chat rendering path.
349
755
  */
350
756
  export function getSessionMessages(sessionKey, limit) {
351
757
  const db = getDb();
352
758
  const effectiveLimit = Math.min(limit ?? DEFAULT_SESSION_MESSAGES_LIMIT, MAX_SESSION_MESSAGES_LIMIT);
353
759
  const rows = db
354
760
  .prepare(`SELECT role, content, ts FROM conversation_log
355
- WHERE session_key = ? AND role IN ('user', 'assistant')
761
+ WHERE session_key = ? AND role IN ('user', 'assistant', 'agent_completion')
356
762
  ORDER BY id DESC LIMIT ?`)
357
763
  .all(sessionKey, effectiveLimit);
358
764
  // Reverse so oldest is first (chronological order for the UI)
359
765
  rows.reverse();
360
766
  return rows.map((r) => ({
361
- role: r.role,
767
+ role: r.role === "agent_completion" ? "assistant" : r.role,
362
768
  content: r.content,
363
769
  ts: normalizeSqliteTsToIso(r.ts),
364
770
  }));
@@ -117,6 +117,89 @@ test("getDb migrates legacy agent_tasks tables to add a nullable prompt column",
117
117
  dbModule.closeDb();
118
118
  }
119
119
  });
120
+ test("getDb migrates legacy memory tiers from glacier to cold and preserves explicit tiers", async () => {
121
+ const seedDb = new Database(dbPath);
122
+ seedDb.exec(`
123
+ CREATE TABLE mem_scopes (
124
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
125
+ slug TEXT NOT NULL UNIQUE,
126
+ title TEXT NOT NULL,
127
+ description TEXT NOT NULL DEFAULT '',
128
+ keywords TEXT NOT NULL DEFAULT '[]',
129
+ active INTEGER NOT NULL DEFAULT 1,
130
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
131
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
132
+ );
133
+ INSERT INTO mem_scopes (id, slug, title) VALUES (1, 'chapterhouse', 'Chapterhouse');
134
+ CREATE TABLE mem_entities (
135
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
136
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
137
+ kind TEXT NOT NULL,
138
+ name TEXT NOT NULL,
139
+ summary TEXT,
140
+ tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'glacier')),
141
+ confidence REAL NOT NULL DEFAULT 1.0,
142
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
143
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
144
+ );
145
+ CREATE TABLE mem_observations (
146
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
147
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
148
+ entity_id INTEGER REFERENCES mem_entities(id),
149
+ content TEXT NOT NULL,
150
+ source TEXT NOT NULL,
151
+ tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'glacier')),
152
+ confidence REAL NOT NULL DEFAULT 1.0,
153
+ embedding BLOB,
154
+ superseded_by INTEGER REFERENCES mem_observations(id) ON DELETE SET NULL,
155
+ archived_at DATETIME,
156
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
157
+ );
158
+ CREATE TABLE mem_decisions (
159
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
160
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
161
+ entity_id INTEGER REFERENCES mem_entities(id),
162
+ title TEXT NOT NULL,
163
+ rationale TEXT NOT NULL,
164
+ decided_at TEXT NOT NULL,
165
+ tier TEXT NOT NULL DEFAULT 'hot' CHECK(tier IN ('hot', 'warm', 'glacier')),
166
+ superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL,
167
+ archived_at DATETIME,
168
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
169
+ );
170
+ INSERT INTO mem_entities (id, scope_id, kind, name, tier) VALUES
171
+ (1, 1, 'tool', 'warm kept', 'warm'),
172
+ (2, 1, 'tool', 'cold mapped', 'glacier');
173
+ INSERT INTO mem_observations (id, scope_id, content, source, tier) VALUES
174
+ (1, 1, 'hot kept', 'test', 'hot'),
175
+ (2, 1, 'cold mapped', 'test', 'glacier');
176
+ INSERT INTO mem_decisions (id, scope_id, title, rationale, decided_at, tier) VALUES
177
+ (1, 1, 'warm kept', 'test', '2026-05-01', 'warm'),
178
+ (2, 1, 'cold mapped', 'test', '2026-05-01', 'glacier');
179
+ `);
180
+ seedDb.close();
181
+ const dbModule = await loadDbModule();
182
+ try {
183
+ const db = dbModule.getDb();
184
+ assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_entities ORDER BY id`).all(), [
185
+ { id: 1, tier: "warm" },
186
+ { id: 2, tier: "cold" },
187
+ ]);
188
+ assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_observations ORDER BY id`).all(), [
189
+ { id: 1, tier: "hot" },
190
+ { id: 2, tier: "cold" },
191
+ ]);
192
+ assert.deepEqual(db.prepare(`SELECT id, tier FROM mem_decisions ORDER BY id`).all(), [
193
+ { id: 1, tier: "warm" },
194
+ { id: 2, tier: "cold" },
195
+ ]);
196
+ db.prepare(`INSERT INTO mem_observations (scope_id, content, source, tier) VALUES (1, 'new cold', 'test', 'cold')`).run();
197
+ assert.throws(() => db.prepare(`INSERT INTO mem_observations (scope_id, content, source, tier) VALUES (1, 'legacy', 'test', 'glacier')`).run(), /CHECK constraint failed/);
198
+ }
199
+ finally {
200
+ dbModule.closeDb();
201
+ }
202
+ });
120
203
  test("getDb prunes oversized conversation logs on startup and during inserts", async () => {
121
204
  const seedDb = new Database(dbPath);
122
205
  seedDb.exec(`
@@ -165,28 +248,32 @@ test("getSessionMessages returns empty array for unknown session", async () => {
165
248
  dbModule.closeDb();
166
249
  }
167
250
  });
168
- test("getSessionMessages returns structured messages in chronological order, excludes system rows, respects limit", async () => {
251
+ test("getSessionMessages returns structured messages in chronological order, includes agent completions, excludes system rows, respects limit", async () => {
169
252
  const dbModule = await loadDbModule();
170
253
  try {
171
- dbModule.getDb();
254
+ const db = dbModule.getDb();
172
255
  dbModule.logConversation("user", "hello", "web", "test-session");
173
256
  dbModule.logConversation("assistant", "hi there", "web", "test-session");
174
257
  dbModule.logConversation("system", "system noise", "worker", "test-session");
258
+ db.prepare(`INSERT INTO conversation_log (role, content, source, session_key)
259
+ VALUES ('agent_completion', ?, 'background', 'test-session')`).run("[Agent task completed] @coder finished task task-1:\n\nDone");
175
260
  dbModule.logConversation("user", "second message", "web", "test-session");
176
261
  dbModule.logConversation("user", "from other session", "web", "other-session");
177
262
  const all = dbModule.getSessionMessages("test-session");
178
- assert.equal(all.length, 3, "3 user/assistant rows, system excluded");
263
+ assert.equal(all.length, 4, "user/assistant rows plus agent completion, system excluded");
179
264
  assert.equal(all[0].role, "user");
180
265
  assert.equal(all[0].content, "hello");
181
266
  assert.equal(all[1].role, "assistant");
182
267
  assert.equal(all[1].content, "hi there");
183
- assert.equal(all[2].role, "user");
184
- assert.equal(all[2].content, "second message");
268
+ assert.equal(all[2].role, "assistant");
269
+ assert.equal(all[2].content, "[Agent task completed] @coder finished task task-1:\n\nDone");
270
+ assert.equal(all[3].role, "user");
271
+ assert.equal(all[3].content, "second message");
185
272
  // Limit clamping
186
273
  const limited = dbModule.getSessionMessages("test-session", 2);
187
274
  assert.equal(limited.length, 2, "limit=2 returns 2 most recent rows");
188
- // After reversal, these should be the 2 most-recent (assistant + second user)
189
- assert.equal(limited[0].content, "hi there");
275
+ // After reversal, these should be the 2 most-recent renderable rows.
276
+ assert.equal(limited[0].content, "[Agent task completed] @coder finished task task-1:\n\nDone");
190
277
  assert.equal(limited[1].content, "second message");
191
278
  // Other session not leaked
192
279
  const other = dbModule.getSessionMessages("other-session");