import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { DatabaseManager } from '../../src/store/db.js'; import { addMemory, searchMemories, getMemories, removeMemory, touchMemory, getMemoryStats, syncMemoryEntry, replaceSyncedMemories, removeSyncedMemories, parseMarkdownMemoryEntry, formatFailureMemoryContent, reconcileMarkdownMemoryScope, reconcileMarkdownFailureScopes, } from '../../src/store/sqlite-memory-store.js'; import { MemoryStore } from '../../src/store/memory-store.js'; import { MDSYNC_METADATA_KEY_PREFIX } from '../../src/constants.js'; function mdsyncKey(target: string, project: string | null): string { return MDSYNC_METADATA_KEY_PREFIX + JSON.stringify([target, project]); } function readMdsyncValue(dbManager: DatabaseManager, target: string, project: string | null): string | undefined { const row = dbManager.getDb().prepare( 'SELECT value FROM extension_metadata WHERE key = ?', ).get(mdsyncKey(target, project)) as { value: string } | undefined; return row?.value; } function withBlockedMemoryWrites( dbManager: DatabaseManager, fn: () => T, mode: 'insert' | 'insert-or-update' = 'insert-or-update', ): T { const db = dbManager.getDb() as { prepare: (sql: string) => unknown }; const originalPrepare = db.prepare.bind(db); db.prepare = (sql: string) => { const normalized = sql.replace(/\s+/g, ' ').trim(); const isInsert = /^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized); const isUpdate = /^UPDATE memories\b/i.test(normalized); if (isInsert || (mode === 'insert-or-update' && isUpdate)) { throw new Error(`unexpected memories write: ${normalized}`); } return originalPrepare(sql); }; try { return fn(); } finally { db.prepare = originalPrepare; } } describe('sqlite-memory-store', () => { let tmpDir: string; let dbManager: DatabaseManager; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'memory-store-test-')); dbManager = new DatabaseManager(tmpDir); }); afterEach(() => { dbManager.close(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); describe('addMemory', () => { it('should add a memory entry', () => { const entry = addMemory(dbManager, 'prefers pnpm over npm'); assert.ok(entry.id > 0); assert.strictEqual(entry.target, 'memory'); assert.strictEqual(entry.content, 'prefers pnpm over npm'); assert.ok(entry.created.length > 0); assert.ok(entry.lastReferenced.length > 0); }); it('should add a user entry', () => { const entry = addMemory(dbManager, 'name: Chandrateja', 'user'); assert.strictEqual(entry.target, 'user'); }); it('should add a project-specific entry', () => { const entry = addMemory(dbManager, 'uses Prisma', 'memory', 'my-project'); assert.strictEqual(entry.project, 'my-project'); }); it('should add a global entry (null project)', () => { const entry = addMemory(dbManager, 'timezone: AEST'); assert.strictEqual(entry.project, null); }); }); describe('syncMemoryEntry', () => { it('deduplicates exact logical entries', () => { const first = syncMemoryEntry(dbManager, { content: 'prefers pnpm over npm', target: 'memory', }); const second = syncMemoryEntry(dbManager, { content: 'prefers pnpm over npm', target: 'memory', }); assert.strictEqual(first.action, 'inserted'); assert.strictEqual(second.action, 'existing'); assert.strictEqual(getMemories(dbManager).length, 1); }); it('stores project-scoped memory with project name', () => { syncMemoryEntry(dbManager, { content: 'uses Prisma', target: 'memory', project: 'project-a', }); const results = getMemories(dbManager, { project: 'project-a' }); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].project, 'project-a'); assert.strictEqual(results[0].target, 'memory'); }); it('preserves failure category metadata', () => { syncMemoryEntry(dbManager, { content: formatFailureMemoryContent('pnpm lockfile mismatch', { category: 'tool-quirk', failureReason: 'npm install rewrote lockfile', }), target: 'failure', category: 'tool-quirk', failureReason: 'npm install rewrote lockfile', }); const results = getMemories(dbManager, { target: 'failure', category: 'tool-quirk' }); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].category, 'tool-quirk'); assert.strictEqual(results[0].failureReason, 'npm install rewrote lockfile'); }); it('parses Markdown failure entries for backfill', () => { const parsed = parseMarkdownMemoryEntry( '[correction] use pnpm — Failed: npm install rewrote lockfile ', 'failure', ); assert.strictEqual(parsed.category, 'correction'); assert.strictEqual(parsed.failureReason, 'npm install rewrote lockfile'); assert.strictEqual(parsed.created, '2026-05-08'); assert.strictEqual(parsed.lastReferenced, '2026-05-09'); }); it('does not infer project scope from spoofable failure content', () => { const raw = '[correction] literal user text — Project: other-project '; reconcileMarkdownFailureScopes(dbManager, [raw]); assert.strictEqual(getMemories(dbManager, { target: 'failure', project: 'other-project' }).length, 0); assert.strictEqual(getMemories(dbManager, { target: 'failure', project: null }).length, 1); }); it('round-trips project correction scope through authoritative Markdown metadata', async () => { const store = new MemoryStore({ memoryDir: tmpDir, memoryCharLimit: 5_000, userCharLimit: 5_000, failureCharLimit: 5_000, } as any); await store.loadFromDisk(); await store.addFailure('use pnpm in this repo', { category: 'correction', failureReason: 'User corrected the agent', project: 'project-a', }); const [raw] = store.getRawEntriesForSync('failure'); reconcileMarkdownFailureScopes(dbManager, [raw]); const entries = getMemories(dbManager, { target: 'failure', project: 'project-a' }); assert.strictEqual(entries.length, 1); assert.strictEqual(entries[0].project, 'project-a'); assert.doesNotMatch(entries[0].content, /Project: project-a/); }); }); describe('reconcileMarkdownMemoryScope', () => { it('keeps explicit global MEMORY and USER scope despite embedded project metadata', () => { const marker = 'c3Bvb2ZlZC1wcm9qZWN0'; reconcileMarkdownMemoryScope( dbManager, [`global memory `], 'memory', null, ); reconcileMarkdownMemoryScope( dbManager, [`global user `], 'user', null, ); assert.deepStrictEqual( getMemories(dbManager).map((entry) => [entry.project, entry.target, entry.content]), [ [null, 'memory', 'global memory'], [null, 'user', 'global user'], ], ); assert.deepStrictEqual(getMemories(dbManager, { project: 'spoofed-project' }), []); }); it('reports a degraded result (not a swallowed success) on FTS5 index errors', () => { // A genuine FTS5 search-index error must not look like a successful // zero-row sync: the result carries degraded + reason so the caller can // surface /memory-sync-markdown repair guidance. const failingDb = { prepare: (sql: string) => ({ get: () => undefined, all: () => [], run: () => { throw new Error('fts5: table memory_fts is corrupted'); }, }), } as never; const stubDbManager = { getDb: () => failingDb, withCorruptionRecovery: (operation: () => T): T => operation(), } as unknown as DatabaseManager; const result = reconcileMarkdownMemoryScope( stubDbManager, ['degraded reconcile test entry'], 'memory', null, ); assert.equal(result.degraded, true); assert.match(result.degradedReason ?? '', /fts5/); assert.equal(result.inserted, 0); }); it('propagates degraded from failure scopes (first reason wins)', () => { // When multiple failure scopes are degraded, the aggregate result must // be degraded with the first reason (not the last) so the user sees the // original failure, not a later duplicate. let callCount = 0; const failingDb = { prepare: (sql: string) => ({ get: () => undefined, all: () => [], run: () => { callCount++; throw new Error(`fts5: error #${callCount}`); }, }), } as never; const stubDbManager = { getDb: () => failingDb, withCorruptionRecovery: (operation: () => T): T => operation(), } as unknown as DatabaseManager; const result = reconcileMarkdownFailureScopes( stubDbManager, [ 'failure entry one — Project: project-a ', 'failure entry two — Project: project-b ', ], ); assert.equal(result.degraded, true); assert.match(result.degradedReason ?? '', /fts5: error #1/); }); it('still propagates corruption errors instead of degrading (recovery must run)', () => { // Corruption signals are NOT FTS5 errors: they must escape so // DatabaseManager quarantines + rebuilds, never a degraded zero-result. const failingDb = { prepare: () => ({ get: () => undefined, all: () => [], run: () => { throw new Error('database disk image is malformed'); }, }), } as never; const stubDbManager = { getDb: () => failingDb, withCorruptionRecovery: (operation: () => T): T => operation(), } as unknown as DatabaseManager; assert.throws( () => reconcileMarkdownMemoryScope(stubDbManager, ['corruption test entry'], 'memory', null), /malformed/, ); }); it('prunes only absent rows in the exact target and project scope', () => { addMemory(dbManager, 'kept global memory', 'memory', null); addMemory(dbManager, 'orphaned global memory', 'memory', null); addMemory(dbManager, 'unrelated global user', 'user', null); addMemory(dbManager, 'unrelated project memory', 'memory', 'project-a'); const first = reconcileMarkdownMemoryScope( dbManager, ['kept global memory '], 'memory', null, ); const second = reconcileMarkdownMemoryScope( dbManager, ['kept global memory '], 'memory', null, ); assert.strictEqual(first.removed, 1); assert.strictEqual(second.removed, 0); assert.deepStrictEqual( getMemories(dbManager).map((entry) => entry.content).sort(), ['kept global memory', 'unrelated global user', 'unrelated project memory'].sort(), ); }); it('removes duplicate and stale-category rows by full Markdown identity', () => { const first = addMemory( dbManager, '[correction] use pnpm', 'failure', 'project-a', 'correction', 'original reason', null, 'pnpm install', '2026-06-01', '2026-07-05', ); addMemory(dbManager, '[correction] use pnpm', 'failure', 'project-a', 'correction'); addMemory(dbManager, '[correction] use pnpm', 'failure', 'project-a', 'tool-quirk'); const result = reconcileMarkdownMemoryScope( dbManager, ['[correction] use pnpm '], 'failure', 'project-a', ); const rows = dbManager.getDb().prepare(` SELECT id, category, failure_reason, corrected_to, created, last_referenced FROM memories WHERE project = 'project-a' AND target = 'failure' `).all() as Array<{ id: number; category: string | null; failure_reason: string | null; corrected_to: string | null; created: string; last_referenced: string; }>; assert.strictEqual(result.removed, 2); assert.deepStrictEqual(rows, [{ id: first.id, category: 'correction', failure_reason: 'original reason', corrected_to: 'pnpm install', created: '2026-06-01', last_referenced: '2026-07-05', }]); }); it('skips an unchanged scope without touching memories', () => { const entries = ['scope one', 'scope two', 'scope three']; const first = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); assert.strictEqual(first.inserted, 3); const ids = getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id); const state = readMdsyncValue(dbManager, 'memory', null); assert.ok(state); const parsed = JSON.parse(state) as { sha256: string; entryCount: number }; assert.strictEqual(parsed.entryCount, 3); assert.match(parsed.sha256, /^[0-9a-f]{64}$/i); const second = withBlockedMemoryWrites(dbManager, () => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null), ); assert.deepStrictEqual(second, { inserted: 0, existing: 3, removed: 0 }); assert.deepStrictEqual( getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id), ids, ); assert.strictEqual(readMdsyncValue(dbManager, 'memory', null), state); }); it('isolates a single-scope change from an untouched sibling scope', () => { const kept = ['kept project memory']; const edited = ['edited project original']; reconcileMarkdownMemoryScope(dbManager, kept, 'memory', 'kept-project'); reconcileMarkdownMemoryScope(dbManager, edited, 'memory', 'edited-project'); const keptIds = getMemories(dbManager, { target: 'memory', project: 'kept-project' }).map((entry) => entry.id); const keptState = readMdsyncValue(dbManager, 'memory', 'kept-project'); const editedResult = reconcileMarkdownMemoryScope( dbManager, ['edited project replacement'], 'memory', 'edited-project', ); const keptResult = withBlockedMemoryWrites(dbManager, () => reconcileMarkdownMemoryScope(dbManager, kept, 'memory', 'kept-project'), ); assert.strictEqual(editedResult.inserted, 1); assert.strictEqual(editedResult.removed, 1); assert.deepStrictEqual(keptResult, { inserted: 0, existing: 1, removed: 0 }); assert.deepStrictEqual( getMemories(dbManager, { target: 'memory', project: 'kept-project' }).map((entry) => entry.id), keptIds, ); assert.strictEqual(readMdsyncValue(dbManager, 'memory', 'kept-project'), keptState); assert.deepStrictEqual( getMemories(dbManager, { target: 'memory', project: 'edited-project' }).map((entry) => entry.content), ['edited project replacement'], ); }); it('reimports when memories are emptied but the fingerprint survives', () => { const entries = ['mirror one', 'mirror two', 'mirror three']; reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); assert.ok(readMdsyncValue(dbManager, 'memory', null)); dbManager.getDb().prepare('DELETE FROM memories').run(); assert.strictEqual(getMemories(dbManager).length, 0); const result = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); assert.strictEqual(result.inserted, 3); assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null }).length, 3); }); it('repopulates a missing fingerprint without inserting existing rows', () => { const entries = ['fingerprint missing one', 'fingerprint missing two']; reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); const ids = getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id); dbManager.getDb().prepare('DELETE FROM extension_metadata WHERE key = ?').run(mdsyncKey('memory', null)); assert.equal(readMdsyncValue(dbManager, 'memory', null), undefined); const result = withBlockedMemoryWrites( dbManager, () => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null), 'insert', ); assert.strictEqual(result.inserted, 0); assert.ok(readMdsyncValue(dbManager, 'memory', null)); assert.deepStrictEqual( getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.id), ids, ); }); it('force-repairs COUNT-preserving sqlite-only drift', () => { const entries = ['canonical markdown content']; reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); dbManager.getDb().prepare(` UPDATE memories SET content = 'drifted sqlite content' WHERE target = 'memory' AND project IS NULL `).run(); const skipped = withBlockedMemoryWrites(dbManager, () => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null), ); assert.deepStrictEqual(skipped, { inserted: 0, existing: 1, removed: 0 }); assert.strictEqual(getMemories(dbManager, { target: 'memory', project: null })[0].content, 'drifted sqlite content'); const forced = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null, { force: true }); assert.strictEqual(forced.inserted, 1); assert.strictEqual(forced.removed, 1); assert.deepStrictEqual( getMemories(dbManager, { target: 'memory', project: null }).map((entry) => entry.content), ['canonical markdown content'], ); const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as { entryCount: number }; assert.strictEqual(rewritten.entryCount, 1); }); it('drops the fingerprint when the force path empties a scope', () => { reconcileMarkdownMemoryScope(dbManager, ['force-emptied entry'], 'memory', 'force-emptied'); assert.ok(readMdsyncValue(dbManager, 'memory', 'force-emptied')); const forced = reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'force-emptied', { force: true }); assert.strictEqual(forced.removed, 1); assert.equal( readMdsyncValue(dbManager, 'memory', 'force-emptied'), undefined, 'the emptied scope must not leave a fingerprint behind', ); }); it('restores search through the force path when the index lost a row', () => { const entries = ['indexed needle entry', 'indexed companion entry']; reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); assert.strictEqual(searchMemories(dbManager, 'needle').length, 1); const lost = dbManager.getDb().prepare( 'SELECT id, content FROM memories WHERE content = ?', ).get('indexed needle entry') as { id: number; content: string }; dbManager.getDb().prepare( "INSERT INTO memory_fts(memory_fts, rowid, content) VALUES ('delete', ?, ?)", ).run(lost.id, lost.content); assert.strictEqual(searchMemories(dbManager, 'needle').length, 0, 'the index gap must hide the entry'); // The fingerprint binds markdown bytes, so an unchanged scope keeps // skipping even though search can no longer see the row. That is the // documented boundary; the forced repair is what has to close it. const skipped = reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); assert.strictEqual(skipped.inserted, 0); assert.strictEqual(searchMemories(dbManager, 'needle').length, 0); reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null, { force: true }); assert.strictEqual(searchMemories(dbManager, 'needle').length, 1, 'force must re-index the scope'); }); it('drops rows and the fingerprint for an emptied project scope', () => { reconcileMarkdownMemoryScope(dbManager, ['doomed project memory'], 'memory', 'doomed-project'); assert.ok(readMdsyncValue(dbManager, 'memory', 'doomed-project')); const firstEmpty = reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'doomed-project'); assert.strictEqual(firstEmpty.removed, 1); assert.deepStrictEqual(getMemories(dbManager, { target: 'memory', project: 'doomed-project' }), []); assert.equal(readMdsyncValue(dbManager, 'memory', 'doomed-project'), undefined); const secondEmpty = withBlockedMemoryWrites(dbManager, () => reconcileMarkdownMemoryScope(dbManager, [], 'memory', 'doomed-project'), ); assert.deepStrictEqual(secondEmpty, { inserted: 0, existing: 0, removed: 0 }); assert.equal(readMdsyncValue(dbManager, 'memory', 'doomed-project'), undefined); }); it('does not write a fingerprint when FTS5 degrades the reconcile', () => { const db = dbManager.getDb() as { prepare: (sql: string) => any }; const originalPrepare = db.prepare.bind(db); db.prepare = (sql: string) => { const stmt = originalPrepare(sql); const normalized = sql.replace(/\s+/g, ' ').trim(); if (/^INSERT(?: OR REPLACE)? INTO memories\b/i.test(normalized) || /^UPDATE memories\b/i.test(normalized)) { return { get: (...args: unknown[]) => stmt.get(...args), all: (...args: unknown[]) => stmt.all(...args), run: () => { throw new Error('fts5: table memory_fts is corrupted'); }, }; } return stmt; }; try { const result = reconcileMarkdownMemoryScope(dbManager, ['degraded fingerprint probe'], 'memory', null); assert.equal(result.degraded, true); assert.equal(readMdsyncValue(dbManager, 'memory', null), undefined); } finally { db.prepare = originalPrepare; } }); it('treats corrupt fingerprint JSON as a miss and rewrites valid state', () => { const entries = ['corrupt-state entry']; reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); dbManager.getDb().prepare( 'UPDATE extension_metadata SET value = ? WHERE key = ?', ).run('{not-json', mdsyncKey('memory', null)); const result = withBlockedMemoryWrites( dbManager, () => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null), 'insert', ); assert.strictEqual(result.inserted, 0); const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as { sha256: string; entryCount: number; }; assert.match(rewritten.sha256, /^[0-9a-f]{64}$/i); assert.strictEqual(rewritten.entryCount, 1); assert.deepStrictEqual(Object.keys(rewritten).sort(), ['entryCount', 'sha256']); }); it('treats extra fingerprint fields as a miss', () => { const entries = ['strict-state entry']; reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null); const current = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as { sha256: string; entryCount: number; }; dbManager.getDb().prepare( 'UPDATE extension_metadata SET value = ? WHERE key = ?', ).run(JSON.stringify({ ...current, extra: true }), mdsyncKey('memory', null)); withBlockedMemoryWrites( dbManager, () => reconcileMarkdownMemoryScope(dbManager, entries, 'memory', null), 'insert', ); const rewritten = JSON.parse(readMdsyncValue(dbManager, 'memory', null)!) as Record; assert.deepStrictEqual(Object.keys(rewritten).sort(), ['entryCount', 'sha256']); }); }); describe('replace/remove synced memories', () => { it('escapes % and _ during replace matching', () => { addMemory(dbManager, 'token 100%_safe value'); addMemory(dbManager, 'token 100XXsafe value'); const result = replaceSyncedMemories(dbManager, '100%_safe', { content: 'token updated literal value', target: 'memory', project: null, }); assert.strictEqual(result.matched, 1); const all = getMemories(dbManager); assert.ok(all.some((entry) => entry.content === 'token updated literal value')); assert.ok(all.some((entry) => entry.content === 'token 100XXsafe value')); }); it('escapes % and _ during remove matching', () => { addMemory(dbManager, 'remove 50%_match literal'); addMemory(dbManager, 'remove 50AAmatch literal'); const result = removeSyncedMemories(dbManager, '50%_match', { target: 'memory', project: null, }); assert.strictEqual(result.matched, 1); const all = getMemories(dbManager); assert.strictEqual(all.length, 1); assert.strictEqual(all[0].content, 'remove 50AAmatch literal'); }); it('normalizes pasted memory_search lines during replace matching', () => { addMemory(dbManager, 'prefers pnpm over npm'); const result = replaceSyncedMemories( dbManager, '🧠 scope=global [target=memory] prefers pnpm over npm\n Created: 2026-05-27 | Last used: 2026-05-27', { content: 'prefers pnpm over npm and bun when needed', target: 'memory', project: null, }, ); assert.strictEqual(result.matched, 1); const all = getMemories(dbManager); assert.ok(all.some((entry) => entry.content === 'prefers pnpm over npm and bun when needed')); }); it('normalizes pasted memory_search lines during remove matching', () => { addMemory(dbManager, '[correction] use pnpm — Failed: npm rewrote the lockfile', 'failure'); const result = removeSyncedMemories( dbManager, '⚠️ [global] [correction] [correction] use pnpm\n Created: 2026-05-27 | Last used: 2026-05-27', { target: 'failure', project: null, }, ); assert.strictEqual(result.matched, 1); const all = getMemories(dbManager); assert.strictEqual(all.length, 0); }); }); describe('searchMemories', () => { beforeEach(() => { addMemory(dbManager, 'prefers pnpm over npm'); addMemory(dbManager, 'uses Prisma with PostgreSQL', 'memory', 'project-a'); addMemory(dbManager, 'debugged gpu timeout issue after driver update'); addMemory(dbManager, 'memory search indexing notes'); addMemory(dbManager, 'exact phrase memory search example'); addMemory(dbManager, 'name: Chandrateja', 'user'); addMemory(dbManager, 'timezone: AEST', 'user'); }); it('degrades an all-stop-word query to the LIKE fallback instead of []', () => { // "the" is a stop word and "and"/"or" connectors, so FTS5 normalization // leaves nothing to match. The query must fall through to the scoped // literal fallback (which still searches the raw terms) rather than // hard-return an empty result set. addMemory(dbManager, 'the build passes after restart'); const results = searchMemories(dbManager, 'the and or'); assert.ok(results.length > 0, 'all-stop-word query must reach the LIKE fallback, not []'); assert.ok(results.some(r => r.content.includes('the'))); }); it('should find memories by keyword', () => { const results = searchMemories(dbManager, 'pnpm'); assert.ok(results.length > 0); assert.ok(results.some(r => r.content.includes('pnpm'))); }); it('should find memories by partial content', () => { const results = searchMemories(dbManager, 'Prisma'); assert.ok(results.length > 0); assert.ok(results.some(r => r.content.includes('Prisma'))); }); it('returns only project-attributed memories for target "project"', () => { const results = searchMemories(dbManager, 'Prisma', { target: 'project' }); assert.ok(results.length > 0); assert.ok(results.every((r) => r.project === 'project-a')); assert.ok(results.every((r) => r.target === 'memory')); }); it('keeps target "memory" matching both global and project-attributed memories', () => { const global = searchMemories(dbManager, 'pnpm', { target: 'memory' }); const project = searchMemories(dbManager, 'Prisma', { target: 'memory' }); assert.ok(global.some((r) => r.project === null)); assert.ok(project.length > 0); assert.ok(project.every((r) => r.project === 'project-a')); }); it('combines target "project" with an explicit project filter', () => { addMemory(dbManager, 'uses Bun with PostgreSQL', 'memory', 'project-b'); const named = searchMemories(dbManager, 'PostgreSQL', { target: 'project', project: 'project-b' }); const anyProject = searchMemories(dbManager, 'PostgreSQL', { target: 'project' }); const globalOnly = searchMemories(dbManager, 'PostgreSQL', { target: 'project', project: null }); assert.ok(named.length > 0); assert.ok(named.every((r) => r.project === 'project-b')); assert.ok(anyProject.some((r) => r.project === 'project-a')); assert.strictEqual(globalOnly.length, 0); }); it('finds pure CJK substrings with the trigram tokenizer', () => { addMemory(dbManager, '设备清单包含 NAS 和备份策略'); const results = searchMemories(dbManager, '设备清单'); assert.ok(results.length > 0); assert.ok(results.some((result) => result.content.includes('设备清单'))); }); it('retains English token searches in the trigram index', () => { addMemory(dbManager, '设备清单包含 NAS 和备份策略'); const results = searchMemories(dbManager, 'NAS'); assert.ok(results.length > 0); assert.ok(results.some((result) => result.content.includes('NAS'))); }); it('falls back to a scoped literal search for one- and two-character CJK queries', () => { addMemory(dbManager, '设备清单包含 NAS 和备份策略'); const results = searchMemories(dbManager, '设备'); assert.ok(results.some((result) => result.content.includes('设备清单'))); }); it('applies target "project" in the CJK literal fallback path', () => { addMemory(dbManager, '项目约定使用 pnpm', 'memory', 'project-a'); addMemory(dbManager, '项目全局笔记'); const results = searchMemories(dbManager, '项目', { target: 'project' }); assert.ok(results.length > 0); assert.ok(results.every((r) => r.project === 'project-a')); assert.ok(results.every((r) => r.target === 'memory')); }); it('should match multi-word queries without requiring an exact phrase', () => { const results = searchMemories(dbManager, 'gpu issue'); assert.ok(results.length > 0); assert.ok(results.some((r) => r.content.includes('gpu timeout issue'))); }); it('should ignore lowercase connector words in natural-language queries', () => { const results = searchMemories(dbManager, 'gpu and issue'); assert.ok(results.length > 0); assert.ok(results.some((r) => r.content.includes('gpu timeout issue'))); }); it('should fall back to broader natural-language matching when strict term matching misses', () => { addMemory(dbManager, "user's name is Naruto", 'user'); const results = searchMemories(dbManager, 'name identity Naruto', { target: 'user' }); assert.ok(results.length > 0); assert.ok(results.some((r) => r.content.includes("Naruto"))); }); it('should not broaden explicit operator queries', () => { const results = searchMemories(dbManager, 'pnpm AND nonexistent'); assert.strictEqual(results.length, 0); }); it('should preserve explicit quoted phrase searches', () => { const results = searchMemories(dbManager, '"memory search"'); assert.ok(results.length > 0); assert.ok(results.every((r) => r.content.includes('memory search'))); }); it('should recover natural-language queries with uppercase operator words and punctuation', () => { addMemory(dbManager, 'Never search whole filesystem from root. Do not run find /.', 'user'); const results = searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' }); assert.ok(results.length > 0); assert.ok(results.some((r) => r.content.includes('find'))); }); it('should preserve valid operator queries', () => { const results = searchMemories(dbManager, 'pnpm OR AEST'); assert.ok(results.length >= 2); assert.ok(results.some((r) => r.content.includes('pnpm'))); assert.ok(results.some((r) => r.content.includes('AEST'))); }); it('should limit results', () => { const results = searchMemories(dbManager, 'pnpm OR Prisma OR AEST', { limit: 2 }); assert.ok(results.length <= 2); }); it('should filter by project', () => { const results = searchMemories(dbManager, 'Prisma', { project: 'project-a' }); assert.ok(results.length > 0); assert.ok(results.every(r => r.project === 'project-a')); }); it('should filter by target', () => { const results = searchMemories(dbManager, 'Chandrateja OR AEST', { target: 'user' }); assert.ok(results.length > 0); assert.ok(results.every(r => r.target === 'user')); }); it('should return empty for no matches', () => { const results = searchMemories(dbManager, 'nonexistent-xyz'); assert.strictEqual(results.length, 0); }); it('should return empty for blank queries', () => { assert.deepStrictEqual(searchMemories(dbManager, ' '), []); }); it('should not throw on unmatched quotes', () => { assert.doesNotThrow(() => { const results = searchMemories(dbManager, 'issue "timeout'); assert.ok(Array.isArray(results)); }); }); it('should return empty for malformed operator queries', () => { const results = searchMemories(dbManager, 'AND OR NOT'); assert.strictEqual(results.length, 0); }); it('ranks a dense old match above a fresher, more diluted one (BM25 before recency)', () => { addMemory(dbManager, 'deploy target is the staging cluster'); addMemory( dbManager, 'unrelated retrospective notes that mention deploy once among a great many other words about hiring, budgets, roadmaps, vendor calls, office moves and quarterly planning for the staging of a conference' ); const db = dbManager.getDb(); db.prepare("UPDATE memories SET last_referenced = '2025-01-01' WHERE content LIKE 'deploy target%'").run(); db.prepare("UPDATE memories SET last_referenced = '2026-08-30' WHERE content LIKE 'unrelated retrospective%'").run(); const results = searchMemories(dbManager, 'deploy staging'); // Both rows match; recency ordering would put the long fresh note first. assert.strictEqual(results.length, 2); assert.ok(results[0].content.startsWith('deploy target is the staging cluster')); }); it('still broadens a two-term natural-language query through the OR fallback', () => { // Neither "dark" nor "chocolate" co-occur, so the implicit-AND query // misses and only the OR fallback can recover this row. The existing // fallback test uses three terms; this pins the two-term case. addMemory(dbManager, 'user prefers dark mode UI theme', 'user'); const results = searchMemories(dbManager, 'dark chocolate', { target: 'user' }); assert.ok(results.some((r) => r.content.includes('dark mode'))); }); it('recovers a natural-language query whose raw FTS5 form fails to parse', () => { // "DO NOT USE FIND /" passes through as raw FTS5 syntax and throws; // runSearch must catch that and let the natural-language retry run. addMemory(dbManager, 'Never search whole filesystem from root. Do not run find /.', 'user'); assert.doesNotThrow(() => searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' })); const results = searchMemories(dbManager, 'DO NOT USE FIND /', { target: 'user' }); assert.ok(results.some((r) => r.content.includes('find /'))); }); }); describe('getMemories', () => { beforeEach(() => { addMemory(dbManager, 'global memory 1'); addMemory(dbManager, 'global memory 2'); addMemory(dbManager, 'project memory', 'memory', 'project-a'); addMemory(dbManager, 'user preference', 'user'); }); it('should return all memories', () => { const results = getMemories(dbManager); assert.strictEqual(results.length, 4); }); it('should filter by project', () => { const results = getMemories(dbManager, { project: 'project-a' }); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].content, 'project memory'); }); it('should filter by null project (global)', () => { const results = getMemories(dbManager, { project: null }); assert.strictEqual(results.length, 3); }); it('should filter by target', () => { const results = getMemories(dbManager, { target: 'user' }); assert.strictEqual(results.length, 1); assert.strictEqual(results[0].content, 'user preference'); }); }); describe('removeMemory', () => { it('should remove a memory by id', () => { const entry = addMemory(dbManager, 'to be removed'); const removed = removeMemory(dbManager, entry.id); assert.strictEqual(removed, true); const all = getMemories(dbManager); assert.strictEqual(all.length, 0); }); it('should return false for non-existent id', () => { const removed = removeMemory(dbManager, 99999); assert.strictEqual(removed, false); }); }); describe('touchMemory', () => { it('should update last_referenced date', () => { const entry = addMemory(dbManager, 'old memory'); // Manually set an old date const db = dbManager.getDb(); db.prepare('UPDATE memories SET last_referenced = ? WHERE id = ?').run('2020-01-01', entry.id); touchMemory(dbManager, entry.id); const updated = db.prepare('SELECT last_referenced FROM memories WHERE id = ?').get(entry.id) as { last_referenced: string }; const today = new Date().toISOString().split('T')[0]; assert.strictEqual(updated.last_referenced, today); }); }); describe('getMemoryStats', () => { it('should return zero stats for empty database', () => { const stats = getMemoryStats(dbManager); assert.strictEqual(stats.total, 0); assert.deepStrictEqual(stats.byProject, []); assert.deepStrictEqual(stats.byTarget, []); }); it('should return correct stats', () => { addMemory(dbManager, 'global 1'); addMemory(dbManager, 'global 2'); addMemory(dbManager, 'project memory', 'memory', 'project-a'); addMemory(dbManager, 'user pref', 'user'); const stats = getMemoryStats(dbManager); assert.strictEqual(stats.total, 4); assert.strictEqual(stats.byTarget.length, 2); assert.ok(stats.byProject.length > 0); }); }); });