chapterhouse 0.7.0 → 0.8.1

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 (81) hide show
  1. package/agents/korg.agent.md +65 -0
  2. package/dist/api/korg.js +34 -0
  3. package/dist/api/korg.test.js +42 -0
  4. package/dist/api/server.js +238 -2
  5. package/dist/api/server.test.js +199 -0
  6. package/dist/config.js +28 -0
  7. package/dist/config.test.js +20 -0
  8. package/dist/copilot/agents.js +3 -4
  9. package/dist/copilot/agents.test.js +12 -1
  10. package/dist/copilot/orchestrator.js +12 -1
  11. package/dist/copilot/orchestrator.test.js +3 -7
  12. package/dist/copilot/system-message.js +12 -10
  13. package/dist/copilot/system-message.test.js +6 -1
  14. package/dist/copilot/tools.js +193 -375
  15. package/dist/copilot/tools.memory.test.js +32 -0
  16. package/dist/copilot/tools.wiki.test.js +80 -59
  17. package/dist/copilot/turn-event-log-env.test.js +11 -15
  18. package/dist/daemon.js +19 -0
  19. package/dist/memory/decisions.js +6 -5
  20. package/dist/memory/entities.js +20 -9
  21. package/dist/memory/eot.js +30 -8
  22. package/dist/memory/eot.test.js +220 -6
  23. package/dist/memory/hooks.js +151 -0
  24. package/dist/memory/hooks.test.js +325 -0
  25. package/dist/memory/hot-tier.js +37 -0
  26. package/dist/memory/hot-tier.test.js +30 -0
  27. package/dist/memory/housekeeping-scheduler.js +35 -0
  28. package/dist/memory/housekeeping-scheduler.test.js +50 -0
  29. package/dist/memory/inbox.js +10 -0
  30. package/dist/memory/index.js +3 -1
  31. package/dist/memory/migration.js +244 -0
  32. package/dist/memory/migration.test.js +108 -0
  33. package/dist/memory/reflect.js +273 -0
  34. package/dist/memory/reflect.test.js +254 -0
  35. package/dist/paths.js +31 -11
  36. package/dist/store/db.js +187 -4
  37. package/dist/store/db.test.js +66 -2
  38. package/dist/test/helpers/reset-singletons.js +8 -0
  39. package/dist/test/helpers/reset-singletons.test.js +37 -0
  40. package/dist/test/setup-env.js +9 -1
  41. package/dist/wiki/consolidation.js +641 -0
  42. package/dist/wiki/consolidation.test.js +143 -0
  43. package/dist/wiki/frontmatter.js +48 -0
  44. package/dist/wiki/frontmatter.test.js +42 -0
  45. package/dist/wiki/fs.js +22 -13
  46. package/dist/wiki/index-manager.js +305 -330
  47. package/dist/wiki/index-manager.test.js +265 -144
  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/log-manager.js +8 -5
  53. package/dist/wiki/log-manager.test.js +4 -0
  54. package/dist/wiki/migrate-topics.test.js +16 -6
  55. package/dist/wiki/scheduler.js +118 -0
  56. package/dist/wiki/scheduler.test.js +64 -0
  57. package/dist/wiki/timeline.js +51 -0
  58. package/dist/wiki/timeline.test.js +65 -0
  59. package/dist/wiki/topic-structure.js +1 -1
  60. package/package.json +1 -1
  61. package/skills/pkb-ideas/SKILL.md +78 -0
  62. package/skills/pkb-ideas/_meta.json +4 -0
  63. package/skills/pkb-org/SKILL.md +82 -0
  64. package/skills/pkb-org/_meta.json +4 -0
  65. package/skills/pkb-people/SKILL.md +74 -0
  66. package/skills/pkb-people/_meta.json +4 -0
  67. package/skills/pkb-research/SKILL.md +83 -0
  68. package/skills/pkb-research/_meta.json +4 -0
  69. package/skills/pkb-source/SKILL.md +38 -0
  70. package/skills/pkb-source/_meta.json +4 -0
  71. package/skills/wiki-conventions/SKILL.md +5 -5
  72. package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
  73. package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
  74. package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
  75. package/web/dist/index.html +2 -2
  76. package/dist/wiki/context.js +0 -138
  77. package/dist/wiki/fix.js +0 -335
  78. package/dist/wiki/fix.test.js +0 -350
  79. package/dist/wiki/lint.js +0 -451
  80. package/dist/wiki/lint.test.js +0 -329
  81. package/web/dist/assets/index-DytB69KC.js.map +0 -1
@@ -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/paths.js CHANGED
@@ -12,36 +12,56 @@ function resolveChapterhouseHome() {
12
12
  ? configuredHome
13
13
  : join(configuredHome, ".chapterhouse");
14
14
  }
15
- export const CHAPTERHOUSE_HOME = resolveChapterhouseHome();
15
+ // Reset in tests via src/test/helpers/reset-singletons.ts
16
+ export let CHAPTERHOUSE_HOME = resolveChapterhouseHome();
16
17
  export function getChapterhouseHome() {
17
18
  return resolveChapterhouseHome();
18
19
  }
19
20
  /** Path to the SQLite database */
20
- export const DB_PATH = join(CHAPTERHOUSE_HOME, "chapterhouse.db");
21
+ export let DB_PATH = join(CHAPTERHOUSE_HOME, "chapterhouse.db");
21
22
  export function getDbPath() {
22
23
  return join(resolveChapterhouseHome(), "chapterhouse.db");
23
24
  }
24
25
  /** Path to the user .env file */
25
- export const ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
26
+ export let ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
26
27
  /** Path to user-local skills */
27
- export const SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
28
+ export let SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
28
29
  /** Path to Chapterhouse's isolated session state (keeps CLI history clean) */
29
- export const SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
30
+ export let SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
30
31
  /** Path to the API bearer token file */
31
- export const API_TOKEN_PATH = join(CHAPTERHOUSE_HOME, "api-token");
32
+ export let API_TOKEN_PATH = join(CHAPTERHOUSE_HOME, "api-token");
33
+ /** Path to Chapterhouse runtime logs */
34
+ export const LOGS_DIR = join(CHAPTERHOUSE_HOME, "logs");
32
35
  /** Agent definition files (~/.chapterhouse/agents/) */
33
- export const AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
36
+ export let AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
34
37
  /** Root of the LLM-maintained wiki knowledge base */
35
- export const WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
38
+ export let WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
36
39
  /** Wiki pages (entity, concept, summary files) */
37
- export const WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
40
+ export let WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
38
41
  /** Raw ingested source documents (immutable) */
39
- export const WIKI_SOURCES_DIR = join(WIKI_DIR, "sources");
42
+ export let WIKI_SOURCES_DIR = join(WIKI_DIR, "sources");
43
+ function refreshCachedPaths() {
44
+ CHAPTERHOUSE_HOME = resolveChapterhouseHome();
45
+ DB_PATH = join(CHAPTERHOUSE_HOME, "chapterhouse.db");
46
+ ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
47
+ SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
48
+ SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
49
+ API_TOKEN_PATH = join(CHAPTERHOUSE_HOME, "api-token");
50
+ AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
51
+ WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
52
+ WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
53
+ WIKI_SOURCES_DIR = join(WIKI_DIR, "sources");
54
+ }
55
+ export function resetPathsForTests() {
56
+ refreshCachedPaths();
57
+ }
40
58
  export function resolveWikiRelativePath(relativePath) {
41
59
  return join(WIKI_DIR, ...normalizeWikiPath(relativePath).split("/"));
42
60
  }
43
61
  /** Ensure ~/.chapterhouse/ exists */
44
62
  export function ensureChapterhouseHome() {
45
- mkdirSync(resolveChapterhouseHome(), { recursive: true });
63
+ const home = resolveChapterhouseHome();
64
+ mkdirSync(home, { recursive: true });
65
+ mkdirSync(join(home, "logs"), { recursive: true });
46
66
  }
47
67
  //# sourceMappingURL=paths.js.map
package/dist/store/db.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { ensureChapterhouseHome, getDbPath } from "../paths.js";
4
+ import { ensureWikiStructure, listPages, readPage } from "../wiki/fs.js";
5
+ import { parseWikiFrontmatter } from "../wiki/frontmatter.js";
6
+ // Reset in tests via src/test/helpers/reset-singletons.ts
4
7
  let db;
5
8
  let logInsertCount = 0;
6
9
  let fts5Available = false;
@@ -49,6 +52,63 @@ function tableCreateSql(database, table) {
49
52
  `).get(table);
50
53
  return row?.sql ?? "";
51
54
  }
55
+ const ACTION_LOG_PAGE_RE = /^pages\/_meta\/log(?:-\d{4})?\.md$/;
56
+ const LEGACY_INDEX_PAGE = "pages/index.md";
57
+ function isIgnoredWikiIndexPage(path) {
58
+ return path === LEGACY_INDEX_PAGE || ACTION_LOG_PAGE_RE.test(path);
59
+ }
60
+ function wikiBasenameTitle(path) {
61
+ const segs = path.split("/").filter(Boolean);
62
+ const file = segs[segs.length - 1] || path;
63
+ const base = file.replace(/\.md$/, "");
64
+ const titleBase = base === "index" && segs.length >= 2 ? segs[segs.length - 2] : base;
65
+ return titleBase.split(/[-_]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
66
+ }
67
+ function summarizeWikiBody(body) {
68
+ for (const raw of body.split("\n")) {
69
+ const line = raw.trim();
70
+ if (!line || line.startsWith("#") || line.startsWith("<!--")) {
71
+ continue;
72
+ }
73
+ const summary = line.replace(/^[-*]\s+/, "").replace(/_\(\d{4}-\d{2}-\d{2}\)_$/, "").trim();
74
+ if (summary) {
75
+ return summary.length > 160 ? `${summary.slice(0, 157)}…` : summary;
76
+ }
77
+ }
78
+ return "";
79
+ }
80
+ function seedWikiPagesFromDisk(database) {
81
+ ensureWikiStructure();
82
+ const pages = listPages().filter((page) => !isIgnoredWikiIndexPage(page));
83
+ if (pages.length === 0) {
84
+ return;
85
+ }
86
+ const wikiPageCount = database.prepare(`SELECT COUNT(*) AS count FROM wiki_pages`).get().count;
87
+ if (wikiPageCount > 0) {
88
+ return;
89
+ }
90
+ const upsert = database.prepare(`
91
+ INSERT INTO wiki_pages (path, title, entity_type, tags, summary, last_updated)
92
+ VALUES (?, ?, ?, ?, ?, ?)
93
+ ON CONFLICT(path) DO UPDATE SET
94
+ title = excluded.title,
95
+ entity_type = excluded.entity_type,
96
+ tags = excluded.tags,
97
+ summary = excluded.summary,
98
+ last_updated = excluded.last_updated,
99
+ version = wiki_pages.version + 1
100
+ `);
101
+ for (const page of pages) {
102
+ const content = readPage(page);
103
+ if (!content) {
104
+ continue;
105
+ }
106
+ const { parsed: fm, body } = parseWikiFrontmatter(content);
107
+ const summary = fm.summary?.trim() || summarizeWikiBody(body) || fm.title || wikiBasenameTitle(page);
108
+ const entityType = fm.metadata?.["entity_type"] ?? null;
109
+ upsert.run(page, fm.title ?? wikiBasenameTitle(page), entityType, JSON.stringify(fm.tags ?? []), summary, fm.updated ?? new Date().toISOString());
110
+ }
111
+ }
52
112
  function rebuildMemoryTierTables(database) {
53
113
  const needsRebuild = ["mem_entities", "mem_observations", "mem_decisions"]
54
114
  .some((table) => tableCreateSql(database, table).includes("'glacier'"));
@@ -63,6 +123,7 @@ function rebuildMemoryTierTables(database) {
63
123
  CREATE TABLE mem_entities (
64
124
  id INTEGER PRIMARY KEY AUTOINCREMENT,
65
125
  scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
126
+ slug TEXT,
66
127
  kind TEXT NOT NULL,
67
128
  name TEXT NOT NULL,
68
129
  summary TEXT,
@@ -76,8 +137,8 @@ function rebuildMemoryTierTables(database) {
76
137
  )
77
138
  `);
78
139
  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
140
+ INSERT INTO mem_entities (id, scope_id, slug, kind, name, summary, tier, confidence, created_at, updated_at)
141
+ SELECT id, scope_id, NULL, kind, name, summary, ${entityTierCase()}, confidence, created_at, updated_at
81
142
  FROM mem_entities_legacy_tier
82
143
  `);
83
144
  database.exec(`DROP TABLE mem_entities_legacy_tier`);
@@ -117,6 +178,7 @@ function rebuildMemoryTierTables(database) {
117
178
  title TEXT NOT NULL,
118
179
  rationale TEXT NOT NULL,
119
180
  decided_at TEXT NOT NULL,
181
+ source TEXT,
120
182
  tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
121
183
  superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL,
122
184
  archived_at DATETIME,
@@ -128,9 +190,9 @@ function rebuildMemoryTierTables(database) {
128
190
  `);
129
191
  database.exec(`
130
192
  INSERT INTO mem_decisions (
131
- id, scope_id, entity_id, title, rationale, decided_at, tier, superseded_by, archived_at, created_at
193
+ id, scope_id, entity_id, title, rationale, decided_at, source, tier, superseded_by, archived_at, created_at
132
194
  )
133
- SELECT id, scope_id, entity_id, title, rationale, decided_at, ${memoryTierCase()}, superseded_by, archived_at, created_at
195
+ SELECT id, scope_id, entity_id, title, rationale, decided_at, NULL, ${memoryTierCase()}, superseded_by, archived_at, created_at
134
196
  FROM mem_decisions_legacy_tier
135
197
  `);
136
198
  database.exec(`DROP TABLE mem_decisions_legacy_tier`);
@@ -198,6 +260,7 @@ function ensureMemoryTierColumns(database) {
198
260
  function ensureMemoryIndexes(database) {
199
261
  database.exec(`CREATE INDEX IF NOT EXISTS mem_entities_scope_kind_idx ON mem_entities(scope_id, kind)`);
200
262
  database.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_kind_name_idx ON mem_entities(scope_id, kind, name)`);
263
+ 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
264
  database.exec(`CREATE INDEX IF NOT EXISTS mem_observations_scope_idx ON mem_observations(scope_id)`);
202
265
  database.exec(`CREATE INDEX IF NOT EXISTS mem_decisions_scope_idx ON mem_decisions(scope_id)`);
203
266
  database.exec(`CREATE INDEX IF NOT EXISTS idx_mem_action_items_scope_status ON mem_action_items(scope_id, status)`);
@@ -752,9 +815,24 @@ export function getDb() {
752
815
  `);
753
816
  db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_scopes_slug_idx ON mem_scopes(slug)`);
754
817
  db.exec(`
818
+ CREATE TABLE IF NOT EXISTS mem_patterns (
819
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
820
+ scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
821
+ title TEXT NOT NULL,
822
+ summary TEXT NOT NULL,
823
+ source_observation_ids TEXT NOT NULL DEFAULT '[]',
824
+ confidence REAL NOT NULL DEFAULT 0.5,
825
+ tier TEXT NOT NULL DEFAULT 'warm',
826
+ created_at TEXT NOT NULL,
827
+ last_updated TEXT NOT NULL
828
+ )
829
+ `);
830
+ db.exec(`CREATE INDEX IF NOT EXISTS mem_patterns_scope_tier_idx ON mem_patterns(scope_id, tier)`);
831
+ db.exec(`
755
832
  CREATE TABLE IF NOT EXISTS mem_entities (
756
833
  id INTEGER PRIMARY KEY AUTOINCREMENT,
757
834
  scope_id INTEGER NOT NULL REFERENCES mem_scopes(id),
835
+ slug TEXT,
758
836
  kind TEXT NOT NULL,
759
837
  name TEXT NOT NULL,
760
838
  summary TEXT,
@@ -767,6 +845,10 @@ export function getDb() {
767
845
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
768
846
  )
769
847
  `);
848
+ const entityCols = db.prepare(`PRAGMA table_info(mem_entities)`).all();
849
+ if (!entityCols.some((column) => column.name === "slug")) {
850
+ db.exec(`ALTER TABLE mem_entities ADD COLUMN slug TEXT`);
851
+ }
770
852
  db.exec(`
771
853
  DELETE FROM mem_entities
772
854
  WHERE id NOT IN (
@@ -776,6 +858,7 @@ export function getDb() {
776
858
  )
777
859
  `);
778
860
  db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS mem_entities_scope_kind_name_idx ON mem_entities(scope_id, kind, name)`);
861
+ 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
862
  db.exec(`
780
863
  CREATE TABLE IF NOT EXISTS mem_observations (
781
864
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -809,6 +892,7 @@ export function getDb() {
809
892
  title TEXT NOT NULL,
810
893
  rationale TEXT NOT NULL,
811
894
  decided_at TEXT NOT NULL,
895
+ source TEXT,
812
896
  tier TEXT NOT NULL DEFAULT 'warm' CHECK(tier IN ('hot', 'warm', 'cold')),
813
897
  superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL,
814
898
  archived_at DATETIME,
@@ -839,6 +923,55 @@ export function getDb() {
839
923
  last_recalled_at TEXT
840
924
  )
841
925
  `);
926
+ db.exec(`
927
+ CREATE TABLE IF NOT EXISTS wiki_pages (
928
+ path TEXT PRIMARY KEY,
929
+ title TEXT NOT NULL,
930
+ entity_type TEXT,
931
+ tags TEXT DEFAULT '[]',
932
+ summary TEXT,
933
+ last_updated TEXT,
934
+ visibility TEXT DEFAULT 'private',
935
+ version INTEGER DEFAULT 1,
936
+ compiled_truth_hash TEXT,
937
+ pinned INTEGER DEFAULT 0
938
+ )
939
+ `);
940
+ db.exec(`
941
+ CREATE TABLE IF NOT EXISTS wiki_sources (
942
+ id TEXT PRIMARY KEY,
943
+ source_type TEXT NOT NULL,
944
+ origin TEXT NOT NULL,
945
+ title TEXT,
946
+ ingested_at TEXT NOT NULL,
947
+ raw_path TEXT,
948
+ parsed_content TEXT,
949
+ pages_updated TEXT DEFAULT '[]',
950
+ status TEXT NOT NULL DEFAULT 'active',
951
+ session_id TEXT,
952
+ session_name TEXT
953
+ )
954
+ `);
955
+ db.exec(`
956
+ CREATE TABLE IF NOT EXISTS wiki_links (
957
+ from_page TEXT NOT NULL,
958
+ to_page TEXT NOT NULL,
959
+ link_type TEXT NOT NULL,
960
+ extracted_at TEXT NOT NULL,
961
+ PRIMARY KEY (from_page, to_page, link_type)
962
+ )
963
+ `);
964
+ db.exec(`CREATE INDEX IF NOT EXISTS wiki_links_to ON wiki_links(to_page)`);
965
+ const wikiSourceCols = db.prepare(`PRAGMA table_info(wiki_sources)`).all();
966
+ if (!wikiSourceCols.some((column) => column.name === "status")) {
967
+ db.exec(`ALTER TABLE wiki_sources ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`);
968
+ }
969
+ if (!wikiSourceCols.some((column) => column.name === "session_id")) {
970
+ db.exec(`ALTER TABLE wiki_sources ADD COLUMN session_id TEXT`);
971
+ }
972
+ if (!wikiSourceCols.some((column) => column.name === "session_name")) {
973
+ db.exec(`ALTER TABLE wiki_sources ADD COLUMN session_name TEXT`);
974
+ }
842
975
  const decisionCols = db.prepare(`PRAGMA table_info(mem_decisions)`).all();
843
976
  if (!decisionCols.some((column) => column.name === "superseded_by")) {
844
977
  db.exec(`ALTER TABLE mem_decisions ADD COLUMN superseded_by INTEGER REFERENCES mem_decisions(id) ON DELETE SET NULL`);
@@ -846,6 +979,9 @@ export function getDb() {
846
979
  if (!decisionCols.some((column) => column.name === "archived_at")) {
847
980
  db.exec(`ALTER TABLE mem_decisions ADD COLUMN archived_at DATETIME`);
848
981
  }
982
+ if (!decisionCols.some((column) => column.name === "source")) {
983
+ db.exec(`ALTER TABLE mem_decisions ADD COLUMN source TEXT`);
984
+ }
849
985
  rebuildMemoryTierTables(db);
850
986
  ensureMemoryTierColumns(db);
851
987
  ensureMemoryIndexes(db);
@@ -998,6 +1134,41 @@ export function getDb() {
998
1134
  VALUES (new.id, new.title, new.detail);
999
1135
  END
1000
1136
  `);
1137
+ db.exec(`
1138
+ CREATE VIRTUAL TABLE IF NOT EXISTS wiki_pages_fts USING fts5(
1139
+ path UNINDEXED,
1140
+ title,
1141
+ entity_type,
1142
+ tags,
1143
+ summary,
1144
+ content='wiki_pages',
1145
+ content_rowid='rowid'
1146
+ )
1147
+ `);
1148
+ db.exec(`DROP TRIGGER IF EXISTS wiki_pages_ai`);
1149
+ db.exec(`DROP TRIGGER IF EXISTS wiki_pages_ad`);
1150
+ db.exec(`DROP TRIGGER IF EXISTS wiki_pages_au`);
1151
+ db.exec(`
1152
+ CREATE TRIGGER wiki_pages_ai AFTER INSERT ON wiki_pages BEGIN
1153
+ INSERT INTO wiki_pages_fts(rowid, path, title, entity_type, tags, summary)
1154
+ VALUES (new.rowid, new.path, new.title, new.entity_type, new.tags, new.summary);
1155
+ END
1156
+ `);
1157
+ db.exec(`
1158
+ CREATE TRIGGER wiki_pages_ad AFTER DELETE ON wiki_pages BEGIN
1159
+ INSERT INTO wiki_pages_fts(wiki_pages_fts, rowid, path, title, entity_type, tags, summary)
1160
+ VALUES('delete', old.rowid, old.path, old.title, old.entity_type, old.tags, old.summary);
1161
+ END
1162
+ `);
1163
+ db.exec(`
1164
+ CREATE TRIGGER wiki_pages_au AFTER UPDATE ON wiki_pages BEGIN
1165
+ INSERT INTO wiki_pages_fts(wiki_pages_fts, rowid, path, title, entity_type, tags, summary)
1166
+ VALUES('delete', old.rowid, old.path, old.title, old.entity_type, old.tags, old.summary);
1167
+ INSERT INTO wiki_pages_fts(rowid, path, title, entity_type, tags, summary)
1168
+ VALUES(new.rowid, new.path, new.title, new.entity_type, new.tags, new.summary);
1169
+ END
1170
+ `);
1171
+ seedWikiPagesFromDisk(db);
1001
1172
  // Backfill: check if FTS is in sync by comparing row counts
1002
1173
  const memCount = db.prepare(`SELECT COUNT(*) as c FROM memories`).get().c;
1003
1174
  const ftsCount = db.prepare(`SELECT COUNT(*) as c FROM memories_fts`).get().c;
@@ -1019,6 +1190,11 @@ export function getDb() {
1019
1190
  if (actionItemCount > 0 && actionItemFtsCount < actionItemCount) {
1020
1191
  db.exec(`INSERT INTO mem_action_items_fts(mem_action_items_fts) VALUES ('rebuild')`);
1021
1192
  }
1193
+ const wikiPageCount = db.prepare(`SELECT COUNT(*) as c FROM wiki_pages`).get().c;
1194
+ const wikiPageFtsCount = db.prepare(`SELECT COUNT(*) as c FROM wiki_pages_fts`).get().c;
1195
+ if (wikiPageCount > 0 && wikiPageFtsCount < wikiPageCount) {
1196
+ db.exec(`INSERT INTO wiki_pages_fts(wiki_pages_fts) VALUES ('rebuild')`);
1197
+ }
1022
1198
  fts5Available = true;
1023
1199
  }
1024
1200
  catch {
@@ -1312,4 +1488,11 @@ export function closeDb() {
1312
1488
  daemonRunRecorded = false;
1313
1489
  }
1314
1490
  }
1491
+ export function resetDbForTests() {
1492
+ closeDb();
1493
+ logInsertCount = 0;
1494
+ fts5Available = false;
1495
+ currentDaemonRunId = undefined;
1496
+ daemonRunRecorded = false;
1497
+ }
1315
1498
  //# sourceMappingURL=db.js.map