memory.test.mjs
1,077 lines 43.3 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /**
2 * Memory layer tests: event model, file provider, MemoryManager, backward compatibility.
3 */
4 import { describe, it, before, after, beforeEach } from 'node:test';
5 import assert from 'node:assert';
6 import fs from 'fs';
7 import path from 'path';
8 import os from 'os';
9
10 import {
11 generateMemoryId,
12 createMemoryEvent,
13 isValidMemoryEvent,
14 hasSensitiveKeys,
15 MEMORY_EVENT_TYPES,
16 MEMORY_EVENT_STATUSES,
17 DEFAULT_CAPTURE_TYPES,
18 } from '../lib/memory-event.mjs';
19 import { FileMemoryProvider } from '../lib/memory-provider-file.mjs';
20 import { MemoryManager, createMemoryManager, generateMemoryIndex, storeMemory, getMemory, resolveMemoryDir } from '../lib/memory.mjs';
21
22 let tmpDir;
23
24 before(() => {
25 tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'knowtation-memory-test-'));
26 });
27
28 after(() => {
29 fs.rmSync(tmpDir, { recursive: true, force: true });
30 });
31
32 describe('memory-event', () => {
33 it('generateMemoryId produces mem_ prefix + 12 hex chars', () => {
34 const id = generateMemoryId();
35 assert.match(id, /^mem_[0-9a-f]{12}$/);
36 const id2 = generateMemoryId();
37 assert.notStrictEqual(id, id2);
38 });
39
40 it('createMemoryEvent produces valid event', () => {
41 const event = createMemoryEvent('search', { query: 'test', count: 1 });
42 assert.match(event.id, /^mem_/);
43 assert.strictEqual(event.type, 'search');
44 assert.strictEqual(event.vault_id, 'default');
45 assert.strictEqual(event.data.query, 'test');
46 assert.strictEqual(typeof event.ts, 'string');
47 assert.strictEqual(event.ttl, null);
48 assert(isValidMemoryEvent(event));
49 });
50
51 it('createMemoryEvent accepts vaultId and airId opts', () => {
52 const event = createMemoryEvent('write', { path: 'a.md' }, { vaultId: 'v1', airId: 'air_123' });
53 assert.strictEqual(event.vault_id, 'v1');
54 assert.strictEqual(event.air_id, 'air_123');
55 });
56
57 it('createMemoryEvent rejects invalid type', () => {
58 assert.throws(() => createMemoryEvent('bogus_type', {}), /Invalid memory event type/);
59 });
60
61 it('createMemoryEvent rejects null data', () => {
62 assert.throws(() => createMemoryEvent('search', null), /non-null object/);
63 });
64
65 it('createMemoryEvent rejects data with secret keys', () => {
66 assert.throws(
67 () => createMemoryEvent('search', { api_key: 'sk-abc', query: 'test' }),
68 /sensitive key patterns/
69 );
70 });
71
72 it('hasSensitiveKeys detects nested secrets', () => {
73 assert.strictEqual(hasSensitiveKeys({ ok: 1 }), false);
74 assert.strictEqual(hasSensitiveKeys({ password: 'x' }), true);
75 assert.strictEqual(hasSensitiveKeys({ nested: { secret_token: 'y' } }), true);
76 assert.strictEqual(hasSensitiveKeys([{ authorization: 'z' }]), true);
77 });
78
79 it('isValidMemoryEvent rejects malformed events', () => {
80 assert.strictEqual(isValidMemoryEvent(null), false);
81 assert.strictEqual(isValidMemoryEvent({}), false);
82 assert.strictEqual(isValidMemoryEvent({ id: 'bad', type: 'search', ts: '2026', vault_id: 'd', data: {} }), false);
83 });
84
85 it('MEMORY_EVENT_TYPES and DEFAULT_CAPTURE_TYPES are frozen arrays', () => {
86 assert(Array.isArray(MEMORY_EVENT_TYPES));
87 assert(Object.isFrozen(MEMORY_EVENT_TYPES));
88 assert(MEMORY_EVENT_TYPES.includes('search'));
89 assert(MEMORY_EVENT_TYPES.includes('user'));
90 assert(Array.isArray(DEFAULT_CAPTURE_TYPES));
91 assert(DEFAULT_CAPTURE_TYPES.includes('search'));
92 assert(!DEFAULT_CAPTURE_TYPES.includes('agent_interaction'));
93 });
94 });
95
96 describe('FileMemoryProvider', () => {
97 let providerDir;
98 let provider;
99
100 beforeEach(() => {
101 providerDir = path.join(tmpDir, 'fmp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6));
102 provider = new FileMemoryProvider(providerDir);
103 });
104
105 it('storeEvent creates files and returns id+ts', () => {
106 const event = createMemoryEvent('search', { query: 'test' });
107 const result = provider.storeEvent(event);
108 assert.strictEqual(result.id, event.id);
109 assert.strictEqual(result.ts, event.ts);
110 assert(fs.existsSync(path.join(providerDir, 'events.jsonl')));
111 assert(fs.existsSync(path.join(providerDir, 'state.json')));
112 });
113
114 it('getLatest returns null for empty store', () => {
115 assert.strictEqual(provider.getLatest('search'), null);
116 });
117
118 it('getLatest returns the most recent event of that type', () => {
119 const e1 = createMemoryEvent('search', { query: 'first' });
120 const e2 = createMemoryEvent('search', { query: 'second' });
121 provider.storeEvent(e1);
122 provider.storeEvent(e2);
123 const latest = provider.getLatest('search');
124 assert.strictEqual(latest.data.query, 'second');
125 });
126
127 it('getLatest isolates by type', () => {
128 provider.storeEvent(createMemoryEvent('search', { query: 'q' }));
129 provider.storeEvent(createMemoryEvent('export', { format: 'md' }));
130 const s = provider.getLatest('search');
131 assert.strictEqual(s.data.query, 'q');
132 const e = provider.getLatest('export');
133 assert.strictEqual(e.data.format, 'md');
134 assert.strictEqual(provider.getLatest('write'), null);
135 });
136
137 it('listEvents returns all events sorted newest-first', () => {
138 provider.storeEvent(createMemoryEvent('search', { query: 'a' }));
139 provider.storeEvent(createMemoryEvent('search', { query: 'b' }));
140 provider.storeEvent(createMemoryEvent('export', { format: 'md' }));
141 const all = provider.listEvents();
142 assert.strictEqual(all.length, 3);
143 assert(all[0].ts >= all[1].ts);
144 });
145
146 it('listEvents filters by type', () => {
147 provider.storeEvent(createMemoryEvent('search', { query: 'a' }));
148 provider.storeEvent(createMemoryEvent('export', { format: 'md' }));
149 const list = provider.listEvents({ type: 'search' });
150 assert.strictEqual(list.length, 1);
151 assert.strictEqual(list[0].type, 'search');
152 });
153
154 it('listEvents filters by since/until', () => {
155 const e1 = createMemoryEvent('search', { query: 'old' });
156 e1.ts = '2025-01-01T00:00:00.000Z';
157 const e2 = createMemoryEvent('search', { query: 'new' });
158 e2.ts = '2026-06-01T00:00:00.000Z';
159 provider.storeEvent(e1);
160 provider.storeEvent(e2);
161 const after = provider.listEvents({ since: '2026-01-01' });
162 assert.strictEqual(after.length, 1);
163 assert.strictEqual(after[0].data.query, 'new');
164 });
165
166 it('listEvents respects limit', () => {
167 for (let i = 0; i < 5; i++) {
168 provider.storeEvent(createMemoryEvent('search', { query: `q${i}` }));
169 }
170 const list = provider.listEvents({ limit: 2 });
171 assert.strictEqual(list.length, 2);
172 });
173
174 it('supportsSearch returns false', () => {
175 assert.strictEqual(provider.supportsSearch(), false);
176 });
177
178 it('searchEvents returns empty array', () => {
179 const result = provider.searchEvents('anything');
180 assert.deepStrictEqual(result, []);
181 });
182
183 it('clearEvents clears all events', () => {
184 provider.storeEvent(createMemoryEvent('search', { query: 'a' }));
185 provider.storeEvent(createMemoryEvent('export', { format: 'md' }));
186 const result = provider.clearEvents();
187 assert.strictEqual(result.cleared, 2);
188 assert.deepStrictEqual(provider.listEvents(), []);
189 assert.strictEqual(provider.getLatest('search'), null);
190 });
191
192 it('clearEvents by type only removes that type', () => {
193 provider.storeEvent(createMemoryEvent('search', { query: 'a' }));
194 provider.storeEvent(createMemoryEvent('export', { format: 'md' }));
195 const result = provider.clearEvents({ type: 'search' });
196 assert.strictEqual(result.cleared, 1);
197 assert.strictEqual(provider.getLatest('search'), null);
198 assert.notStrictEqual(provider.getLatest('export'), null);
199 });
200
201 it('clearEvents by before date', () => {
202 const e1 = createMemoryEvent('search', { query: 'old' });
203 e1.ts = '2025-01-01T00:00:00.000Z';
204 const e2 = createMemoryEvent('search', { query: 'new' });
205 e2.ts = '2026-06-01T00:00:00.000Z';
206 provider.storeEvent(e1);
207 provider.storeEvent(e2);
208 const result = provider.clearEvents({ before: '2026-01-01' });
209 assert.strictEqual(result.cleared, 1);
210 const remaining = provider.listEvents();
211 assert.strictEqual(remaining.length, 1);
212 assert.strictEqual(remaining[0].data.query, 'new');
213 });
214
215 it('getStats returns correct counts', () => {
216 provider.storeEvent(createMemoryEvent('search', { query: 'a' }));
217 provider.storeEvent(createMemoryEvent('search', { query: 'b' }));
218 provider.storeEvent(createMemoryEvent('export', { format: 'md' }));
219 const stats = provider.getStats();
220 assert.strictEqual(stats.total, 3);
221 assert.strictEqual(stats.counts_by_type.search, 2);
222 assert.strictEqual(stats.counts_by_type.export, 1);
223 assert(stats.size_bytes > 0);
224 assert.strictEqual(typeof stats.oldest, 'string');
225 assert.strictEqual(typeof stats.newest, 'string');
226 });
227
228 it('getStats returns empty for no events', () => {
229 const stats = provider.getStats();
230 assert.strictEqual(stats.total, 0);
231 assert.strictEqual(stats.oldest, null);
232 });
233
234 it('pruneExpired removes events older than retentionDays', () => {
235 const old = createMemoryEvent('search', { query: 'old' });
236 old.ts = new Date(Date.now() - 100 * 86_400_000).toISOString();
237 const recent = createMemoryEvent('search', { query: 'recent' });
238 provider.storeEvent(old);
239 provider.storeEvent(recent);
240 const result = provider.pruneExpired(30);
241 assert.strictEqual(result.pruned, 1);
242 const remaining = provider.listEvents();
243 assert.strictEqual(remaining.length, 1);
244 assert.strictEqual(remaining[0].data.query, 'recent');
245 });
246
247 it('pruneExpired returns 0 when nothing to prune', () => {
248 provider.storeEvent(createMemoryEvent('search', { query: 'fresh' }));
249 const result = provider.pruneExpired(365);
250 assert.strictEqual(result.pruned, 0);
251 });
252
253 it('pruneExpired updates state.json when latest was pruned', () => {
254 const old = createMemoryEvent('export', { format: 'md' });
255 old.ts = new Date(Date.now() - 200 * 86_400_000).toISOString();
256 provider.storeEvent(old);
257 assert.notStrictEqual(provider.getLatest('export'), null);
258 provider.pruneExpired(30);
259 assert.strictEqual(provider.getLatest('export'), null);
260 });
261
262 it('pruneExpired with 0 or null retentionDays is no-op', () => {
263 provider.storeEvent(createMemoryEvent('search', { query: 'x' }));
264 assert.deepStrictEqual(provider.pruneExpired(0), { pruned: 0 });
265 assert.deepStrictEqual(provider.pruneExpired(null), { pruned: 0 });
266 assert.strictEqual(provider.listEvents().length, 1);
267 });
268 });
269
270 describe('MemoryManager', () => {
271 let memDir;
272 let manager;
273
274 beforeEach(() => {
275 memDir = path.join(tmpDir, 'mm-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6));
276 const provider = new FileMemoryProvider(memDir);
277 manager = new MemoryManager(provider);
278 });
279
280 it('store and getLatest round-trip', () => {
281 const result = manager.store('search', { query: 'test', count: 5 });
282 assert.match(result.id, /^mem_/);
283 const latest = manager.getLatest('search');
284 assert.strictEqual(latest.data.query, 'test');
285 });
286
287 it('shouldCapture respects default capture types', () => {
288 assert.strictEqual(manager.shouldCapture('search'), true);
289 assert.strictEqual(manager.shouldCapture('export'), true);
290 assert.strictEqual(manager.shouldCapture('write'), true);
291 assert.strictEqual(manager.shouldCapture('agent_interaction'), false);
292 });
293
294 it('custom capture types', () => {
295 const p = new FileMemoryProvider(path.join(tmpDir, 'mm-custom-' + Date.now()));
296 const m = new MemoryManager(p, { capture: ['search', 'error'] });
297 assert.strictEqual(m.shouldCapture('search'), true);
298 assert.strictEqual(m.shouldCapture('error'), true);
299 assert.strictEqual(m.shouldCapture('export'), false);
300 });
301
302 it('list returns events', () => {
303 manager.store('search', { query: 'a' });
304 manager.store('export', { format: 'md' });
305 const list = manager.list();
306 assert.strictEqual(list.length, 2);
307 });
308
309 it('clear removes events', () => {
310 manager.store('search', { query: 'a' });
311 const result = manager.clear();
312 assert.strictEqual(result.cleared, 1);
313 assert.strictEqual(manager.getLatest('search'), null);
314 });
315
316 it('stats returns summary', () => {
317 manager.store('search', { query: 'a' });
318 manager.store('search', { query: 'b' });
319 const s = manager.stats();
320 assert.strictEqual(s.total, 2);
321 assert.strictEqual(s.counts_by_type.search, 2);
322 });
323
324 it('supportsSearch returns false for file provider', () => {
325 assert.strictEqual(manager.supportsSearch(), false);
326 });
327
328 it('prune() is no-op when retentionDays is null', () => {
329 manager.store('search', { query: 'x' });
330 const result = manager.prune();
331 assert.strictEqual(result, null);
332 assert.strictEqual(manager.list().length, 1);
333 });
334
335 it('store() triggers retention pruning when retentionDays is set', () => {
336 const dir = path.join(tmpDir, 'mm-ret-' + Date.now());
337 const p = new FileMemoryProvider(dir);
338 const m = new MemoryManager(p, { retentionDays: 10 });
339
340 const old = createMemoryEvent('search', { query: 'old' });
341 old.ts = new Date(Date.now() - 30 * 86_400_000).toISOString();
342 p.storeEvent(old);
343
344 m.store('search', { query: 'new' });
345 const events = m.list();
346 assert.strictEqual(events.length, 1);
347 assert.strictEqual(events[0].data.query, 'new');
348 });
349
350 it('prune() throttles execution (second call within window is no-op)', () => {
351 const dir = path.join(tmpDir, 'mm-thr-' + Date.now());
352 const p = new FileMemoryProvider(dir);
353 const m = new MemoryManager(p, { retentionDays: 10 });
354 const first = m.prune();
355 assert.notStrictEqual(first, null);
356 assert.strictEqual(first.pruned, 0);
357 const second = m.prune();
358 assert.strictEqual(second, null);
359 });
360 });
361
362 describe('createMemoryManager', () => {
363 it('creates a MemoryManager with file provider from config', () => {
364 const dataDir = path.join(tmpDir, 'cmm-' + Date.now());
365 fs.mkdirSync(dataDir, { recursive: true });
366 const config = {
367 data_dir: dataDir,
368 memory: { enabled: true, provider: 'file' },
369 };
370 const mm = createMemoryManager(config);
371 assert(mm instanceof MemoryManager);
372 const result = mm.store('search', { query: 'hello' });
373 assert.match(result.id, /^mem_/);
374 const latest = mm.getLatest('search');
375 assert.strictEqual(latest.data.query, 'hello');
376 });
377
378 it('respects custom capture list from config', () => {
379 const dataDir = path.join(tmpDir, 'cmm-cap-' + Date.now());
380 fs.mkdirSync(dataDir, { recursive: true });
381 const config = {
382 data_dir: dataDir,
383 memory: { enabled: true, provider: 'file', capture: ['search', 'error'] },
384 };
385 const mm = createMemoryManager(config);
386 assert.strictEqual(mm.shouldCapture('search'), true);
387 assert.strictEqual(mm.shouldCapture('error'), true);
388 assert.strictEqual(mm.shouldCapture('write'), false);
389 });
390
391 it('uses default vault_id when not specified', () => {
392 const dataDir = path.join(tmpDir, 'cmm-vid-' + Date.now());
393 fs.mkdirSync(dataDir, { recursive: true });
394 const config = { data_dir: dataDir, memory: { enabled: true, provider: 'file' } };
395 const mm = createMemoryManager(config);
396 mm.store('search', { query: 'x' });
397 const expectedDir = path.join(dataDir, 'memory', 'default');
398 assert(fs.existsSync(expectedDir));
399 });
400
401 it('creates per-vault memory directory', () => {
402 const dataDir = path.join(tmpDir, 'cmm-mv-' + Date.now());
403 fs.mkdirSync(dataDir, { recursive: true });
404 const config = { data_dir: dataDir, memory: { enabled: true, provider: 'file' } };
405 const mm = createMemoryManager(config, 'vault-alpha');
406 mm.store('search', { query: 'x' });
407 const expectedDir = path.join(dataDir, 'memory', 'vault-alpha');
408 assert(fs.existsSync(expectedDir));
409 });
410 });
411
412 describe('backward compatibility (storeMemory / getMemory)', () => {
413 let dataDir;
414
415 beforeEach(() => {
416 dataDir = path.join(tmpDir, 'compat-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6));
417 fs.mkdirSync(dataDir, { recursive: true });
418 });
419
420 it('storeMemory + getMemory round-trip for last_search', () => {
421 storeMemory(dataDir, 'last_search', { query: 'test', paths: ['a.md'], count: 1 });
422 const val = getMemory(dataDir, 'last_search');
423 assert.notStrictEqual(val, null);
424 assert.strictEqual(val.query, 'test');
425 assert.strictEqual(typeof val._at, 'string');
426 });
427
428 it('storeMemory + getMemory round-trip for last_export', () => {
429 storeMemory(dataDir, 'last_export', { provenance: 'p', exported: [{ path: 'a.md' }] });
430 const val = getMemory(dataDir, 'last_export');
431 assert.notStrictEqual(val, null);
432 assert.strictEqual(val.provenance, 'p');
433 });
434
435 it('storeMemory also writes to new event log', () => {
436 storeMemory(dataDir, 'last_search', { query: 'q' });
437 const eventsFile = path.join(dataDir, 'memory', 'default', 'events.jsonl');
438 assert(fs.existsSync(eventsFile));
439 const lines = fs.readFileSync(eventsFile, 'utf8').trim().split('\n');
440 assert.strictEqual(lines.length, 1);
441 const event = JSON.parse(lines[0]);
442 assert.strictEqual(event.type, 'search');
443 assert.strictEqual(event.data.query, 'q');
444 });
445
446 it('storeMemory also writes to legacy memory.json', () => {
447 storeMemory(dataDir, 'last_search', { query: 'legacy' });
448 const legacyFile = path.join(dataDir, 'memory.json');
449 assert(fs.existsSync(legacyFile));
450 const data = JSON.parse(fs.readFileSync(legacyFile, 'utf8'));
451 assert.strictEqual(data.last_search.query, 'legacy');
452 });
453
454 it('getMemory falls back to legacy memory.json when no event log', () => {
455 const legacyFile = path.join(dataDir, 'memory.json');
456 fs.writeFileSync(legacyFile, JSON.stringify({ last_search: { query: 'old', _at: '2025-01-01T00:00:00Z' } }), 'utf8');
457 const val = getMemory(dataDir, 'last_search');
458 assert.strictEqual(val.query, 'old');
459 });
460
461 it('getMemory returns null for missing key', () => {
462 const val = getMemory(dataDir, 'last_search');
463 assert.strictEqual(val, null);
464 });
465 });
466
467 describe('Mem0MemoryProvider (file fallback)', () => {
468 it('stores and retrieves via file layer when no URL', async () => {
469 const { Mem0MemoryProvider } = await import('../lib/memory-provider-mem0.mjs');
470 const provDir = path.join(tmpDir, 'mem0-' + Date.now());
471 const m0p = new Mem0MemoryProvider(provDir, { url: '' });
472 assert.strictEqual(m0p.supportsSearch(), false);
473
474 const event = (await import('../lib/memory-event.mjs')).createMemoryEvent('search', { query: 'test' });
475 const result = m0p.storeEvent(event);
476 assert.strictEqual(result.id, event.id);
477
478 const latest = m0p.getLatest('search');
479 assert.strictEqual(latest.data.query, 'test');
480
481 const list = m0p.listEvents();
482 assert.strictEqual(list.length, 1);
483 });
484
485 it('supportsSearch returns true when URL is set', async () => {
486 const { Mem0MemoryProvider } = await import('../lib/memory-provider-mem0.mjs');
487 const provDir = path.join(tmpDir, 'mem0url-' + Date.now());
488 const m0p = new Mem0MemoryProvider(provDir, { url: 'http://localhost:9999' });
489 assert.strictEqual(m0p.supportsSearch(), true);
490 });
491 });
492
493 describe('VectorMemoryProvider (file fallback)', () => {
494 it('VectorMemoryProvider stores and retrieves via file layer', async () => {
495 const { VectorMemoryProvider } = await import('../lib/memory-provider-vector.mjs');
496 const provDir = path.join(tmpDir, 'vmp-' + Date.now());
497 const config = {
498 data_dir: provDir,
499 embedding: { provider: 'ollama', model: 'nomic-embed-text' },
500 vector_store: 'sqlite-vec',
501 };
502 const vmp = new VectorMemoryProvider(provDir, config);
503 assert.strictEqual(vmp.supportsSearch(), true);
504
505 const event = (await import('../lib/memory-event.mjs')).createMemoryEvent('search', { query: 'hello world' });
506 const result = vmp.storeEvent(event);
507 assert.strictEqual(result.id, event.id);
508
509 const latest = vmp.getLatest('search');
510 assert.strictEqual(latest.data.query, 'hello world');
511
512 const list = vmp.listEvents();
513 assert.strictEqual(list.length, 1);
514
515 const stats = vmp.getStats();
516 assert.strictEqual(stats.total, 1);
517 });
518 });
519
520 describe('resolveMemoryDir', () => {
521 it('builds correct path for default vault', () => {
522 const dir = resolveMemoryDir('/data');
523 assert.strictEqual(dir, path.join('/data', 'memory', 'default'));
524 });
525
526 it('builds correct path for named vault', () => {
527 const dir = resolveMemoryDir('/data', 'my-vault');
528 assert.strictEqual(dir, path.join('/data', 'memory', 'my-vault'));
529 });
530
531 it('builds _global path when scope is global', () => {
532 const dir = resolveMemoryDir('/data', 'my-vault', { scope: 'global' });
533 assert.strictEqual(dir, path.join('/data', 'memory', '_global'));
534 });
535
536 it('uses vault scope by default', () => {
537 const dir = resolveMemoryDir('/data', 'v1', {});
538 assert.strictEqual(dir, path.join('/data', 'memory', 'v1'));
539 });
540 });
541
542 describe('mem0 import enrichment', () => {
543 it('onMemoryEvent callback is called for each imported memory', async () => {
544 const { importMem0 } = await import('../lib/importers/mem0.mjs');
545 const vaultDir = path.join(tmpDir, 'mem0-enrich-' + Date.now());
546 fs.mkdirSync(vaultDir, { recursive: true });
547
548 const mem0Data = [
549 { id: 'm1', memory: 'First memory text', created_at: '2026-01-15' },
550 { id: 'm2', memory: 'Second memory text', created_at: '2026-02-20' },
551 ];
552 const exportFile = path.join(tmpDir, 'mem0-enrich-export.json');
553 fs.writeFileSync(exportFile, JSON.stringify(mem0Data), 'utf8');
554
555 const captured = [];
556 const result = await importMem0(exportFile, {
557 vaultPath: vaultDir,
558 outputBase: 'inbox',
559 tags: [],
560 dryRun: false,
561 onMemoryEvent: (data) => captured.push(data),
562 });
563
564 assert.strictEqual(result.count, 2);
565 assert.strictEqual(captured.length, 2);
566 assert.strictEqual(captured[0].source, 'mem0');
567 assert.strictEqual(captured[0].source_id, 'm1');
568 assert.ok(captured[0].text.includes('First memory'));
569 assert.strictEqual(captured[1].source_id, 'm2');
570 });
571
572 it('onMemoryEvent is not called during dry run', async () => {
573 const { importMem0 } = await import('../lib/importers/mem0.mjs');
574 const exportFile = path.join(tmpDir, 'mem0-dry.json');
575 fs.writeFileSync(exportFile, JSON.stringify([{ id: 'd1', memory: 'dry test' }]), 'utf8');
576
577 const captured = [];
578 const result = await importMem0(exportFile, {
579 vaultPath: path.join(tmpDir, 'dry-vault'),
580 outputBase: 'inbox',
581 tags: [],
582 dryRun: true,
583 onMemoryEvent: (data) => captured.push(data),
584 });
585
586 assert.strictEqual(result.count, 1);
587 assert.strictEqual(captured.length, 0);
588 });
589
590 it('onMemoryEvent errors do not break import', async () => {
591 const { importMem0 } = await import('../lib/importers/mem0.mjs');
592 const vaultDir = path.join(tmpDir, 'mem0-err-' + Date.now());
593 fs.mkdirSync(vaultDir, { recursive: true });
594
595 const exportFile = path.join(tmpDir, 'mem0-err.json');
596 fs.writeFileSync(exportFile, JSON.stringify([{ id: 'e1', memory: 'test' }]), 'utf8');
597
598 const result = await importMem0(exportFile, {
599 vaultPath: vaultDir,
600 outputBase: 'inbox',
601 tags: [],
602 dryRun: false,
603 onMemoryEvent: () => { throw new Error('callback failure'); },
604 });
605
606 assert.strictEqual(result.count, 1);
607 });
608 });
609
610 describe('EncryptedFileMemoryProvider', () => {
611 it('stores and retrieves events with encryption', async () => {
612 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
613 const dir = path.join(tmpDir, 'enc-' + Date.now());
614 const provider = new EncryptedFileMemoryProvider(dir, 'test-secret-key-12345');
615
616 const event = createMemoryEvent('search', { query: 'encrypted test' });
617 const result = provider.storeEvent(event);
618 assert.strictEqual(result.id, event.id);
619
620 const latest = provider.getLatest('search');
621 assert.strictEqual(latest.data.query, 'encrypted test');
622
623 const list = provider.listEvents();
624 assert.strictEqual(list.length, 1);
625 assert.strictEqual(list[0].data.query, 'encrypted test');
626 });
627
628 it('encrypted files are not readable as plaintext', async () => {
629 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
630 const dir = path.join(tmpDir, 'enc-plain-' + Date.now());
631 const provider = new EncryptedFileMemoryProvider(dir, 'test-secret-key-12345');
632
633 provider.storeEvent(createMemoryEvent('search', { query: 'secret data' }));
634
635 const raw = fs.readFileSync(path.join(dir, 'events.jsonl.enc'), 'utf8');
636 assert.ok(!raw.includes('secret data'));
637 assert.ok(!raw.includes('"query"'));
638 });
639
640 it('wrong key cannot decrypt', async () => {
641 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
642 const dir = path.join(tmpDir, 'enc-wrongkey-' + Date.now());
643 const p1 = new EncryptedFileMemoryProvider(dir, 'correct-key-12345');
644 p1.storeEvent(createMemoryEvent('search', { query: 'private' }));
645
646 const p2 = new EncryptedFileMemoryProvider(dir, 'wrong-key-67890');
647 const list = p2.listEvents();
648 assert.strictEqual(list.length, 0);
649 assert.strictEqual(p2.getLatest('search'), null);
650 });
651
652 it('per-vault salt is created and reused', async () => {
653 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
654 const dir = path.join(tmpDir, 'enc-salt-' + Date.now());
655 new EncryptedFileMemoryProvider(dir, 'my-secret-12345');
656 const saltPath = path.join(dir, '.salt');
657 assert.ok(fs.existsSync(saltPath));
658 const salt1 = fs.readFileSync(saltPath);
659
660 new EncryptedFileMemoryProvider(dir, 'my-secret-12345');
661 const salt2 = fs.readFileSync(saltPath);
662 assert.deepStrictEqual(salt1, salt2);
663 });
664
665 it('clearEvents works on encrypted data', async () => {
666 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
667 const dir = path.join(tmpDir, 'enc-clear-' + Date.now());
668 const p = new EncryptedFileMemoryProvider(dir, 'clear-secret-12345');
669 p.storeEvent(createMemoryEvent('search', { query: 'a' }));
670 p.storeEvent(createMemoryEvent('export', { format: 'md' }));
671 const result = p.clearEvents({ type: 'search' });
672 assert.strictEqual(result.cleared, 1);
673 assert.strictEqual(p.getLatest('search'), null);
674 assert.notStrictEqual(p.getLatest('export'), null);
675 });
676
677 it('pruneExpired works on encrypted data', async () => {
678 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
679 const dir = path.join(tmpDir, 'enc-prune-' + Date.now());
680 const p = new EncryptedFileMemoryProvider(dir, 'prune-secret-12345');
681 const old = createMemoryEvent('search', { query: 'old' });
682 old.ts = new Date(Date.now() - 100 * 86_400_000).toISOString();
683 p.storeEvent(old);
684 p.storeEvent(createMemoryEvent('search', { query: 'new' }));
685 const result = p.pruneExpired(30);
686 assert.strictEqual(result.pruned, 1);
687 assert.strictEqual(p.listEvents().length, 1);
688 });
689
690 it('getStats works on encrypted data', async () => {
691 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
692 const dir = path.join(tmpDir, 'enc-stats-' + Date.now());
693 const p = new EncryptedFileMemoryProvider(dir, 'stats-secret-12345');
694 p.storeEvent(createMemoryEvent('search', { query: 'a' }));
695 p.storeEvent(createMemoryEvent('export', { format: 'md' }));
696 const stats = p.getStats();
697 assert.strictEqual(stats.total, 2);
698 assert.strictEqual(stats.counts_by_type.search, 1);
699 assert.ok(stats.size_bytes > 0);
700 });
701
702 it('rejects short secret', async () => {
703 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
704 const dir = path.join(tmpDir, 'enc-short-' + Date.now());
705 assert.throws(() => new EncryptedFileMemoryProvider(dir, 'short'), /at least 8/);
706 });
707
708 it('supportsSearch returns false', async () => {
709 const { EncryptedFileMemoryProvider } = await import('../lib/memory-provider-encrypted.mjs');
710 const dir = path.join(tmpDir, 'enc-search-' + Date.now());
711 const p = new EncryptedFileMemoryProvider(dir, 'search-secret-12345');
712 assert.strictEqual(p.supportsSearch(), false);
713 });
714 });
715
716 describe('SupabaseMemoryProvider (file fallback, no connection)', () => {
717 it('stores and retrieves via file layer when no url/key', async () => {
718 const { SupabaseMemoryProvider } = await import('../lib/memory-provider-supabase.mjs');
719 const dir = path.join(tmpDir, 'sb-' + Date.now());
720 const provider = new SupabaseMemoryProvider(dir, { url: '', key: '' });
721 assert.strictEqual(provider.supportsSearch(), false);
722
723 const event = createMemoryEvent('search', { query: 'supabase-test' });
724 const result = provider.storeEvent(event);
725 assert.strictEqual(result.id, event.id);
726
727 const latest = provider.getLatest('search');
728 assert.strictEqual(latest.data.query, 'supabase-test');
729
730 const list = provider.listEvents();
731 assert.strictEqual(list.length, 1);
732
733 const stats = provider.getStats();
734 assert.strictEqual(stats.total, 1);
735 });
736
737 it('supportsSearch returns true when url and key are set', async () => {
738 const { SupabaseMemoryProvider } = await import('../lib/memory-provider-supabase.mjs');
739 const dir = path.join(tmpDir, 'sb-search-' + Date.now());
740 const provider = new SupabaseMemoryProvider(dir, { url: 'https://fake.supabase.co', key: 'fake-key' });
741 assert.strictEqual(provider.supportsSearch(), true);
742 });
743
744 it('clearEvents works via file layer', async () => {
745 const { SupabaseMemoryProvider } = await import('../lib/memory-provider-supabase.mjs');
746 const dir = path.join(tmpDir, 'sb-clear-' + Date.now());
747 const provider = new SupabaseMemoryProvider(dir, { url: '', key: '' });
748 provider.storeEvent(createMemoryEvent('search', { query: 'a' }));
749 provider.storeEvent(createMemoryEvent('export', { format: 'md' }));
750 const result = provider.clearEvents({ type: 'search' });
751 assert.strictEqual(result.cleared, 1);
752 });
753
754 it('pruneExpired works via file layer', async () => {
755 const { SupabaseMemoryProvider } = await import('../lib/memory-provider-supabase.mjs');
756 const dir = path.join(tmpDir, 'sb-prune-' + Date.now());
757 const provider = new SupabaseMemoryProvider(dir, { url: '', key: '' });
758 const old = createMemoryEvent('search', { query: 'old' });
759 old.ts = new Date(Date.now() - 100 * 86_400_000).toISOString();
760 provider.storeEvent(old);
761 provider.storeEvent(createMemoryEvent('search', { query: 'new' }));
762 const result = provider.pruneExpired(30);
763 assert.strictEqual(result.pruned, 1);
764 });
765
766 it('searchEvents returns empty when no url/key', async () => {
767 const { SupabaseMemoryProvider } = await import('../lib/memory-provider-supabase.mjs');
768 const dir = path.join(tmpDir, 'sb-nosearch-' + Date.now());
769 const provider = new SupabaseMemoryProvider(dir, { url: '', key: '' });
770 const results = await provider.searchEvents('test');
771 assert.deepStrictEqual(results, []);
772 });
773 });
774
775 describe('createMemoryManagerAsync with encrypted provider', () => {
776 it('creates encrypted provider when encrypt=true and secret set', async () => {
777 const { createMemoryManagerAsync } = await import('../lib/memory.mjs');
778 const dataDir = path.join(tmpDir, 'cmma-enc-' + Date.now());
779 fs.mkdirSync(dataDir, { recursive: true });
780 const config = {
781 data_dir: dataDir,
782 memory: { enabled: true, provider: 'file', encrypt: true, secret: 'my-async-secret-12345' },
783 };
784 const mm = await createMemoryManagerAsync(config);
785 mm.store('search', { query: 'encrypted-async' });
786 const latest = mm.getLatest('search');
787 assert.strictEqual(latest.data.query, 'encrypted-async');
788
789 const encFile = path.join(dataDir, 'memory', 'default', 'events.jsonl.enc');
790 assert.ok(fs.existsSync(encFile));
791 const raw = fs.readFileSync(encFile, 'utf8');
792 assert.ok(!raw.includes('encrypted-async'));
793 });
794 });
795
796 describe('session summary', () => {
797 it('generateSessionSummary with no events returns early', async () => {
798 const { generateSessionSummary } = await import('../lib/memory-session-summary.mjs');
799 const dataDir = path.join(tmpDir, 'ss-empty-' + Date.now());
800 fs.mkdirSync(dataDir, { recursive: true });
801 const config = { data_dir: dataDir, memory: { enabled: true, provider: 'file' } };
802 const result = await generateSessionSummary(config, { since: new Date().toISOString() });
803 assert.strictEqual(result.event_count, 0);
804 assert.strictEqual(result.summary, 'No events to summarize.');
805 assert.strictEqual(result.id, undefined);
806 });
807 });
808
809 describe('supabase-memory import source type', () => {
810 it('supabase-memory is a valid import source type', async () => {
811 const { isValidImportSourceType, IMPORT_SOURCE_TYPES } = await import('../lib/import-source-types.mjs');
812 assert.ok(IMPORT_SOURCE_TYPES.includes('supabase-memory'));
813 assert.ok(isValidImportSourceType('supabase-memory'));
814 });
815 });
816
817 describe('cross-vault memory (global scope)', () => {
818 it('createMemoryManager with scope=global uses _global directory', () => {
819 const dataDir = path.join(tmpDir, 'cv-glob-' + Date.now());
820 fs.mkdirSync(dataDir, { recursive: true });
821 const config = { data_dir: dataDir, memory: { enabled: true, provider: 'file', scope: 'global' } };
822 const mm = createMemoryManager(config, 'vault-a');
823 mm.store('search', { query: 'global-test' });
824 const expectedDir = path.join(dataDir, 'memory', '_global');
825 assert(fs.existsSync(expectedDir));
826 assert(!fs.existsSync(path.join(dataDir, 'memory', 'vault-a')));
827 });
828
829 it('scope=vault stores in per-vault directory', () => {
830 const dataDir = path.join(tmpDir, 'cv-vault-' + Date.now());
831 fs.mkdirSync(dataDir, { recursive: true });
832 const config = { data_dir: dataDir, memory: { enabled: true, provider: 'file', scope: 'vault' } };
833 const mm = createMemoryManager(config, 'vault-b');
834 mm.store('search', { query: 'vault-test' });
835 assert(fs.existsSync(path.join(dataDir, 'memory', 'vault-b')));
836 });
837
838 it('opts.scope overrides config scope', () => {
839 const dataDir = path.join(tmpDir, 'cv-ovr-' + Date.now());
840 fs.mkdirSync(dataDir, { recursive: true });
841 const config = { data_dir: dataDir, memory: { enabled: true, provider: 'file', scope: 'vault' } };
842 const mm = createMemoryManager(config, 'vault-c', { scope: 'global' });
843 mm.store('search', { query: 'override-test' });
844 assert(fs.existsSync(path.join(dataDir, 'memory', '_global')));
845 });
846
847 it('global memory is shared across vault IDs', () => {
848 const dataDir = path.join(tmpDir, 'cv-shared-' + Date.now());
849 fs.mkdirSync(dataDir, { recursive: true });
850 const config = { data_dir: dataDir, memory: { enabled: true, provider: 'file', scope: 'global' } };
851 const mm1 = createMemoryManager(config, 'vault-x');
852 const mm2 = createMemoryManager(config, 'vault-y');
853 mm1.store('search', { query: 'from-x' });
854 const events = mm2.list();
855 assert.strictEqual(events.length, 1);
856 assert.strictEqual(events[0].data.query, 'from-x');
857 });
858 });
859
860 describe('memory event status field', () => {
861 it('MEMORY_EVENT_STATUSES is a frozen array with success and failed', () => {
862 assert(Array.isArray(MEMORY_EVENT_STATUSES));
863 assert(Object.isFrozen(MEMORY_EVENT_STATUSES));
864 assert(MEMORY_EVENT_STATUSES.includes('success'));
865 assert(MEMORY_EVENT_STATUSES.includes('failed'));
866 assert.strictEqual(MEMORY_EVENT_STATUSES.length, 2);
867 });
868
869 it('createMemoryEvent defaults status to success', () => {
870 const event = createMemoryEvent('search', { query: 'test' });
871 assert.strictEqual(event.status, 'success');
872 });
873
874 it('createMemoryEvent accepts status=failed', () => {
875 const event = createMemoryEvent('search', { query: 'test' }, { status: 'failed' });
876 assert.strictEqual(event.status, 'failed');
877 });
878
879 it('createMemoryEvent accepts status=success explicitly', () => {
880 const event = createMemoryEvent('search', { query: 'test' }, { status: 'success' });
881 assert.strictEqual(event.status, 'success');
882 });
883
884 it('createMemoryEvent rejects invalid status', () => {
885 assert.throws(
886 () => createMemoryEvent('search', { query: 'test' }, { status: 'pending' }),
887 /Invalid memory event status/
888 );
889 });
890
891 it('isValidMemoryEvent accepts events with valid status', () => {
892 const event = createMemoryEvent('search', { query: 'test' });
893 assert(isValidMemoryEvent(event));
894 event.status = 'failed';
895 assert(isValidMemoryEvent(event));
896 });
897
898 it('isValidMemoryEvent accepts events without status (backward compat)', () => {
899 const event = createMemoryEvent('search', { query: 'test' });
900 delete event.status;
901 assert(isValidMemoryEvent(event));
902 });
903
904 it('isValidMemoryEvent rejects events with invalid status', () => {
905 const event = createMemoryEvent('search', { query: 'test' });
906 event.status = 'bogus';
907 assert.strictEqual(isValidMemoryEvent(event), false);
908 });
909
910 it('MemoryManager.store accepts status option', () => {
911 const dir = path.join(tmpDir, 'mm-status-' + Date.now());
912 const provider = new FileMemoryProvider(dir);
913 const mm = new MemoryManager(provider);
914 mm.store('search', { query: 'ok' });
915 mm.store('search', { query: 'fail' }, { status: 'failed' });
916 const events = mm.list();
917 assert.strictEqual(events.length, 2);
918 const statuses = events.map((e) => e.status);
919 assert(statuses.includes('success'));
920 assert(statuses.includes('failed'));
921 });
922 });
923
924 describe('generateMemoryIndex', () => {
925 it('returns empty index when no events exist', () => {
926 const dir = path.join(tmpDir, 'idx-empty-' + Date.now());
927 const provider = new FileMemoryProvider(dir);
928 const mm = new MemoryManager(provider);
929 const idx = generateMemoryIndex(mm);
930 assert.strictEqual(typeof idx.markdown, 'string');
931 assert(idx.markdown.includes('# Memory Index'));
932 assert(idx.markdown.includes('empty'));
933 assert.strictEqual(idx.total_events, 0);
934 assert.deepStrictEqual(idx.types, []);
935 assert.strictEqual(typeof idx.generated_at, 'string');
936 });
937
938 it('includes event types with counts and latest summary', () => {
939 const dir = path.join(tmpDir, 'idx-types-' + Date.now());
940 const provider = new FileMemoryProvider(dir);
941 const mm = new MemoryManager(provider);
942 mm.store('search', { query: 'blockchain architecture' });
943 mm.store('search', { query: 'memory patterns' });
944 mm.store('write', { path: 'vault/notes/test.md' });
945 const idx = generateMemoryIndex(mm);
946 assert(idx.markdown.includes('search: 2 events'));
947 assert(idx.markdown.includes('write: 1 events'));
948 assert(idx.markdown.includes('memory patterns'));
949 assert(idx.markdown.includes('vault/notes/test.md'));
950 assert.strictEqual(idx.total_events, 3);
951 assert(idx.types.includes('search'));
952 assert(idx.types.includes('write'));
953 });
954
955 it('includes recent activity section', () => {
956 const dir = path.join(tmpDir, 'idx-recent-' + Date.now());
957 const provider = new FileMemoryProvider(dir);
958 const mm = new MemoryManager(provider);
959 mm.store('search', { query: 'recent query' });
960 const idx = generateMemoryIndex(mm);
961 assert(idx.markdown.includes('## Recent Activity'));
962 assert(idx.markdown.includes('[search]'));
963 assert(idx.markdown.includes('recent query'));
964 });
965
966 it('filters out failed events from recent activity', () => {
967 const dir = path.join(tmpDir, 'idx-filter-' + Date.now());
968 const provider = new FileMemoryProvider(dir);
969 const mm = new MemoryManager(provider);
970 mm.store('search', { query: 'good query' });
971 mm.store('search', { query: 'bad query' }, { status: 'failed' });
972 const idx = generateMemoryIndex(mm);
973 assert(idx.markdown.includes('good query'));
974 assert(!idx.markdown.includes('bad query'));
975 });
976
977 it('respects recentLimit option', () => {
978 const dir = path.join(tmpDir, 'idx-limit-' + Date.now());
979 const provider = new FileMemoryProvider(dir);
980 const mm = new MemoryManager(provider);
981 for (let i = 0; i < 10; i++) {
982 mm.store('search', { query: `query-${i}` });
983 }
984 const idx = generateMemoryIndex(mm, { recentLimit: 3 });
985 const activitySection = idx.markdown.split('## Recent Activity')[1];
986 const activityLines = activitySection.trim().split('\n').filter((l) => l.startsWith('- '));
987 assert.strictEqual(activityLines.length, 3);
988 });
989
990 it('truncates long summaries', () => {
991 const dir = path.join(tmpDir, 'idx-trunc-' + Date.now());
992 const provider = new FileMemoryProvider(dir);
993 const mm = new MemoryManager(provider);
994 mm.store('search', { query: 'A'.repeat(200) });
995 const idx = generateMemoryIndex(mm);
996 assert(idx.markdown.includes('…'));
997 const lines = idx.markdown.split('\n');
998 for (const line of lines) {
999 if (line.startsWith('- ') && line.includes('[search]')) {
1000 assert(line.length < 200);
1001 }
1002 }
1003 });
1004
1005 it('handles events with different data shapes', () => {
1006 const dir = path.join(tmpDir, 'idx-shapes-' + Date.now());
1007 const provider = new FileMemoryProvider(dir);
1008 const mm = new MemoryManager(provider);
1009 mm.store('search', { query: 'test' });
1010 mm.store('write', { path: 'notes/hello.md' });
1011 mm.store('export', { format: 'md' });
1012 mm.store('user', { key: 'preference', theme: 'dark' });
1013 const idx = generateMemoryIndex(mm);
1014 assert.strictEqual(idx.total_events, 4);
1015 assert(idx.types.includes('search'));
1016 assert(idx.types.includes('write'));
1017 assert(idx.types.includes('export'));
1018 assert(idx.types.includes('user'));
1019 });
1020 });
1021
1022 describe('MemoryManager.generateIndex', () => {
1023 it('returns index and caches it', () => {
1024 const dir = path.join(tmpDir, 'mm-idx-cache-' + Date.now());
1025 const provider = new FileMemoryProvider(dir);
1026 const mm = new MemoryManager(provider);
1027 mm.store('search', { query: 'test' });
1028 const idx1 = mm.generateIndex();
1029 const idx2 = mm.generateIndex();
1030 assert.strictEqual(idx1.generated_at, idx2.generated_at);
1031 });
1032
1033 it('force bypasses cache', () => {
1034 const dir = path.join(tmpDir, 'mm-idx-force-' + Date.now());
1035 const provider = new FileMemoryProvider(dir);
1036 const mm = new MemoryManager(provider);
1037 mm.store('search', { query: 'test' });
1038 const idx1 = mm.generateIndex();
1039 const idx2 = mm.generateIndex({ force: true });
1040 assert(idx2.generated_at >= idx1.generated_at);
1041 });
1042
1043 it('clear invalidates cached index', () => {
1044 const dir = path.join(tmpDir, 'mm-idx-clear-' + Date.now());
1045 const provider = new FileMemoryProvider(dir);
1046 const mm = new MemoryManager(provider);
1047 mm.store('search', { query: 'test' });
1048 const idx1 = mm.generateIndex({ force: true });
1049 assert.strictEqual(idx1.total_events, 1);
1050 mm.clear();
1051 const idx2 = mm.generateIndex({ force: true });
1052 assert.strictEqual(idx2.total_events, 0);
1053 });
1054 });
1055
1056 describe('buildMemoryIndexResource', () => {
1057 it('returns enabled:false when memory is disabled', async () => {
1058 const { buildMemoryIndexResource } = await import('../mcp/resources/metadata.mjs');
1059 const result = buildMemoryIndexResource({ memory: { enabled: false } });
1060 assert.strictEqual(result.enabled, false);
1061 assert.strictEqual(result.index, null);
1062 });
1063
1064 it('returns index when memory is enabled', async () => {
1065 const { buildMemoryIndexResource } = await import('../mcp/resources/metadata.mjs');
1066 const dataDir = path.join(tmpDir, 'mcp-idx-' + Date.now());
1067 fs.mkdirSync(dataDir, { recursive: true });
1068 const config = { data_dir: dataDir, memory: { enabled: true, provider: 'file' } };
1069 const mm = createMemoryManager(config);
1070 mm.store('search', { query: 'mcp test' });
1071 const result = buildMemoryIndexResource(config);
1072 assert.strictEqual(result.enabled, true);
1073 assert.notStrictEqual(result.index, null);
1074 assert(result.index.markdown.includes('# Memory Index'));
1075 assert(result.index.markdown.includes('search'));
1076 });
1077 });
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