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.
@@ -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
 
@@ -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 CHAPTERHOUSE_SSE_BUFFER_CAPACITY", async () => {
4
- const previous = process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
5
- process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY = "3";
6
- try {
7
- const module = await import(`./turn-event-log.js?capacity=${Date.now()}`);
8
- assert.equal(module.SESSION_BUFFER_CAPACITY, 3);
9
- }
10
- finally {
11
- if (previous === undefined) {
12
- delete process.env.CHAPTERHOUSE_SSE_BUFFER_CAPACITY;
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)
@@ -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: observation.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
- summary.implicit_extracted++;
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");
@@ -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(async () => {
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(async () => {
52
+ test.after(() => {
35
53
  delete process.env.CHAPTERHOUSE_MEMORY_EOT_HOOK_ENABLED;
36
54
  delete process.env.CHAPTERHOUSE_MEMORY_AUTO_ACCEPT;
37
- const dbModule = await import(new URL("../store/db.js", import.meta.url).href);
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 pages directory is empty or absent", async () => {
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")), false);
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
- 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