Project Files
src / indexManager.ts
/**
* IndexManager — orchestrates file watching, hashing, indexing, and search.
* Owned by the ToolsProvider; tools call methods on this singleton.
*/
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { v4 as uuidv4 } from "uuid";
import { DocIndex, hashContent, type DocRecord } from "./db/docIndex.js";
import { FileWatcher } from "./watcher/fileWatcher.js";
import { DocSearch } from "./search/docSearch.js";
import type { EmbeddingClient } from "./embeddings/embeddingClient.js";
export interface PlaybookConfig {
playbookDirectory: string;
embeddingModel: string;
lmStudioBaseUrl: string;
maxRecallDocuments: number;
minRecallScore: number;
}
import type { SearchResult } from "./search/docSearch.js";
const SUPPORTED_EXT = new Set([".md", ".txt"]);
function isSupportedFile(filePath: string): boolean {
return SUPPORTED_EXT.has(path.extname(filePath).toLowerCase());
}
/**
* Parse YAML-style frontmatter from a Markdown file.
* Returns { title, tags, body } where body is the content after the frontmatter block.
*/
function parseFrontmatter(content: string): {
title: string | null;
tags: string[];
body: string;
} {
const fm = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/);
if (!fm) return { title: null, tags: [], body: content };
const yaml = fm[1];
const body = fm[2] ?? "";
const titleMatch = yaml.match(/^title:\s*(.+)$/m);
const tagsMatch = yaml.match(/^tags:\s*\[([^\]]*)\]/m);
const title = titleMatch ? titleMatch[1].trim().replace(/^["']|["']$/g, "") : null;
const tags = tagsMatch
? tagsMatch[1]
.split(",")
.map((t) => t.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean)
: [];
return { title, tags, body };
}
export class IndexManager {
private docIndex: DocIndex;
private docSearch: DocSearch;
private fileWatcher: FileWatcher | null = null;
private embeddingClient: EmbeddingClient | null = null;
private config: PlaybookConfig;
private baseUrl: string;
private ready = false;
constructor(config: PlaybookConfig, dataDir: string, baseUrl: string) {
this.config = config;
this.baseUrl = baseUrl;
this.docIndex = new DocIndex(dataDir);
this.docSearch = new DocSearch();
}
async initialize(): Promise<void> {
await this.docIndex.load();
// EmbeddingClient is injected lazily by recall.ts after capability check.
if (!fs.existsSync(this.config.playbookDirectory)) {
fs.mkdirSync(this.config.playbookDirectory, { recursive: true });
}
await this.syncDirectory();
await this.rebuildBM25();
this.fileWatcher = new FileWatcher(this.config.playbookDirectory, {
onFileAdded: (fp) => this.handleFileChange(fp),
onFileChanged: (fp) => this.handleFileChange(fp),
onFileDeleted: (fp) => this.handleFileDeleted(fp),
onReady: () => { this.ready = true; },
});
await this.fileWatcher.start();
this.ready = true;
console.log("[IndexManager] Ready.");
}
/** Scan directory on startup: index new/changed files, remove orphans. */
private async syncDirectory(): Promise<void> {
const dir = this.config.playbookDirectory;
const onDisk = new Set<string>();
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
const fp = path.join(dir, entry.name);
if (!isSupportedFile(fp)) continue;
onDisk.add(fp);
const content = fs.readFileSync(fp, "utf8");
const hash = hashContent(content);
const existing = this.docIndex.getByPath(fp);
if (!existing || existing.hash !== hash) {
await this.indexFile(fp, content, hash);
}
}
// Remove index entries whose files no longer exist
for (const meta of this.docIndex.getAll()) {
if (!onDisk.has(meta.filePath)) {
this.docIndex.deleteByPath(meta.filePath);
}
}
this.docIndex.save();
}
private async rebuildBM25(): Promise<void> {
const all = this.docIndex.getAll();
const entries: Array<{ id: string; title: string; tags: string[]; bodyText: string }> = [];
for (const meta of all) {
if (!fs.existsSync(meta.filePath)) continue;
try {
const body = parseFrontmatter(fs.readFileSync(meta.filePath, "utf8")).body;
entries.push({ id: meta.id, title: meta.title, tags: meta.tags, bodyText: body });
} catch {
// skip unreadable files
}
}
this.docSearch.rebuild(entries);
}
private async indexFile(
filePath: string,
content: string,
hash: string
): Promise<void> {
const { title: fmTitle, tags, body } = parseFrontmatter(content);
const title = fmTitle ?? path.basename(filePath, path.extname(filePath));
let embedding: Float32Array | null = null;
if (this.embeddingClient) {
try {
const passages = [`${title}\n${body}`];
[embedding] = await this.embeddingClient.embedPassages(passages);
} catch (err) {
console.warn("[IndexManager] Embedding failed for", filePath, err);
}
}
const existing = this.docIndex.getByPath(filePath);
const record: DocRecord = {
id: existing?.id ?? uuidv4(),
filePath,
title,
tags,
hash,
embedding,
updatedAt: new Date().toISOString(),
};
this.docIndex.upsert(record);
this.docSearch.addToIndex(record.id, title, tags, body);
this.docIndex.save();
}
private async handleFileChange(filePath: string): Promise<void> {
if (!isSupportedFile(filePath)) return;
try {
const content = fs.readFileSync(filePath, "utf8");
const hash = hashContent(content);
const existing = this.docIndex.getByPath(filePath);
if (existing?.hash === hash) return; // no real change
await this.indexFile(filePath, content, hash);
} catch (err) {
console.error("[IndexManager] handleFileChange error:", filePath, err);
}
}
private async handleFileDeleted(filePath: string): Promise<void> {
const meta = this.docIndex.getByPath(filePath);
if (meta) {
this.docSearch.removeFromIndex(meta.id);
this.docIndex.deleteByPath(filePath);
this.docIndex.save();
}
}
// ── Public API used by Tools ──────────────────────────────────────────────
setEmbeddingClient(client: EmbeddingClient | null): void {
this.embeddingClient = client;
this.docSearch.setEmbeddingClient(client);
}
getConfig(): PlaybookConfig {
return this.config;
}
getDocumentCount(): number {
return this.docIndex.getAll().length;
}
async search(query: string, tags?: string[]): Promise<SearchResult[]> {
const docsWithEmbeddings = this.docIndex.getAllWithEmbeddings();
return this.docSearch.search(query, docsWithEmbeddings, {
limit: this.config.maxRecallDocuments,
minScore: this.config.minRecallScore,
tags,
});
}
readSource(filePath: string): string {
return fs.readFileSync(filePath, "utf8");
}
/** Write a new or overwrite an existing playbook file and re-index it. */
async writeFile(filePath: string, content: string): Promise<void> {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
await this.handleFileChange(filePath);
}
/** Apply a unique-match text replacement and re-index. Also refreshes the frontmatter `updated` timestamp. */
async editFile(filePath: string, oldText: string, newText: string): Promise<void> {
const content = fs.readFileSync(filePath, "utf8");
const count = content.split(oldText).length - 1;
if (count === 0) throw new Error("oldText not found in file.");
if (count > 1) throw new Error("oldText matches multiple locations; make it more specific.");
let result = content.replace(oldText, newText);
// Refresh the frontmatter `updated` timestamp if the file has one.
result = result.replace(
/^(updated:\s*")[^"]*(")/m,
`$1${new Date().toISOString()}$2`,
);
fs.writeFileSync(filePath, result, "utf8");
await this.handleFileChange(filePath);
}
/** Delete a file and remove it from the index. */
async deleteFile(filePath: string): Promise<void> {
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
await this.handleFileDeleted(filePath);
}
getPlaybookDirectory(): string {
return this.config.playbookDirectory;
}
isReady(): boolean {
return this.ready;
}
async shutdown(): Promise<void> {
await this.fileWatcher?.stop();
}
}