chapterhouse 0.6.0 → 0.8.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 (80) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/agent-edit-access.js +11 -0
  3. package/dist/api/agents.api.test.js +48 -0
  4. package/dist/api/korg.js +34 -0
  5. package/dist/api/korg.test.js +42 -0
  6. package/dist/api/server.js +420 -13
  7. package/dist/api/server.test.js +533 -3
  8. package/dist/config.js +28 -0
  9. package/dist/config.test.js +20 -0
  10. package/dist/copilot/agent-event-bus.js +1 -0
  11. package/dist/copilot/agents.js +117 -50
  12. package/dist/copilot/agents.mcp-servers.test.js +87 -0
  13. package/dist/copilot/agents.parse.test.js +69 -0
  14. package/dist/copilot/agents.test.js +137 -2
  15. package/dist/copilot/orchestrator.js +62 -13
  16. package/dist/copilot/orchestrator.test.js +130 -8
  17. package/dist/copilot/session-manager.js +34 -0
  18. package/dist/copilot/system-message.js +11 -10
  19. package/dist/copilot/system-message.test.js +6 -1
  20. package/dist/copilot/tools.js +184 -376
  21. package/dist/copilot/tools.memory.test.js +32 -0
  22. package/dist/copilot/tools.wiki.test.js +53 -59
  23. package/dist/daemon.js +9 -0
  24. package/dist/memory/decisions.js +6 -5
  25. package/dist/memory/entities.js +20 -9
  26. package/dist/memory/hooks.js +151 -0
  27. package/dist/memory/hooks.test.js +325 -0
  28. package/dist/memory/hot-tier.js +37 -0
  29. package/dist/memory/hot-tier.test.js +30 -0
  30. package/dist/memory/housekeeping-scheduler.js +35 -0
  31. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  32. package/dist/memory/inbox.js +10 -0
  33. package/dist/memory/index.js +3 -1
  34. package/dist/memory/migration.js +244 -0
  35. package/dist/memory/migration.test.js +100 -0
  36. package/dist/memory/reflect.js +273 -0
  37. package/dist/memory/reflect.test.js +254 -0
  38. package/dist/store/db.js +119 -4
  39. package/dist/store/db.test.js +19 -1
  40. package/dist/test/setup-env.js +3 -1
  41. package/dist/test/setup-env.test.js +8 -1
  42. package/dist/wiki/consolidation.js +641 -0
  43. package/dist/wiki/consolidation.test.js +140 -0
  44. package/dist/wiki/frontmatter.js +48 -0
  45. package/dist/wiki/frontmatter.test.js +42 -0
  46. package/dist/wiki/index-manager.js +246 -330
  47. package/dist/wiki/index-manager.test.js +138 -145
  48. package/dist/wiki/ingest.js +347 -0
  49. package/dist/wiki/ingest.test.js +111 -0
  50. package/dist/wiki/links.js +151 -0
  51. package/dist/wiki/links.test.js +176 -0
  52. package/dist/wiki/migrate-topics.test.js +16 -6
  53. package/dist/wiki/scheduler.js +118 -0
  54. package/dist/wiki/scheduler.test.js +64 -0
  55. package/dist/wiki/timeline.js +51 -0
  56. package/dist/wiki/timeline.test.js +65 -0
  57. package/dist/wiki/topic-structure.js +1 -1
  58. package/package.json +3 -1
  59. package/skills/pkb-ideas/SKILL.md +78 -0
  60. package/skills/pkb-ideas/_meta.json +4 -0
  61. package/skills/pkb-org/SKILL.md +82 -0
  62. package/skills/pkb-org/_meta.json +4 -0
  63. package/skills/pkb-people/SKILL.md +74 -0
  64. package/skills/pkb-people/_meta.json +4 -0
  65. package/skills/pkb-research/SKILL.md +83 -0
  66. package/skills/pkb-research/_meta.json +4 -0
  67. package/skills/pkb-source/SKILL.md +38 -0
  68. package/skills/pkb-source/_meta.json +4 -0
  69. package/skills/wiki-conventions/SKILL.md +5 -5
  70. package/web/dist/assets/index-5kz9aRU9.css +10 -0
  71. package/web/dist/assets/{index-B5oDsQ5y.js → index-BbX9RKf3.js} +101 -99
  72. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  73. package/web/dist/index.html +2 -2
  74. package/dist/wiki/context.js +0 -138
  75. package/dist/wiki/fix.js +0 -335
  76. package/dist/wiki/fix.test.js +0 -350
  77. package/dist/wiki/lint.js +0 -451
  78. package/dist/wiki/lint.test.js +0 -329
  79. package/web/dist/assets/index-B5oDsQ5y.js.map +0 -1
  80. package/web/dist/assets/index-DknKAtDS.css +0 -10
@@ -0,0 +1,254 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import test from "node:test";
5
+ const repoRoot = process.cwd();
6
+ const testWorkRoot = join(repoRoot, ".test-work");
7
+ let sandboxRoot = "";
8
+ function resetSandbox() {
9
+ mkdirSync(testWorkRoot, { recursive: true });
10
+ sandboxRoot = mkdtempSync(join(testWorkRoot, "memory-reflect-"));
11
+ process.env.CHAPTERHOUSE_HOME = sandboxRoot;
12
+ }
13
+ async function loadBaseModules() {
14
+ const nonce = `${Date.now()}-${Math.random()}`;
15
+ const dbModule = await import(new URL(`../store/db.js?case=${nonce}`, import.meta.url).href);
16
+ const memoryModule = await import(new URL(`./index.js?case=${nonce}`, import.meta.url).href);
17
+ return { dbModule, memoryModule };
18
+ }
19
+ async function loadReflectModule(t, llmResponse) {
20
+ t.mock.module("../copilot/oneshot.js", {
21
+ namedExports: {
22
+ runOneShotPrompt: async () => ({ content: llmResponse, model: "mock-model", attempts: 1 }),
23
+ },
24
+ });
25
+ t.mock.module("../copilot/client.js", {
26
+ namedExports: {
27
+ getClient: async () => ({}),
28
+ },
29
+ });
30
+ t.mock.module("../util/logger.js", {
31
+ namedExports: {
32
+ childLogger: () => ({ info: () => { }, warn: () => { }, error: () => { } }),
33
+ },
34
+ });
35
+ return await import(new URL(`./reflect.js?case=${Date.now()}-${Math.random()}`, import.meta.url).href);
36
+ }
37
+ async function loadToolsModule(t) {
38
+ t.mock.module("../copilot/orchestrator.js", {
39
+ namedExports: {
40
+ getCurrentSourceChannel: () => "web",
41
+ getCurrentActivityCallback: () => undefined,
42
+ getCurrentActiveProjectRules: () => null,
43
+ getCurrentAuthenticatedUser: () => undefined,
44
+ getLastAuthenticatedUser: () => undefined,
45
+ getCurrentAuthorizationHeader: () => undefined,
46
+ getCurrentSessionKey: () => "session-reflect-test",
47
+ sendToAgentSession: async () => "",
48
+ invalidateOrchestratorSession: () => { },
49
+ maybeScheduleScopeChangeCheckpoint: () => { },
50
+ resetCheckpointSessionState: () => { },
51
+ switchSessionModel: async () => { },
52
+ },
53
+ });
54
+ t.mock.module("../memory/reflect.js", {
55
+ namedExports: {
56
+ reflectOnScope: async () => ({ patternsCreated: 1, patternsUpdated: 0, contradictionsFound: 0 }),
57
+ reflectAllScopes: async () => ({
58
+ chapterhouse: { patternsCreated: 1, patternsUpdated: 0, contradictionsFound: 0 },
59
+ }),
60
+ },
61
+ });
62
+ t.mock.module("../util/logger.js", {
63
+ namedExports: {
64
+ childLogger: () => ({ info: () => { }, warn: () => { }, error: () => { } }),
65
+ },
66
+ });
67
+ const nonce = `${Date.now()}-${Math.random()}`;
68
+ const toolsModule = await import(new URL(`../copilot/tools.js?case=${nonce}`, import.meta.url).href);
69
+ const agentsModule = await import(new URL(`../copilot/agents.js?case=${nonce}`, import.meta.url).href);
70
+ const dbModule = await import(new URL(`../store/db.js?case=${nonce}`, import.meta.url).href);
71
+ return { toolsModule, agentsModule, dbModule };
72
+ }
73
+ function getFunction(module, name) {
74
+ const value = module[name];
75
+ assert.equal(typeof value, "function", `expected ${name} to be exported`);
76
+ return value;
77
+ }
78
+ function findTool(tools, name) {
79
+ const tool = tools.find((entry) => entry.name === name);
80
+ assert.ok(tool, `${name} tool should be registered`);
81
+ return tool;
82
+ }
83
+ test.beforeEach(async () => {
84
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
85
+ dbModule.closeDb();
86
+ resetSandbox();
87
+ });
88
+ test.afterEach(async () => {
89
+ const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
90
+ dbModule.closeDb();
91
+ if (sandboxRoot) {
92
+ rmSync(sandboxRoot, { recursive: true, force: true });
93
+ }
94
+ });
95
+ test("reflectOnScope creates a pattern when three similar observations accumulate for one entity", async (t) => {
96
+ const { dbModule, memoryModule } = await loadBaseModules();
97
+ const db = dbModule.getDb();
98
+ const getScope = getFunction(memoryModule, "getScope");
99
+ const upsertEntity = getFunction(memoryModule, "upsertEntity");
100
+ const recordObservation = getFunction(memoryModule, "recordObservation");
101
+ const chapterhouse = getScope("chapterhouse");
102
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
103
+ const workerQueue = upsertEntity({ scope_id: chapterhouse.id, kind: "subsystem", name: "worker-queue" });
104
+ const first = recordObservation({
105
+ scope_id: chapterhouse.id,
106
+ entity_id: workerQueue.id,
107
+ content: "The worker queue serializes task execution through SQLite state.",
108
+ source: "test",
109
+ tier: "hot",
110
+ });
111
+ const second = recordObservation({
112
+ scope_id: chapterhouse.id,
113
+ entity_id: workerQueue.id,
114
+ content: "Worker queue execution is serialized using SQLite-backed state.",
115
+ source: "test",
116
+ tier: "warm",
117
+ });
118
+ const third = recordObservation({
119
+ scope_id: chapterhouse.id,
120
+ entity_id: workerQueue.id,
121
+ content: "SQLite keeps worker queue execution serialized across turns.",
122
+ source: "test",
123
+ tier: "warm",
124
+ });
125
+ const reflectModule = await loadReflectModule(t, JSON.stringify({
126
+ title: "Queue execution pattern",
127
+ summary: "Worker queue execution stays serialized through SQLite-backed coordination.",
128
+ confidence: 0.88,
129
+ }));
130
+ const result = await reflectModule.reflectOnScope("chapterhouse", db);
131
+ assert.equal(result.patternsCreated >= 1, true);
132
+ assert.equal(result.patternsCreated + result.patternsUpdated >= 1, true);
133
+ assert.equal(result.contradictionsFound, 0);
134
+ const pattern = db.prepare(`
135
+ SELECT title, summary, source_observation_ids, confidence, tier
136
+ FROM mem_patterns
137
+ ORDER BY id DESC
138
+ LIMIT 1
139
+ `).get();
140
+ assert.ok(pattern, "reflectOnScope should persist a pattern");
141
+ assert.equal(pattern.title, "Queue execution pattern");
142
+ assert.match(pattern.summary, /serialized/i);
143
+ assert.deepEqual(JSON.parse(pattern.source_observation_ids), [first.id, second.id, third.id]);
144
+ assert.equal(pattern.confidence, 0.88);
145
+ assert.equal(pattern.tier, "warm");
146
+ });
147
+ test("reflectOnScope counts contradictions inside the same entity group", async (t) => {
148
+ const { dbModule, memoryModule } = await loadBaseModules();
149
+ const db = dbModule.getDb();
150
+ const getScope = getFunction(memoryModule, "getScope");
151
+ const upsertEntity = getFunction(memoryModule, "upsertEntity");
152
+ const recordObservation = getFunction(memoryModule, "recordObservation");
153
+ const chapterhouse = getScope("chapterhouse");
154
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
155
+ const auth = upsertEntity({ scope_id: chapterhouse.id, kind: "subsystem", name: "auth" });
156
+ recordObservation({ scope_id: chapterhouse.id, entity_id: auth.id, content: "Auth used GitHub login for sign-in.", source: "test" });
157
+ recordObservation({ scope_id: chapterhouse.id, entity_id: auth.id, content: "Auth changed to Entra ID for sign-in.", source: "test" });
158
+ recordObservation({ scope_id: chapterhouse.id, entity_id: auth.id, content: "Auth no longer uses GitHub login.", source: "test" });
159
+ const reflectModule = await loadReflectModule(t, JSON.stringify({
160
+ title: "Auth provider transition",
161
+ summary: "Authentication moved away from GitHub login toward Entra ID.",
162
+ confidence: 0.82,
163
+ }));
164
+ const result = await reflectModule.reflectOnScope("chapterhouse", db);
165
+ assert.equal(result.contradictionsFound, 1);
166
+ });
167
+ test("reflectOnScope folds global observations into project-scope reflection", async (t) => {
168
+ const { dbModule, memoryModule } = await loadBaseModules();
169
+ const db = dbModule.getDb();
170
+ const getScope = getFunction(memoryModule, "getScope");
171
+ const recordObservation = getFunction(memoryModule, "recordObservation");
172
+ const chapterhouse = getScope("chapterhouse");
173
+ const global = getScope("global");
174
+ assert.ok(chapterhouse, "chapterhouse scope should be seeded");
175
+ assert.ok(global, "global scope should be seeded");
176
+ const globalObservation = recordObservation({
177
+ scope_id: global.id,
178
+ content: "SQLite WAL keeps Chapterhouse memory writes fast.",
179
+ source: "test",
180
+ tier: "warm",
181
+ });
182
+ const scopedOne = recordObservation({
183
+ scope_id: chapterhouse.id,
184
+ content: "Chapterhouse memory writes stay fast because SQLite uses WAL mode.",
185
+ source: "test",
186
+ tier: "hot",
187
+ });
188
+ const scopedTwo = recordObservation({
189
+ scope_id: chapterhouse.id,
190
+ content: "WAL mode keeps Chapterhouse memory writes quick under concurrency.",
191
+ source: "test",
192
+ tier: "warm",
193
+ });
194
+ const reflectModule = await loadReflectModule(t, JSON.stringify({
195
+ title: "SQLite WAL performance pattern",
196
+ summary: "Across scopes, Chapterhouse relies on SQLite WAL mode for fast memory writes.",
197
+ confidence: 0.91,
198
+ }));
199
+ const result = await reflectModule.reflectOnScope("chapterhouse", db);
200
+ assert.equal(result.patternsCreated, 1);
201
+ const pattern = db.prepare(`
202
+ SELECT source_observation_ids
203
+ FROM mem_patterns
204
+ ORDER BY id DESC
205
+ LIMIT 1
206
+ `).get();
207
+ assert.ok(pattern, "cross-scope reflection should persist a pattern");
208
+ assert.deepEqual(JSON.parse(pattern.source_observation_ids), [globalObservation.id, scopedOne.id, scopedTwo.id]);
209
+ });
210
+ test("memory_reflect runs end-to-end for chapterhouse and is only bound to orchestrator tools", async (t) => {
211
+ const { toolsModule, agentsModule, dbModule } = await loadToolsModule(t);
212
+ const db = dbModule.getDb();
213
+ const tools = toolsModule.createTools({
214
+ client: { async listModels() { return []; } },
215
+ onAgentTaskComplete: () => { },
216
+ });
217
+ const bindToolsToAgent = agentsModule.bindToolsToAgent;
218
+ const filterToolsForAgent = agentsModule.filterToolsForAgent;
219
+ assert.equal(typeof bindToolsToAgent, "function", "bindToolsToAgent should be exported");
220
+ assert.equal(typeof filterToolsForAgent, "function", "filterToolsForAgent should be exported");
221
+ const chapterhouseVisibleTools = filterToolsForAgent({
222
+ slug: "chapterhouse",
223
+ name: "Chapterhouse",
224
+ description: "Orchestrator",
225
+ model: "auto",
226
+ systemMessage: "test",
227
+ }, tools);
228
+ const coderVisibleTools = filterToolsForAgent({
229
+ slug: "coder",
230
+ name: "Coder",
231
+ description: "Software engineer",
232
+ model: "gpt-5.4",
233
+ systemMessage: "test",
234
+ }, tools);
235
+ const chapterhouseTools = bindToolsToAgent("chapterhouse", chapterhouseVisibleTools);
236
+ const coderTools = bindToolsToAgent("coder", coderVisibleTools);
237
+ assert.equal(chapterhouseTools.some((tool) => tool.name === "memory_reflect"), true);
238
+ assert.equal(coderTools.some((tool) => tool.name === "memory_reflect"), false);
239
+ const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
240
+ db.prepare(`
241
+ INSERT INTO mem_observations (scope_id, content, source, tier)
242
+ VALUES (?, ?, 'test', 'hot'), (?, ?, 'test', 'warm'), (?, ?, 'test', 'warm')
243
+ `).run(scope.id, "The worker queue serializes task execution through SQLite state.", scope.id, "Worker queue execution is serialized using SQLite-backed state.", scope.id, "SQLite keeps worker queue execution serialized across turns.");
244
+ const memoryReflect = findTool(chapterhouseTools, "memory_reflect");
245
+ const result = await memoryReflect.handler({ scope: "chapterhouse" }, {});
246
+ assert.deepEqual(result, {
247
+ ok: true,
248
+ scope: "chapterhouse",
249
+ patterns_created: 1,
250
+ patterns_updated: 0,
251
+ contradictions_found: 0,
252
+ });
253
+ });
254
+ //# sourceMappingURL=reflect.test.js.map
package/dist/store/db.js CHANGED
@@ -63,6 +63,7 @@ function rebuildMemoryTierTables(database) {
63
63
  CREATE TABLE mem_entities (
64
64
  id INTEGER PRIMARY KEY AUTOINCREMENT,
65
65
  scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
66
+ slug TEXT,
66
67
  kind TEXT NOT NULL,
67
68
  name TEXT NOT NULL,
68
69
  summary TEXT,
@@ -76,8 +77,8 @@ function rebuildMemoryTierTables(database) {
76
77
  )
77
78
  `);
78
79
  database.exec(`
79
- INSERT INTO mem_entities (id, scope_id, kind, name, summary, tier, confidence, created_at, updated_at)
80
- SELECT id, scope_id, kind, name, summary, ${entityTierCase()}, confidence, created_at, updated_at
80
+ INSERT INTO mem_entities (id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at)
81
+ SELECT id, scope_id, NULL, kind, name, summary, ${entityTierCase()}, confidence, created_at, updated_at
81
82
  FROM mem_entities_legacy_tier
82
83
  `);
83
84
  database.exec(`DROP TABLE mem_entities_legacy_tier`);
@@ -117,6 +118,7 @@ function rebuildMemoryTierTables(database) {
117
118
  title TEXT NOT NULL,
118
119
  rationale TEXT NOT NULL,
119
120
  decided_at TEXT NOT NULL,
121
+ source TEXT,
120
122
  tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
121
123
  superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL,
122
124
  archived_at DATETIME,
@@ -128,9 +130,9 @@ function rebuildMemoryTierTables(database) {
128
130
  `);
129
131
  database.exec(`
130
132
  INSERT INTO mem_decisions (
131
- id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
133
+ id, scope_id, entity_id, title, rationale, decided_at, source, tier, superseded_by, archived_at, created_at
132
134
  )
133
- SELECT id, scope_id, entity_id, title, rationale, decided_at, ${memoryTierCase()}, superseded_by, archived_at, created_at
135
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, NULL, ${memoryTierCase()}, superseded_by, archived_at, created_at
134
136
  FROM mem_decisions_legacy_tier
135
137
  `);
136
138
  database.exec(`DROP TABLE mem_decisions_legacy_tier`);
@@ -198,6 +200,7 @@ function ensureMemoryTierColumns(database) {
198
200
  function ensureMemoryIndexes(database) {
199
201
  database.exec(`CREATE INDEX IF NOT EXISTS mem_entities_scope_kind_idx ON mem_entities(scope_id, kind)`);
200
202
  database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_kind_name_idx ON mem_entities(scope_id, kind, name)`);
203
+ database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_slug_idx ON mem_entities(scope_id, slug) WHERE slug IS NOT NULL`);
201
204
  database.exec(`CREATE INDEX IF NOT EXISTS mem_observations_scope_idx ON mem_observations(scope_id)`);
202
205
  database.exec(`CREATE INDEX IF NOT EXISTS mem_decisions_scope_idx ON mem_decisions(scope_id)`);
203
206
  database.exec(`CREATE INDEX IF NOT EXISTS idx_mem_action_items_scope_status ON mem_action_items(scope_id, status)`);
@@ -752,9 +755,24 @@ export function getDb() {
752
755
  `);
753
756
  db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_scopes_slug_idx ON mem_scopes(slug)`);
754
757
  db.exec(`
758
+ CREATE TABLE IF NOT EXISTS mem_patterns (
759
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
760
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
761
+ title TEXT NOT NULL,
762
+ summary TEXT NOT NULL,
763
+ source_observation_ids TEXT NOT NULL DEFAULT '[]',
764
+ confidence REAL NOT NULL DEFAULT 0.5,
765
+ tier TEXT NOT NULL DEFAULT 'warm',
766
+ created_at TEXT NOT NULL,
767
+ last_updated TEXT NOT NULL
768
+ )
769
+ `);
770
+ db.exec(`CREATE INDEX IF NOT EXISTS mem_patterns_scope_tier_idx ON mem_patterns(scope_id, tier)`);
771
+ db.exec(`
755
772
  CREATE TABLE IF NOT EXISTS mem_entities (
756
773
  id INTEGER PRIMARY KEY AUTOINCREMENT,
757
774
  scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
775
+ slug TEXT,
758
776
  kind TEXT NOT NULL,
759
777
  name TEXT NOT NULL,
760
778
  summary TEXT,
@@ -767,6 +785,10 @@ export function getDb() {
767
785
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
768
786
  )
769
787
  `);
788
+ const entityCols = db.prepare(`PRAGMA table_info(mem_entities)`).all();
789
+ if (!entityCols.some((column) => column.name === "slug")) {
790
+ db.exec(`ALTER TABLE mem_entities ADD COLUMN slug TEXT`);
791
+ }
770
792
  db.exec(`
771
793
  DELETE FROM mem_entities
772
794
  WHERE id NOT IN (
@@ -776,6 +798,7 @@ export function getDb() {
776
798
  )
777
799
  `);
778
800
  db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_kind_name_idx ON mem_entities(scope_id, kind, name)`);
801
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_slug_idx ON mem_entities(scope_id, slug) WHERE slug IS NOT NULL`);
779
802
  db.exec(`
780
803
  CREATE TABLE IF NOT EXISTS mem_observations (
781
804
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -809,6 +832,7 @@ export function getDb() {
809
832
  title TEXT NOT NULL,
810
833
  rationale TEXT NOT NULL,
811
834
  decided_at TEXT NOT NULL,
835
+ source TEXT,
812
836
  tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
813
837
  superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL,
814
838
  archived_at DATETIME,
@@ -839,6 +863,55 @@ export function getDb() {
839
863
  last_recalled_at TEXT
840
864
  )
841
865
  `);
866
+ db.exec(`
867
+ CREATE TABLE IF NOT EXISTS wiki_pages (
868
+ path TEXT PRIMARY KEY,
869
+ title TEXT NOT NULL,
870
+ entity_type TEXT,
871
+ tags TEXT DEFAULT '[]',
872
+ summary TEXT,
873
+ last_updated TEXT,
874
+ visibility TEXT DEFAULT 'private',
875
+ version INTEGER DEFAULT 1,
876
+ compiled_truth_hash TEXT,
877
+ pinned INTEGER DEFAULT 0
878
+ )
879
+ `);
880
+ db.exec(`
881
+ CREATE TABLE IF NOT EXISTS wiki_sources (
882
+ id TEXT PRIMARY KEY,
883
+ source_type TEXT NOT NULL,
884
+ origin TEXT NOT NULL,
885
+ title TEXT,
886
+ ingested_at TEXT NOT NULL,
887
+ raw_path TEXT,
888
+ parsed_content TEXT,
889
+ pages_updated TEXT DEFAULT '[]',
890
+ status TEXT NOT NULL DEFAULT 'active',
891
+ session_id TEXT,
892
+ session_name TEXT
893
+ )
894
+ `);
895
+ db.exec(`
896
+ CREATE TABLE IF NOT EXISTS wiki_links (
897
+ from_page TEXT NOT NULL,
898
+ to_page TEXT NOT NULL,
899
+ link_type TEXT NOT NULL,
900
+ extracted_at TEXT NOT NULL,
901
+ PRIMARY KEY (from_page, to_page, link_type)
902
+ )
903
+ `);
904
+ db.exec(`CREATE INDEX IF NOT EXISTS wiki_links_to ON wiki_links(to_page)`);
905
+ const wikiSourceCols = db.prepare(`PRAGMA table_info(wiki_sources)`).all();
906
+ if (!wikiSourceCols.some((column) => column.name === "status")) {
907
+ db.exec(`ALTER TABLE wiki_sources ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`);
908
+ }
909
+ if (!wikiSourceCols.some((column) => column.name === "session_id")) {
910
+ db.exec(`ALTER TABLE wiki_sources ADD COLUMN session_id TEXT`);
911
+ }
912
+ if (!wikiSourceCols.some((column) => column.name === "session_name")) {
913
+ db.exec(`ALTER TABLE wiki_sources ADD COLUMN session_name TEXT`);
914
+ }
842
915
  const decisionCols = db.prepare(`PRAGMA table_info(mem_decisions)`).all();
843
916
  if (!decisionCols.some((column) => column.name === "superseded_by")) {
844
917
  db.exec(`ALTER TABLE mem_decisions ADD COLUMN superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL`);
@@ -846,6 +919,9 @@ export function getDb() {
846
919
  if (!decisionCols.some((column) => column.name === "archived_at")) {
847
920
  db.exec(`ALTER TABLE mem_decisions ADD COLUMN archived_at DATETIME`);
848
921
  }
922
+ if (!decisionCols.some((column) => column.name === "source")) {
923
+ db.exec(`ALTER TABLE mem_decisions ADD COLUMN source TEXT`);
924
+ }
849
925
  rebuildMemoryTierTables(db);
850
926
  ensureMemoryTierColumns(db);
851
927
  ensureMemoryIndexes(db);
@@ -997,6 +1073,40 @@ export function getDb() {
997
1073
  INSERT INTO mem_action_items_fts(rowid, title, detail)
998
1074
  VALUES (new.id, new.title, new.detail);
999
1075
  END
1076
+ `);
1077
+ db.exec(`
1078
+ CREATE VIRTUAL TABLE IF NOT EXISTS wiki_pages_fts USING fts5(
1079
+ path UNINDEXED,
1080
+ title,
1081
+ entity_type,
1082
+ tags,
1083
+ summary,
1084
+ content='wiki_pages',
1085
+ content_rowid='rowid'
1086
+ )
1087
+ `);
1088
+ db.exec(`DROP TRIGGER IF EXISTS wiki_pages_ai`);
1089
+ db.exec(`DROP TRIGGER IF EXISTS wiki_pages_ad`);
1090
+ db.exec(`DROP TRIGGER IF EXISTS wiki_pages_au`);
1091
+ db.exec(`
1092
+ CREATE TRIGGER wiki_pages_ai AFTER INSERT ON wiki_pages BEGIN
1093
+ INSERT INTO wiki_pages_fts(rowid, path, title, entity_type, tags, summary)
1094
+ VALUES (new.rowid, new.path, new.title, new.entity_type, new.tags, new.summary);
1095
+ END
1096
+ `);
1097
+ db.exec(`
1098
+ CREATE TRIGGER wiki_pages_ad AFTER DELETE ON wiki_pages BEGIN
1099
+ INSERT INTO wiki_pages_fts(wiki_pages_fts, rowid, path, title, entity_type, tags, summary)
1100
+ VALUES('delete', old.rowid, old.path, old.title, old.entity_type, old.tags, old.summary);
1101
+ END
1102
+ `);
1103
+ db.exec(`
1104
+ CREATE TRIGGER wiki_pages_au AFTER UPDATE ON wiki_pages BEGIN
1105
+ INSERT INTO wiki_pages_fts(wiki_pages_fts, rowid, path, title, entity_type, tags, summary)
1106
+ VALUES('delete', old.rowid, old.path, old.title, old.entity_type, old.tags, old.summary);
1107
+ INSERT INTO wiki_pages_fts(rowid, path, title, entity_type, tags, summary)
1108
+ VALUES(new.rowid, new.path, new.title, new.entity_type, new.tags, new.summary);
1109
+ END
1000
1110
  `);
1001
1111
  // Backfill: check if FTS is in sync by comparing row counts
1002
1112
  const memCount = db.prepare(`SELECT COUNT(*) as c FROM memories`).get().c;
@@ -1019,6 +1129,11 @@ export function getDb() {
1019
1129
  if (actionItemCount > 0 && actionItemFtsCount < actionItemCount) {
1020
1130
  db.exec(`INSERT INTO mem_action_items_fts(mem_action_items_fts) VALUES ('rebuild')`);
1021
1131
  }
1132
+ const wikiPageCount = db.prepare(`SELECT COUNT(*) as c FROM wiki_pages`).get().c;
1133
+ const wikiPageFtsCount = db.prepare(`SELECT COUNT(*) as c FROM wiki_pages_fts`).get().c;
1134
+ if (wikiPageCount > 0 && wikiPageFtsCount < wikiPageCount) {
1135
+ db.exec(`INSERT INTO wiki_pages_fts(wiki_pages_fts) VALUES ('rebuild')`);
1136
+ }
1022
1137
  fts5Available = true;
1023
1138
  }
1024
1139
  catch {
@@ -58,7 +58,7 @@ test("getDb initializes schema, state helpers, and conversation formatting", asy
58
58
  dbModule.closeDb();
59
59
  }
60
60
  });
61
- test("getDb initializes action-item memory schema and FTS shadow", async () => {
61
+ test("getDb initializes action-item memory schema, reflect patterns schema, and FTS shadow", async () => {
62
62
  const dbModule = await loadDbModule();
63
63
  try {
64
64
  const db = dbModule.getDb();
@@ -66,6 +66,7 @@ test("getDb initializes action-item memory schema and FTS shadow", async () => {
66
66
  const tableNames = new Set(tables.map((row) => row.name));
67
67
  assert.equal(tableNames.has("mem_action_items"), true, "expected mem_action_items table");
68
68
  assert.equal(tableNames.has("mem_action_items_fts"), true, "expected mem_action_items_fts virtual table");
69
+ assert.equal(tableNames.has("mem_patterns"), true, "expected mem_patterns table");
69
70
  const columns = db.prepare(`PRAGMA table_info(mem_action_items)`).all();
70
71
  const columnNames = new Set(columns.map((column) => column.name));
71
72
  for (const name of [
@@ -89,6 +90,23 @@ test("getDb initializes action-item memory schema and FTS shadow", async () => {
89
90
  ]) {
90
91
  assert.equal(columnNames.has(name), true, `expected mem_action_items.${name}`);
91
92
  }
93
+ const patternColumns = db.prepare(`PRAGMA table_info(mem_patterns)`).all();
94
+ const patternColumnNames = new Set(patternColumns.map((column) => column.name));
95
+ for (const name of [
96
+ "id",
97
+ "scope_id",
98
+ "title",
99
+ "summary",
100
+ "source_observation_ids",
101
+ "confidence",
102
+ "tier",
103
+ "created_at",
104
+ "last_updated",
105
+ ]) {
106
+ assert.equal(patternColumnNames.has(name), true, `expected mem_patterns.${name}`);
107
+ }
108
+ const patternIndexes = db.prepare(`PRAGMA index_list(mem_patterns)`).all();
109
+ assert.equal(patternIndexes.some((index) => index.name === "mem_patterns_scope_tier_idx"), true, "expected mem_patterns scope/tier index");
92
110
  const scope = db.prepare(`SELECT id FROM mem_scopes WHERE slug = 'chapterhouse'`).get();
93
111
  const inserted = db.prepare(`
94
112
  INSERT INTO mem_action_items (scope_id, title, detail, source)
@@ -12,6 +12,7 @@ const RUNTIME_OVERRIDE_ENV_VARS = [
12
12
  "CHAPTERHOUSE_MEMORY_INJECT",
13
13
  "CHAPTERHOUSE_MEMORY_AUTO_ACCEPT",
14
14
  "CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED",
15
+ "CHAPTERHOUSE_MEMORY_HOOKS_ENABLED",
15
16
  "CHAPTERHOUSE_MEMORY_HOUSEKEEPING_ENABLED",
16
17
  "CHAPTERHOUSE_MEMORY_HOUSEKEEPING_TURNS",
17
18
  "CHAPTERHOUSE_MEMORY_DECAY_DAYS",
@@ -20,7 +21,8 @@ const RUNTIME_OVERRIDE_ENV_VARS = [
20
21
  "CHAPTERHOUSE_MEMORY_HOT_RECALL_BOOST",
21
22
  "CHAPTERHOUSE_MEMORY_HOT_AGE_DAYS",
22
23
  ];
23
- for (const name of RUNTIME_OVERRIDE_ENV_VARS) {
24
+ const AUTH_ENV_VARS = ["COPILOT_TOKEN", "GITHUB_TOKEN"];
25
+ for (const name of [...RUNTIME_OVERRIDE_ENV_VARS, ...AUTH_ENV_VARS]) {
24
26
  delete process.env[name];
25
27
  }
26
28
  process.env.CHAPTERHOUSE_DISABLE_DOTENV = "1";
@@ -8,8 +8,10 @@ const RUNTIME_OVERRIDE_ENV_VARS = [
8
8
  "CHAPTERHOUSE_SSE_BUFFER_CAPACITY",
9
9
  "CHAPTERHOUSE_SSE_REPLAY_LIMIT",
10
10
  ];
11
+ const AUTH_ENV_VARS = ["COPILOT_TOKEN", "GITHUB_TOKEN"];
11
12
  test("setup-env clears ambient Chapterhouse runtime overrides", async () => {
12
- const originalValues = new Map(["CHAPTERHOUSE_DISABLE_DOTENV", ...RUNTIME_OVERRIDE_ENV_VARS].map((name) => [name, process.env[name]]));
13
+ const originalValues = new Map(["CHAPTERHOUSE_DISABLE_DOTENV", ...RUNTIME_OVERRIDE_ENV_VARS, ...AUTH_ENV_VARS]
14
+ .map((name) => [name, process.env[name]]));
13
15
  try {
14
16
  process.env.CHAPTERHOUSE_DISABLE_DOTENV = "0";
15
17
  process.env.CHAPTERHOUSE_MODE = "team";
@@ -18,11 +20,16 @@ test("setup-env clears ambient Chapterhouse runtime overrides", async () => {
18
20
  process.env.CHAPTERHOUSE_CHAT_SSE = "0";
19
21
  process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = "3";
20
22
  process.env.CHAPTERHOUSE_SSE_REPLAY_LIMIT = "9";
23
+ process.env.COPILOT_TOKEN = "ambient-copilot-token";
24
+ process.env.GITHUB_TOKEN = "ambient-github-token";
21
25
  await import(`./setup-env.js?cache-bust=${Date.now()}`);
22
26
  assert.equal(process.env.CHAPTERHOUSE_DISABLE_DOTENV, "1");
23
27
  for (const name of RUNTIME_OVERRIDE_ENV_VARS) {
24
28
  assert.equal(process.env[name], undefined, `${name} should be cleared by setup-env`);
25
29
  }
30
+ for (const name of AUTH_ENV_VARS) {
31
+ assert.equal(process.env[name], undefined, `${name} should be cleared by setup-env`);
32
+ }
26
33
  }
27
34
  finally {
28
35
  for (const [name, value] of originalValues) {