import { afterEach, describe, it } from 'node:test'; import * as assert from 'node:assert/strict'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { DatabaseManager } from '../../src/store/db.js'; import { addMemory, getMemoryStats, getMemoryUsageSignals, searchMemories } from '../../src/store/sqlite-memory-store.js'; import { normalizeMemoryLookupText } from '../../src/store/memory-lookup.js'; import { registerMemorySearchTool } from '../../src/tools/memory-search-tool.js'; import { MEMORY_SEARCH_REQUEST_EVENT } from '../../src/memory-search-bridge.js'; let ROOT_DIR = ''; afterEach(() => { if (ROOT_DIR) fs.rmSync(ROOT_DIR, { recursive: true, force: true }); ROOT_DIR = ''; }); function makeDbManager(): DatabaseManager { ROOT_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-memory-search-tool-test-')); return new DatabaseManager(ROOT_DIR); } describe('registerMemorySearchTool', () => { it('returns a broader natural-language match when strict term matching misses', async () => { const dbManager = makeDbManager(); addMemory(dbManager, "user's name is Naruto", 'user'); let captured: any; const mockPi = { registerTool: (def: any) => { captured = def; }, } as any; registerMemorySearchTool(mockPi, dbManager); const result = await captured.execute('tc-1', { query: 'name identity Naruto', target: 'user' }); assert.strictEqual(result.details.success, true); assert.strictEqual(result.details.count, 1); assert.match(result.content[0].text, /Naruto/); dbManager.close(); }); it('labels every result with its mutation target and unambiguous scope', async () => { const dbManager = makeDbManager(); addMemory(dbManager, 'global deployment convention'); addMemory(dbManager, 'user deployment preference', 'user'); addMemory(dbManager, 'failure deployment lesson', 'failure'); addMemory(dbManager, 'project deployment convention', 'memory', 'project-a'); addMemory(dbManager, 'project failure deployment lesson', 'failure', 'project-a'); let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager); const result = await captured.execute('tc-1', { query: 'deployment' }); const text = result.content[0].text; assert.match(text, /scope=global \[target=memory\] global deployment convention/); assert.match(text, /scope=global \[target=user\] user deployment preference/); assert.match(text, /scope=global \[target=failure\] failure deployment lesson/); assert.match(text, /scope=project:project-a \[target=project\] project deployment convention/); assert.match(text, /scope=project:project-a \[target=failure\] project failure deployment lesson/); dbManager.close(); }); it('accepts target "project" as a filter and shows the schema value', async () => { const dbManager = makeDbManager(); addMemory(dbManager, 'project deployment convention', 'memory', 'project-a'); addMemory(dbManager, 'global deployment convention'); addMemory(dbManager, 'project failure deployment lesson', 'failure', 'project-a'); let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager); assert.ok(captured.parameters.properties.target.enum.includes('project')); const result = await captured.execute('tc-1', { query: 'deployment', target: 'project' }); const text = result.content[0].text; assert.strictEqual(result.details.success, true); assert.strictEqual(result.details.count, 1); assert.match(text, /scope=project:project-a \[target=project\]/); assert.doesNotMatch(text, /scope=global/); dbManager.close(); }); it('keeps copied results reversible when a project name contains brackets', async () => { const dbManager = makeDbManager(); addMemory(dbManager, 'literal project entry', 'memory', 'foo] bar'); let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager); const result = await captured.execute('tc-1', { query: 'literal' }); const firstResultLine = result.content[0].text.split('\n').find((line: string) => line.startsWith('🧠'))!; assert.match(firstResultLine, /scope=project:foo%5D%20bar \[target=project\]/); assert.equal(normalizeMemoryLookupText(firstResultLine), 'literal project entry'); dbManager.close(); }); it('exposes structured entries with real store ids in the same order as the formatted output', async () => { const dbManager = makeDbManager(); const projectEntry = addMemory(dbManager, 'project deployment convention', 'memory', 'project-a', 'convention'); const failureEntry = addMemory(dbManager, 'project failure deployment lesson', 'failure', 'project-a', 'failure'); const userEntry = addMemory(dbManager, 'user deployment preference', 'user'); let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager); // Same query the tool issues, taken before the call records usage hits. const storeRows = searchMemories(dbManager, 'deployment', { limit: 10 }); const result = await captured.execute('tc-1', { query: 'deployment' }); const details = result.details; assert.ok(Array.isArray(details.entries)); assert.strictEqual(details.entries.length, details.count); assert.deepStrictEqual(details.entries.map((entry: any) => entry.id), storeRows.map((entry) => entry.id)); const knownIds = new Set([projectEntry.id, failureEntry.id, userEntry.id]); for (const entry of details.entries) { assert.strictEqual(typeof entry.id, 'number'); assert.ok(knownIds.has(entry.id), `entry id ${entry.id} must be a real store id`); assert.ok(['memory', 'user', 'failure', 'project'].includes(entry.target)); assert.strictEqual(typeof entry.content, 'string'); assert.strictEqual(typeof entry.created, 'string'); assert.strictEqual(typeof entry.lastReferenced, 'string'); } // The displayed target is the mutation target, matching the text output. const byContent = new Map(details.entries.map((entry: any) => [entry.content, entry])); assert.strictEqual(byContent.get('project deployment convention').target, 'project'); assert.strictEqual(byContent.get('project deployment convention').project, 'project-a'); assert.strictEqual(byContent.get('project deployment convention').category, 'convention'); assert.strictEqual(byContent.get('project failure deployment lesson').target, 'failure'); assert.strictEqual(byContent.get('user deployment preference').target, 'user'); assert.strictEqual(byContent.get('user deployment preference').category, undefined); // Entries keep the exact order of the formatted text. let cursor = -1; for (const entry of details.entries) { const at = details.output.indexOf(entry.content, cursor + 1); assert.ok(at > cursor, `entry out of order in output: ${entry.content}`); cursor = at; } dbManager.close(); }); it('keeps structured entries inside the requested project scope', async () => { const dbManager = makeDbManager(); addMemory(dbManager, 'deployment convention alpha', 'memory', 'project-a'); addMemory(dbManager, 'deployment convention beta', 'memory', 'project-b'); let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager); const result = await captured.execute('tc-1', { query: 'deployment', project: 'project-a' }); assert.strictEqual(result.details.count, 1); assert.strictEqual(result.details.entries.length, 1); assert.strictEqual(result.details.entries[0].project, 'project-a'); assert.match(result.details.entries[0].content, /alpha/); assert.doesNotMatch(result.details.output, /beta/); dbManager.close(); }); it('omits structured entries when the store is empty', async () => { const dbManager = makeDbManager(); let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager); const result = await captured.execute('tc-1', { query: 'anything' }); assert.strictEqual(result.details.success, false); assert.strictEqual(result.details.entries, undefined); dbManager.close(); }); it('serves one local read-only lookup through the extension bridge', () => { const dbManager = makeDbManager(); addMemory(dbManager, 'project deployment convention', 'memory', 'project-a'); let handler: ((request: unknown) => void) | undefined; registerMemorySearchTool({ registerTool: () => {}, events: { on: (channel: string, listener: (request: unknown) => void) => { if (channel === MEMORY_SEARCH_REQUEST_EVENT) handler = listener; } }, } as any, dbManager); let response: any; handler!({ input: { query: 'deployment', target: 'project', project: 'project-a', limit: 5 }, respond: (result: unknown) => { response = result; }, }); assert.equal(response.success, true); assert.equal(response.count, 1); assert.match(response.output, /project deployment convention/); assert.equal(response.entries.length, 1); assert.equal(typeof response.entries[0].id, 'number'); assert.equal(response.entries[0].project, 'project-a'); assert.equal(response.entries[0].target, 'project'); assert.equal(getMemoryUsageSignals(dbManager, { target: 'memory', project: 'project-a' }).size, 0); assert.equal(getMemoryStats(dbManager).total, 1); dbManager.close(); }); it('records a usage hit for every returned entry', async () => { const dbManager = makeDbManager(); addMemory(dbManager, 'recallable volcano note', 'memory'); addMemory(dbManager, 'recallable tide note', 'memory'); let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager); await captured.execute('tc-1', { query: 'recallable' }); await captured.execute('tc-2', { query: 'volcano' }); const signals = getMemoryUsageSignals(dbManager, { target: 'memory', project: null }); assert.strictEqual(signals.get('recallable volcano note')?.hits, 2); assert.strictEqual(signals.get('recallable tide note')?.hits, 1); dbManager.close(); }); it('records nothing when the search returns no results', async () => { const dbManager = makeDbManager(); addMemory(dbManager, 'unmatched entry', 'memory'); let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, dbManager); const result = await captured.execute('tc-1', { query: 'zzz-no-match-q' }); assert.strictEqual(result.details.count, 0); assert.strictEqual(result.details.entries, undefined); assert.strictEqual(getMemoryUsageSignals(dbManager, { target: 'memory', project: null }).size, 0); dbManager.close(); }); it('skips recording when usage tracking is disabled', async () => { const dbManager = makeDbManager(); addMemory(dbManager, 'tracked-off entry', 'memory'); let captured: any; registerMemorySearchTool( { registerTool: (def: any) => { captured = def; } } as any, dbManager, { usageTrackingEnabled: false }, ); const result = await captured.execute('tc-1', { query: 'tracked-off' }); assert.strictEqual(result.details.count, 1); assert.strictEqual(getMemoryUsageSignals(dbManager, { target: 'memory', project: null }).size, 0); dbManager.close(); }); it('never breaks the search when hit recording fails (fail-open)', async () => { const dbManager = makeDbManager(); addMemory(dbManager, 'resilient entry', 'memory'); let getDbCalls = 0; const realGetDb = dbManager.getDb.bind(dbManager); const flaky = Object.create(dbManager) as DatabaseManager; flaky.getDb = () => { getDbCalls++; if (getDbCalls > 2) throw new Error('injected recording failure'); return realGetDb(); }; let captured: any; registerMemorySearchTool({ registerTool: (def: any) => { captured = def; } } as any, flaky); // getDb calls: getMemoryStats(1) + searchMemories(1) + recordSearchHits(1, throws). const result = await captured.execute('tc-1', { query: 'resilient' }); assert.strictEqual(result.details.success, true); assert.strictEqual(result.details.count, 1); assert.match(result.content[0].text, /resilient entry/); // The hit did NOT land (recording failed) but the search succeeded. assert.strictEqual(getMemoryUsageSignals(dbManager, { target: 'memory', project: null }).size, 0); dbManager.close(); }); });