chapterhouse 0.8.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/dist/copilot/agents.js +1 -1
- package/dist/copilot/system-message.js +1 -0
- package/dist/copilot/tools.js +11 -1
- package/dist/copilot/tools.wiki.test.js +27 -0
- package/dist/copilot/turn-event-log-env.test.js +11 -15
- package/dist/daemon.js +10 -0
- package/dist/memory/eot.js +30 -8
- package/dist/memory/eot.test.js +220 -6
- package/dist/memory/migration.test.js +10 -2
- package/dist/paths.js +31 -11
- package/dist/store/db.js +68 -0
- package/dist/store/db.test.js +47 -1
- 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 +8 -1
- package/dist/wiki/consolidation.test.js +3 -0
- package/dist/wiki/fs.js +22 -13
- package/dist/wiki/index-manager.js +82 -23
- package/dist/wiki/index-manager.test.js +129 -1
- package/dist/wiki/log-manager.js +8 -5
- package/dist/wiki/log-manager.test.js +4 -0
- package/package.json +1 -1
package/dist/copilot/agents.js
CHANGED
|
@@ -340,7 +340,7 @@ export function buildAgentRoster() {
|
|
|
340
340
|
}
|
|
341
341
|
// The wiki tools that every agent gets regardless of tool config
|
|
342
342
|
const WIKI_TOOL_NAMES = new Set([
|
|
343
|
-
"wiki_search", "wiki_read", "wiki_update", "wiki_append_timeline", "wiki_ingest_source",
|
|
343
|
+
"wiki_search", "wiki_read", "wiki_update", "wiki_reindex", "wiki_append_timeline", "wiki_ingest_source",
|
|
344
344
|
"memory_recall", "memory_propose", "memory_list_action_items",
|
|
345
345
|
]);
|
|
346
346
|
// Management tools that only @chapterhouse should have
|
|
@@ -119,6 +119,7 @@ You can delegate **multiple tasks simultaneously**. Different agents can work in
|
|
|
119
119
|
- \`memory_recall\`: Search scoped agent memory for stored facts, decisions, and observations.
|
|
120
120
|
- \`memory_reflect\`: Synthesize durable patterns from repeated observations in the scoped memory store.
|
|
121
121
|
- \`wiki_update\`: Create or update wiki pages when knowledge belongs in the shared wiki.
|
|
122
|
+
- \`wiki_reindex\`: Force a filesystem-to-SQLite wiki reindex if existing pages are missing from search.
|
|
122
123
|
|
|
123
124
|
Subagent proposals from \`memory_propose\` are processed automatically at end-of-task, so you do not need to manually review them mid-conversation.
|
|
124
125
|
|
package/dist/copilot/tools.js
CHANGED
|
@@ -11,7 +11,7 @@ import { agentEventBus } from "./agent-event-bus.js";
|
|
|
11
11
|
import { getCurrentSourceChannel, getCurrentActivityCallback, getCurrentActiveProjectRules, getCurrentAuthenticatedUser, getLastAuthenticatedUser, getCurrentAuthorizationHeader, getCurrentSessionKey, sendToAgentSession, invalidateOrchestratorSession, maybeScheduleScopeChangeCheckpoint, resetCheckpointSessionState, switchSessionModel, } from "./orchestrator.js";
|
|
12
12
|
import { getRouterConfig, updateRouterConfig } from "./router.js";
|
|
13
13
|
import { ensureWikiStructure, writePage, assertPagePath } from "../wiki/fs.js";
|
|
14
|
-
import { searchIndex, addToIndex, buildIndexEntryForPage, } from "../wiki/index-manager.js";
|
|
14
|
+
import { searchIndex, addToIndex, buildIndexEntryForPage, reindexWikiPages, } from "../wiki/index-manager.js";
|
|
15
15
|
import { traverse as wikiTraverse } from "../wiki/links.js";
|
|
16
16
|
import { validateWikiFrontmatter, validateAndBackfillFrontmatter } from "../wiki/frontmatter.js";
|
|
17
17
|
import { appendTimeline } from "../wiki/timeline.js";
|
|
@@ -1558,6 +1558,16 @@ export function createTools(deps) {
|
|
|
1558
1558
|
parameters: z.object({}).passthrough(),
|
|
1559
1559
|
handler: async () => "This tool has been removed. The wiki index is now maintained automatically via SQLite FTS5.",
|
|
1560
1560
|
}),
|
|
1561
|
+
defineTool("wiki_reindex", {
|
|
1562
|
+
description: "Force a full wiki filesystem-to-SQLite reindex pass.",
|
|
1563
|
+
parameters: z.object({}),
|
|
1564
|
+
handler: async () => {
|
|
1565
|
+
ensureWikiStructure();
|
|
1566
|
+
const result = reindexWikiPages();
|
|
1567
|
+
appendLog("update", `wiki_reindex: rebuilt ${result.indexedPageCount} page(s)`);
|
|
1568
|
+
return `Reindexed ${result.indexedPageCount} wiki page(s) from disk.`;
|
|
1569
|
+
},
|
|
1570
|
+
}),
|
|
1561
1571
|
defineTool("wiki_traverse", {
|
|
1562
1572
|
description: "Walk the wiki entity graph from a starting page. Returns pages connected by typed links. " +
|
|
1563
1573
|
"Use to discover related knowledge, trace dependencies, find who works on a project, etc. " +
|
|
@@ -146,6 +146,33 @@ Runtime notes with enough content to avoid incidental lint noise in the audit-lo
|
|
|
146
146
|
const log = wikiFs.readLogFile();
|
|
147
147
|
assert.match(log, /update \| wiki_update: Chapterhouse \(pages\/shared\/chapterhouse\.md\) \| tools-test-agent/);
|
|
148
148
|
});
|
|
149
|
+
test("wiki_reindex rebuilds wiki_pages from disk on demand", async () => {
|
|
150
|
+
const toolsModule = await loadToolsModule();
|
|
151
|
+
const tools = toolsModule.createTools({
|
|
152
|
+
client: { async listModels() { return []; } },
|
|
153
|
+
onAgentTaskComplete: () => { },
|
|
154
|
+
});
|
|
155
|
+
const wikiReindex = tools.find((entry) => entry.name === "wiki_reindex");
|
|
156
|
+
assert.ok(wikiReindex);
|
|
157
|
+
const { wikiFs, indexManager } = await readWikiArtifacts();
|
|
158
|
+
wikiFs.ensureWikiStructure();
|
|
159
|
+
wikiFs.writePage("pages/topics/rust/index.md", `---
|
|
160
|
+
title: Rust
|
|
161
|
+
summary: Systems programming
|
|
162
|
+
updated: 2026-05-12
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
# Rust
|
|
166
|
+
`);
|
|
167
|
+
for (const entry of indexManager.parseIndex()) {
|
|
168
|
+
indexManager.removeFromIndex(entry.path);
|
|
169
|
+
}
|
|
170
|
+
assert.equal(indexManager.parseIndex().length, 0, "Precondition: wiki_pages should start empty");
|
|
171
|
+
const result = await wikiReindex.handler({});
|
|
172
|
+
assert.match(result, /^Reindexed \d+ wiki page\(s\) from disk\.$/);
|
|
173
|
+
assert.ok(indexManager.parseIndex().some((entry) => entry.path === "pages/topics/rust/index.md"));
|
|
174
|
+
assert.match(wikiFs.readLogFile(), /update \| wiki_reindex: rebuilt \d+ page\(s\) \| tools-test-agent/);
|
|
175
|
+
});
|
|
149
176
|
test("removed legacy wiki tools return helpful stub messages without mutating wiki pages", async () => {
|
|
150
177
|
const toolsModule = await loadToolsModule();
|
|
151
178
|
const tools = toolsModule.createTools({
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
test("SESSION_BUFFER_CAPACITY respects
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
else {
|
|
15
|
-
process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = previous;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
3
|
+
test("SESSION_BUFFER_CAPACITY respects config.sseBufferCapacity", async (t) => {
|
|
4
|
+
t.mock.module("../config.js", {
|
|
5
|
+
namedExports: {
|
|
6
|
+
config: {
|
|
7
|
+
sseBufferCapacity: 3,
|
|
8
|
+
sseReplayLimit: 50,
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
const module = await import(`./turn-event-log.js?capacity=${Date.now()}`);
|
|
13
|
+
assert.equal(module.SESSION_BUFFER_CAPACITY, 3);
|
|
18
14
|
});
|
|
19
15
|
//# sourceMappingURL=turn-event-log-env.test.js.map
|
package/dist/daemon.js
CHANGED
|
@@ -23,6 +23,7 @@ import { isWorkiqAutoInstallEnabled, ensureWorkiqMcpEntry } from "./copilot/work
|
|
|
23
23
|
import { MemoryHousekeepingScheduler } from "./memory/housekeeping-scheduler.js";
|
|
24
24
|
import { runP6Migration } from "./memory/migration.js";
|
|
25
25
|
import { WikiConsolidationScheduler } from "./wiki/scheduler.js";
|
|
26
|
+
import { ensureWikiIndexPopulated } from "./wiki/index-manager.js";
|
|
26
27
|
const log = logger.child({ module: "daemon" });
|
|
27
28
|
const modeContext = new ModeContext(config);
|
|
28
29
|
let memoryHousekeepingScheduler;
|
|
@@ -131,6 +132,15 @@ async function main() {
|
|
|
131
132
|
}
|
|
132
133
|
const p6Migration = await runP6Migration(getDb());
|
|
133
134
|
log.info({ p6Migration }, "P6 wiki seed migration complete");
|
|
135
|
+
try {
|
|
136
|
+
const wikiReindex = ensureWikiIndexPopulated();
|
|
137
|
+
if (wikiReindex.reindexed) {
|
|
138
|
+
log.info(wikiReindex, "Rebuilt wiki index from disk on startup");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
log.error({ err: err instanceof Error ? err.message : err }, "Startup wiki reindex check failed");
|
|
143
|
+
}
|
|
134
144
|
// Prune orphaned session folders older than 7 days
|
|
135
145
|
pruneOldSessions();
|
|
136
146
|
// One-time deprecation note for legacy Telegram users (v1 → v2)
|
package/dist/memory/eot.js
CHANGED
|
@@ -127,6 +127,16 @@ function isNonEmptyString(value) {
|
|
|
127
127
|
function isIsoTimestamp(value) {
|
|
128
128
|
return !Number.isNaN(Date.parse(value));
|
|
129
129
|
}
|
|
130
|
+
function validateObservationPayload(payload) {
|
|
131
|
+
const observation = payload;
|
|
132
|
+
if (!isNonEmptyString(observation.content)) {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
...observation,
|
|
137
|
+
content: observation.content.trim(),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
130
140
|
function validateActionItemPayload(payload) {
|
|
131
141
|
const actionItem = payload;
|
|
132
142
|
if (!isNonEmptyString(actionItem.title)) {
|
|
@@ -195,15 +205,20 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
|
|
|
195
205
|
throw new Error(`Unknown memory scope '${scopeSlug}'.`);
|
|
196
206
|
}
|
|
197
207
|
if (kind === "observation") {
|
|
198
|
-
const observation = payload;
|
|
208
|
+
const observation = validateObservationPayload(payload);
|
|
209
|
+
if (!observation) {
|
|
210
|
+
log.warn({ scopeSlug, source, sourceAgent }, "Skipping accepted observation proposal with empty content");
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const content = observation.content;
|
|
199
214
|
recordObservation({
|
|
200
215
|
scope_id: scope.id,
|
|
201
216
|
entity_id: observation.entity_id,
|
|
202
|
-
content
|
|
217
|
+
content,
|
|
203
218
|
source: observation.source ?? source,
|
|
204
219
|
confidence,
|
|
205
220
|
});
|
|
206
|
-
return;
|
|
221
|
+
return true;
|
|
207
222
|
}
|
|
208
223
|
if (kind === "decision") {
|
|
209
224
|
const decision = payload;
|
|
@@ -213,7 +228,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
|
|
|
213
228
|
rationale: decision.rationale ?? decision.title,
|
|
214
229
|
decided_at: decision.decided_at,
|
|
215
230
|
});
|
|
216
|
-
return;
|
|
231
|
+
return true;
|
|
217
232
|
}
|
|
218
233
|
if (kind === "action_item") {
|
|
219
234
|
const actionItem = validateActionItemPayload(payload);
|
|
@@ -233,7 +248,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
|
|
|
233
248
|
due_at: actionItem.due_at,
|
|
234
249
|
source: sourceAgent ? `subagent_proposal:${sourceAgent}` : actionItem.source ?? source,
|
|
235
250
|
});
|
|
236
|
-
return;
|
|
251
|
+
return true;
|
|
237
252
|
}
|
|
238
253
|
const entity = payload;
|
|
239
254
|
const entityKind = entity.entity_kind ?? entity.kind;
|
|
@@ -247,6 +262,7 @@ function rememberAcceptedMemory(kind, scopeSlug, payload, source, confidence, so
|
|
|
247
262
|
summary: entity.summary,
|
|
248
263
|
confidence,
|
|
249
264
|
});
|
|
265
|
+
return true;
|
|
250
266
|
}
|
|
251
267
|
export async function runEndOfTaskMemoryHook(input) {
|
|
252
268
|
const autoAcceptEnabled = isMemoryAutoAcceptEnabled();
|
|
@@ -293,7 +309,12 @@ export async function runEndOfTaskMemoryHook(input) {
|
|
|
293
309
|
else {
|
|
294
310
|
const envelope = parseEnvelope(proposal.payload);
|
|
295
311
|
try {
|
|
296
|
-
rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
|
|
312
|
+
const accepted = rememberAcceptedMemory(envelope.kind, resolveAcceptedProposalScopeSlug(envelope, proposal), envelope.payload, `agent:${proposal.sourceAgent}`, envelope.confidence, proposal.sourceAgent);
|
|
313
|
+
if (!accepted) {
|
|
314
|
+
resolveInboxItem(proposal.id, "accepted", decision.reason);
|
|
315
|
+
summary.accepted++;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
297
318
|
resolveInboxItem(proposal.id, "accepted", decision.reason);
|
|
298
319
|
summary.accepted++;
|
|
299
320
|
}
|
|
@@ -321,8 +342,9 @@ export async function runEndOfTaskMemoryHook(input) {
|
|
|
321
342
|
}
|
|
322
343
|
if (autoAcceptEnabled) {
|
|
323
344
|
for (const implicitMemory of review.implicit_memories) {
|
|
324
|
-
rememberAcceptedMemory(implicitMemory.kind, implicitMemory.scope_slug, implicitMemory.payload, "agent:eot", implicitMemory.confidence)
|
|
325
|
-
|
|
345
|
+
if (rememberAcceptedMemory(implicitMemory.kind, implicitMemory.scope_slug, implicitMemory.payload, "agent:eot", implicitMemory.confidence)) {
|
|
346
|
+
summary.implicit_extracted++;
|
|
347
|
+
}
|
|
326
348
|
}
|
|
327
349
|
}
|
|
328
350
|
log.info(summary, "memory.eot.processed");
|
package/dist/memory/eot.test.js
CHANGED
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
|
+
import { resetSingletons } from "../test/helpers/reset-singletons.js";
|
|
5
6
|
const repoRoot = process.cwd();
|
|
6
7
|
const sandboxRoot = join(repoRoot, ".test-work", `memory-eot-${process.pid}`);
|
|
7
8
|
const chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
@@ -18,24 +19,40 @@ async function loadModules(cacheBust = `${Date.now()}-${Math.random()}`) {
|
|
|
18
19
|
const agentsModule = await import(new URL(`../copilot/agents.js?case=${cacheBust}`, import.meta.url).href);
|
|
19
20
|
return { dbModule, memoryModule, eotModule, agentsModule };
|
|
20
21
|
}
|
|
22
|
+
async function loadModulesWithWarnSpy(t, cacheBust) {
|
|
23
|
+
const warnings = [];
|
|
24
|
+
t.mock.module("../util/logger.js", {
|
|
25
|
+
namedExports: {
|
|
26
|
+
childLogger: () => ({
|
|
27
|
+
info: () => { },
|
|
28
|
+
warn: (...args) => {
|
|
29
|
+
warnings.push(args);
|
|
30
|
+
},
|
|
31
|
+
error: () => { },
|
|
32
|
+
}),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
36
|
+
const memoryModule = await import(new URL(`./index.js?case=${cacheBust}`, import.meta.url).href);
|
|
37
|
+
const eotModule = await import(new URL(`./eot.js?case=${cacheBust}`, import.meta.url).href);
|
|
38
|
+
return { dbModule, memoryModule, eotModule, warnings };
|
|
39
|
+
}
|
|
21
40
|
function getFunction(module, name) {
|
|
22
41
|
const value = module[name];
|
|
23
42
|
assert.equal(typeof value, "function", `expected ${name} to be exported`);
|
|
24
43
|
return value;
|
|
25
44
|
}
|
|
26
|
-
test.beforeEach(
|
|
45
|
+
test.beforeEach(() => {
|
|
27
46
|
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
28
47
|
delete process.env.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED;
|
|
29
48
|
delete process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT;
|
|
30
|
-
const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
|
|
31
|
-
dbModule.closeDb();
|
|
32
49
|
resetSandbox();
|
|
50
|
+
resetSingletons();
|
|
33
51
|
});
|
|
34
|
-
test.after(
|
|
52
|
+
test.after(() => {
|
|
35
53
|
delete process.env.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED;
|
|
36
54
|
delete process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT;
|
|
37
|
-
|
|
38
|
-
dbModule.closeDb();
|
|
55
|
+
resetSingletons();
|
|
39
56
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
40
57
|
});
|
|
41
58
|
test("runEndOfTaskMemoryHook does nothing when CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED=0", async () => {
|
|
@@ -239,6 +256,98 @@ test("runEndOfTaskMemoryHook rejects invalid action_item proposals with a clear
|
|
|
239
256
|
assert.match(inbox.resolution_reason ?? "", /title/i);
|
|
240
257
|
assert.ok(inbox.resolved_at);
|
|
241
258
|
});
|
|
259
|
+
async function runAcceptedObservationHookScenario(t, cacheBust, taskId, payload) {
|
|
260
|
+
const warnings = [];
|
|
261
|
+
t.mock.module("../util/logger.js", {
|
|
262
|
+
namedExports: {
|
|
263
|
+
childLogger: () => ({
|
|
264
|
+
info: () => { },
|
|
265
|
+
warn: (obj, msg) => warnings.push({ obj, msg }),
|
|
266
|
+
error: () => { },
|
|
267
|
+
}),
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
const { dbModule, memoryModule, eotModule } = await loadModules(cacheBust);
|
|
271
|
+
const db = dbModule.getDb();
|
|
272
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
273
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
274
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
275
|
+
const chapterhouse = getScope("chapterhouse");
|
|
276
|
+
assert.ok(chapterhouse);
|
|
277
|
+
const before = listObservations({ scope_id: chapterhouse.id });
|
|
278
|
+
const inserted = db.prepare(`
|
|
279
|
+
INSERT INTO mem_inbox (scope_id, kind, payload, source_agent, source_task_id, status)
|
|
280
|
+
VALUES (?, 'memory_proposal', ?, 'coder', ?, 'pending')
|
|
281
|
+
`).run(chapterhouse.id, JSON.stringify({
|
|
282
|
+
kind: "observation",
|
|
283
|
+
payload,
|
|
284
|
+
confidence: 0.9,
|
|
285
|
+
}), taskId);
|
|
286
|
+
await runEndOfTaskMemoryHook({
|
|
287
|
+
taskId,
|
|
288
|
+
finalResult: "Completed and reviewed an observation proposal.",
|
|
289
|
+
copilotClient: {},
|
|
290
|
+
callLLM: async () => JSON.stringify({
|
|
291
|
+
decisions: [{
|
|
292
|
+
proposal_id: Number(inserted.lastInsertRowid),
|
|
293
|
+
decision: "accept",
|
|
294
|
+
reason: "Durable finding.",
|
|
295
|
+
}],
|
|
296
|
+
implicit_memories: [],
|
|
297
|
+
}),
|
|
298
|
+
});
|
|
299
|
+
const inbox = db.prepare(`
|
|
300
|
+
SELECT status, resolution_reason
|
|
301
|
+
FROM mem_inbox
|
|
302
|
+
WHERE id = ?
|
|
303
|
+
`).get(Number(inserted.lastInsertRowid));
|
|
304
|
+
return {
|
|
305
|
+
beforeCount: before.length,
|
|
306
|
+
after: listObservations({ scope_id: chapterhouse.id }),
|
|
307
|
+
warnings,
|
|
308
|
+
inbox,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
test("runEndOfTaskMemoryHook skips accepted observation proposals with null content and warns", async (t) => {
|
|
312
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-null-content", "task-eot-observation-null-content", { content: null });
|
|
313
|
+
assert.equal(result.after.length, result.beforeCount);
|
|
314
|
+
assert.equal(result.after.some((row) => row.content.trim().length === 0), false);
|
|
315
|
+
assert.equal(result.inbox.status, "accepted");
|
|
316
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
317
|
+
assert.ok(result.warnings.some((entry) => /empty content/i.test(entry.msg)));
|
|
318
|
+
});
|
|
319
|
+
test("runEndOfTaskMemoryHook skips accepted observation proposals with undefined content and warns", async (t) => {
|
|
320
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-undefined-content", "task-eot-observation-undefined-content", {});
|
|
321
|
+
assert.equal(result.after.length, result.beforeCount);
|
|
322
|
+
assert.equal(result.after.some((row) => row.content.trim().length === 0), false);
|
|
323
|
+
assert.equal(result.inbox.status, "accepted");
|
|
324
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
325
|
+
assert.ok(result.warnings.some((entry) => /empty content/i.test(entry.msg)));
|
|
326
|
+
});
|
|
327
|
+
test("runEndOfTaskMemoryHook skips accepted observation proposals with empty string content and warns", async (t) => {
|
|
328
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-empty-string-content", "task-eot-observation-empty-string-content", { content: "" });
|
|
329
|
+
assert.equal(result.after.length, result.beforeCount);
|
|
330
|
+
assert.equal(result.after.some((row) => row.content.trim().length === 0), false);
|
|
331
|
+
assert.equal(result.inbox.status, "accepted");
|
|
332
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
333
|
+
assert.ok(result.warnings.some((entry) => /empty content/i.test(entry.msg)));
|
|
334
|
+
});
|
|
335
|
+
test("runEndOfTaskMemoryHook skips accepted observation proposals with whitespace-only content and warns", async (t) => {
|
|
336
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-whitespace-content", "task-eot-observation-whitespace-content", { content: " " });
|
|
337
|
+
assert.equal(result.after.length, result.beforeCount);
|
|
338
|
+
assert.equal(result.after.some((row) => row.content.trim().length === 0), false);
|
|
339
|
+
assert.equal(result.inbox.status, "accepted");
|
|
340
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
341
|
+
assert.ok(result.warnings.some((entry) => /empty content/i.test(entry.msg)));
|
|
342
|
+
});
|
|
343
|
+
test("runEndOfTaskMemoryHook inserts accepted observation proposals with valid content", async (t) => {
|
|
344
|
+
const result = await runAcceptedObservationHookScenario(t, "observation-valid-content", "task-eot-observation-valid-content", { content: " Durable finding from the task. " });
|
|
345
|
+
assert.equal(result.after.length, result.beforeCount + 1);
|
|
346
|
+
assert.ok(result.after.some((row) => row.content === "Durable finding from the task."));
|
|
347
|
+
assert.equal(result.inbox.status, "accepted");
|
|
348
|
+
assert.equal(result.inbox.resolution_reason, "Durable finding.");
|
|
349
|
+
assert.equal(result.warnings.length, 0);
|
|
350
|
+
});
|
|
242
351
|
test("runEndOfTaskMemoryHook rejects action_item proposals with ambiguous entity references", async () => {
|
|
243
352
|
const { dbModule, memoryModule, eotModule } = await loadModules("action-item-ambiguous-entity");
|
|
244
353
|
const db = dbModule.getDb();
|
|
@@ -549,4 +658,109 @@ test("runEndOfTaskMemoryHook accepts implicit entity memories with entity_kind",
|
|
|
549
658
|
&& entity.kind === "host"
|
|
550
659
|
&& entity.summary === "NAS host used by Bellonda."), true);
|
|
551
660
|
});
|
|
661
|
+
test("runEndOfTaskMemoryHook skips implicit observation memories with null content and warns", async (t) => {
|
|
662
|
+
const { memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "implicit-null-content");
|
|
663
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
664
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
665
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
666
|
+
const chapterhouse = getScope("chapterhouse");
|
|
667
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
668
|
+
const initialCount = listObservations({ scope_id: chapterhouse.id }).length;
|
|
669
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
670
|
+
taskId: "task-eot-implicit-null",
|
|
671
|
+
finalResult: "The reviewer attempted to persist an invalid implicit memory.",
|
|
672
|
+
copilotClient: {},
|
|
673
|
+
callLLM: async () => JSON.stringify({
|
|
674
|
+
decisions: [],
|
|
675
|
+
implicit_memories: [{
|
|
676
|
+
kind: "observation",
|
|
677
|
+
scope_slug: "chapterhouse",
|
|
678
|
+
payload: {
|
|
679
|
+
content: null,
|
|
680
|
+
},
|
|
681
|
+
}],
|
|
682
|
+
}),
|
|
683
|
+
});
|
|
684
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).length, initialCount);
|
|
685
|
+
assert.equal(summary.implicit_extracted, 0);
|
|
686
|
+
assert.equal(warnings.length, 1);
|
|
687
|
+
});
|
|
688
|
+
test("runEndOfTaskMemoryHook skips implicit observation memories with undefined content and warns", async (t) => {
|
|
689
|
+
const { memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "implicit-undefined-content");
|
|
690
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
691
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
692
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
693
|
+
const chapterhouse = getScope("chapterhouse");
|
|
694
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
695
|
+
const initialCount = listObservations({ scope_id: chapterhouse.id }).length;
|
|
696
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
697
|
+
taskId: "task-eot-implicit-undefined",
|
|
698
|
+
finalResult: "The reviewer attempted to persist an invalid implicit memory.",
|
|
699
|
+
copilotClient: {},
|
|
700
|
+
callLLM: async () => JSON.stringify({
|
|
701
|
+
decisions: [],
|
|
702
|
+
implicit_memories: [{
|
|
703
|
+
kind: "observation",
|
|
704
|
+
scope_slug: "chapterhouse",
|
|
705
|
+
payload: {},
|
|
706
|
+
}],
|
|
707
|
+
}),
|
|
708
|
+
});
|
|
709
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).length, initialCount);
|
|
710
|
+
assert.equal(summary.implicit_extracted, 0);
|
|
711
|
+
assert.equal(warnings.length, 1);
|
|
712
|
+
});
|
|
713
|
+
test("runEndOfTaskMemoryHook skips implicit observation memories with empty content and warns", async (t) => {
|
|
714
|
+
const { memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "implicit-empty-content");
|
|
715
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
716
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
717
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
718
|
+
const chapterhouse = getScope("chapterhouse");
|
|
719
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
720
|
+
const initialCount = listObservations({ scope_id: chapterhouse.id }).length;
|
|
721
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
722
|
+
taskId: "task-eot-implicit-empty",
|
|
723
|
+
finalResult: "The reviewer attempted to persist an invalid implicit memory.",
|
|
724
|
+
copilotClient: {},
|
|
725
|
+
callLLM: async () => JSON.stringify({
|
|
726
|
+
decisions: [],
|
|
727
|
+
implicit_memories: [{
|
|
728
|
+
kind: "observation",
|
|
729
|
+
scope_slug: "chapterhouse",
|
|
730
|
+
payload: {
|
|
731
|
+
content: " ",
|
|
732
|
+
},
|
|
733
|
+
}],
|
|
734
|
+
}),
|
|
735
|
+
});
|
|
736
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).length, initialCount);
|
|
737
|
+
assert.equal(summary.implicit_extracted, 0);
|
|
738
|
+
assert.equal(warnings.length, 1);
|
|
739
|
+
});
|
|
740
|
+
test("runEndOfTaskMemoryHook persists implicit observation memories with valid content", async (t) => {
|
|
741
|
+
const { memoryModule, eotModule, warnings } = await loadModulesWithWarnSpy(t, "implicit-valid-content");
|
|
742
|
+
const getScope = getFunction(memoryModule, "getScope");
|
|
743
|
+
const listObservations = getFunction(memoryModule, "listObservations");
|
|
744
|
+
const runEndOfTaskMemoryHook = getFunction(eotModule, "runEndOfTaskMemoryHook");
|
|
745
|
+
const chapterhouse = getScope("chapterhouse");
|
|
746
|
+
assert.ok(chapterhouse, "chapterhouse scope should be seeded");
|
|
747
|
+
const summary = await runEndOfTaskMemoryHook({
|
|
748
|
+
taskId: "task-eot-implicit-valid",
|
|
749
|
+
finalResult: "The reviewer discovered a valid durable memory.",
|
|
750
|
+
copilotClient: {},
|
|
751
|
+
callLLM: async () => JSON.stringify({
|
|
752
|
+
decisions: [],
|
|
753
|
+
implicit_memories: [{
|
|
754
|
+
kind: "observation",
|
|
755
|
+
scope_slug: "chapterhouse",
|
|
756
|
+
payload: {
|
|
757
|
+
content: "A valid implicit memory should still be stored.",
|
|
758
|
+
},
|
|
759
|
+
}],
|
|
760
|
+
}),
|
|
761
|
+
});
|
|
762
|
+
assert.equal(listObservations({ scope_id: chapterhouse.id }).some((row) => row.content === "A valid implicit memory should still be stored."), true);
|
|
763
|
+
assert.equal(summary.implicit_extracted, 1);
|
|
764
|
+
assert.equal(warnings.length, 0);
|
|
765
|
+
});
|
|
552
766
|
//# sourceMappingURL=eot.test.js.map
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import test from "node:test";
|
|
5
|
+
import { resetSingletons } from "../test/helpers/reset-singletons.js";
|
|
5
6
|
let sandboxRoot;
|
|
6
7
|
let chapterhouseHome;
|
|
7
8
|
let dbModule;
|
|
@@ -20,12 +21,15 @@ test.before(async () => {
|
|
|
20
21
|
sandboxRoot = mkdtempSync(join(process.cwd(), ".test-work", "memory-migration-"));
|
|
21
22
|
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
22
23
|
chapterhouseHome = join(sandboxRoot, ".chapterhouse");
|
|
24
|
+
resetSingletons();
|
|
23
25
|
const nonce = `${Date.now()}-${Math.random()}`;
|
|
24
26
|
dbModule = await import(new URL(`../store/db.js?case=${nonce}`, import.meta.url).href);
|
|
25
27
|
migrationModule = await import(new URL(`./migration.js?case=${nonce}`, import.meta.url).href);
|
|
26
28
|
});
|
|
27
29
|
test.beforeEach(() => {
|
|
30
|
+
process.env.CHAPTERHOUSE_HOME = sandboxRoot;
|
|
28
31
|
dbModule.closeDb();
|
|
32
|
+
resetSingletons();
|
|
29
33
|
resetSandbox();
|
|
30
34
|
});
|
|
31
35
|
test.after(() => {
|
|
@@ -33,6 +37,10 @@ test.after(() => {
|
|
|
33
37
|
dbModule.closeDb();
|
|
34
38
|
}
|
|
35
39
|
catch { }
|
|
40
|
+
try {
|
|
41
|
+
resetSingletons();
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
36
44
|
try {
|
|
37
45
|
rmSync(sandboxRoot, { recursive: true, force: true });
|
|
38
46
|
}
|
|
@@ -84,9 +92,9 @@ test("runP6Migration is idempotent across repeated runs", async () => {
|
|
|
84
92
|
assert.equal(db.prepare(`SELECT COUNT(*) AS count FROM mem_observations WHERE source = 'migration:p6'`).get().count, 1);
|
|
85
93
|
assert.equal(db.prepare(`SELECT COUNT(*) AS count FROM mem_migrations WHERE name = 'p6-wiki-seed'`).get().count, 1);
|
|
86
94
|
});
|
|
87
|
-
test("runP6Migration skips gracefully when the wiki
|
|
95
|
+
test("runP6Migration skips gracefully when the wiki skeleton exists but contains no migratable pages", async () => {
|
|
88
96
|
const db = dbModule.getDb();
|
|
89
|
-
assert.equal(existsSync(join(chapterhouseHome, "wiki", "pages")),
|
|
97
|
+
assert.equal(existsSync(join(chapterhouseHome, "wiki", "pages")), true);
|
|
90
98
|
const result = await migrationModule.runP6Migration(db);
|
|
91
99
|
assert.deepEqual(result, {
|
|
92
100
|
entitiesCreated: 0,
|
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
|