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.
- package/dist/api/server.js +12 -0
- package/dist/api/server.test.js +39 -0
- package/dist/config.js +70 -0
- package/dist/config.test.js +109 -0
- package/dist/copilot/agents.js +27 -4
- package/dist/copilot/agents.test.js +7 -0
- package/dist/copilot/oneshot.js +54 -0
- package/dist/copilot/orchestrator.js +227 -3
- package/dist/copilot/orchestrator.test.js +372 -0
- package/dist/copilot/system-message.js +4 -0
- package/dist/copilot/system-message.test.js +24 -0
- package/dist/copilot/tools.agent.test.js +23 -0
- package/dist/copilot/tools.js +350 -4
- package/dist/copilot/tools.memory.test.js +248 -0
- package/dist/copilot/turn-event-log-env.test.js +19 -0
- package/dist/copilot/turn-event-log.js +22 -23
- package/dist/copilot/turn-event-log.test.js +61 -2
- package/dist/memory/active-scope.js +69 -0
- package/dist/memory/active-scope.test.js +76 -0
- package/dist/memory/checkpoint-prompt.js +71 -0
- package/dist/memory/checkpoint.js +257 -0
- package/dist/memory/checkpoint.test.js +255 -0
- package/dist/memory/decisions.js +53 -0
- package/dist/memory/decisions.test.js +92 -0
- package/dist/memory/entities.js +59 -0
- package/dist/memory/entities.test.js +65 -0
- package/dist/memory/eot.js +219 -0
- package/dist/memory/eot.test.js +263 -0
- package/dist/memory/hot-tier.js +187 -0
- package/dist/memory/hot-tier.test.js +197 -0
- package/dist/memory/housekeeping.js +352 -0
- package/dist/memory/housekeeping.test.js +280 -0
- package/dist/memory/inbox.js +73 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/observations.js +46 -0
- package/dist/memory/observations.test.js +86 -0
- package/dist/memory/recall.js +197 -0
- package/dist/memory/recall.test.js +196 -0
- package/dist/memory/scopes.js +89 -0
- package/dist/memory/scopes.test.js +201 -0
- package/dist/memory/tiering.js +193 -0
- package/dist/memory/types.js +2 -0
- package/dist/paths.js +7 -1
- package/dist/store/db.js +412 -8
- package/dist/store/db.test.js +83 -0
- package/dist/test/setup-env.js +16 -0
- package/dist/test/setup-env.test.js +4 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BRPJa1DK.js → index-DmYLALt0.js} +70 -70
- package/web/dist/assets/index-DmYLALt0.js.map +1 -0
- package/web/dist/index.html +1 -1
- 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 {
|
|
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(
|
|
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,
|
|
@@ -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
|
|
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
|
|
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
|
|
206
|
-
|
|
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
|
|
211
|
-
|
|
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);
|
package/dist/store/db.test.js
CHANGED
|
@@ -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(`
|
package/dist/test/setup-env.js
CHANGED
|
@@ -3,6 +3,22 @@ const RUNTIME_OVERRIDE_ENV_VARS = [
|
|
|
3
3
|
"CHAPTERHOUSE_SELF_EDIT",
|
|
4
4
|
"CHAPTERHOUSE_WORKIQ_AUTO_INSTALL",
|
|
5
5
|
"CHAPTERHOUSE_CHAT_SSE",
|
|
6
|
+
"CHAPTERHOUSE_SSE_BUFFER_CAPACITY",
|
|
7
|
+
"CHAPTERHOUSE_SSE_REPLAY_LIMIT",
|
|
8
|
+
"CHAPTERHOUSE_MEMORY_CHECKPOINT_ENABLED",
|
|
9
|
+
"CHAPTERHOUSE_MEMORY_CHECKPOINT_TURNS",
|
|
10
|
+
"CHAPTERHOUSE_MEMORY_CHECKPOINT_ON_SCOPE_CHANGE",
|
|
11
|
+
"CHAPTERHOUSE_MEMORY_CHECKPOINT_MIN_TURNS_FOR_SCOPE_FIRE",
|
|
12
|
+
"CHAPTERHOUSE_MEMORY_INJECT",
|
|
13
|
+
"CHAPTERHOUSE_MEMORY_AUTO_ACCEPT",
|
|
14
|
+
"CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED",
|
|
15
|
+
"CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED",
|
|
16
|
+
"CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS",
|
|
17
|
+
"CHAPTERHOUSE_MEMORY_DECAY_DAYS",
|
|
18
|
+
"CHAPTERHOUSE_MEMORY_INBOX_RETENTION_DAYS",
|
|
19
|
+
"CHAPTERHOUSE_MEMORY_TIERING_ENABLED",
|
|
20
|
+
"CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST",
|
|
21
|
+
"CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS",
|
|
6
22
|
];
|
|
7
23
|
for (const name of RUNTIME_OVERRIDE_ENV_VARS) {
|
|
8
24
|
delete process.env[name];
|
|
@@ -5,6 +5,8 @@ const RUNTIME_OVERRIDE_ENV_VARS = [
|
|
|
5
5
|
"CHAPTERHOUSE_SELF_EDIT",
|
|
6
6
|
"CHAPTERHOUSE_WORKIQ_AUTO_INSTALL",
|
|
7
7
|
"CHAPTERHOUSE_CHAT_SSE",
|
|
8
|
+
"CHAPTERHOUSE_SSE_BUFFER_CAPACITY",
|
|
9
|
+
"CHAPTERHOUSE_SSE_REPLAY_LIMIT",
|
|
8
10
|
];
|
|
9
11
|
test("setup-env clears ambient Chapterhouse runtime overrides", async () => {
|
|
10
12
|
const originalValues = new Map(["CHAPTERHOUSE_DISABLE_DOTENV", ...RUNTIME_OVERRIDE_ENV_VARS].map((name) => [name, process.env[name]]));
|
|
@@ -14,6 +16,8 @@ test("setup-env clears ambient Chapterhouse runtime overrides", async () => {
|
|
|
14
16
|
process.env.CHAPTERHOUSE_SELF_EDIT = "1";
|
|
15
17
|
process.env.CHAPTERHOUSE_WORKIQ_AUTO_INSTALL = "false";
|
|
16
18
|
process.env.CHAPTERHOUSE_CHAT_SSE = "0";
|
|
19
|
+
process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = "3";
|
|
20
|
+
process.env.CHAPTERHOUSE_SSE_REPLAY_LIMIT = "9";
|
|
17
21
|
await import(`./setup-env.js?cache-bust=${Date.now()}`);
|
|
18
22
|
assert.equal(process.env.CHAPTERHOUSE_DISABLE_DOTENV, "1");
|
|
19
23
|
for (const name of RUNTIME_OVERRIDE_ENV_VARS) {
|
package/package.json
CHANGED