feat(index): add sqlite index with fts5 search
This commit is contained in:
parent
c5af4c7b6a
commit
2b9e409b8b
111
src/core/index.ts
Normal file
111
src/core/index.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { mkdirSync } from 'fs';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
import { getIndexPath } from './paths.js';
|
||||||
|
|
||||||
|
export interface IndexEntry {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
filePath: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
status?: string;
|
||||||
|
role?: string;
|
||||||
|
assignedTo?: string;
|
||||||
|
tags?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Index {
|
||||||
|
private db: Database.Database;
|
||||||
|
|
||||||
|
constructor(cwd: string) {
|
||||||
|
const path = getIndexPath(cwd);
|
||||||
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
|
this.db = new Database(path);
|
||||||
|
this.db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
filePath TEXT NOT NULL,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
status TEXT,
|
||||||
|
role TEXT,
|
||||||
|
assignedTo TEXT,
|
||||||
|
tags TEXT
|
||||||
|
);
|
||||||
|
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(id, title, content);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert(entry: IndexEntry): void {
|
||||||
|
const params = {
|
||||||
|
status: null,
|
||||||
|
role: null,
|
||||||
|
assignedTo: null,
|
||||||
|
tags: null,
|
||||||
|
...entry,
|
||||||
|
};
|
||||||
|
|
||||||
|
const insert = this.db.prepare(`
|
||||||
|
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags)
|
||||||
|
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
type=@type, title=@title, content=@content, filePath=@filePath,
|
||||||
|
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
|
||||||
|
assignedTo=@assignedTo, tags=@tags
|
||||||
|
`);
|
||||||
|
insert.run(params);
|
||||||
|
|
||||||
|
const search = this.db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO search (id, title, content) VALUES (@id, @title, @content)
|
||||||
|
`);
|
||||||
|
search.run(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
search(query: string): Array<{ id: string; type: string; title: string }> {
|
||||||
|
const stmt = this.db.prepare(`
|
||||||
|
SELECT e.id, e.type, e.title
|
||||||
|
FROM search s
|
||||||
|
JOIN entities e ON s.id = e.id
|
||||||
|
WHERE search MATCH @query
|
||||||
|
ORDER BY rank
|
||||||
|
`);
|
||||||
|
return stmt.all({ query }) as Array<{ id: string; type: string; title: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(type?: string, filters?: { status?: string; role?: string; assignedTo?: string }): IndexEntry[] {
|
||||||
|
let sql = 'SELECT * FROM entities WHERE 1=1';
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (type) {
|
||||||
|
sql += ' AND type = @type';
|
||||||
|
params.type = type;
|
||||||
|
}
|
||||||
|
if (filters?.status) {
|
||||||
|
sql += ' AND status = @status';
|
||||||
|
params.status = filters.status;
|
||||||
|
}
|
||||||
|
if (filters?.role) {
|
||||||
|
sql += ' AND role = @role';
|
||||||
|
params.role = filters.role;
|
||||||
|
}
|
||||||
|
if (filters?.assignedTo) {
|
||||||
|
sql += ' AND assignedTo = @assignedTo';
|
||||||
|
params.assignedTo = filters.assignedTo;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY createdAt DESC';
|
||||||
|
|
||||||
|
const stmt = this.db.prepare(sql);
|
||||||
|
return stmt.all(params) as IndexEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
35
tests/index.test.ts
Normal file
35
tests/index.test.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { Index } from '../src/core/index.js';
|
||||||
|
|
||||||
|
describe('index', () => {
|
||||||
|
let cwd: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cwd = mkdtempSync(join(tmpdir(), 'agenthub-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('indexes and searches a task', () => {
|
||||||
|
const index = new Index(cwd);
|
||||||
|
index.upsert({
|
||||||
|
id: 'TSK-0001',
|
||||||
|
type: 'task',
|
||||||
|
title: 'Implement DNS cache',
|
||||||
|
content: 'Add local DNS cache for performance',
|
||||||
|
filePath: join(cwd, '.agenthub', 'tasks', 'TSK-0001.md'),
|
||||||
|
createdAt: '2026-06-24T10:00:00Z',
|
||||||
|
updatedAt: '2026-06-24T10:00:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = index.search('DNS');
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
expect(results[0].id).toBe('TSK-0001');
|
||||||
|
index.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user