diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000..b0da6f0 --- /dev/null +++ b/src/core/index.ts @@ -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 = {}; + + 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(); + } +} diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..5dda339 --- /dev/null +++ b/tests/index.test.ts @@ -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(); + }); +});