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.
- package/agents/korg.agent.md +65 -0
- package/dist/api/korg.js +34 -0
- package/dist/api/korg.test.js +42 -0
- package/dist/api/server.js +238 -2
- package/dist/api/server.test.js +199 -0
- package/dist/config.js +28 -0
- package/dist/config.test.js +20 -0
- package/dist/copilot/agents.js +3 -4
- package/dist/copilot/agents.test.js +12 -1
- package/dist/copilot/orchestrator.js +12 -1
- package/dist/copilot/orchestrator.test.js +3 -7
- package/dist/copilot/system-message.js +12 -10
- package/dist/copilot/system-message.test.js +6 -1
- package/dist/copilot/tools.js +193 -375
- package/dist/copilot/tools.memory.test.js +32 -0
- package/dist/copilot/tools.wiki.test.js +80 -59
- package/dist/copilot/turn-event-log-env.test.js +11 -15
- package/dist/daemon.js +19 -0
- package/dist/memory/decisions.js +6 -5
- package/dist/memory/entities.js +20 -9
- package/dist/memory/eot.js +30 -8
- package/dist/memory/eot.test.js +220 -6
- package/dist/memory/hooks.js +151 -0
- package/dist/memory/hooks.test.js +325 -0
- package/dist/memory/hot-tier.js +37 -0
- package/dist/memory/hot-tier.test.js +30 -0
- package/dist/memory/housekeeping-scheduler.js +35 -0
- package/dist/memory/housekeeping-scheduler.test.js +50 -0
- package/dist/memory/inbox.js +10 -0
- package/dist/memory/index.js +3 -1
- package/dist/memory/migration.js +244 -0
- package/dist/memory/migration.test.js +108 -0
- package/dist/memory/reflect.js +273 -0
- package/dist/memory/reflect.test.js +254 -0
- package/dist/paths.js +31 -11
- package/dist/store/db.js +187 -4
- package/dist/store/db.test.js +66 -2
- package/dist/test/helpers/reset-singletons.js +8 -0
- package/dist/test/helpers/reset-singletons.test.js +37 -0
- package/dist/test/setup-env.js +9 -1
- package/dist/wiki/consolidation.js +641 -0
- package/dist/wiki/consolidation.test.js +143 -0
- package/dist/wiki/frontmatter.js +48 -0
- package/dist/wiki/frontmatter.test.js +42 -0
- package/dist/wiki/fs.js +22 -13
- package/dist/wiki/index-manager.js +305 -330
- package/dist/wiki/index-manager.test.js +265 -144
- package/dist/wiki/ingest.js +347 -0
- package/dist/wiki/ingest.test.js +111 -0
- package/dist/wiki/links.js +151 -0
- package/dist/wiki/links.test.js +176 -0
- package/dist/wiki/log-manager.js +8 -5
- package/dist/wiki/log-manager.test.js +4 -0
- package/dist/wiki/migrate-topics.test.js +16 -6
- package/dist/wiki/scheduler.js +118 -0
- package/dist/wiki/scheduler.test.js +64 -0
- package/dist/wiki/timeline.js +51 -0
- package/dist/wiki/timeline.test.js +65 -0
- package/dist/wiki/topic-structure.js +1 -1
- package/package.json +1 -1
- package/skills/pkb-ideas/SKILL.md +78 -0
- package/skills/pkb-ideas/_meta.json +4 -0
- package/skills/pkb-org/SKILL.md +82 -0
- package/skills/pkb-org/_meta.json +4 -0
- package/skills/pkb-people/SKILL.md +74 -0
- package/skills/pkb-people/_meta.json +4 -0
- package/skills/pkb-research/SKILL.md +83 -0
- package/skills/pkb-research/_meta.json +4 -0
- package/skills/pkb-source/SKILL.md +38 -0
- package/skills/pkb-source/_meta.json +4 -0
- package/skills/wiki-conventions/SKILL.md +5 -5
- package/web/dist/assets/{index-DuKYxMIR.css → index-5kz9aRU9.css} +1 -1
- package/web/dist/assets/{index-DytB69KC.js → index-BbX9RKf3.js} +91 -89
- package/web/dist/assets/index-BbX9RKf3.js.map +1 -0
- package/web/dist/index.html +2 -2
- package/dist/wiki/context.js +0 -138
- package/dist/wiki/fix.js +0 -335
- package/dist/wiki/fix.test.js +0 -350
- package/dist/wiki/lint.js +0 -451
- package/dist/wiki/lint.test.js +0 -329
- 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
|
-
|
|
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
|
|
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
|
|
26
|
+
export let ENV_PATH = join(CHAPTERHOUSE_HOME, ".env");
|
|
26
27
|
/** Path to user-local skills */
|
|
27
|
-
export
|
|
28
|
+
export let SKILLS_DIR = join(CHAPTERHOUSE_HOME, "skills");
|
|
28
29
|
/** Path to Chapterhouse's isolated session state (keeps CLI history clean) */
|
|
29
|
-
export
|
|
30
|
+
export let SESSIONS_DIR = join(CHAPTERHOUSE_HOME, "sessions");
|
|
30
31
|
/** Path to the API bearer token file */
|
|
31
|
-
export
|
|
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
|
|
36
|
+
export let AGENTS_DIR = join(CHAPTERHOUSE_HOME, "agents");
|
|
34
37
|
/** Root of the LLM-maintained wiki knowledge base */
|
|
35
|
-
export
|
|
38
|
+
export let WIKI_DIR = join(CHAPTERHOUSE_HOME, "wiki");
|
|
36
39
|
/** Wiki pages (entity, concept, summary files) */
|
|
37
|
-
export
|
|
40
|
+
export let WIKI_PAGES_DIR = join(WIKI_DIR, "pages");
|
|
38
41
|
/** Raw ingested source documents (immutable) */
|
|
39
|
-
export
|
|
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
|
-
|
|
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
|