Skip to content

Commit 019e499

Browse files
author
StackMemory Bot (CLI)
committed
feat(cross-search): multi-database frame search across projects (STA-480)
- Add CrossProjectSearch engine with FTS5/BM25 ranking across N databases - Project registry (~/.stackmemory/projects.json) with CRUD + auto-discovery - Read-only SQLite connections for safety, LIKE fallback for non-FTS databases - 4 MCP tools: sm_cross_search, sm_cross_discover, sm_cross_register, sm_cross_list - CLI: `stackmemory search --all-projects "query"` for cross-project search - 17 tests: registry CRUD, multi-db FTS5 search, ranking, LIKE fallback, graceful skip
1 parent fd74c33 commit 019e499

10 files changed

Lines changed: 2151 additions & 20 deletions

File tree

src/cli/commands/search.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Database from 'better-sqlite3';
88
import { join } from 'path';
99
import { existsSync } from 'fs';
1010
import { z } from 'zod';
11+
import { CrossProjectSearch } from '../../core/cross-search/cross-project-search.js';
1112

1213
/** Raw task row from task_cache table */
1314
interface TaskRow {
@@ -58,6 +59,10 @@ export function createSearchCommand(): Command {
5859
.argument('<query>', 'Search query')
5960
.option('-t, --tasks', 'Search only tasks')
6061
.option('-c, --context', 'Search only context')
62+
.option(
63+
'-a, --all-projects',
64+
'Search across all registered project databases'
65+
)
6166
.option('-l, --limit <n>', 'Limit results', '20')
6267
.action(async (rawQuery, options) => {
6368
const projectRoot = process.cwd();
@@ -86,6 +91,40 @@ export function createSearchCommand(): Command {
8691
return;
8792
}
8893

94+
// Cross-project search mode
95+
if (options.allProjects) {
96+
console.log(
97+
`\n🔍 Searching across all projects for "${rawQuery}"...\n`
98+
);
99+
const crossSearch = new CrossProjectSearch();
100+
const results = await crossSearch.search({
101+
query: rawQuery,
102+
limit,
103+
});
104+
105+
if (results.length === 0) {
106+
console.log('No results found across project databases.\n');
107+
console.log(
108+
'Tip: Run "stackmemory search --all-projects" after "stackmemory projects scan" to discover databases.'
109+
);
110+
return;
111+
}
112+
113+
console.log(`📁 Cross-Project Results (${results.length})\n`);
114+
for (const r of results) {
115+
const date = new Date(r.createdAt).toLocaleDateString();
116+
console.log(
117+
` [${r.projectName}] ${r.name} (${r.type}, score: ${r.score.toFixed(3)})`
118+
);
119+
if (r.digestText) {
120+
console.log(` ${r.digestText.slice(0, 100)}`);
121+
}
122+
console.log(` ${date} | ${r.projectPath}`);
123+
}
124+
console.log(`\nFound ${results.length} results.\n`);
125+
return;
126+
}
127+
89128
const db = new Database(dbPath);
90129
const searchTasks = !options.context || options.tasks;
91130
const searchContext = !options.tasks || options.context;
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
/**
2+
* Tests for Cross-Project Search
3+
* Tests project registry CRUD and cross-database FTS5 search
4+
*/
5+
6+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
7+
import { CrossProjectSearch } from '../cross-project-search.js';
8+
import { SQLiteAdapter } from '../../database/sqlite-adapter.js';
9+
import * as fs from 'fs';
10+
import * as path from 'path';
11+
import * as os from 'os';
12+
13+
describe('CrossProjectSearch', () => {
14+
let tmpDir: string;
15+
let crossSearch: CrossProjectSearch;
16+
17+
beforeEach(() => {
18+
tmpDir = fs.mkdtempSync(
19+
path.join(os.tmpdir(), 'stackmemory-cross-search-')
20+
);
21+
crossSearch = new CrossProjectSearch(tmpDir);
22+
});
23+
24+
afterEach(() => {
25+
try {
26+
fs.rmSync(tmpDir, { recursive: true });
27+
} catch {
28+
// cleanup best-effort
29+
}
30+
});
31+
32+
describe('Project Registry CRUD', () => {
33+
it('should start with empty registry', () => {
34+
const projects = crossSearch.listProjects();
35+
expect(projects).toEqual([]);
36+
});
37+
38+
it('should register a project', () => {
39+
crossSearch.registerProject({
40+
name: 'test-project',
41+
path: '/tmp/test-project',
42+
dbPath: '/tmp/test-project/.stackmemory/context.db',
43+
lastAccessed: Date.now(),
44+
});
45+
46+
const projects = crossSearch.listProjects();
47+
expect(projects).toHaveLength(1);
48+
expect(projects[0].name).toBe('test-project');
49+
});
50+
51+
it('should update existing project on re-register with same path', () => {
52+
const entry = {
53+
name: 'test-project',
54+
path: '/tmp/test-project',
55+
dbPath: '/tmp/test-project/.stackmemory/context.db',
56+
lastAccessed: 1000,
57+
};
58+
59+
crossSearch.registerProject(entry);
60+
crossSearch.registerProject({ ...entry, lastAccessed: 2000 });
61+
62+
const projects = crossSearch.listProjects();
63+
expect(projects).toHaveLength(1);
64+
expect(projects[0].lastAccessed).toBe(2000);
65+
});
66+
67+
it('should unregister a project by path', () => {
68+
crossSearch.registerProject({
69+
name: 'a',
70+
path: '/tmp/a',
71+
dbPath: '/tmp/a/.stackmemory/context.db',
72+
lastAccessed: Date.now(),
73+
});
74+
crossSearch.registerProject({
75+
name: 'b',
76+
path: '/tmp/b',
77+
dbPath: '/tmp/b/.stackmemory/context.db',
78+
lastAccessed: Date.now(),
79+
});
80+
81+
const removed = crossSearch.unregisterProject('/tmp/a');
82+
expect(removed).toBe(true);
83+
expect(crossSearch.listProjects()).toHaveLength(1);
84+
expect(crossSearch.listProjects()[0].name).toBe('b');
85+
});
86+
87+
it('should unregister a project by name', () => {
88+
crossSearch.registerProject({
89+
name: 'my-app',
90+
path: '/tmp/my-app',
91+
dbPath: '/tmp/my-app/.stackmemory/context.db',
92+
lastAccessed: Date.now(),
93+
});
94+
95+
const removed = crossSearch.unregisterProject('my-app');
96+
expect(removed).toBe(true);
97+
expect(crossSearch.listProjects()).toHaveLength(0);
98+
});
99+
100+
it('should return false when unregistering non-existent project', () => {
101+
const removed = crossSearch.unregisterProject('ghost');
102+
expect(removed).toBe(false);
103+
});
104+
105+
it('should persist registry to disk', () => {
106+
crossSearch.registerProject({
107+
name: 'persisted',
108+
path: '/tmp/persisted',
109+
dbPath: '/tmp/persisted/.stackmemory/context.db',
110+
lastAccessed: Date.now(),
111+
});
112+
113+
// Load from disk in a new instance
114+
const crossSearch2 = new CrossProjectSearch(tmpDir);
115+
const projects = crossSearch2.listProjects();
116+
expect(projects).toHaveLength(1);
117+
expect(projects[0].name).toBe('persisted');
118+
});
119+
});
120+
121+
describe('Cross-Database Search', () => {
122+
let projectADir: string;
123+
let projectBDir: string;
124+
let adapterA: SQLiteAdapter;
125+
let adapterB: SQLiteAdapter;
126+
127+
beforeEach(async () => {
128+
// Create two project databases with frames
129+
projectADir = path.join(tmpDir, 'project-a', '.stackmemory');
130+
projectBDir = path.join(tmpDir, 'project-b', '.stackmemory');
131+
fs.mkdirSync(projectADir, { recursive: true });
132+
fs.mkdirSync(projectBDir, { recursive: true });
133+
134+
const dbPathA = path.join(projectADir, 'context.db');
135+
const dbPathB = path.join(projectBDir, 'context.db');
136+
137+
adapterA = new SQLiteAdapter('project-a', { dbPath: dbPathA });
138+
adapterB = new SQLiteAdapter('project-b', { dbPath: dbPathB });
139+
140+
await adapterA.connect();
141+
await adapterA.initializeSchema();
142+
await adapterB.connect();
143+
await adapterB.initializeSchema();
144+
145+
// Populate project A
146+
await adapterA.createFrame({
147+
run_id: 'run-a1',
148+
project_id: 'project-a',
149+
type: 'task',
150+
name: 'authentication login flow',
151+
digest_text: 'implements JWT-based auth with refresh tokens',
152+
});
153+
await adapterA.createFrame({
154+
run_id: 'run-a1',
155+
project_id: 'project-a',
156+
type: 'debug',
157+
name: 'fix database migration',
158+
digest_text: 'resolved foreign key constraint on users table',
159+
});
160+
161+
// Populate project B
162+
await adapterB.createFrame({
163+
run_id: 'run-b1',
164+
project_id: 'project-b',
165+
type: 'task',
166+
name: 'authentication OAuth integration',
167+
digest_text: 'added Google and GitHub OAuth providers',
168+
});
169+
await adapterB.createFrame({
170+
run_id: 'run-b1',
171+
project_id: 'project-b',
172+
type: 'task',
173+
name: 'API rate limiting',
174+
digest_text: 'token bucket algorithm for API endpoints',
175+
});
176+
177+
await adapterA.disconnect();
178+
await adapterB.disconnect();
179+
180+
// Register both projects
181+
crossSearch.registerProject({
182+
name: 'project-a',
183+
path: path.join(tmpDir, 'project-a'),
184+
dbPath: dbPathA,
185+
lastAccessed: Date.now(),
186+
});
187+
crossSearch.registerProject({
188+
name: 'project-b',
189+
path: path.join(tmpDir, 'project-b'),
190+
dbPath: dbPathB,
191+
lastAccessed: Date.now(),
192+
});
193+
});
194+
195+
it('should search across multiple databases with FTS5', async () => {
196+
const results = await crossSearch.search({ query: 'authentication' });
197+
198+
expect(results.length).toBe(2);
199+
// Both projects should have auth-related results
200+
const projectNames = results.map((r) => r.projectName);
201+
expect(projectNames).toContain('project-a');
202+
expect(projectNames).toContain('project-b');
203+
});
204+
205+
it('should rank results by BM25 score', async () => {
206+
const results = await crossSearch.search({ query: 'authentication' });
207+
208+
// Results should be sorted by score descending
209+
for (let i = 1; i < results.length; i++) {
210+
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
211+
}
212+
});
213+
214+
it('should search with term matching in digest_text', async () => {
215+
const results = await crossSearch.search({ query: 'OAuth' });
216+
217+
expect(results.length).toBeGreaterThanOrEqual(1);
218+
expect(results[0].projectName).toBe('project-b');
219+
});
220+
221+
it('should respect limit parameter', async () => {
222+
const results = await crossSearch.search({
223+
query: 'authentication',
224+
limit: 1,
225+
});
226+
227+
expect(results.length).toBe(1);
228+
});
229+
230+
it('should exclude a project when specified', async () => {
231+
const results = await crossSearch.search({
232+
query: 'authentication',
233+
excludeProject: 'project-a',
234+
});
235+
236+
expect(results.length).toBe(1);
237+
expect(results[0].projectName).toBe('project-b');
238+
});
239+
240+
it('should return empty array when no matches', async () => {
241+
const results = await crossSearch.search({
242+
query: 'xyznonexistent123',
243+
});
244+
245+
expect(results).toEqual([]);
246+
});
247+
248+
it('should skip missing databases gracefully', async () => {
249+
crossSearch.registerProject({
250+
name: 'ghost',
251+
path: '/tmp/nonexistent',
252+
dbPath: '/tmp/nonexistent/.stackmemory/context.db',
253+
lastAccessed: Date.now(),
254+
});
255+
256+
// Should not throw, just skip the missing db
257+
const results = await crossSearch.search({ query: 'authentication' });
258+
expect(results.length).toBe(2);
259+
});
260+
261+
it('should return empty array with no registered projects', async () => {
262+
const emptySearch = new CrossProjectSearch(
263+
fs.mkdtempSync(path.join(os.tmpdir(), 'empty-'))
264+
);
265+
const results = await emptySearch.search({ query: 'test' });
266+
expect(results).toEqual([]);
267+
});
268+
269+
it('should include project metadata in results', async () => {
270+
const results = await crossSearch.search({ query: 'migration' });
271+
272+
expect(results.length).toBeGreaterThanOrEqual(1);
273+
const result = results[0];
274+
expect(result.projectName).toBeDefined();
275+
expect(result.projectPath).toBeDefined();
276+
expect(result.frameId).toBeDefined();
277+
expect(result.name).toBeDefined();
278+
expect(result.type).toBeDefined();
279+
expect(typeof result.score).toBe('number');
280+
expect(typeof result.createdAt).toBe('number');
281+
});
282+
});
283+
284+
describe('LIKE fallback', () => {
285+
let projectDir: string;
286+
287+
beforeEach(async () => {
288+
// Create a database without FTS5 table
289+
projectDir = path.join(tmpDir, 'no-fts', '.stackmemory');
290+
fs.mkdirSync(projectDir, { recursive: true });
291+
const dbPath = path.join(projectDir, 'context.db');
292+
293+
// Manually create a minimal frames table without FTS
294+
const Database = (await import('better-sqlite3')).default;
295+
const db = new Database(dbPath);
296+
db.exec(`
297+
CREATE TABLE frames (
298+
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
299+
frame_id TEXT UNIQUE,
300+
run_id TEXT,
301+
project_id TEXT,
302+
type TEXT DEFAULT 'task',
303+
name TEXT DEFAULT '',
304+
state TEXT DEFAULT 'active',
305+
depth INTEGER DEFAULT 0,
306+
inputs TEXT DEFAULT '{}',
307+
outputs TEXT DEFAULT '{}',
308+
digest_text TEXT DEFAULT '',
309+
digest_json TEXT DEFAULT '{}',
310+
created_at INTEGER DEFAULT 0
311+
);
312+
INSERT INTO frames (frame_id, name, type, state, digest_text, inputs, created_at)
313+
VALUES ('f1', 'fallback test frame', 'task', 'active', 'should be found via LIKE', '{}', 1000);
314+
`);
315+
db.close();
316+
317+
crossSearch.registerProject({
318+
name: 'no-fts-project',
319+
path: path.join(tmpDir, 'no-fts'),
320+
dbPath,
321+
lastAccessed: Date.now(),
322+
});
323+
});
324+
325+
it('should fall back to LIKE search when FTS5 table is absent', async () => {
326+
const results = await crossSearch.search({ query: 'fallback' });
327+
328+
expect(results.length).toBe(1);
329+
expect(results[0].name).toBe('fallback test frame');
330+
expect(results[0].projectName).toBe('no-fts-project');
331+
});
332+
});
333+
});

0 commit comments

Comments
 (0)