memory-consolidate.test.mjs
1,912 lines 79.5 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Tests for the core consolidation engine (Phase A of Daemon Consolidation Spec).
3 *
4 * Covers: new event types, prompt construction, LLM response parsing,
5 * event grouping, consolidateMemory function (with mocked LLM), dry-run mode,
6 * error handling, daemon config loading, and CLI integration.
7 */
8
9 import { describe, it, before, after, beforeEach } from 'node:test';
10 import assert from 'node:assert';
11 import fs from 'fs';
12 import path from 'path';
13 import os from 'os';
14 import { execSync } from 'child_process';
15 import { fileURLToPath } from 'url';
16
17 import {
18 MEMORY_EVENT_TYPES,
19 createMemoryEvent,
20 extractTopicFromEvent,
21 } from '../lib/memory-event.mjs';
22
23 import {
24 buildConsolidationPrompt,
25 parseConsolidationResponse,
26 groupEventsByTopic,
27 consolidateMemory,
28 extractPathsFromEventData,
29 resolvePassNames,
30 runVerifyPass,
31 buildDiscoverPrompt,
32 parseDiscoverResponse,
33 runDiscoverPass,
34 } from '../lib/memory-consolidate.mjs';
35
36 import { loadDaemonConfig } from '../lib/config.mjs';
37
38 const __dirname = path.dirname(fileURLToPath(import.meta.url));
39 const cliPath = path.join(__dirname, '..', 'cli', 'index.mjs');
40
41 let tmpDir;
42 let vaultDir;
43 let dataDir;
44
45 before(() => {
46 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-consolidate-test-'));
47 vaultDir = path.join(tmpDir, 'vault');
48 dataDir = path.join(tmpDir, 'data');
49 fs.mkdirSync(vaultDir, { recursive: true });
50 fs.mkdirSync(dataDir, { recursive: true });
51 fs.writeFileSync(path.join(vaultDir, 'test.md'), '---\ntitle: test\n---\nHello', 'utf8');
52 });
53
54 after(() => {
55 fs.rmSync(tmpDir, { recursive: true, force: true });
56 });
57
58 function makeConfig() {
59 return {
60 vault_path: vaultDir,
61 data_dir: dataDir,
62 memory: { enabled: true, provider: 'file' },
63 daemon: loadDaemonConfig({}),
64 };
65 }
66
67 function makeMockLlmFn(response) {
68 const calls = [];
69 const fn = async (config, opts) => {
70 calls.push({ config, opts });
71 if (typeof response === 'function') return response(opts);
72 return response;
73 };
74 fn.calls = calls;
75 return fn;
76 }
77
78 function seedEvents(config, events) {
79 const { createMemoryManager } = await_import_sync();
80 const mm = createMemoryManager(config);
81 for (const { type, data } of events) {
82 mm.store(type, data);
83 }
84 return mm;
85 }
86
87 function await_import_sync() {
88 // We already import createMemoryManager at the top-level scope in consolidateMemory,
89 // so just re-import directly here.
90 return { createMemoryManager: _createMemoryManager };
91 }
92
93 import { createMemoryManager as _createMemoryManager } from '../lib/memory.mjs';
94
95 // ───────────────────────────────────────────────────
96 // 1. New Event Types
97 // ───────────────────────────────────────────────────
98
99 describe('New event types (consolidation, maintenance, insight)', () => {
100 it('MEMORY_EVENT_TYPES includes consolidation', () => {
101 assert(MEMORY_EVENT_TYPES.includes('consolidation'));
102 });
103
104 it('MEMORY_EVENT_TYPES includes maintenance', () => {
105 assert(MEMORY_EVENT_TYPES.includes('maintenance'));
106 });
107
108 it('MEMORY_EVENT_TYPES includes insight', () => {
109 assert(MEMORY_EVENT_TYPES.includes('insight'));
110 });
111
112 it('MEMORY_EVENT_TYPES includes consolidation_pass for pass-level summary events', () => {
113 assert(MEMORY_EVENT_TYPES.includes('consolidation_pass'));
114 });
115
116 it('createMemoryEvent accepts consolidation type', () => {
117 const event = createMemoryEvent('consolidation', {
118 topic: 'blockchain',
119 facts: ['fact1', 'fact2'],
120 event_count: 5,
121 since: '2026-04-01T00:00:00Z',
122 until: '2026-04-04T00:00:00Z',
123 });
124 assert.strictEqual(event.type, 'consolidation');
125 assert.deepStrictEqual(event.data.facts, ['fact1', 'fact2']);
126 assert.match(event.id, /^mem_/);
127 });
128
129 it('createMemoryEvent accepts maintenance type', () => {
130 const event = createMemoryEvent('maintenance', {
131 stale_paths: ['/notes/old.md'],
132 verified_paths: ['/notes/current.md'],
133 checked_count: 2,
134 });
135 assert.strictEqual(event.type, 'maintenance');
136 assert.deepStrictEqual(event.data.stale_paths, ['/notes/old.md']);
137 });
138
139 it('createMemoryEvent accepts insight type', () => {
140 const event = createMemoryEvent('insight', {
141 connections: ['A relates to B'],
142 contradictions: ['X conflicts with Y'],
143 open_questions: ['Why Z?'],
144 });
145 assert.strictEqual(event.type, 'insight');
146 assert.deepStrictEqual(event.data.connections, ['A relates to B']);
147 });
148
149 it('consolidation events can be stored and listed via MemoryManager', () => {
150 const config = makeConfig();
151 const mm = _createMemoryManager(config);
152 const result = mm.store('consolidation', {
153 topic: 'testing',
154 facts: ['tests pass'],
155 event_count: 3,
156 since: '2026-04-01T00:00:00Z',
157 until: '2026-04-04T00:00:00Z',
158 });
159 assert.match(result.id, /^mem_/);
160 const latest = mm.getLatest('consolidation');
161 assert.strictEqual(latest.type, 'consolidation');
162 assert.strictEqual(latest.data.topic, 'testing');
163 });
164 });
165
166 // ───────────────────────────────────────────────────
167 // 2. Prompt Construction
168 // ───────────────────────────────────────────────────
169
170 describe('buildConsolidationPrompt', () => {
171 it('includes topic name', () => {
172 const prompt = buildConsolidationPrompt('blockchain', [
173 { ts: '2026-04-01T10:00:00Z', type: 'search', data: { query: 'bitcoin' } },
174 ]);
175 assert(prompt.includes('Topic: "blockchain"'));
176 });
177
178 it('includes event count', () => {
179 const events = [
180 { ts: '2026-04-01T10:00:00Z', type: 'search', data: { query: 'test1' } },
181 { ts: '2026-04-01T11:00:00Z', type: 'search', data: { query: 'test2' } },
182 { ts: '2026-04-01T12:00:00Z', type: 'write', data: { path: 'notes/a.md' } },
183 ];
184 const prompt = buildConsolidationPrompt('testing', events);
185 assert(prompt.includes('Events (3):'));
186 });
187
188 it('includes timestamps and event types', () => {
189 const events = [
190 { ts: '2026-04-01T10:00:00Z', type: 'search', data: { query: 'bitcoin' } },
191 ];
192 const prompt = buildConsolidationPrompt('crypto', events);
193 assert(prompt.includes('[2026-04-01T10:00:00Z] search:'));
194 });
195
196 it('includes event data summary', () => {
197 const events = [
198 { ts: '2026-04-01T10:00:00Z', type: 'write', data: { path: 'notes/deep-topic.md' } },
199 ];
200 const prompt = buildConsolidationPrompt('notes', events);
201 assert(prompt.includes('deep-topic.md'));
202 });
203
204 it('truncates long data payloads', () => {
205 const longData = { text: 'x'.repeat(500) };
206 const events = [{ ts: '2026-04-01T10:00:00Z', type: 'user', data: longData }];
207 const prompt = buildConsolidationPrompt('verbose', events);
208 assert(prompt.length < 500 + 200);
209 });
210
211 it('with encrypt true omits raw event data from the prompt', () => {
212 const secret = 'SECRET_QUERY_STRING_XYZ';
213 const events = [
214 { ts: '2026-04-01T10:00:00Z', type: 'search', data: { query: secret } },
215 { ts: '2026-04-01T11:00:00Z', type: 'write', data: { path: 'vault/notes/nope.md' } },
216 ];
217 const prompt = buildConsolidationPrompt('topic-a', events, { encrypt: true });
218 assert(!prompt.includes(secret), 'query must not appear');
219 assert(!prompt.includes('nope.md'), 'path must not appear');
220 assert(prompt.includes('encrypted memory mode'));
221 assert(prompt.includes('[2026-04-01T10:00:00Z] search'));
222 });
223
224 it('with encrypt false keeps JSON snippets (default)', () => {
225 const events = [{ ts: '2026-04-01T10:00:00Z', type: 'search', data: { query: 'visible' } }];
226 const prompt = buildConsolidationPrompt('t', events);
227 assert(prompt.includes('visible'));
228 });
229 });
230
231 // ───────────────────────────────────────────────────
232 // 3. Response Parsing
233 // ───────────────────────────────────────────────────
234
235 describe('parseConsolidationResponse', () => {
236 it('parses valid JSON array', () => {
237 const facts = parseConsolidationResponse('["fact one", "fact two", "fact three"]');
238 assert.deepStrictEqual(facts, ['fact one', 'fact two', 'fact three']);
239 });
240
241 it('strips markdown code fences', () => {
242 const raw = '```json\n["a", "b"]\n```';
243 const facts = parseConsolidationResponse(raw);
244 assert.deepStrictEqual(facts, ['a', 'b']);
245 });
246
247 it('strips code fences without json tag', () => {
248 const raw = '```\n["x", "y"]\n```';
249 const facts = parseConsolidationResponse(raw);
250 assert.deepStrictEqual(facts, ['x', 'y']);
251 });
252
253 it('filters non-string array elements', () => {
254 const raw = '["good", 42, null, "also good", ""]';
255 const facts = parseConsolidationResponse(raw);
256 assert.deepStrictEqual(facts, ['good', 'also good']);
257 });
258
259 it('trims whitespace from facts', () => {
260 const raw = '[" spaced ", " also "]';
261 const facts = parseConsolidationResponse(raw);
262 assert.deepStrictEqual(facts, ['spaced', 'also']);
263 });
264
265 it('returns empty array for null/undefined input', () => {
266 assert.deepStrictEqual(parseConsolidationResponse(null), []);
267 assert.deepStrictEqual(parseConsolidationResponse(undefined), []);
268 assert.deepStrictEqual(parseConsolidationResponse(''), []);
269 });
270
271 it('returns empty array for non-array JSON (object)', () => {
272 const raw = '{"fact": "not an array"}';
273 const facts = parseConsolidationResponse(raw);
274 assert.deepStrictEqual(facts, []);
275 });
276
277 it('falls back to line-based parsing for invalid JSON', () => {
278 const raw = '- fact one\n- fact two\n- fact three';
279 const facts = parseConsolidationResponse(raw);
280 assert.strictEqual(facts.length, 3);
281 assert(facts[0].includes('fact one'));
282 });
283
284 it('handles numbered list fallback', () => {
285 const raw = '1. First fact\n2. Second fact';
286 const facts = parseConsolidationResponse(raw);
287 assert.strictEqual(facts.length, 2);
288 assert(facts[0].includes('First fact'));
289 });
290 });
291
292 // ───────────────────────────────────────────────────
293 // 4. Event Grouping
294 // ───────────────────────────────────────────────────
295
296 describe('groupEventsByTopic', () => {
297 it('groups events by extracted topic slug', () => {
298 const events = [
299 { type: 'search', data: { query: 'bitcoin transactions' } },
300 { type: 'write', data: { path: 'blockchain/contracts.md' } },
301 { type: 'search', data: { query: 'bitcoin mining' } },
302 { type: 'write', data: { path: 'testing/unit.md' } },
303 ];
304 const groups = groupEventsByTopic(events);
305 assert(groups.size >= 2, `Expected at least 2 groups, got ${groups.size}`);
306 const topics = [...groups.keys()];
307 assert(topics.some((t) => t.includes('bitcoin') || t.includes('blockchain')));
308 });
309
310 it('returns empty map for empty input', () => {
311 const groups = groupEventsByTopic([]);
312 assert.strictEqual(groups.size, 0);
313 });
314
315 it('puts all single-topic events in one group', () => {
316 const events = [
317 { type: 'write', data: { path: 'docs/readme.md' } },
318 { type: 'write', data: { path: 'docs/guide.md' } },
319 ];
320 const groups = groupEventsByTopic(events);
321 assert.strictEqual(groups.size, 1);
322 const [topic, evts] = [...groups.entries()][0];
323 assert.strictEqual(topic, 'docs');
324 assert.strictEqual(evts.length, 2);
325 });
326 });
327
328 // ───────────────────────────────────────────────────
329 // 5. consolidateMemory (with mocked LLM)
330 // ───────────────────────────────────────────────────
331
332 describe('consolidateMemory', () => {
333 let config;
334
335 beforeEach(() => {
336 const freshDataDir = path.join(tmpDir, `data-${Date.now()}-${Math.random().toString(36).slice(2)}`);
337 fs.mkdirSync(freshDataDir, { recursive: true });
338 config = {
339 vault_path: vaultDir,
340 data_dir: freshDataDir,
341 memory: { enabled: true, provider: 'file' },
342 daemon: loadDaemonConfig({}),
343 };
344 });
345
346 it('returns empty topics when no events exist', async () => {
347 const mockLlm = makeMockLlmFn('["fact"]');
348 const result = await consolidateMemory(config, { llmFn: mockLlm });
349 assert.strictEqual(result.topics.length, 0);
350 assert.strictEqual(result.total_events, 0);
351 assert.strictEqual(result.dry_run, false);
352 });
353
354 it('consolidates events and stores consolidation events', async () => {
355 const mm = _createMemoryManager(config);
356 mm.store('write', { path: 'crypto/price.md' });
357 mm.store('write', { path: 'crypto/mining.md' });
358 mm.store('write', { path: 'crypto/wallets.md' });
359
360 const mockLlm = makeMockLlmFn('["Crypto notes cover price, mining, and wallet info"]');
361 const result = await consolidateMemory(config, { llmFn: mockLlm });
362
363 assert.strictEqual(result.dry_run, false);
364 assert(result.total_events >= 3);
365 assert(result.topics.length >= 1);
366
367 const topicResult = result.topics[0];
368 assert(topicResult.facts.length >= 1);
369 assert.match(topicResult.id, /^mem_/);
370 assert(topicResult.event_count >= 2);
371 });
372
373 it('calls LLM with correct system and user prompts', async () => {
374 const mm = _createMemoryManager(config);
375 mm.store('write', { path: 'blockchain/sol.md' });
376 mm.store('write', { path: 'blockchain/eth.md' });
377
378 const mockLlm = makeMockLlmFn('["Blockchain notes written"]');
379 await consolidateMemory(config, { llmFn: mockLlm });
380
381 assert(mockLlm.calls.length >= 1, 'LLM should have been called at least once');
382 const call = mockLlm.calls[0];
383 assert(call.opts.system.includes('memory consolidation engine'));
384 assert(call.opts.user.includes('Topic:'));
385 assert(call.opts.user.includes('Events ('));
386 });
387
388 it('with memory.encrypt true omits event payload from LLM user prompt', async () => {
389 const mm = _createMemoryManager(config);
390 const secret = 'ULTRA_SECRET_CONSOLIDATION_PAYLOAD_XYZ';
391 mm.store('write', { path: 'crypto/a.md', note: secret });
392 mm.store('write', { path: 'crypto/b.md', note: 'ok' });
393 const encConfig = {
394 ...config,
395 memory: { enabled: true, provider: 'file', encrypt: true },
396 };
397 const mockLlm = makeMockLlmFn('["Merged crypto activity"]');
398 await consolidateMemory(encConfig, { llmFn: mockLlm });
399 assert(mockLlm.calls.length >= 1);
400 const user = mockLlm.calls[0].opts.user;
401 assert(!user.includes(secret), 'sensitive payload must not reach LLM prompt');
402 assert(user.includes('encrypted memory mode'));
403 });
404
405 it('dry-run does not store events or call LLM', async () => {
406 const mm = _createMemoryManager(config);
407 mm.store('write', { path: 'alpha/one.md' });
408 mm.store('write', { path: 'alpha/two.md' });
409 mm.store('write', { path: 'alpha/three.md' });
410
411 const mockLlm = makeMockLlmFn('["should not be called"]');
412 const result = await consolidateMemory(config, { dryRun: true, llmFn: mockLlm });
413
414 assert.strictEqual(result.dry_run, true);
415 assert.strictEqual(mockLlm.calls.length, 0);
416 assert(result.topics.length >= 1);
417 for (const t of result.topics) {
418 assert.strictEqual(t.facts.length, 0);
419 assert(t.dry_run_estimate != null);
420 assert.strictEqual(t.id, undefined);
421 }
422
423 const mm2 = _createMemoryManager(config);
424 const consolidations = mm2.list({ type: 'consolidation' });
425 assert.strictEqual(consolidations.length, 0);
426 });
427
428 it('handles LLM error gracefully without crashing', async () => {
429 const mm = _createMemoryManager(config);
430 mm.store('write', { path: 'errors/test.md' });
431 mm.store('write', { path: 'errors/other.md' });
432
433 const errorLlm = makeMockLlmFn(() => {
434 throw new Error('LLM connection refused');
435 });
436 const result = await consolidateMemory(config, { llmFn: errorLlm });
437
438 assert(result.topics.length >= 1);
439 const topicResult = result.topics[0];
440 assert.strictEqual(topicResult.facts.length, 0);
441 assert(topicResult.error.includes('LLM connection refused'));
442 });
443
444 it('handles LLM returning unparseable response', async () => {
445 const mm = _createMemoryManager(config);
446 mm.store('write', { path: 'parsefail/one.md' });
447 mm.store('write', { path: 'parsefail/two.md' });
448 mm.store('write', { path: 'parsefail/three.md' });
449
450 const badLlm = makeMockLlmFn('{}');
451 const result = await consolidateMemory(config, { llmFn: badLlm });
452
453 assert(result.topics.length >= 1);
454 assert.strictEqual(result.topics[0].facts.length, 0);
455 assert(result.topics[0].error != null);
456 });
457
458 it('skips consolidation, maintenance, and insight events from input', async () => {
459 const mm = _createMemoryManager(config);
460 mm.store('search', { query: 'real event' });
461 mm.store('search', { query: 'another real event' });
462 mm.store('consolidation', {
463 topic: 'old',
464 facts: ['old fact'],
465 event_count: 1,
466 since: '2026-04-01T00:00:00Z',
467 until: '2026-04-01T00:00:00Z',
468 });
469
470 const mockLlm = makeMockLlmFn('["consolidated fact"]');
471 const result = await consolidateMemory(config, { llmFn: mockLlm });
472
473 assert.strictEqual(result.total_events, 2, 'Should count only non-consolidation events');
474 });
475
476 it('respects lookbackHours parameter', async () => {
477 const mm = _createMemoryManager(config);
478 mm.store('write', { path: 'lookback/a.md' });
479 mm.store('write', { path: 'lookback/b.md' });
480
481 const mockLlm = makeMockLlmFn('["fact"]');
482
483 const result48 = await consolidateMemory(config, { lookbackHours: 48, llmFn: mockLlm });
484 assert(result48.total_events >= 2, 'With 48h lookback, events should be found');
485
486 const result1 = await consolidateMemory(config, { lookbackHours: 1, llmFn: mockLlm });
487 assert(result1.total_events >= 2, 'With 1h lookback, recently stored events should be found');
488 });
489
490 it('rebuilds pointer index after consolidation', async () => {
491 const mm = _createMemoryManager(config);
492 mm.store('write', { path: 'idxtest/alpha.md' });
493 mm.store('write', { path: 'idxtest/beta.md' });
494 mm.store('write', { path: 'idxtest/gamma.md' });
495
496 const mockLlm = makeMockLlmFn('["Write activity for index tests"]');
497 await consolidateMemory(config, { llmFn: mockLlm });
498
499 const mm2 = _createMemoryManager(config);
500 const idx = mm2.generateIndex({ force: true });
501 assert(idx.markdown.includes('consolidation'), `Index should mention consolidation type: ${idx.markdown}`);
502 });
503
504 it('respects maxEventsPerPass limit', async () => {
505 const mm = _createMemoryManager(config);
506 for (let i = 0; i < 10; i++) {
507 mm.store('search', { query: `event ${i}` });
508 }
509
510 const mockLlm = makeMockLlmFn('["fact"]');
511 const result = await consolidateMemory(config, { maxEventsPerPass: 3, llmFn: mockLlm });
512
513 assert(result.total_events <= 3, `Expected <= 3 events, got ${result.total_events}`);
514 });
515
516 it('consolidation event has correct shape', async () => {
517 const mm = _createMemoryManager(config);
518 mm.store('write', { path: 'shape/test.md' });
519 mm.store('write', { path: 'shape/other.md' });
520
521 const mockLlm = makeMockLlmFn('["Note writes recorded in shape directory"]');
522 const result = await consolidateMemory(config, { llmFn: mockLlm });
523
524 const mm2 = _createMemoryManager(config);
525 const consolidations = mm2.list({ type: 'consolidation' });
526 assert(consolidations.length >= 1);
527
528 const c = consolidations[0];
529 assert.strictEqual(typeof c.data.topic, 'string');
530 assert(Array.isArray(c.data.facts));
531 assert.strictEqual(typeof c.data.event_count, 'number');
532 assert.strictEqual(typeof c.data.since, 'string');
533 assert.strictEqual(typeof c.data.until, 'string');
534 });
535 });
536
537 // ───────────────────────────────────────────────────
538 // 6. Daemon Config Loading
539 // ───────────────────────────────────────────────────
540
541 describe('loadDaemonConfig', () => {
542 it('returns full defaults when called with empty/undefined', () => {
543 const cfg = loadDaemonConfig(undefined);
544 assert.strictEqual(cfg.enabled, false);
545 assert.strictEqual(cfg.interval_minutes, 120);
546 assert.strictEqual(cfg.idle_only, true);
547 assert.strictEqual(cfg.idle_threshold_minutes, 15);
548 assert.strictEqual(cfg.run_on_start, false);
549 assert.strictEqual(cfg.lookback_hours, 24);
550 assert.strictEqual(cfg.max_events_per_pass, 200);
551 assert.strictEqual(cfg.max_topics_per_pass, 10);
552 assert.deepStrictEqual(cfg.passes, {
553 consolidate: true,
554 verify: true,
555 discover: false,
556 rebuild_index: true,
557 });
558 assert.strictEqual(cfg.llm.provider, null);
559 assert.strictEqual(cfg.llm.model, null);
560 assert.strictEqual(cfg.llm.max_tokens, 1024);
561 assert.strictEqual(cfg.llm.temperature, 0.2);
562 assert.strictEqual(cfg.dry_run, false);
563 assert.strictEqual(cfg.log_file, null);
564 assert.strictEqual(cfg.max_cost_per_day_usd, null);
565 });
566
567 it('respects YAML overrides', () => {
568 const cfg = loadDaemonConfig({
569 enabled: true,
570 interval_minutes: 60,
571 lookback_hours: 48,
572 max_events_per_pass: 100,
573 passes: { discover: true, verify: false },
574 llm: { model: 'gpt-4o-mini', max_tokens: 512 },
575 });
576 assert.strictEqual(cfg.enabled, true);
577 assert.strictEqual(cfg.interval_minutes, 60);
578 assert.strictEqual(cfg.lookback_hours, 48);
579 assert.strictEqual(cfg.max_events_per_pass, 100);
580 assert.strictEqual(cfg.passes.discover, true);
581 assert.strictEqual(cfg.passes.verify, false);
582 assert.strictEqual(cfg.passes.consolidate, true);
583 assert.strictEqual(cfg.llm.model, 'gpt-4o-mini');
584 assert.strictEqual(cfg.llm.max_tokens, 512);
585 });
586
587 it('environment variables override YAML values', () => {
588 const origEnabled = process.env.KNOWTATION_DAEMON_ENABLED;
589 const origInterval = process.env.KNOWTATION_DAEMON_INTERVAL;
590 const origDryRun = process.env.KNOWTATION_DAEMON_DRY_RUN;
591 const origProvider = process.env.KNOWTATION_DAEMON_LLM_PROVIDER;
592 const origModel = process.env.KNOWTATION_DAEMON_LLM_MODEL;
593
594 try {
595 process.env.KNOWTATION_DAEMON_ENABLED = 'true';
596 process.env.KNOWTATION_DAEMON_INTERVAL = '30';
597 process.env.KNOWTATION_DAEMON_DRY_RUN = 'true';
598 process.env.KNOWTATION_DAEMON_LLM_PROVIDER = 'anthropic';
599 process.env.KNOWTATION_DAEMON_LLM_MODEL = 'claude-3-5-haiku-20241022';
600
601 const cfg = loadDaemonConfig({ enabled: false, interval_minutes: 120, dry_run: false });
602 assert.strictEqual(cfg.enabled, true);
603 assert.strictEqual(cfg.interval_minutes, 30);
604 assert.strictEqual(cfg.dry_run, true);
605 assert.strictEqual(cfg.llm.provider, 'anthropic');
606 assert.strictEqual(cfg.llm.model, 'claude-3-5-haiku-20241022');
607 } finally {
608 if (origEnabled === undefined) delete process.env.KNOWTATION_DAEMON_ENABLED;
609 else process.env.KNOWTATION_DAEMON_ENABLED = origEnabled;
610 if (origInterval === undefined) delete process.env.KNOWTATION_DAEMON_INTERVAL;
611 else process.env.KNOWTATION_DAEMON_INTERVAL = origInterval;
612 if (origDryRun === undefined) delete process.env.KNOWTATION_DAEMON_DRY_RUN;
613 else process.env.KNOWTATION_DAEMON_DRY_RUN = origDryRun;
614 if (origProvider === undefined) delete process.env.KNOWTATION_DAEMON_LLM_PROVIDER;
615 else process.env.KNOWTATION_DAEMON_LLM_PROVIDER = origProvider;
616 if (origModel === undefined) delete process.env.KNOWTATION_DAEMON_LLM_MODEL;
617 else process.env.KNOWTATION_DAEMON_LLM_MODEL = origModel;
618 }
619 });
620
621 it('handles non-object input gracefully', () => {
622 assert.strictEqual(loadDaemonConfig(null).enabled, false);
623 assert.strictEqual(loadDaemonConfig('string').enabled, false);
624 assert.strictEqual(loadDaemonConfig(42).enabled, false);
625 });
626 });
627
628 // ───────────────────────────────────────────────────
629 // 7. CLI Integration: memory consolidate
630 // ───────────────────────────────────────────────────
631
632 function runCli(cmdArgs, opts = {}) {
633 const env = {
634 ...process.env,
635 KNOWTATION_VAULT_PATH: vaultDir,
636 KNOWTATION_DATA_DIR: opts.dataDir || dataDir,
637 KNOWTATION_MEMORY_ENABLED: 'true',
638 KNOWTATION_MEMORY_PROVIDER: 'file',
639 };
640 try {
641 const out = execSync(`node ${cliPath} ${cmdArgs}`, {
642 cwd: path.join(__dirname, '..'),
643 env,
644 timeout: 15000,
645 encoding: 'utf8',
646 stdio: ['pipe', 'pipe', 'pipe'],
647 });
648 return { stdout: out.trim(), exitCode: 0 };
649 } catch (e) {
650 return { stdout: (e.stdout || '').trim(), stderr: (e.stderr || '').trim(), exitCode: e.status };
651 }
652 }
653
654 describe('CLI: memory consolidate', () => {
655 it('memory --help includes consolidate action', () => {
656 const r = runCli('memory --help');
657 assert.strictEqual(r.exitCode, 0);
658 assert(r.stdout.includes('consolidate'));
659 });
660
661 it('memory consolidate --dry-run with no events says no events', async () => {
662 const freshDir = path.join(tmpDir, `data-cli-dry-${Date.now()}`);
663 fs.mkdirSync(freshDir, { recursive: true });
664 const r = runCli('memory consolidate --dry-run', { dataDir: freshDir });
665 assert.strictEqual(r.exitCode, 0);
666 const stdout = r.stdout;
667 assert(
668 stdout.includes('No events') || stdout.includes('0 events') || stdout.includes('0 topics'),
669 `Expected no-events message, got: ${stdout}`,
670 );
671 });
672
673 it('memory consolidate --dry-run --json returns valid JSON', () => {
674 const freshDir = path.join(tmpDir, `data-cli-json-${Date.now()}`);
675 fs.mkdirSync(freshDir, { recursive: true });
676 const r = runCli('memory consolidate --dry-run --json', { dataDir: freshDir });
677 assert.strictEqual(r.exitCode, 0);
678 const data = JSON.parse(r.stdout);
679 assert.strictEqual(data.dry_run, true);
680 assert(Array.isArray(data.topics));
681 });
682
683 it('consolidate is in valid actions list', () => {
684 const r = runCli('memory consolidate-invalid');
685 assert.notStrictEqual(r.exitCode, 0);
686 });
687 });
688
689 // ───────────────────────────────────────────────────
690 // 8. extractPathsFromEventData
691 // ───────────────────────────────────────────────────
692
693 describe('extractPathsFromEventData', () => {
694 it('extracts data.path', () => {
695 const paths = extractPathsFromEventData({ path: 'notes/a.md' });
696 assert.deepStrictEqual(paths, ['notes/a.md']);
697 });
698
699 it('extracts data.paths array when not encrypted', () => {
700 const paths = extractPathsFromEventData({ paths: ['notes/a.md', 'notes/b.md'] }, false);
701 assert.deepStrictEqual(paths, ['notes/a.md', 'notes/b.md']);
702 });
703
704 it('skips data.paths when encrypt=true', () => {
705 const paths = extractPathsFromEventData({ paths: ['notes/a.md', 'notes/b.md'] }, true);
706 assert.deepStrictEqual(paths, []);
707 });
708
709 it('extracts both data.path and data.paths when not encrypted', () => {
710 const paths = extractPathsFromEventData({ path: 'notes/a.md', paths: ['notes/b.md', 'notes/c.md'] }, false);
711 assert.deepStrictEqual(paths, ['notes/a.md', 'notes/b.md', 'notes/c.md']);
712 });
713
714 it('deduplicates paths appearing in both data.path and data.paths', () => {
715 const paths = extractPathsFromEventData({ path: 'notes/a.md', paths: ['notes/a.md', 'notes/b.md'] }, false);
716 assert.deepStrictEqual(paths, ['notes/a.md', 'notes/b.md']);
717 });
718
719 it('returns empty array for null data', () => {
720 assert.deepStrictEqual(extractPathsFromEventData(null), []);
721 assert.deepStrictEqual(extractPathsFromEventData(undefined), []);
722 });
723
724 it('returns empty array when data has no path fields', () => {
725 assert.deepStrictEqual(extractPathsFromEventData({ query: 'bitcoin' }), []);
726 });
727
728 it('ignores non-string path entries in data.paths', () => {
729 const paths = extractPathsFromEventData({ paths: [42, null, 'valid.md', ''] }, false);
730 assert.deepStrictEqual(paths, ['valid.md']);
731 });
732
733 it('returns only data.path when encrypt=true even if data.paths present', () => {
734 const paths = extractPathsFromEventData({ path: 'notes/a.md', paths: ['notes/b.md'] }, true);
735 assert.deepStrictEqual(paths, ['notes/a.md']);
736 });
737 });
738
739 // ───────────────────────────────────────────────────
740 // 9. resolvePassNames
741 // ───────────────────────────────────────────────────
742
743 describe('resolvePassNames', () => {
744 it('returns default passes from daemon config when opts.passes is undefined', () => {
745 const names = resolvePassNames(undefined, { consolidate: true, verify: true });
746 assert.deepStrictEqual(names, ['consolidate', 'verify']);
747 });
748
749 it('omits verify when daemon config has verify: false', () => {
750 const names = resolvePassNames(undefined, { consolidate: true, verify: false });
751 assert.deepStrictEqual(names, ['consolidate']);
752 });
753
754 it('omits consolidate when daemon config has consolidate: false', () => {
755 const names = resolvePassNames(undefined, { consolidate: false, verify: true });
756 assert.deepStrictEqual(names, ['verify']);
757 });
758
759 it('accepts string array', () => {
760 const names = resolvePassNames(['consolidate', 'verify'], {});
761 assert.deepStrictEqual(names, ['consolidate', 'verify']);
762 });
763
764 it('accepts comma-separated string', () => {
765 const names = resolvePassNames('consolidate,verify', {});
766 assert.deepStrictEqual(names, ['consolidate', 'verify']);
767 });
768
769 it('accepts single pass name string', () => {
770 const names = resolvePassNames('verify', {});
771 assert.deepStrictEqual(names, ['verify']);
772 });
773
774 it('trims whitespace in comma-separated string', () => {
775 const names = resolvePassNames(' consolidate , verify ', {});
776 assert.deepStrictEqual(names, ['consolidate', 'verify']);
777 });
778
779 it('returns empty array for empty string', () => {
780 const names = resolvePassNames('', {});
781 assert.deepStrictEqual(names, []);
782 });
783
784 it('uses empty default when daemon config is null/undefined', () => {
785 // Both consolidate and verify default to "enabled" when key is absent
786 const names = resolvePassNames(undefined, undefined);
787 assert.deepStrictEqual(names, ['consolidate', 'verify']);
788 });
789
790 it('defaults to consolidate+verify when daemon config keys are absent', () => {
791 const names = resolvePassNames(undefined, {});
792 assert.deepStrictEqual(names, ['consolidate', 'verify']);
793 });
794 });
795
796 // ───────────────────────────────────────────────────
797 // 10. runVerifyPass
798 // ───────────────────────────────────────────────────
799
800 describe('runVerifyPass', () => {
801 let verifyConfig;
802
803 beforeEach(() => {
804 const freshDataDir = path.join(tmpDir, `data-verify-${Date.now()}-${Math.random().toString(36).slice(2)}`);
805 fs.mkdirSync(freshDataDir, { recursive: true });
806 verifyConfig = {
807 vault_path: vaultDir,
808 data_dir: freshDataDir,
809 memory: { enabled: true, provider: 'file', encrypt: false },
810 daemon: loadDaemonConfig({}),
811 };
812 });
813
814 it('returns correct shape', () => {
815 const result = runVerifyPass(verifyConfig, [], { dryRun: true });
816 assert(Array.isArray(result.stale_paths));
817 assert(Array.isArray(result.verified_paths));
818 assert.strictEqual(typeof result.checked_count, 'number');
819 assert.strictEqual(typeof result.dry_run, 'boolean');
820 });
821
822 it('returns dry_run: true in dryRun mode', () => {
823 const result = runVerifyPass(verifyConfig, [], { dryRun: true });
824 assert.strictEqual(result.dry_run, true);
825 });
826
827 it('returns dry_run: false when not in dryRun mode', () => {
828 const result = runVerifyPass(verifyConfig, [], { dryRun: false });
829 assert.strictEqual(result.dry_run, false);
830 });
831
832 it('classifies event with existing, unmodified file as verified', () => {
833 // test.md was created in before(); using the current time as eventTs guarantees
834 // that mtime (creation time) <= eventTs, so the file is not "modified after event".
835 const nowTs = new Date().toISOString();
836 const events = [
837 { id: 'mem_aaa111', type: 'write', ts: nowTs, vault_id: 'default', status: 'success', data: { path: 'test.md' } },
838 ];
839 const result = runVerifyPass(verifyConfig, events, { dryRun: true });
840 assert.strictEqual(result.checked_count, 1);
841 assert(result.verified_paths.includes('test.md'), `Expected test.md in verified: ${JSON.stringify(result)}`);
842 assert.strictEqual(result.stale_paths.length, 0);
843 });
844
845 it('classifies event referencing a missing file as stale', () => {
846 const events = [
847 { id: 'mem_bbb222', type: 'write', ts: new Date().toISOString(), vault_id: 'default', status: 'success', data: { path: 'does-not-exist.md' } },
848 ];
849 const result = runVerifyPass(verifyConfig, events, { dryRun: true });
850 assert.strictEqual(result.checked_count, 1);
851 assert(result.stale_paths.includes('does-not-exist.md'), `Expected stale: ${JSON.stringify(result)}`);
852 assert.strictEqual(result.verified_paths.length, 0);
853 });
854
855 it('classifies events with no path reference as no_ref (not counted in checked_count)', () => {
856 const events = [
857 { id: 'mem_ccc333', type: 'search', ts: new Date().toISOString(), vault_id: 'default', status: 'success', data: { query: 'blockchain' } },
858 ];
859 const result = runVerifyPass(verifyConfig, events, { dryRun: true });
860 assert.strictEqual(result.checked_count, 0);
861 assert.strictEqual(result.stale_paths.length, 0);
862 assert.strictEqual(result.verified_paths.length, 0);
863 });
864
865 it('checks all paths in data.paths array when not encrypted', () => {
866 const nowTs = new Date().toISOString();
867 const events = [
868 {
869 id: 'mem_ddd444', type: 'export', ts: nowTs,
870 vault_id: 'default', status: 'success',
871 data: { paths: ['test.md', 'missing-file.md'] },
872 },
873 ];
874 const result = runVerifyPass(verifyConfig, events, { dryRun: true });
875 assert.strictEqual(result.checked_count, 1);
876 assert(result.verified_paths.includes('test.md'));
877 assert(result.stale_paths.includes('missing-file.md'));
878 });
879
880 it('skips data.paths when encrypt=true', () => {
881 const encryptConfig = { ...verifyConfig, memory: { ...verifyConfig.memory, encrypt: true } };
882 const events = [
883 {
884 id: 'mem_eee555', type: 'export', ts: new Date().toISOString(),
885 vault_id: 'default', status: 'success',
886 data: { paths: ['test.md', 'missing-file.md'] },
887 },
888 ];
889 // encrypt=true: data.paths is skipped, data.path is undefined → no paths → no_ref
890 const result = runVerifyPass(encryptConfig, events, { dryRun: true });
891 assert.strictEqual(result.checked_count, 0);
892 assert.strictEqual(result.stale_paths.length, 0);
893 assert.strictEqual(result.verified_paths.length, 0);
894 });
895
896 it('handles empty events array', () => {
897 const result = runVerifyPass(verifyConfig, [], { dryRun: true });
898 assert.strictEqual(result.checked_count, 0);
899 assert.strictEqual(result.stale_paths.length, 0);
900 assert.strictEqual(result.verified_paths.length, 0);
901 });
902
903 it('deduplicates stale_paths across multiple events referencing the same missing file', () => {
904 const events = [
905 { id: 'mem_f1', type: 'write', ts: new Date().toISOString(), vault_id: 'default', status: 'success', data: { path: 'ghost.md' } },
906 { id: 'mem_f2', type: 'write', ts: new Date().toISOString(), vault_id: 'default', status: 'success', data: { path: 'ghost.md' } },
907 ];
908 const result = runVerifyPass(verifyConfig, events, { dryRun: true });
909 assert.strictEqual(result.stale_paths.filter((p) => p === 'ghost.md').length, 1);
910 });
911
912 it('deduplicates verified_paths across multiple events referencing the same existing file', () => {
913 const nowTs = new Date().toISOString();
914 const events = [
915 { id: 'mem_g1', type: 'write', ts: nowTs, vault_id: 'default', status: 'success', data: { path: 'test.md' } },
916 { id: 'mem_g2', type: 'write', ts: nowTs, vault_id: 'default', status: 'success', data: { path: 'test.md' } },
917 ];
918 const result = runVerifyPass(verifyConfig, events, { dryRun: true });
919 assert.strictEqual(result.verified_paths.filter((p) => p === 'test.md').length, 1);
920 });
921
922 it('in dryRun mode does NOT write a maintenance event', () => {
923 const events = [
924 { id: 'mem_h1', type: 'write', ts: new Date().toISOString(), vault_id: 'default', status: 'success', data: { path: 'ghost.md' } },
925 ];
926 runVerifyPass(verifyConfig, events, { dryRun: true });
927 const mm = _createMemoryManager(verifyConfig);
928 const maintenance = mm.list({ type: 'maintenance' });
929 assert.strictEqual(maintenance.length, 0);
930 });
931
932 it('in non-dryRun mode writes a maintenance event with correct shape', () => {
933 const events = [
934 { id: 'mem_i1', type: 'write', ts: new Date().toISOString(), vault_id: 'default', status: 'success', data: { path: 'ghost.md' } },
935 ];
936 runVerifyPass(verifyConfig, events, { dryRun: false });
937 const mm = _createMemoryManager(verifyConfig);
938 const maintenance = mm.list({ type: 'maintenance' });
939 assert.strictEqual(maintenance.length, 1);
940 const m = maintenance[0];
941 assert.strictEqual(m.type, 'maintenance');
942 assert(Array.isArray(m.data.stale_paths));
943 assert(Array.isArray(m.data.verified_paths));
944 assert.strictEqual(typeof m.data.checked_count, 'number');
945 assert(m.data.stale_paths.includes('ghost.md'));
946 });
947
948 it('maintenance event stale_paths contains the stale path', () => {
949 const events = [
950 { id: 'mem_j1', type: 'write', ts: new Date().toISOString(), vault_id: 'default', status: 'success', data: { path: 'never-existed.md' } },
951 ];
952 runVerifyPass(verifyConfig, events, { dryRun: false });
953 const mm = _createMemoryManager(verifyConfig);
954 const [m] = mm.list({ type: 'maintenance' });
955 assert.deepStrictEqual(m.data.stale_paths, ['never-existed.md']);
956 assert.deepStrictEqual(m.data.verified_paths, []);
957 assert.strictEqual(m.data.checked_count, 1);
958 });
959
960 it('maintenance event verified_paths contains verified path', () => {
961 const nowTs = new Date().toISOString();
962 const events = [
963 { id: 'mem_k1', type: 'write', ts: nowTs, vault_id: 'default', status: 'success', data: { path: 'test.md' } },
964 ];
965 runVerifyPass(verifyConfig, events, { dryRun: false });
966 const mm = _createMemoryManager(verifyConfig);
967 const [m] = mm.list({ type: 'maintenance' });
968 assert(m.data.verified_paths.includes('test.md'));
969 assert.deepStrictEqual(m.data.stale_paths, []);
970 });
971
972 it('processes mixed verified and stale paths in same pass', () => {
973 const nowTs = new Date().toISOString();
974 const events = [
975 { id: 'mem_l1', type: 'write', ts: nowTs, vault_id: 'default', status: 'success', data: { path: 'test.md' } },
976 { id: 'mem_l2', type: 'write', ts: nowTs, vault_id: 'default', status: 'success', data: { path: 'missing.md' } },
977 ];
978 const result = runVerifyPass(verifyConfig, events, { dryRun: true });
979 assert.strictEqual(result.checked_count, 2);
980 assert(result.verified_paths.includes('test.md'));
981 assert(result.stale_paths.includes('missing.md'));
982 });
983 });
984
985 // ───────────────────────────────────────────────────
986 // 11. runVerifyPass wired into consolidateMemory
987 // ───────────────────────────────────────────────────
988
989 describe('consolidateMemory — verify pass wiring', () => {
990 let config;
991
992 beforeEach(() => {
993 const freshDataDir = path.join(tmpDir, `data-wire-${Date.now()}-${Math.random().toString(36).slice(2)}`);
994 fs.mkdirSync(freshDataDir, { recursive: true });
995 config = {
996 vault_path: vaultDir,
997 data_dir: freshDataDir,
998 memory: { enabled: true, provider: 'file', encrypt: false },
999 daemon: loadDaemonConfig({ passes: { consolidate: true, verify: true } }),
1000 };
1001 });
1002
1003 it('includes verify result in return when verify pass enabled', async () => {
1004 const mm = _createMemoryManager(config);
1005 mm.store('write', { path: 'test.md' });
1006 mm.store('write', { path: 'missing.md' });
1007
1008 const mockLlm = makeMockLlmFn('["fact one"]');
1009 const result = await consolidateMemory(config, { passes: ['consolidate', 'verify'], llmFn: mockLlm });
1010
1011 assert(result.verify !== null, 'verify result should not be null');
1012 assert(Array.isArray(result.verify.stale_paths));
1013 assert(Array.isArray(result.verify.verified_paths));
1014 assert.strictEqual(typeof result.verify.checked_count, 'number');
1015 });
1016
1017 it('verify result is null when only consolidate pass requested', async () => {
1018 const mm = _createMemoryManager(config);
1019 mm.store('write', { path: 'test.md' });
1020 mm.store('write', { path: 'other.md' });
1021
1022 const mockLlm = makeMockLlmFn('["fact one"]');
1023 const result = await consolidateMemory(config, { passes: ['consolidate'], llmFn: mockLlm });
1024
1025 assert.strictEqual(result.verify, null);
1026 });
1027
1028 it('verify pass detects stale paths among event set', async () => {
1029 const mm = _createMemoryManager(config);
1030 mm.store('write', { path: 'ghost-path.md' });
1031 mm.store('write', { path: 'ghost-path.md' });
1032
1033 const mockLlm = makeMockLlmFn('["ghost path facts"]');
1034 const result = await consolidateMemory(config, { passes: ['consolidate', 'verify'], llmFn: mockLlm });
1035
1036 assert(result.verify.stale_paths.includes('ghost-path.md'));
1037 });
1038
1039 it('verify pass detects verified paths for existing files', async () => {
1040 // Use a past timestamp so test.md is "not modified after event"
1041 const pastTs = new Date(Date.now() - 60_000).toISOString();
1042 const mm = _createMemoryManager(config);
1043 // Directly store an event with a past timestamp via the provider (store sets ts = now)
1044 // We simulate by using a search event (no path) + a real write that references test.md
1045 // The MemoryManager sets ts=now, so test.md may appear stale if mtime > ts.
1046 // Instead, test only stale detection (ghost path) to avoid timing sensitivity.
1047 mm.store('write', { path: 'another-ghost.md' });
1048
1049 const mockLlm = makeMockLlmFn('["fact"]');
1050 const result = await consolidateMemory(config, { passes: ['consolidate', 'verify'], llmFn: mockLlm });
1051
1052 assert(result.verify !== null);
1053 assert(result.verify.stale_paths.includes('another-ghost.md'));
1054 });
1055
1056 it('runs verify-only pass when passes: [verify]', async () => {
1057 const mm = _createMemoryManager(config);
1058 mm.store('write', { path: 'ghost.md' });
1059
1060 const mockLlm = makeMockLlmFn('["should not be called"]');
1061 const result = await consolidateMemory(config, { passes: ['verify'], llmFn: mockLlm });
1062
1063 assert.strictEqual(mockLlm.calls.length, 0, 'LLM should not be called for verify-only');
1064 assert(result.verify !== null);
1065 assert(result.verify.stale_paths.includes('ghost.md'));
1066 });
1067
1068 it('dryRun: true propagates to verify pass — no maintenance event written', async () => {
1069 const mm = _createMemoryManager(config);
1070 mm.store('write', { path: 'ghost.md' });
1071 mm.store('write', { path: 'ghost2.md' });
1072
1073 const mockLlm = makeMockLlmFn('["fact"]');
1074 const result = await consolidateMemory(config, {
1075 dryRun: true, passes: ['consolidate', 'verify'], llmFn: mockLlm,
1076 });
1077
1078 assert.strictEqual(result.dry_run, true);
1079 assert(result.verify !== null);
1080 assert.strictEqual(result.verify.dry_run, true);
1081
1082 // No maintenance events written
1083 const mm2 = _createMemoryManager(config);
1084 assert.strictEqual(mm2.list({ type: 'maintenance' }).length, 0);
1085 });
1086
1087 it('verify pass uses the same event set read by consolidateMemory (not re-reading)', async () => {
1088 // Seed events that have path references; verify should see all of them
1089 const mm = _createMemoryManager(config);
1090 mm.store('write', { path: 'pathA.md' });
1091 mm.store('write', { path: 'pathB.md' });
1092 mm.store('search', { query: 'no path here' });
1093
1094 const mockLlm = makeMockLlmFn('["fact"]');
1095 const result = await consolidateMemory(config, { passes: ['verify'], llmFn: mockLlm });
1096
1097 // pathA and pathB are stale (don't exist); search event is no_ref (not counted)
1098 assert.strictEqual(result.verify.checked_count, 2);
1099 assert(result.verify.stale_paths.includes('pathA.md'));
1100 assert(result.verify.stale_paths.includes('pathB.md'));
1101 });
1102
1103 it('maintains total_events count for non-daemon events', async () => {
1104 const mm = _createMemoryManager(config);
1105 mm.store('search', { query: 'q1' });
1106 mm.store('search', { query: 'q2' });
1107 mm.store('consolidation', {
1108 topic: 'old', facts: ['f'], event_count: 1,
1109 since: '2026-01-01T00:00:00Z', until: '2026-01-01T00:00:00Z',
1110 });
1111
1112 const mockLlm = makeMockLlmFn('["fact"]');
1113 const result = await consolidateMemory(config, { passes: ['consolidate', 'verify'], llmFn: mockLlm });
1114 assert.strictEqual(result.total_events, 2);
1115 });
1116 });
1117
1118 // ───────────────────────────────────────────────────
1119 // 12. CLI: memory consolidate --passes flag
1120 // ───────────────────────────────────────────────────
1121
1122 describe('CLI: memory consolidate --passes', () => {
1123 it('--passes consolidate runs only consolidate pass in JSON output', () => {
1124 const freshDir = path.join(tmpDir, `data-cli-passes-${Date.now()}`);
1125 fs.mkdirSync(freshDir, { recursive: true });
1126 const r = runCli('memory consolidate --dry-run --passes consolidate --json', { dataDir: freshDir });
1127 assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}`);
1128 const data = JSON.parse(r.stdout);
1129 assert.strictEqual(data.dry_run, true);
1130 assert(Array.isArray(data.topics));
1131 assert.strictEqual(data.verify, null, 'verify should be null when only consolidate requested');
1132 });
1133
1134 it('--passes verify runs only verify pass in JSON output', () => {
1135 const freshDir = path.join(tmpDir, `data-cli-passes-verify-${Date.now()}`);
1136 fs.mkdirSync(freshDir, { recursive: true });
1137 const r = runCli('memory consolidate --dry-run --passes verify --json', { dataDir: freshDir });
1138 assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}`);
1139 const data = JSON.parse(r.stdout);
1140 assert.strictEqual(data.dry_run, true);
1141 assert.deepStrictEqual(data.topics, []);
1142 });
1143
1144 it('--passes consolidate,verify runs both passes in JSON output', () => {
1145 const freshDir = path.join(tmpDir, `data-cli-passes-both-${Date.now()}`);
1146 fs.mkdirSync(freshDir, { recursive: true });
1147 const r = runCli('memory consolidate --dry-run --passes consolidate,verify --json', { dataDir: freshDir });
1148 assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}`);
1149 const data = JSON.parse(r.stdout);
1150 assert.strictEqual(data.dry_run, true);
1151 assert(Array.isArray(data.topics));
1152 });
1153
1154 it('memory consolidate --dry-run --json with no events returns expected shape', () => {
1155 const freshDir = path.join(tmpDir, `data-cli-shape-${Date.now()}`);
1156 fs.mkdirSync(freshDir, { recursive: true });
1157 const r = runCli('memory consolidate --dry-run --json', { dataDir: freshDir });
1158 assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}`);
1159 const data = JSON.parse(r.stdout);
1160 assert.strictEqual(data.dry_run, true);
1161 assert(Array.isArray(data.topics));
1162 assert.strictEqual(typeof data.total_events, 'number');
1163 assert('verify' in data, 'result should include verify key');
1164 });
1165
1166 it('--passes with invalid name is handled gracefully (no crash)', () => {
1167 const freshDir = path.join(tmpDir, `data-cli-unknown-pass-${Date.now()}`);
1168 fs.mkdirSync(freshDir, { recursive: true });
1169 const r = runCli('memory consolidate --dry-run --passes unknown --json', { dataDir: freshDir });
1170 assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}`);
1171 const data = JSON.parse(r.stdout);
1172 assert(Array.isArray(data.topics));
1173 });
1174 });
1175
1176 // ───────────────────────────────────────────────────
1177 // 13. MCP: memory_consolidate passes param
1178 // ───────────────────────────────────────────────────
1179
1180 describe('MCP memory_consolidate passes param (programmatic)', () => {
1181 let mcpConfig;
1182
1183 beforeEach(() => {
1184 const freshDataDir = path.join(tmpDir, `data-mcp-${Date.now()}-${Math.random().toString(36).slice(2)}`);
1185 fs.mkdirSync(freshDataDir, { recursive: true });
1186 mcpConfig = {
1187 vault_path: vaultDir,
1188 data_dir: freshDataDir,
1189 memory: { enabled: true, provider: 'file', encrypt: false },
1190 daemon: loadDaemonConfig({}),
1191 };
1192 });
1193
1194 it('passes: ["consolidate"] runs only consolidate pass, verify is null', async () => {
1195 const mm = _createMemoryManager(mcpConfig);
1196 mm.store('write', { path: 'ghost.md' });
1197 mm.store('write', { path: 'ghost2.md' });
1198
1199 const mockLlm = makeMockLlmFn('["fact"]');
1200 const result = await consolidateMemory(mcpConfig, {
1201 dryRun: true, passes: ['consolidate'], llmFn: mockLlm,
1202 });
1203 assert.strictEqual(result.verify, null);
1204 assert(Array.isArray(result.topics));
1205 });
1206
1207 it('passes: ["verify"] runs only verify pass, topics is empty', async () => {
1208 const mm = _createMemoryManager(mcpConfig);
1209 mm.store('write', { path: 'ghost.md' });
1210
1211 const mockLlm = makeMockLlmFn('["should not be called"]');
1212 const result = await consolidateMemory(mcpConfig, {
1213 dryRun: true, passes: ['verify'], llmFn: mockLlm,
1214 });
1215 assert.strictEqual(mockLlm.calls.length, 0);
1216 assert.deepStrictEqual(result.topics, []);
1217 assert(result.verify !== null);
1218 assert.strictEqual(result.verify.dry_run, true);
1219 });
1220
1221 it('passes: ["consolidate", "verify"] runs both passes', async () => {
1222 const mm = _createMemoryManager(mcpConfig);
1223 mm.store('write', { path: 'ghost.md' });
1224 mm.store('write', { path: 'ghost2.md' });
1225
1226 const mockLlm = makeMockLlmFn('["fact"]');
1227 const result = await consolidateMemory(mcpConfig, {
1228 dryRun: true, passes: ['consolidate', 'verify'], llmFn: mockLlm,
1229 });
1230 assert(Array.isArray(result.topics));
1231 assert(result.verify !== null);
1232 });
1233
1234 it('passes: undefined uses daemon config defaults (both passes)', async () => {
1235 const mm = _createMemoryManager(mcpConfig);
1236 mm.store('write', { path: 'ghost.md' });
1237 mm.store('write', { path: 'ghost2.md' });
1238
1239 const mockLlm = makeMockLlmFn('["fact"]');
1240 const result = await consolidateMemory(mcpConfig, {
1241 dryRun: true, passes: undefined, llmFn: mockLlm,
1242 });
1243 // Default daemon config has verify: true
1244 assert(result.verify !== null, 'verify should run by default');
1245 });
1246
1247 it('verify result has correct shape when passed via MCP-style params', async () => {
1248 const mm = _createMemoryManager(mcpConfig);
1249 mm.store('write', { path: 'stale-ref.md' });
1250
1251 const mockLlm = makeMockLlmFn('["fact"]');
1252 const result = await consolidateMemory(mcpConfig, {
1253 dryRun: true, passes: ['verify'], llmFn: mockLlm,
1254 });
1255 const v = result.verify;
1256 assert(Array.isArray(v.stale_paths));
1257 assert(Array.isArray(v.verified_paths));
1258 assert.strictEqual(typeof v.checked_count, 'number');
1259 assert.strictEqual(v.dry_run, true);
1260 assert(v.stale_paths.includes('stale-ref.md'));
1261 });
1262 });
1263
1264 // ───────────────────────────────────────────────────
1265 // 14. buildDiscoverPrompt
1266 // ───────────────────────────────────────────────────
1267
1268 describe('buildDiscoverPrompt', () => {
1269 const makeConsolidation = (topic, facts) => ({ data: { topic, facts } });
1270
1271 it('includes "Topic summaries:" header', () => {
1272 const prompt = buildDiscoverPrompt([makeConsolidation('blockchain', ['fact one'])]);
1273 assert(prompt.startsWith('Topic summaries:'));
1274 });
1275
1276 it('includes topic name in prompt', () => {
1277 const prompt = buildDiscoverPrompt([makeConsolidation('blockchain', ['btc fact'])]);
1278 assert(prompt.includes('Topic: "blockchain"'));
1279 });
1280
1281 it('includes facts when encrypt is false', () => {
1282 const prompt = buildDiscoverPrompt(
1283 [makeConsolidation('blockchain', ['btc is a coin', 'mining uses energy'])],
1284 false,
1285 );
1286 assert(prompt.includes('btc is a coin'));
1287 assert(prompt.includes('mining uses energy'));
1288 });
1289
1290 it('suppresses facts when encrypt is true', () => {
1291 const prompt = buildDiscoverPrompt(
1292 [makeConsolidation('blockchain', ['btc is a coin', 'mining uses energy'])],
1293 true,
1294 );
1295 assert(prompt.includes('Topic: "blockchain"'));
1296 assert(!prompt.includes('btc is a coin'), 'Facts should not appear when encrypted');
1297 assert(!prompt.includes('mining uses energy'), 'Facts should not appear when encrypted');
1298 });
1299
1300 it('includes all consolidation topics when multiple are passed', () => {
1301 const consolidations = [
1302 makeConsolidation('blockchain', ['btc fact']),
1303 makeConsolidation('architecture', ['monorepo fact']),
1304 makeConsolidation('testing', ['unit tests fact']),
1305 ];
1306 const prompt = buildDiscoverPrompt(consolidations);
1307 assert(prompt.includes('Topic: "blockchain"'));
1308 assert(prompt.includes('Topic: "architecture"'));
1309 assert(prompt.includes('Topic: "testing"'));
1310 });
1311
1312 it('shows (no facts) for consolidation with empty facts array', () => {
1313 const prompt = buildDiscoverPrompt([makeConsolidation('empty-topic', [])]);
1314 assert(prompt.includes('(no facts)'));
1315 });
1316
1317 it('handles consolidation events with raw data shape (data.topic, data.facts)', () => {
1318 const event = { data: { topic: 'crypto', facts: ['fact A'] } };
1319 const prompt = buildDiscoverPrompt([event]);
1320 assert(prompt.includes('Topic: "crypto"'));
1321 assert(prompt.includes('fact A'));
1322 });
1323
1324 it('handles consolidation objects without data wrapper (flat topic/facts)', () => {
1325 // buildDiscoverPrompt supports { data: { topic, facts } } — top-level data is mandatory per spec
1326 const event = { data: { topic: 'flat', facts: ['flat fact'] } };
1327 const prompt = buildDiscoverPrompt([event]);
1328 assert(prompt.includes('Topic: "flat"'));
1329 assert(prompt.includes('flat fact'));
1330 });
1331
1332 it('returns header-only block for empty consolidations array', () => {
1333 const prompt = buildDiscoverPrompt([]);
1334 assert(prompt.startsWith('Topic summaries:'));
1335 });
1336
1337 it('encrypt default is false (facts included)', () => {
1338 const prompt = buildDiscoverPrompt([makeConsolidation('t', ['a secret fact'])]);
1339 assert(prompt.includes('a secret fact'));
1340 });
1341 });
1342
1343 // ───────────────────────────────────────────────────
1344 // 15. parseDiscoverResponse
1345 // ───────────────────────────────────────────────────
1346
1347 describe('parseDiscoverResponse', () => {
1348 it('parses valid JSON object with all three arrays', () => {
1349 const raw = JSON.stringify({
1350 connections: ['A connects to B'],
1351 contradictions: ['X contradicts Y'],
1352 open_questions: ['Why Z?'],
1353 });
1354 const result = parseDiscoverResponse(raw);
1355 assert.deepStrictEqual(result.connections, ['A connects to B']);
1356 assert.deepStrictEqual(result.contradictions, ['X contradicts Y']);
1357 assert.deepStrictEqual(result.open_questions, ['Why Z?']);
1358 });
1359
1360 it('strips markdown code fences (```json ... ```)', () => {
1361 const raw = '```json\n{"connections":["conn"],"contradictions":[],"open_questions":["q?"]}\n```';
1362 const result = parseDiscoverResponse(raw);
1363 assert.deepStrictEqual(result.connections, ['conn']);
1364 assert.deepStrictEqual(result.open_questions, ['q?']);
1365 });
1366
1367 it('strips code fences without json tag', () => {
1368 const raw = '```\n{"connections":["c"],"contradictions":["d"],"open_questions":["q"]}\n```';
1369 const result = parseDiscoverResponse(raw);
1370 assert.deepStrictEqual(result.connections, ['c']);
1371 assert.deepStrictEqual(result.contradictions, ['d']);
1372 });
1373
1374 it('returns empty arrays for all keys on invalid JSON', () => {
1375 const result = parseDiscoverResponse('this is not json at all');
1376 assert.deepStrictEqual(result, { connections: [], contradictions: [], open_questions: [] });
1377 });
1378
1379 it('returns empty arrays for null/undefined input', () => {
1380 assert.deepStrictEqual(parseDiscoverResponse(null), { connections: [], contradictions: [], open_questions: [] });
1381 assert.deepStrictEqual(parseDiscoverResponse(undefined), { connections: [], contradictions: [], open_questions: [] });
1382 assert.deepStrictEqual(parseDiscoverResponse(''), { connections: [], contradictions: [], open_questions: [] });
1383 });
1384
1385 it('handles partial object — missing contradictions defaults to empty array', () => {
1386 const raw = JSON.stringify({ connections: ['c1'], open_questions: ['q1'] });
1387 const result = parseDiscoverResponse(raw);
1388 assert.deepStrictEqual(result.connections, ['c1']);
1389 assert.deepStrictEqual(result.contradictions, []);
1390 assert.deepStrictEqual(result.open_questions, ['q1']);
1391 });
1392
1393 it('handles partial object — missing all keys defaults to all empty arrays', () => {
1394 const result = parseDiscoverResponse('{}');
1395 assert.deepStrictEqual(result, { connections: [], contradictions: [], open_questions: [] });
1396 });
1397
1398 it('filters non-string items from arrays', () => {
1399 const raw = JSON.stringify({
1400 connections: ['valid', 42, null, 'also valid', ''],
1401 contradictions: [],
1402 open_questions: [true, 'real question'],
1403 });
1404 const result = parseDiscoverResponse(raw);
1405 assert.deepStrictEqual(result.connections, ['valid', 'also valid']);
1406 assert.deepStrictEqual(result.open_questions, ['real question']);
1407 });
1408
1409 it('trims whitespace from string items', () => {
1410 const raw = JSON.stringify({
1411 connections: [' conn with spaces '],
1412 contradictions: [],
1413 open_questions: [],
1414 });
1415 const result = parseDiscoverResponse(raw);
1416 assert.deepStrictEqual(result.connections, ['conn with spaces']);
1417 });
1418
1419 it('returns empty object shape when input is a JSON array (not object)', () => {
1420 const result = parseDiscoverResponse('["not", "an", "object"]');
1421 assert.deepStrictEqual(result, { connections: [], contradictions: [], open_questions: [] });
1422 });
1423
1424 it('returns empty object shape when input is a JSON primitive', () => {
1425 assert.deepStrictEqual(parseDiscoverResponse('"just a string"'), { connections: [], contradictions: [], open_questions: [] });
1426 });
1427 });
1428
1429 // ───────────────────────────────────────────────────
1430 // 16. runDiscoverPass
1431 // ───────────────────────────────────────────────────
1432
1433 describe('runDiscoverPass', () => {
1434 let discoverConfig;
1435
1436 beforeEach(() => {
1437 const freshDataDir = path.join(tmpDir, `data-discover-${Date.now()}-${Math.random().toString(36).slice(2)}`);
1438 fs.mkdirSync(freshDataDir, { recursive: true });
1439 discoverConfig = {
1440 vault_path: vaultDir,
1441 data_dir: freshDataDir,
1442 memory: { enabled: true, provider: 'file', encrypt: false },
1443 daemon: loadDaemonConfig({}),
1444 };
1445 });
1446
1447 const makeConsolidationEvent = (topic, facts) => ({ data: { topic, facts } });
1448
1449 it('returns correct shape', async () => {
1450 const mockLlm = makeMockLlmFn(JSON.stringify({ connections: ['c1'], contradictions: [], open_questions: ['q1'] }));
1451 const result = await runDiscoverPass(
1452 discoverConfig,
1453 [makeConsolidationEvent('blockchain', ['fact a'])],
1454 { llmFn: mockLlm },
1455 );
1456 assert(Array.isArray(result.connections));
1457 assert(Array.isArray(result.contradictions));
1458 assert(Array.isArray(result.open_questions));
1459 assert.strictEqual(typeof result.topic_count, 'number');
1460 assert.strictEqual(typeof result.dry_run, 'boolean');
1461 });
1462
1463 it('calls LLM with discover system prompt and topic summaries', async () => {
1464 const mockLlm = makeMockLlmFn(JSON.stringify({ connections: [], contradictions: [], open_questions: [] }));
1465 await runDiscoverPass(
1466 discoverConfig,
1467 [makeConsolidationEvent('crypto', ['fact1', 'fact2'])],
1468 { llmFn: mockLlm },
1469 );
1470 assert.strictEqual(mockLlm.calls.length, 1);
1471 const call = mockLlm.calls[0];
1472 assert(call.opts.system.includes('insight engine'), `System prompt should mention insight engine: ${call.opts.system}`);
1473 assert(call.opts.user.includes('Topic summaries:'));
1474 assert(call.opts.user.includes('Topic: "crypto"'));
1475 });
1476
1477 it('stores insight event with correct shape', async () => {
1478 const mockLlm = makeMockLlmFn(JSON.stringify({
1479 connections: ['blockchain and testing are related'],
1480 contradictions: ['conflicting fact'],
1481 open_questions: ['what next?'],
1482 }));
1483 await runDiscoverPass(
1484 discoverConfig,
1485 [makeConsolidationEvent('blockchain', ['btc']), makeConsolidationEvent('testing', ['jest'])],
1486 { llmFn: mockLlm },
1487 );
1488 const mm = _createMemoryManager(discoverConfig);
1489 const insights = mm.list({ type: 'insight' });
1490 assert.strictEqual(insights.length, 1);
1491 const insight = insights[0];
1492 assert.strictEqual(insight.type, 'insight');
1493 assert(Array.isArray(insight.data.connections));
1494 assert(Array.isArray(insight.data.contradictions));
1495 assert(Array.isArray(insight.data.open_questions));
1496 assert.strictEqual(typeof insight.data.topic_count, 'number');
1497 assert.strictEqual(insight.data.topic_count, 2);
1498 });
1499
1500 it('topic_count matches number of consolidations passed', async () => {
1501 const mockLlm = makeMockLlmFn(JSON.stringify({ connections: [], contradictions: [], open_questions: [] }));
1502 const result = await runDiscoverPass(
1503 discoverConfig,
1504 [
1505 makeConsolidationEvent('a', ['f1']),
1506 makeConsolidationEvent('b', ['f2']),
1507 makeConsolidationEvent('c', ['f3']),
1508 ],
1509 { llmFn: mockLlm },
1510 );
1511 assert.strictEqual(result.topic_count, 3);
1512 });
1513
1514 it('dryRun: true does not call LLM and does not store insight event', async () => {
1515 const mockLlm = makeMockLlmFn(JSON.stringify({ connections: ['conn'], contradictions: [], open_questions: [] }));
1516 const result = await runDiscoverPass(
1517 discoverConfig,
1518 [makeConsolidationEvent('topic', ['fact'])],
1519 { dryRun: true, llmFn: mockLlm },
1520 );
1521 assert.strictEqual(result.dry_run, true);
1522 assert.strictEqual(mockLlm.calls.length, 0);
1523 const mm = _createMemoryManager(discoverConfig);
1524 assert.strictEqual(mm.list({ type: 'insight' }).length, 0);
1525 });
1526
1527 it('dryRun result has empty arrays and correct topic_count', async () => {
1528 const mockLlm = makeMockLlmFn('should not be called');
1529 const result = await runDiscoverPass(
1530 discoverConfig,
1531 [makeConsolidationEvent('t1', ['f1']), makeConsolidationEvent('t2', ['f2'])],
1532 { dryRun: true, llmFn: mockLlm },
1533 );
1534 assert.deepStrictEqual(result.connections, []);
1535 assert.deepStrictEqual(result.contradictions, []);
1536 assert.deepStrictEqual(result.open_questions, []);
1537 assert.strictEqual(result.topic_count, 2);
1538 });
1539
1540 it('encrypt=true suppresses facts in LLM prompt', async () => {
1541 const encryptConfig = { ...discoverConfig, memory: { ...discoverConfig.memory, encrypt: true } };
1542 const mockLlm = makeMockLlmFn(JSON.stringify({ connections: [], contradictions: [], open_questions: [] }));
1543 await runDiscoverPass(
1544 encryptConfig,
1545 [makeConsolidationEvent('crypto', ['secret fact about btc'])],
1546 { llmFn: mockLlm },
1547 );
1548 assert.strictEqual(mockLlm.calls.length, 1);
1549 const userPrompt = mockLlm.calls[0].opts.user;
1550 assert(userPrompt.includes('Topic: "crypto"'), 'Topic name should appear');
1551 assert(!userPrompt.includes('secret fact about btc'), 'Fact content should be suppressed when encrypted');
1552 });
1553
1554 it('encrypt=false includes facts in LLM prompt', async () => {
1555 const mockLlm = makeMockLlmFn(JSON.stringify({ connections: [], contradictions: [], open_questions: [] }));
1556 await runDiscoverPass(
1557 discoverConfig,
1558 [makeConsolidationEvent('crypto', ['btc price is volatile'])],
1559 { llmFn: mockLlm },
1560 );
1561 const userPrompt = mockLlm.calls[0].opts.user;
1562 assert(userPrompt.includes('btc price is volatile'), 'Facts should appear when not encrypted');
1563 });
1564
1565 it('handles LLM error gracefully — returns empty arrays and still stores insight', async () => {
1566 const errorLlm = makeMockLlmFn(() => { throw new Error('LLM down'); });
1567 const result = await runDiscoverPass(
1568 discoverConfig,
1569 [makeConsolidationEvent('topic', ['fact'])],
1570 { llmFn: errorLlm },
1571 );
1572 assert.deepStrictEqual(result.connections, []);
1573 assert.deepStrictEqual(result.contradictions, []);
1574 assert.deepStrictEqual(result.open_questions, []);
1575 assert.strictEqual(result.dry_run, false);
1576 const mm = _createMemoryManager(discoverConfig);
1577 assert.strictEqual(mm.list({ type: 'insight' }).length, 1);
1578 });
1579
1580 it('handles LLM returning unparseable JSON gracefully', async () => {
1581 const badLlm = makeMockLlmFn('not json at all');
1582 const result = await runDiscoverPass(
1583 discoverConfig,
1584 [makeConsolidationEvent('topic', ['fact'])],
1585 { llmFn: badLlm },
1586 );
1587 assert.deepStrictEqual(result.connections, []);
1588 assert.deepStrictEqual(result.contradictions, []);
1589 assert.deepStrictEqual(result.open_questions, []);
1590 });
1591 });
1592
1593 // ───────────────────────────────────────────────────
1594 // 17. resolvePassNames — discover handling
1595 // ───────────────────────────────────────────────────
1596
1597 describe('resolvePassNames — discover handling', () => {
1598 it('does NOT include discover by default when daemon config has discover: false', () => {
1599 const names = resolvePassNames(undefined, { consolidate: true, verify: true, discover: false });
1600 assert(!names.includes('discover'), `discover should not be in defaults: ${names}`);
1601 });
1602
1603 it('includes discover when daemon config has discover: true', () => {
1604 const names = resolvePassNames(undefined, { consolidate: true, verify: true, discover: true });
1605 assert(names.includes('discover'));
1606 });
1607
1608 it('includes discover when passed as comma-string "consolidate,verify,discover"', () => {
1609 const names = resolvePassNames('consolidate,verify,discover', {});
1610 assert.deepStrictEqual(names, ['consolidate', 'verify', 'discover']);
1611 });
1612
1613 it('includes discover when passed as array ["discover"]', () => {
1614 const names = resolvePassNames(['discover'], {});
1615 assert.deepStrictEqual(names, ['discover']);
1616 });
1617
1618 it('includes discover when passed as array ["consolidate","verify","discover"]', () => {
1619 const names = resolvePassNames(['consolidate', 'verify', 'discover'], {});
1620 assert.deepStrictEqual(names, ['consolidate', 'verify', 'discover']);
1621 });
1622
1623 it('discover is absent from default daemon config passes (passes.discover: false)', () => {
1624 const cfg = loadDaemonConfig({});
1625 assert.strictEqual(cfg.passes.discover, false);
1626 const names = resolvePassNames(undefined, cfg.passes);
1627 assert(!names.includes('discover'));
1628 });
1629 });
1630
1631 // ───────────────────────────────────────────────────
1632 // 18. consolidateMemory — discover pass wiring
1633 // ───────────────────────────────────────────────────
1634
1635 describe('consolidateMemory — discover pass wiring', () => {
1636 let config;
1637
1638 beforeEach(() => {
1639 const freshDataDir = path.join(tmpDir, `data-discover-wire-${Date.now()}-${Math.random().toString(36).slice(2)}`);
1640 fs.mkdirSync(freshDataDir, { recursive: true });
1641 config = {
1642 vault_path: vaultDir,
1643 data_dir: freshDataDir,
1644 memory: { enabled: true, provider: 'file', encrypt: false },
1645 daemon: loadDaemonConfig({ passes: { consolidate: true, verify: false, discover: false } }),
1646 };
1647 });
1648
1649 it('discover is null when discover not in passes', async () => {
1650 const mm = _createMemoryManager(config);
1651 mm.store('write', { path: 'topic/a.md' });
1652 mm.store('write', { path: 'topic/b.md' });
1653
1654 const mockLlm = makeMockLlmFn('["fact"]');
1655 const result = await consolidateMemory(config, { passes: ['consolidate'], llmFn: mockLlm });
1656 assert.strictEqual(result.discover, null);
1657 });
1658
1659 it('discover is null when no consolidations were written (dry-run)', async () => {
1660 const mm = _createMemoryManager(config);
1661 mm.store('write', { path: 'topic/a.md' });
1662 mm.store('write', { path: 'topic/b.md' });
1663
1664 const mockLlm = makeMockLlmFn('["fact"]');
1665 const result = await consolidateMemory(config, {
1666 dryRun: true, passes: ['consolidate', 'discover'], llmFn: mockLlm,
1667 });
1668 assert.strictEqual(result.dry_run, true);
1669 assert.strictEqual(result.discover, null, 'discover should be null when dry-run (no consolidations written)');
1670 });
1671
1672 it('discover is null when consolidate pass is skipped', async () => {
1673 const mm = _createMemoryManager(config);
1674 mm.store('write', { path: 'topic/a.md' });
1675
1676 const mockLlm = makeMockLlmFn('["fact"]');
1677 const result = await consolidateMemory(config, {
1678 passes: ['discover'], llmFn: mockLlm,
1679 });
1680 assert.strictEqual(result.discover, null, 'discover should be null when consolidate was skipped');
1681 });
1682
1683 it('discover result has correct shape when consolidations exist', async () => {
1684 const mm = _createMemoryManager(config);
1685 mm.store('write', { path: 'topicA/x.md' });
1686 mm.store('write', { path: 'topicA/y.md' });
1687
1688 const discoverResponse = JSON.stringify({
1689 connections: ['topicA connects to something'],
1690 contradictions: [],
1691 open_questions: ['What is topicA?'],
1692 });
1693 const mockLlm = makeMockLlmFn((opts) => {
1694 if (opts.system && opts.system.includes('insight engine')) return discoverResponse;
1695 return '["fact from topicA"]';
1696 });
1697
1698 const result = await consolidateMemory(config, {
1699 passes: ['consolidate', 'discover'], llmFn: mockLlm,
1700 });
1701
1702 assert(result.discover !== null, 'discover should not be null when consolidations exist');
1703 assert(Array.isArray(result.discover.connections));
1704 assert(Array.isArray(result.discover.contradictions));
1705 assert(Array.isArray(result.discover.open_questions));
1706 assert.strictEqual(typeof result.discover.topic_count, 'number');
1707 assert.strictEqual(result.discover.dry_run, false);
1708 });
1709
1710 it('discover LLM call receives topic summaries built from stored consolidations', async () => {
1711 const mm = _createMemoryManager(config);
1712 mm.store('write', { path: 'blockchain/eth.md' });
1713 mm.store('write', { path: 'blockchain/sol.md' });
1714
1715 const capturedCalls = [];
1716 const mockLlm = makeMockLlmFn((opts) => {
1717 capturedCalls.push(opts);
1718 if (opts.system && opts.system.includes('insight engine')) {
1719 return JSON.stringify({ connections: [], contradictions: [], open_questions: [] });
1720 }
1721 return '["blockchain notes recorded"]';
1722 });
1723
1724 await consolidateMemory(config, { passes: ['consolidate', 'discover'], llmFn: mockLlm });
1725
1726 const discoverCall = capturedCalls.find((c) => c.system && c.system.includes('insight engine'));
1727 assert(discoverCall, 'Should have made a discover LLM call');
1728 assert(discoverCall.user.includes('Topic summaries:'));
1729 assert(discoverCall.user.includes('blockchain'));
1730 });
1731
1732 it('discover pass stores insight event in memory log', async () => {
1733 const mm = _createMemoryManager(config);
1734 mm.store('write', { path: 'science/a.md' });
1735 mm.store('write', { path: 'science/b.md' });
1736
1737 const mockLlm = makeMockLlmFn((opts) => {
1738 if (opts.system && opts.system.includes('insight engine')) {
1739 return JSON.stringify({ connections: ['science relates to testing'], contradictions: [], open_questions: [] });
1740 }
1741 return '["science note written"]';
1742 });
1743
1744 await consolidateMemory(config, { passes: ['consolidate', 'discover'], llmFn: mockLlm });
1745
1746 const mm2 = _createMemoryManager(config);
1747 const insights = mm2.list({ type: 'insight' });
1748 assert.strictEqual(insights.length, 1);
1749 assert.strictEqual(insights[0].type, 'insight');
1750 });
1751
1752 it('discover pass runs AFTER consolidate and verify passes', async () => {
1753 const mmSetup = _createMemoryManager(config);
1754 mmSetup.store('write', { path: 'ordered/a.md' });
1755 mmSetup.store('write', { path: 'ordered/b.md' });
1756
1757 const callOrder = [];
1758 const mockLlm = makeMockLlmFn((opts) => {
1759 if (opts.system && opts.system.includes('insight engine')) {
1760 callOrder.push('discover');
1761 return JSON.stringify({ connections: [], contradictions: [], open_questions: [] });
1762 }
1763 callOrder.push('consolidate');
1764 return '["ordered fact"]';
1765 });
1766
1767 await consolidateMemory(config, {
1768 passes: ['consolidate', 'verify', 'discover'], llmFn: mockLlm,
1769 });
1770
1771 const consolidateIdx = callOrder.indexOf('consolidate');
1772 const discoverIdx = callOrder.indexOf('discover');
1773 assert(consolidateIdx !== -1, 'consolidate LLM call should exist');
1774 assert(discoverIdx !== -1, 'discover LLM call should exist');
1775 assert(consolidateIdx < discoverIdx, 'consolidate should run before discover');
1776 });
1777
1778 it('result always contains a discover key (null when not run)', async () => {
1779 const mm = _createMemoryManager(config);
1780 mm.store('search', { query: 'something' });
1781 mm.store('search', { query: 'else' });
1782
1783 const mockLlm = makeMockLlmFn('["fact"]');
1784 const result = await consolidateMemory(config, { passes: ['consolidate'], llmFn: mockLlm });
1785 assert('discover' in result, 'result must always have discover key');
1786 assert.strictEqual(result.discover, null);
1787 });
1788
1789 it('result discover key is null for empty event set', async () => {
1790 const result = await consolidateMemory(config, {
1791 passes: ['consolidate', 'discover'], llmFn: makeMockLlmFn('["fact"]'),
1792 });
1793 assert('discover' in result);
1794 assert.strictEqual(result.discover, null);
1795 });
1796 });
1797
1798 // ───────────────────────────────────────────────────
1799 // 19. CLI: memory consolidate --passes discover
1800 // ───────────────────────────────────────────────────
1801
1802 describe('CLI: memory consolidate --passes discover', () => {
1803 it('--passes discover --dry-run --json returns valid JSON with discover key', () => {
1804 const freshDir = path.join(tmpDir, `data-cli-discover-${Date.now()}`);
1805 fs.mkdirSync(freshDir, { recursive: true });
1806 const r = runCli('memory consolidate --dry-run --passes discover --json', { dataDir: freshDir });
1807 assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}`);
1808 const data = JSON.parse(r.stdout);
1809 assert.strictEqual(data.dry_run, true);
1810 assert('discover' in data, 'result must include discover key');
1811 });
1812
1813 it('--passes consolidate,verify,discover --dry-run --json returns all keys', () => {
1814 const freshDir = path.join(tmpDir, `data-cli-discover-all-${Date.now()}`);
1815 fs.mkdirSync(freshDir, { recursive: true });
1816 const r = runCli('memory consolidate --dry-run --passes consolidate,verify,discover --json', { dataDir: freshDir });
1817 assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}`);
1818 const data = JSON.parse(r.stdout);
1819 assert.strictEqual(data.dry_run, true);
1820 assert(Array.isArray(data.topics));
1821 assert('verify' in data);
1822 assert('discover' in data);
1823 });
1824
1825 it('--passes discover with no events returns discover: null', () => {
1826 const freshDir = path.join(tmpDir, `data-cli-discover-empty-${Date.now()}`);
1827 fs.mkdirSync(freshDir, { recursive: true });
1828 const r = runCli('memory consolidate --dry-run --passes discover --json', { dataDir: freshDir });
1829 assert.strictEqual(r.exitCode, 0, `stderr: ${r.stderr}`);
1830 const data = JSON.parse(r.stdout);
1831 assert.strictEqual(data.discover, null);
1832 });
1833
1834 it('memory consolidate --help mentions discover pass', () => {
1835 const r = runCli('memory --help');
1836 assert.strictEqual(r.exitCode, 0);
1837 assert(r.stdout.includes('discover'), `Help should mention discover: ${r.stdout}`);
1838 });
1839 });
1840
1841 // ───────────────────────────────────────────────────
1842 // 20. MCP: memory_consolidate passes: ["discover"]
1843 // ───────────────────────────────────────────────────
1844
1845 describe('MCP memory_consolidate — discover pass (programmatic)', () => {
1846 let mcpConfig;
1847
1848 beforeEach(() => {
1849 const freshDataDir = path.join(tmpDir, `data-mcp-discover-${Date.now()}-${Math.random().toString(36).slice(2)}`);
1850 fs.mkdirSync(freshDataDir, { recursive: true });
1851 mcpConfig = {
1852 vault_path: vaultDir,
1853 data_dir: freshDataDir,
1854 memory: { enabled: true, provider: 'file', encrypt: false },
1855 daemon: loadDaemonConfig({}),
1856 };
1857 });
1858
1859 it('passes: ["discover"] with no prior consolidations returns discover: null', async () => {
1860 const mm = _createMemoryManager(mcpConfig);
1861 mm.store('search', { query: 'no consolidations' });
1862
1863 const mockLlm = makeMockLlmFn('should not be called');
1864 const result = await consolidateMemory(mcpConfig, {
1865 dryRun: true, passes: ['discover'], llmFn: mockLlm,
1866 });
1867 assert.strictEqual(result.discover, null);
1868 assert.strictEqual(mockLlm.calls.length, 0);
1869 });
1870
1871 it('passes: ["consolidate", "discover"] runs both and discover result is non-null', async () => {
1872 const mm = _createMemoryManager(mcpConfig);
1873 mm.store('write', { path: 'noded/a.md' });
1874 mm.store('write', { path: 'noded/b.md' });
1875
1876 const mockLlm = makeMockLlmFn((opts) => {
1877 if (opts.system && opts.system.includes('insight engine')) {
1878 return JSON.stringify({ connections: ['conn'], contradictions: [], open_questions: [] });
1879 }
1880 return '["fact"]';
1881 });
1882
1883 const result = await consolidateMemory(mcpConfig, {
1884 passes: ['consolidate', 'discover'], llmFn: mockLlm,
1885 });
1886
1887 assert(result.discover !== null, 'discover should be non-null when consolidations exist');
1888 assert(Array.isArray(result.discover.connections));
1889 assert.strictEqual(result.discover.dry_run, false);
1890 });
1891
1892 it('passes: ["consolidate", "verify", "discover"] runs all three', async () => {
1893 const mm = _createMemoryManager(mcpConfig);
1894 mm.store('write', { path: 'multi/a.md' });
1895 mm.store('write', { path: 'multi/b.md' });
1896
1897 const mockLlm = makeMockLlmFn((opts) => {
1898 if (opts.system && opts.system.includes('insight engine')) {
1899 return JSON.stringify({ connections: [], contradictions: [], open_questions: [] });
1900 }
1901 return '["multi fact"]';
1902 });
1903
1904 const result = await consolidateMemory(mcpConfig, {
1905 passes: ['consolidate', 'verify', 'discover'], llmFn: mockLlm,
1906 });
1907
1908 assert(Array.isArray(result.topics));
1909 assert(result.verify !== null);
1910 assert(result.discover !== null);
1911 });
1912 });
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago