chapterhouse 0.4.1 → 0.4.3
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 +14 -8
- package/dist/api/server.test.js +30 -0
- package/dist/copilot/agents.js +3 -2
- package/dist/copilot/orchestrator.js +48 -10
- package/dist/copilot/orchestrator.test.js +59 -14
- package/dist/copilot/tools.js +183 -7
- package/dist/copilot/tools.memory.test.js +125 -1
- package/dist/copilot/turn-event-log.js +35 -15
- package/dist/copilot/turn-event-log.test.js +31 -0
- package/dist/daemon.js +6 -0
- package/dist/memory/action-items.js +100 -0
- package/dist/memory/action-items.test.js +83 -0
- package/dist/memory/eot.js +28 -3
- package/dist/memory/eot.test.js +111 -3
- package/dist/memory/hot-tier.js +60 -1
- package/dist/memory/hot-tier.test.js +38 -0
- package/dist/memory/housekeeping-scheduler.js +152 -0
- package/dist/memory/housekeeping-scheduler.test.js +187 -0
- package/dist/memory/housekeeping.test.js +26 -26
- package/dist/memory/index.js +1 -0
- package/dist/memory/recall.js +59 -0
- package/dist/memory/recall.test.js +27 -0
- package/dist/memory/tiering.js +33 -3
- package/dist/store/db.js +430 -16
- package/dist/store/db.test.js +452 -10
- package/package.json +1 -1
- package/web/dist/assets/index-BTI_m0OE.css +10 -0
- package/web/dist/assets/{index-DmYLALt0.js → index-D4-uRAi6.js} +52 -52
- package/web/dist/assets/index-D4-uRAi6.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DhY5yWmC.css +0 -10
- package/web/dist/assets/index-DmYLALt0.js.map +0 -1
package/dist/store/db.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { ensureChapterhouseHome, getDbPath } from "../paths.js";
|
|
3
4
|
let db;
|
|
4
5
|
let logInsertCount = 0;
|
|
5
6
|
let fts5Available = false;
|
|
7
|
+
let currentDaemonRunId;
|
|
8
|
+
let daemonRunRecorded = false;
|
|
6
9
|
function hasColumn(database, table, column) {
|
|
7
10
|
return database.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column);
|
|
8
11
|
}
|
|
12
|
+
export function getCurrentRunId() {
|
|
13
|
+
currentDaemonRunId ??= randomUUID();
|
|
14
|
+
return currentDaemonRunId;
|
|
15
|
+
}
|
|
16
|
+
function recordCurrentDaemonRun(database) {
|
|
17
|
+
if (daemonRunRecorded) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
database.prepare(`INSERT OR IGNORE INTO daemon_runs (run_id) VALUES (?)`).run(getCurrentRunId());
|
|
21
|
+
daemonRunRecorded = true;
|
|
22
|
+
}
|
|
9
23
|
function memoryTierCase(tableAlias = "") {
|
|
10
24
|
const prefix = tableAlias ? `${tableAlias}.` : "";
|
|
11
25
|
return `
|
|
@@ -127,7 +141,7 @@ function rebuildMemoryTierTables(database) {
|
|
|
127
141
|
}
|
|
128
142
|
}
|
|
129
143
|
function ensureMemoryTierColumns(database) {
|
|
130
|
-
for (const table of ["mem_entities", "mem_observations", "mem_decisions"]) {
|
|
144
|
+
for (const table of ["mem_entities", "mem_observations", "mem_decisions", "mem_action_items"]) {
|
|
131
145
|
if (!hasColumn(database, table, "tier")) {
|
|
132
146
|
database.exec(`ALTER TABLE ${table} ADD COLUMN tier TEXT DEFAULT 'warm'`);
|
|
133
147
|
}
|
|
@@ -170,12 +184,24 @@ function ensureMemoryTierColumns(database) {
|
|
|
170
184
|
ELSE 'warm'
|
|
171
185
|
END
|
|
172
186
|
`);
|
|
187
|
+
database.exec(`
|
|
188
|
+
UPDATE mem_action_items
|
|
189
|
+
SET tier = CASE
|
|
190
|
+
WHEN status IN ('done', 'dropped') THEN 'cold'
|
|
191
|
+
WHEN tier = 'glacier' THEN 'cold'
|
|
192
|
+
WHEN tier IN ('hot', 'warm', 'cold') THEN tier
|
|
193
|
+
WHEN status = 'open' AND due_at IS NOT NULL AND datetime(due_at) <= datetime('now', '+7 days') THEN 'hot'
|
|
194
|
+
ELSE 'warm'
|
|
195
|
+
END
|
|
196
|
+
`);
|
|
173
197
|
}
|
|
174
198
|
function ensureMemoryIndexes(database) {
|
|
175
199
|
database.exec(`CREATE INDEX IF NOT EXISTS mem_entities_scope_kind_idx ON mem_entities(scope_id, kind)`);
|
|
176
200
|
database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_kind_name_idx ON mem_entities(scope_id, kind, name)`);
|
|
177
201
|
database.exec(`CREATE INDEX IF NOT EXISTS mem_observations_scope_idx ON mem_observations(scope_id)`);
|
|
178
202
|
database.exec(`CREATE INDEX IF NOT EXISTS mem_decisions_scope_idx ON mem_decisions(scope_id)`);
|
|
203
|
+
database.exec(`CREATE INDEX IF NOT EXISTS idx_mem_action_items_scope_status ON mem_action_items(scope_id, status)`);
|
|
204
|
+
database.exec(`CREATE INDEX IF NOT EXISTS idx_mem_action_items_due ON mem_action_items(status, due_at)`);
|
|
179
205
|
}
|
|
180
206
|
const MEMORY_SCOPE_SEEDS = [
|
|
181
207
|
{
|
|
@@ -209,6 +235,272 @@ const MEMORY_SCOPE_SEEDS = [
|
|
|
209
235
|
keywords: ["brian"],
|
|
210
236
|
},
|
|
211
237
|
];
|
|
238
|
+
const CHAPTERHOUSE_WIKI_INDEX_SOURCE = "wiki:pages/projects/chapterhouse/index.md";
|
|
239
|
+
const CHAPTERHOUSE_WIKI_HOT_TIER_REASON = "P6 PR1 wiki migration hot-tier candidate";
|
|
240
|
+
const CHAPTERHOUSE_WIKI_DECISION_HOT_TIER_REASON = "P6 PR2 wiki migration hot-tier candidate";
|
|
241
|
+
const CHAPTERHOUSE_PROJECT_ENTITY_SEED = {
|
|
242
|
+
name: "Chapterhouse",
|
|
243
|
+
kind: "project",
|
|
244
|
+
summary: "Always-on team-level AI assistant daemon that orchestrates specialist subagents and maintains a persistent knowledge wiki.",
|
|
245
|
+
};
|
|
246
|
+
const CHAPTERHOUSE_WIKI_OBSERVATION_SEEDS = [
|
|
247
|
+
{
|
|
248
|
+
content: "Chapterhouse architecture: Node.js daemon, web UI, per-conversation sessions, specialist agents, modular skills, MCP servers, local markdown wiki, project-rules prompt injection, and background workers with SSE event streaming.",
|
|
249
|
+
tier: "warm",
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
content: "Chapterhouse source lives at bketelsen/chapterhouse with local checkout ~/projects/chapterhouse; engineering plans are in docs/plans/ and product specs are in docs/prd/.",
|
|
253
|
+
tier: "warm",
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
content: "Background agent completions arrive as automatic system notifications; after spawning a background agent, end the turn and wait instead of polling read_agent, sleeping, or re-checking list_agents.",
|
|
257
|
+
tier: "hot",
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
content: "Use the default Chapterhouse chat for research and general questions; move code changes against a specific project into that project's chat session.",
|
|
261
|
+
tier: "warm",
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
content: "Squad manages Chapterhouse's own development workflow.",
|
|
265
|
+
tier: "warm",
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
content: "Chapterhouse uses frequent semver patch releases published to npm via the Publish to npm GitHub Actions workflow on tag push; never publish manually.",
|
|
269
|
+
tier: "hot",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
content: "Agent-memory P1 shipped on 2026-05-13 as Chapterhouse v0.4.0 across 7 PRs: #215 through #225.",
|
|
273
|
+
tier: "warm",
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
content: "The v0.3.2 per-session orchestrator was confirmed under real concurrent load on 2026-05-08, enabling concurrent sessions without cross-blocking.",
|
|
277
|
+
tier: "hot",
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
const CHAPTERHOUSE_WIKI_DECISION_SEEDS = [
|
|
281
|
+
{
|
|
282
|
+
title: "Standardize API validation and JSON errors around `src/api/errors.ts`",
|
|
283
|
+
rationale: "Keep one 400/403/404/500 contract across routes instead of ad hoc parsing and responses; `src/api/errors.ts`, `src/api/server.ts`.",
|
|
284
|
+
decidedAt: "2026-05-06",
|
|
285
|
+
tier: "warm",
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
title: "Run in standalone mode when neither Entra nor an API token is configured",
|
|
289
|
+
rationale: "Disable auth checks and team sync together so local single-user startup still works; `src/config.ts`, `src/api/auth.ts`.",
|
|
290
|
+
decidedAt: "2026-05-06",
|
|
291
|
+
tier: "warm",
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
title: "Use bearer auth for SSE and serialize wiki writes",
|
|
295
|
+
rationale: "`/stream` no longer accepts query tokens, and wiki mutations go through `withWikiWrite()`; `web/src/stream.ts`, `src/wiki/lock.ts`.",
|
|
296
|
+
decidedAt: "2026-05-06",
|
|
297
|
+
tier: "warm",
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
title: "Default production to least privilege",
|
|
301
|
+
rationale: "Run as a non-root container, validate env early, and keep CORS and security headers tight in production; `Dockerfile`, `deploy/deploy.sh`, `src/config.ts`.",
|
|
302
|
+
decidedAt: "2026-05-06",
|
|
303
|
+
tier: "warm",
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
title: "Keep chat state session-scoped by `sessionKey`",
|
|
307
|
+
rationale: "Conversation history, SSE routing, and frontend buffers stay isolated per session instead of bleeding across chats; `src/store/db.ts`, `web/src/store.ts`.",
|
|
308
|
+
decidedAt: "2026-05-07",
|
|
309
|
+
tier: "warm",
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
title: "Preserve the 3-layer daemon timing contract",
|
|
313
|
+
rationale: "Orchestrator timeout must stay below daemon shutdown grace, which must stay below systemd `TimeoutStopSec`; `README.md`, `src/daemon.ts`, `src/daemon-install.ts`.",
|
|
314
|
+
decidedAt: "2026-05-08",
|
|
315
|
+
tier: "hot",
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
title: "Start WorkIQ MCP auto-install at daemon startup",
|
|
319
|
+
rationale: "Write the MCP entry before the SDK client starts so new sessions see it immediately; issue #33, PR #78.",
|
|
320
|
+
decidedAt: "2026-05-08",
|
|
321
|
+
tier: "warm",
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
title: "Use per-session orchestrators instead of a global queue",
|
|
325
|
+
rationale: "`SessionManager` and `SessionRegistry` keep independent queues so one chat cannot block another; issue #74, `src/copilot/session-manager.ts`.",
|
|
326
|
+
decidedAt: "2026-05-08",
|
|
327
|
+
tier: "hot",
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
title: "Make wiki path hierarchy the primary navigation model",
|
|
331
|
+
rationale: "`path` drives the tree and breadcrumbs, while `section` remains secondary metadata; `web/src/wiki/index.ts`, `web/src/routes/Wiki.tsx`.",
|
|
332
|
+
decidedAt: "2026-05-06",
|
|
333
|
+
tier: "warm",
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
title: "Route wiki breadcrumbs through `selected` state",
|
|
337
|
+
rationale: "Breadcrumb clicks use the same route-state flow as the sidebar so navigation stays predictable; `web/src/components/wiki/WikiBreadcrumbs.tsx`.",
|
|
338
|
+
decidedAt: "2026-05-06",
|
|
339
|
+
tier: "warm",
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
title: "Derive wiki page scope from configured team paths",
|
|
343
|
+
rationale: "The API returns `scope` and the UI shows personal/team indicators without adding new persisted metadata; `src/api/server.ts`, `web/src/components/wiki/WikiScopeIndicator.tsx`.",
|
|
344
|
+
decidedAt: "2026-05-06",
|
|
345
|
+
tier: "warm",
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
title: "Sort sidebar recents by actual project activity",
|
|
349
|
+
rationale: "`last_used_at` replaces registration time so recent projects reflect chat usage rather than signup order; issue #26, PR #30.",
|
|
350
|
+
decidedAt: "2026-05-08",
|
|
351
|
+
tier: "warm",
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
title: "Preload `CHAPTERHOUSE_DISABLE_DOTENV=1` for Node tests",
|
|
355
|
+
rationale: "Tests must not inherit developer-local env files before config singletons initialize; `src/test/setup-env.ts`, `src/config.ts`.",
|
|
356
|
+
decidedAt: "2026-05-06",
|
|
357
|
+
tier: "warm",
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
title: "Test API routes through a spawned server process",
|
|
361
|
+
rationale: "Real HTTP coverage catches auth, config preload, error middleware, and wiki I/O behavior that an unexported in-process app would miss; `src/api/server.test.ts`.",
|
|
362
|
+
decidedAt: "2026-05-06",
|
|
363
|
+
tier: "warm",
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
title: "Centralize frontend API Zod schemas in `web/src/api-schemas.ts`",
|
|
367
|
+
rationale: "Keep one audit surface for the browser-to-daemon contract and require validated JSON reads; issue #40, PR #63.",
|
|
368
|
+
decidedAt: "2026-05-08",
|
|
369
|
+
tier: "warm",
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
title: "Make frontend test isolation an infrastructure concern",
|
|
373
|
+
rationale: "Vitest globals, `test-setup.ts`, and MSW own cleanup so tests do not hand-roll it; issue #43, PR #62.",
|
|
374
|
+
decidedAt: "2026-05-08",
|
|
375
|
+
tier: "warm",
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
title: "Clean `dist/` before `npm test`",
|
|
379
|
+
rationale: "Remove stale compiled artifacts so deleted tests cannot linger in the Node test run; `package.json`.",
|
|
380
|
+
decidedAt: "2026-05-08",
|
|
381
|
+
tier: "warm",
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
title: "Adopt `pino` as the backend logger",
|
|
385
|
+
rationale: "Use `childLogger()` everywhere and keep chat content at `debug`, not `info`; issue #13, PR #28.",
|
|
386
|
+
decidedAt: "2026-05-08",
|
|
387
|
+
tier: "warm",
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
title: "Keep daemon control as a thin CLI over `src/daemon-install.ts`",
|
|
391
|
+
rationale: "Generate service artifacts in one module and support launchd and systemd user services without a separate control layer; issue #14, PR #24.",
|
|
392
|
+
decidedAt: "2026-05-08",
|
|
393
|
+
tier: "warm",
|
|
394
|
+
},
|
|
395
|
+
{
|
|
396
|
+
title: "Default publish workflows to Node 24+",
|
|
397
|
+
rationale: "npm Trusted Publishing depends on npm 11.5.1+, which Node 22 does not provide; `.github/workflows/npm-publish.yml`.",
|
|
398
|
+
decidedAt: "2026-05-08",
|
|
399
|
+
tier: "warm",
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
title: "Make `chapterhouse update` registry-aware",
|
|
403
|
+
rationale: "Registry installs update via npm, while legacy git installs keep the older pull-and-rebuild path; issue #31, PR #32.",
|
|
404
|
+
decidedAt: "2026-05-08",
|
|
405
|
+
tier: "warm",
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
title: "Keep markdownlint pragmatic rather than maximalist",
|
|
409
|
+
rationale: "Disable MD060, ignore `.github/agents/**`, and suppress MD041 only where template UX requires it; `.markdownlint-cli2.jsonc`.",
|
|
410
|
+
decidedAt: "2026-05-08",
|
|
411
|
+
tier: "warm",
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
title: "Enforce issue-closing references in feature and fix PRs",
|
|
415
|
+
rationale: "PR bodies must include `Closes #N`, `Fixes #N`, or `Resolves #N`, with a documented `no-issue` bypass; PR #60, `.github/workflows/lint-pr-closes.yml`.",
|
|
416
|
+
decidedAt: "2026-05-08",
|
|
417
|
+
tier: "warm",
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
title: "Use Conventional Commits for commits and PR titles",
|
|
421
|
+
rationale: "Keep repo history machine-readable and enforce it with commitlint, husky, and PR-title linting; `commitlint.config.js`, `.github/workflows/lint-pr-title.yml`.",
|
|
422
|
+
decidedAt: "2026-05-08",
|
|
423
|
+
tier: "warm",
|
|
424
|
+
},
|
|
425
|
+
];
|
|
426
|
+
function seedChapterhouseWikiIndexMemory(database) {
|
|
427
|
+
database.transaction(() => {
|
|
428
|
+
const chapterhouseScope = database.prepare(`
|
|
429
|
+
SELECT id
|
|
430
|
+
FROM mem_scopes
|
|
431
|
+
WHERE slug = 'chapterhouse'
|
|
432
|
+
`).get();
|
|
433
|
+
if (!chapterhouseScope) {
|
|
434
|
+
throw new Error("Cannot seed Chapterhouse wiki memory because scope 'chapterhouse' does not exist.");
|
|
435
|
+
}
|
|
436
|
+
database.prepare(`
|
|
437
|
+
INSERT INTO mem_entities (scope_id, kind, name, summary, tier, confidence, created_at, updated_at)
|
|
438
|
+
VALUES (?, ?, ?, ?, 'warm', 1.0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
|
439
|
+
ON CONFLICT(scope_id, kind, name) DO UPDATE SET
|
|
440
|
+
summary = excluded.summary,
|
|
441
|
+
updated_at = CURRENT_TIMESTAMP
|
|
442
|
+
`).run(chapterhouseScope.id, CHAPTERHOUSE_PROJECT_ENTITY_SEED.kind, CHAPTERHOUSE_PROJECT_ENTITY_SEED.name, CHAPTERHOUSE_PROJECT_ENTITY_SEED.summary);
|
|
443
|
+
const chapterhouseEntity = database.prepare(`
|
|
444
|
+
SELECT id
|
|
445
|
+
FROM mem_entities
|
|
446
|
+
WHERE scope_id = ? AND kind = ? AND name = ?
|
|
447
|
+
`).get(chapterhouseScope.id, CHAPTERHOUSE_PROJECT_ENTITY_SEED.kind, CHAPTERHOUSE_PROJECT_ENTITY_SEED.name);
|
|
448
|
+
if (!chapterhouseEntity) {
|
|
449
|
+
throw new Error("Cannot seed Chapterhouse wiki observations because the Chapterhouse entity was not created.");
|
|
450
|
+
}
|
|
451
|
+
const insertObservation = database.prepare(`
|
|
452
|
+
INSERT INTO mem_observations (
|
|
453
|
+
scope_id, entity_id, content, source, tier, confidence, tier_pinned_at, tier_reason, created_at
|
|
454
|
+
)
|
|
455
|
+
SELECT ?, ?, ?, ?, ?, 1.0,
|
|
456
|
+
CASE WHEN ? = 'hot' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
|
457
|
+
CASE WHEN ? = 'hot' THEN ? ELSE NULL END,
|
|
458
|
+
CURRENT_TIMESTAMP
|
|
459
|
+
WHERE NOT EXISTS (
|
|
460
|
+
SELECT 1 FROM mem_observations WHERE scope_id = ? AND content = ?
|
|
461
|
+
)
|
|
462
|
+
`);
|
|
463
|
+
const updateObservation = database.prepare(`
|
|
464
|
+
UPDATE mem_observations
|
|
465
|
+
SET entity_id = ?,
|
|
466
|
+
source = ?,
|
|
467
|
+
tier = CASE WHEN ? = 'hot' THEN 'hot' ELSE tier END,
|
|
468
|
+
tier_pinned_at = CASE WHEN ? = 'hot' THEN COALESCE(tier_pinned_at, CURRENT_TIMESTAMP) ELSE tier_pinned_at END,
|
|
469
|
+
tier_reason = CASE WHEN ? = 'hot' THEN ? ELSE tier_reason END
|
|
470
|
+
WHERE scope_id = ? AND content = ?
|
|
471
|
+
`);
|
|
472
|
+
for (const observation of CHAPTERHOUSE_WIKI_OBSERVATION_SEEDS) {
|
|
473
|
+
insertObservation.run(chapterhouseScope.id, chapterhouseEntity.id, observation.content, CHAPTERHOUSE_WIKI_INDEX_SOURCE, observation.tier, observation.tier, observation.tier, CHAPTERHOUSE_WIKI_HOT_TIER_REASON, chapterhouseScope.id, observation.content);
|
|
474
|
+
updateObservation.run(chapterhouseEntity.id, CHAPTERHOUSE_WIKI_INDEX_SOURCE, observation.tier, observation.tier, observation.tier, CHAPTERHOUSE_WIKI_HOT_TIER_REASON, chapterhouseScope.id, observation.content);
|
|
475
|
+
}
|
|
476
|
+
const insertDecision = database.prepare(`
|
|
477
|
+
INSERT INTO mem_decisions (
|
|
478
|
+
scope_id, entity_id, title, rationale, decided_at, tier, tier_pinned_at, tier_reason, created_at
|
|
479
|
+
)
|
|
480
|
+
SELECT ?, ?, ?, ?, ?, ?,
|
|
481
|
+
CASE WHEN ? = 'hot' THEN CURRENT_TIMESTAMP ELSE NULL END,
|
|
482
|
+
CASE WHEN ? = 'hot' THEN ? ELSE NULL END,
|
|
483
|
+
CURRENT_TIMESTAMP
|
|
484
|
+
WHERE NOT EXISTS (
|
|
485
|
+
SELECT 1 FROM mem_decisions WHERE scope_id = ? AND title = ?
|
|
486
|
+
)
|
|
487
|
+
`);
|
|
488
|
+
const updateDecision = database.prepare(`
|
|
489
|
+
UPDATE mem_decisions
|
|
490
|
+
SET entity_id = ?,
|
|
491
|
+
rationale = ?,
|
|
492
|
+
decided_at = ?,
|
|
493
|
+
tier = ?,
|
|
494
|
+
tier_pinned_at = CASE WHEN ? = 'hot' THEN COALESCE(tier_pinned_at, CURRENT_TIMESTAMP) ELSE NULL END,
|
|
495
|
+
tier_reason = CASE WHEN ? = 'hot' THEN ? ELSE NULL END
|
|
496
|
+
WHERE scope_id = ? AND title = ?
|
|
497
|
+
`);
|
|
498
|
+
for (const decision of CHAPTERHOUSE_WIKI_DECISION_SEEDS) {
|
|
499
|
+
insertDecision.run(chapterhouseScope.id, chapterhouseEntity.id, decision.title, decision.rationale, decision.decidedAt, decision.tier, decision.tier, decision.tier, CHAPTERHOUSE_WIKI_DECISION_HOT_TIER_REASON, chapterhouseScope.id, decision.title);
|
|
500
|
+
updateDecision.run(chapterhouseEntity.id, decision.rationale, decision.decidedAt, decision.tier, decision.tier, decision.tier, CHAPTERHOUSE_WIKI_DECISION_HOT_TIER_REASON, chapterhouseScope.id, decision.title);
|
|
501
|
+
}
|
|
502
|
+
})();
|
|
503
|
+
}
|
|
212
504
|
export function getDb() {
|
|
213
505
|
if (!db) {
|
|
214
506
|
ensureChapterhouseHome();
|
|
@@ -267,11 +559,23 @@ export function getDb() {
|
|
|
267
559
|
)
|
|
268
560
|
`);
|
|
269
561
|
db.exec(`
|
|
562
|
+
CREATE TABLE IF NOT EXISTS daemon_runs (
|
|
563
|
+
run_id TEXT PRIMARY KEY,
|
|
564
|
+
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
565
|
+
)
|
|
566
|
+
`);
|
|
567
|
+
recordCurrentDaemonRun(db);
|
|
568
|
+
db.exec(`
|
|
270
569
|
CREATE TABLE IF NOT EXISTS conversation_log (
|
|
271
570
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
272
571
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
|
|
273
572
|
content TEXT NOT NULL,
|
|
274
573
|
source TEXT NOT NULL DEFAULT 'unknown',
|
|
574
|
+
session_key TEXT NOT NULL DEFAULT 'default',
|
|
575
|
+
turn_id TEXT,
|
|
576
|
+
agent_slug TEXT,
|
|
577
|
+
agent_display_name TEXT,
|
|
578
|
+
run_id TEXT,
|
|
275
579
|
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
276
580
|
)
|
|
277
581
|
`);
|
|
@@ -293,16 +597,31 @@ export function getDb() {
|
|
|
293
597
|
catch {
|
|
294
598
|
// CHECK constraint doesn't allow current synthetic roles — recreate table preserving data
|
|
295
599
|
db.exec(`ALTER TABLE conversation_log RENAME TO conversation_log_old`);
|
|
600
|
+
const oldConvCols = db.prepare(`PRAGMA table_info(conversation_log_old)`).all();
|
|
601
|
+
const oldConvColNames = new Set(oldConvCols.map((column) => column.name));
|
|
602
|
+
const sessionKeySelect = oldConvColNames.has("session_key") ? "session_key" : "'default'";
|
|
603
|
+
const turnIdSelect = oldConvColNames.has("turn_id") ? "turn_id" : "NULL";
|
|
604
|
+
const agentSlugSelect = oldConvColNames.has("agent_slug") ? "agent_slug" : "NULL";
|
|
605
|
+
const agentDisplayNameSelect = oldConvColNames.has("agent_display_name") ? "agent_display_name" : "NULL";
|
|
606
|
+
const runIdSelect = oldConvColNames.has("run_id") ? "run_id" : "NULL";
|
|
296
607
|
db.exec(`
|
|
297
608
|
CREATE TABLE conversation_log (
|
|
298
609
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
299
610
|
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system', 'agent_completion')),
|
|
300
611
|
content TEXT NOT NULL,
|
|
301
612
|
source TEXT NOT NULL DEFAULT 'unknown',
|
|
613
|
+
session_key TEXT NOT NULL DEFAULT 'default',
|
|
614
|
+
turn_id TEXT,
|
|
615
|
+
agent_slug TEXT,
|
|
616
|
+
agent_display_name TEXT,
|
|
617
|
+
run_id TEXT,
|
|
302
618
|
ts DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
303
619
|
)
|
|
304
620
|
`);
|
|
305
|
-
db.exec(`
|
|
621
|
+
db.exec(`
|
|
622
|
+
INSERT INTO conversation_log (role, content, source, session_key, turn_id, agent_slug, agent_display_name, run_id, ts)
|
|
623
|
+
SELECT role, content, source, ${sessionKeySelect}, ${turnIdSelect}, ${agentSlugSelect}, ${agentDisplayNameSelect}, ${runIdSelect}, ts FROM conversation_log_old
|
|
624
|
+
`);
|
|
306
625
|
db.exec(`DROP TABLE conversation_log_old`);
|
|
307
626
|
}
|
|
308
627
|
// New persistent session table — one row per chat session (default or project)
|
|
@@ -316,11 +635,24 @@ export function getDb() {
|
|
|
316
635
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
317
636
|
)
|
|
318
637
|
`);
|
|
319
|
-
// Migrate: add
|
|
638
|
+
// Migrate: add metadata columns to conversation_log if not present
|
|
320
639
|
const convCols = db.prepare(`PRAGMA table_info(conversation_log)`).all();
|
|
321
640
|
if (!convCols.some((c) => c.name === 'session_key')) {
|
|
322
641
|
db.exec(`ALTER TABLE conversation_log ADD COLUMN session_key TEXT NOT NULL DEFAULT 'default'`);
|
|
323
642
|
}
|
|
643
|
+
if (!convCols.some((c) => c.name === 'turn_id')) {
|
|
644
|
+
db.exec(`ALTER TABLE conversation_log ADD COLUMN turn_id TEXT`);
|
|
645
|
+
}
|
|
646
|
+
if (!convCols.some((c) => c.name === 'agent_slug')) {
|
|
647
|
+
db.exec(`ALTER TABLE conversation_log ADD COLUMN agent_slug TEXT`);
|
|
648
|
+
}
|
|
649
|
+
if (!convCols.some((c) => c.name === 'agent_display_name')) {
|
|
650
|
+
db.exec(`ALTER TABLE conversation_log ADD COLUMN agent_display_name TEXT`);
|
|
651
|
+
}
|
|
652
|
+
if (!convCols.some((c) => c.name === "run_id")) {
|
|
653
|
+
db.exec(`ALTER TABLE conversation_log ADD COLUMN run_id TEXT`);
|
|
654
|
+
}
|
|
655
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_conversation_log_session_run ON conversation_log(session_key, run_id, id)`);
|
|
324
656
|
// Migrate: add session_key column to agent_tasks if not present
|
|
325
657
|
const taskCols = db.prepare(`PRAGMA table_info(agent_tasks)`).all();
|
|
326
658
|
if (!taskCols.some((c) => c.name === 'session_key')) {
|
|
@@ -384,14 +716,20 @@ export function getDb() {
|
|
|
384
716
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
385
717
|
turn_id TEXT NOT NULL,
|
|
386
718
|
session_key TEXT NOT NULL DEFAULT 'default',
|
|
719
|
+
run_id TEXT,
|
|
387
720
|
seq INTEGER NOT NULL,
|
|
388
721
|
ts INTEGER NOT NULL,
|
|
389
722
|
event_type TEXT NOT NULL,
|
|
390
723
|
payload TEXT NOT NULL
|
|
391
724
|
)
|
|
392
725
|
`);
|
|
726
|
+
const turnEventCols = db.prepare(`PRAGMA table_info(turn_events)`).all();
|
|
727
|
+
if (!turnEventCols.some((c) => c.name === "run_id")) {
|
|
728
|
+
db.exec(`ALTER TABLE turn_events ADD COLUMN run_id TEXT`);
|
|
729
|
+
}
|
|
393
730
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_events_turn_id ON turn_events(turn_id, seq)`);
|
|
394
731
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_events_session_key ON turn_events(session_key, seq)`);
|
|
732
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_turn_events_session_run ON turn_events(session_key, run_id, seq)`);
|
|
395
733
|
db.exec(`
|
|
396
734
|
CREATE TABLE IF NOT EXISTS mem_scopes (
|
|
397
735
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -471,6 +809,27 @@ export function getDb() {
|
|
|
471
809
|
last_recalled_at DATETIME,
|
|
472
810
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
473
811
|
)
|
|
812
|
+
`);
|
|
813
|
+
db.exec(`
|
|
814
|
+
CREATE TABLE IF NOT EXISTS mem_action_items (
|
|
815
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
816
|
+
scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
|
|
817
|
+
entity_id INTEGER REFERENCES mem_entities(id),
|
|
818
|
+
title TEXT NOT NULL,
|
|
819
|
+
detail TEXT,
|
|
820
|
+
status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'done', 'dropped', 'snoozed')),
|
|
821
|
+
due_at TEXT,
|
|
822
|
+
snooze_until TEXT,
|
|
823
|
+
source TEXT,
|
|
824
|
+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
825
|
+
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
826
|
+
resolved_at TEXT,
|
|
827
|
+
resolution_reason TEXT,
|
|
828
|
+
tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
|
|
829
|
+
tier_pinned_at TEXT,
|
|
830
|
+
tier_reason TEXT,
|
|
831
|
+
last_recalled_at TEXT
|
|
832
|
+
)
|
|
474
833
|
`);
|
|
475
834
|
const decisionCols = db.prepare(`PRAGMA table_info(mem_decisions)`).all();
|
|
476
835
|
if (!decisionCols.some((column) => column.name === "superseded_by")) {
|
|
@@ -519,6 +878,7 @@ export function getDb() {
|
|
|
519
878
|
}
|
|
520
879
|
});
|
|
521
880
|
seedMemoryScopes();
|
|
881
|
+
seedChapterhouseWikiIndexMemory(db);
|
|
522
882
|
// Prune conversation log at startup — keep more history for better recovery
|
|
523
883
|
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 1000)`).run();
|
|
524
884
|
// Set up FTS5 for memory search (graceful fallback if not available)
|
|
@@ -541,6 +901,13 @@ export function getDb() {
|
|
|
541
901
|
rationale,
|
|
542
902
|
content_rowid='id'
|
|
543
903
|
)
|
|
904
|
+
`);
|
|
905
|
+
db.exec(`
|
|
906
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS mem_action_items_fts USING fts5(
|
|
907
|
+
title,
|
|
908
|
+
detail,
|
|
909
|
+
content_rowid='id'
|
|
910
|
+
)
|
|
544
911
|
`);
|
|
545
912
|
// Sync triggers
|
|
546
913
|
db.exec(`DROP TRIGGER IF EXISTS memories_ai`);
|
|
@@ -552,6 +919,9 @@ export function getDb() {
|
|
|
552
919
|
db.exec(`DROP TRIGGER IF EXISTS mem_decisions_ai`);
|
|
553
920
|
db.exec(`DROP TRIGGER IF EXISTS mem_decisions_ad`);
|
|
554
921
|
db.exec(`DROP TRIGGER IF EXISTS mem_decisions_au`);
|
|
922
|
+
db.exec(`DROP TRIGGER IF EXISTS mem_action_items_ai`);
|
|
923
|
+
db.exec(`DROP TRIGGER IF EXISTS mem_action_items_ad`);
|
|
924
|
+
db.exec(`DROP TRIGGER IF EXISTS mem_action_items_au`);
|
|
555
925
|
db.exec(`
|
|
556
926
|
CREATE TRIGGER memories_ai AFTER INSERT ON memories BEGIN
|
|
557
927
|
INSERT INTO memories_fts(rowid, content) VALUES (new.id, new.content);
|
|
@@ -601,6 +971,24 @@ export function getDb() {
|
|
|
601
971
|
INSERT INTO mem_decisions_fts(rowid, title, rationale)
|
|
602
972
|
VALUES (new.id, new.title, new.rationale);
|
|
603
973
|
END
|
|
974
|
+
`);
|
|
975
|
+
db.exec(`
|
|
976
|
+
CREATE TRIGGER mem_action_items_ai AFTER INSERT ON mem_action_items BEGIN
|
|
977
|
+
INSERT INTO mem_action_items_fts(rowid, title, detail)
|
|
978
|
+
VALUES (new.id, new.title, new.detail);
|
|
979
|
+
END
|
|
980
|
+
`);
|
|
981
|
+
db.exec(`
|
|
982
|
+
CREATE TRIGGER mem_action_items_ad AFTER DELETE ON mem_action_items BEGIN
|
|
983
|
+
DELETE FROM mem_action_items_fts WHERE rowid = old.id;
|
|
984
|
+
END
|
|
985
|
+
`);
|
|
986
|
+
db.exec(`
|
|
987
|
+
CREATE TRIGGER mem_action_items_au AFTER UPDATE ON mem_action_items BEGIN
|
|
988
|
+
DELETE FROM mem_action_items_fts WHERE rowid = old.id;
|
|
989
|
+
INSERT INTO mem_action_items_fts(rowid, title, detail)
|
|
990
|
+
VALUES (new.id, new.title, new.detail);
|
|
991
|
+
END
|
|
604
992
|
`);
|
|
605
993
|
// Backfill: check if FTS is in sync by comparing row counts
|
|
606
994
|
const memCount = db.prepare(`SELECT COUNT(*) as c FROM memories`).get().c;
|
|
@@ -618,6 +1006,11 @@ export function getDb() {
|
|
|
618
1006
|
if (decisionCount > 0 && decisionFtsCount < decisionCount) {
|
|
619
1007
|
db.exec(`INSERT INTO mem_decisions_fts(mem_decisions_fts) VALUES ('rebuild')`);
|
|
620
1008
|
}
|
|
1009
|
+
const actionItemCount = db.prepare(`SELECT COUNT(*) as c FROM mem_action_items`).get().c;
|
|
1010
|
+
const actionItemFtsCount = db.prepare(`SELECT COUNT(*) as c FROM mem_action_items_fts`).get().c;
|
|
1011
|
+
if (actionItemCount > 0 && actionItemFtsCount < actionItemCount) {
|
|
1012
|
+
db.exec(`INSERT INTO mem_action_items_fts(mem_action_items_fts) VALUES ('rebuild')`);
|
|
1013
|
+
}
|
|
621
1014
|
fts5Available = true;
|
|
622
1015
|
}
|
|
623
1016
|
catch {
|
|
@@ -646,9 +1039,10 @@ export function deleteState(key) {
|
|
|
646
1039
|
db.prepare(`DELETE FROM max_state WHERE key = ?`).run(key);
|
|
647
1040
|
}
|
|
648
1041
|
/** Log a conversation turn (user, assistant, or system). */
|
|
649
|
-
export function logConversation(role, content, source, sessionKey = "default") {
|
|
1042
|
+
export function logConversation(role, content, source, sessionKey = "default", metadata) {
|
|
650
1043
|
const db = getDb();
|
|
651
|
-
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key
|
|
1044
|
+
db.prepare(`INSERT INTO conversation_log (role, content, source, session_key, turn_id, agent_slug, agent_display_name, run_id)
|
|
1045
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(role, content, source, sessionKey, metadata?.turnId ?? null, metadata?.agentSlug ?? null, metadata?.agentDisplayName ?? null, getCurrentRunId());
|
|
652
1046
|
// Keep last 1000 entries to support context recovery after session loss
|
|
653
1047
|
logInsertCount++;
|
|
654
1048
|
if (logInsertCount % 50 === 0) {
|
|
@@ -753,21 +1147,40 @@ export function normalizeSqliteTsToIso(ts) {
|
|
|
753
1147
|
* completion notices are included and mapped to assistant-style turns so reload
|
|
754
1148
|
* matches the live chat rendering path.
|
|
755
1149
|
*/
|
|
756
|
-
export function getSessionMessages(sessionKey, limit) {
|
|
1150
|
+
export function getSessionMessages(sessionKey, limit, options = {}) {
|
|
757
1151
|
const db = getDb();
|
|
758
1152
|
const effectiveLimit = Math.min(limit ?? DEFAULT_SESSION_MESSAGES_LIMIT, MAX_SESSION_MESSAGES_LIMIT);
|
|
759
|
-
const
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1153
|
+
const includeHistorical = options.includeHistorical ?? false;
|
|
1154
|
+
const runId = options.runId ?? getCurrentRunId();
|
|
1155
|
+
const rows = includeHistorical
|
|
1156
|
+
? db
|
|
1157
|
+
.prepare(`SELECT id, role, content, ts, turn_id, agent_slug, agent_display_name FROM conversation_log
|
|
1158
|
+
WHERE session_key = ? AND role IN ('user', 'assistant', 'agent_completion')
|
|
1159
|
+
ORDER BY id DESC LIMIT ?`)
|
|
1160
|
+
.all(sessionKey, effectiveLimit)
|
|
1161
|
+
: db
|
|
1162
|
+
.prepare(`SELECT id, role, content, ts, turn_id, agent_slug, agent_display_name FROM conversation_log
|
|
1163
|
+
WHERE session_key = ? AND run_id = ? AND role IN ('user', 'assistant', 'agent_completion')
|
|
1164
|
+
ORDER BY id DESC LIMIT ?`)
|
|
1165
|
+
.all(sessionKey, runId, effectiveLimit);
|
|
764
1166
|
// Reverse so oldest is first (chronological order for the UI)
|
|
765
1167
|
rows.reverse();
|
|
766
|
-
return rows.map((r) =>
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
1168
|
+
return rows.map((r) => {
|
|
1169
|
+
const message = {
|
|
1170
|
+
id: r.id,
|
|
1171
|
+
role: r.role === "agent_completion" ? "assistant" : r.role,
|
|
1172
|
+
content: r.content,
|
|
1173
|
+
ts: normalizeSqliteTsToIso(r.ts),
|
|
1174
|
+
turn_id: r.turn_id,
|
|
1175
|
+
};
|
|
1176
|
+
if (r.turn_id)
|
|
1177
|
+
message.turnId = r.turn_id;
|
|
1178
|
+
if (r.agent_slug)
|
|
1179
|
+
message.agentSlug = r.agent_slug;
|
|
1180
|
+
if (r.agent_display_name)
|
|
1181
|
+
message.agentDisplayName = r.agent_display_name;
|
|
1182
|
+
return message;
|
|
1183
|
+
});
|
|
771
1184
|
}
|
|
772
1185
|
/**
|
|
773
1186
|
* Append one event to agent_task_events and return the new event.
|
|
@@ -825,6 +1238,7 @@ export function closeDb() {
|
|
|
825
1238
|
if (db) {
|
|
826
1239
|
db.close();
|
|
827
1240
|
db = undefined;
|
|
1241
|
+
daemonRunRecorded = false;
|
|
828
1242
|
}
|
|
829
1243
|
}
|
|
830
1244
|
//# sourceMappingURL=db.js.map
|