/** * Contract-first operation definitions. Single source of truth for CLI, MCP, and tools-json. * Each operation defines its schema, handler, and optional CLI hints. */ import type { BrainEngine } from './engine.ts'; import type { GBrainConfig } from './config.ts'; import { importFromContent } from './import-file.ts'; import { hybridSearch } from './search/hybrid.ts'; import { expandQuery } from './search/expansion.ts'; import * as db from './db.ts'; // --- Types --- export type ErrorCode = | 'page_not_found' | 'invalid_params' | 'embedding_failed' | 'storage_error' | 'bucket_not_found' | 'database_error'; export class OperationError extends Error { constructor( public code: ErrorCode, message: string, public suggestion?: string, public docs?: string, ) { super(message); this.name = 'OperationError'; } toJSON() { return { error: this.code, message: this.message, suggestion: this.suggestion, docs: this.docs, }; } } export interface ParamDef { type: 'string' | 'number' | 'boolean' | 'object' | 'array'; required?: boolean; description?: string; default?: unknown; enum?: string[]; items?: ParamDef; } export interface Logger { info(msg: string): void; warn(msg: string): void; error(msg: string): void; } export interface OperationContext { engine: BrainEngine; config: GBrainConfig; logger: Logger; dryRun: boolean; } export interface Operation { name: string; description: string; params: Record; handler: (ctx: OperationContext, params: Record) => Promise; mutating?: boolean; cliHints?: { name?: string; positional?: string[]; stdin?: string; hidden?: boolean; }; } // --- Page CRUD --- const get_page: Operation = { name: 'get_page', description: 'Read a page by slug (supports optional fuzzy matching)', params: { slug: { type: 'string', required: true, description: 'Page slug' }, fuzzy: { type: 'boolean', description: 'Enable fuzzy slug resolution (default: false)' }, }, handler: async (ctx, p) => { const slug = p.slug as string; const fuzzy = (p.fuzzy as boolean) || false; let page = await ctx.engine.getPage(slug); let resolved_slug: string | undefined; if (!page && fuzzy) { const candidates = await ctx.engine.resolveSlugs(slug); if (candidates.length === 1) { page = await ctx.engine.getPage(candidates[0]); resolved_slug = candidates[0]; } else if (candidates.length > 1) { return { error: 'ambiguous_slug', candidates }; } } if (!page) { throw new OperationError('page_not_found', `Page not found: ${slug}`, 'Check the slug or use fuzzy: true'); } const tags = await ctx.engine.getTags(page.slug); return { ...page, tags, ...(resolved_slug ? { resolved_slug } : {}) }; }, cliHints: { name: 'get', positional: ['slug'] }, }; const put_page: Operation = { name: 'put_page', description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, and reconciles tags.', params: { slug: { type: 'string', required: true, description: 'Page slug' }, content: { type: 'string', required: true, description: 'Full markdown content with YAML frontmatter' }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'put_page', slug: p.slug }; const result = await importFromContent(ctx.engine, p.slug as string, p.content as string); return { slug: result.slug, status: result.status === 'imported' ? 'created_or_updated' : result.status, chunks: result.chunks }; }, cliHints: { name: 'put', positional: ['slug'], stdin: 'content' }, }; const delete_page: Operation = { name: 'delete_page', description: 'Delete a page', params: { slug: { type: 'string', required: true }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'delete_page', slug: p.slug }; await ctx.engine.deletePage(p.slug as string); return { status: 'deleted' }; }, cliHints: { name: 'delete', positional: ['slug'] }, }; const list_pages: Operation = { name: 'list_pages', description: 'List pages with optional filters', params: { type: { type: 'string', description: 'Filter by page type' }, tag: { type: 'string', description: 'Filter by tag' }, limit: { type: 'number', description: 'Max results (default 50)' }, }, handler: async (ctx, p) => { const pages = await ctx.engine.listPages({ type: p.type as any, tag: p.tag as string, limit: (p.limit as number) || 50, }); return pages.map(pg => ({ slug: pg.slug, type: pg.type, title: pg.title, updated_at: pg.updated_at, })); }, cliHints: { name: 'list' }, }; // --- Search --- const search: Operation = { name: 'search', description: 'Keyword search using full-text search', params: { query: { type: 'string', required: true }, limit: { type: 'number', description: 'Max results (default 20)' }, offset: { type: 'number', description: 'Skip first N results (for pagination)' }, }, handler: async (ctx, p) => { return ctx.engine.searchKeyword(p.query as string, { limit: (p.limit as number) || 20, offset: (p.offset as number) || 0, }); }, cliHints: { name: 'search', positional: ['query'] }, }; const query: Operation = { name: 'query', description: 'Hybrid search with vector + keyword + multi-query expansion', params: { query: { type: 'string', required: true }, limit: { type: 'number', description: 'Max results (default 20)' }, offset: { type: 'number', description: 'Skip first N results (for pagination)' }, expand: { type: 'boolean', description: 'Enable multi-query expansion (default: true)' }, }, handler: async (ctx, p) => { const expand = p.expand !== false; return hybridSearch(ctx.engine, p.query as string, { limit: (p.limit as number) || 20, offset: (p.offset as number) || 0, expansion: expand, expandFn: expand ? expandQuery : undefined, }); }, cliHints: { name: 'query', positional: ['query'] }, }; // --- Tags --- const add_tag: Operation = { name: 'add_tag', description: 'Add tag to page', params: { slug: { type: 'string', required: true }, tag: { type: 'string', required: true }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'add_tag', slug: p.slug, tag: p.tag }; await ctx.engine.addTag(p.slug as string, p.tag as string); return { status: 'ok' }; }, cliHints: { name: 'tag', positional: ['slug', 'tag'] }, }; const remove_tag: Operation = { name: 'remove_tag', description: 'Remove tag from page', params: { slug: { type: 'string', required: true }, tag: { type: 'string', required: true }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'remove_tag', slug: p.slug, tag: p.tag }; await ctx.engine.removeTag(p.slug as string, p.tag as string); return { status: 'ok' }; }, cliHints: { name: 'untag', positional: ['slug', 'tag'] }, }; const get_tags: Operation = { name: 'get_tags', description: 'List tags for a page', params: { slug: { type: 'string', required: true }, }, handler: async (ctx, p) => { return ctx.engine.getTags(p.slug as string); }, cliHints: { name: 'tags', positional: ['slug'] }, }; // --- Links --- const add_link: Operation = { name: 'add_link', description: 'Create link between pages', params: { from: { type: 'string', required: true }, to: { type: 'string', required: true }, link_type: { type: 'string', description: 'Link type (e.g., invested_in, works_at)' }, context: { type: 'string', description: 'Context for the link' }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'add_link', from: p.from, to: p.to }; await ctx.engine.addLink( p.from as string, p.to as string, (p.context as string) || '', (p.link_type as string) || '', ); return { status: 'ok' }; }, cliHints: { name: 'link', positional: ['from', 'to'] }, }; const remove_link: Operation = { name: 'remove_link', description: 'Remove link between pages', params: { from: { type: 'string', required: true }, to: { type: 'string', required: true }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'remove_link', from: p.from, to: p.to }; await ctx.engine.removeLink(p.from as string, p.to as string); return { status: 'ok' }; }, cliHints: { name: 'unlink', positional: ['from', 'to'] }, }; const get_links: Operation = { name: 'get_links', description: 'List outgoing links from a page', params: { slug: { type: 'string', required: true }, }, handler: async (ctx, p) => { return ctx.engine.getLinks(p.slug as string); }, }; const get_backlinks: Operation = { name: 'get_backlinks', description: 'List incoming links to a page', params: { slug: { type: 'string', required: true }, }, handler: async (ctx, p) => { return ctx.engine.getBacklinks(p.slug as string); }, cliHints: { name: 'backlinks', positional: ['slug'] }, }; const traverse_graph: Operation = { name: 'traverse_graph', description: 'Traverse link graph from a page', params: { slug: { type: 'string', required: true }, depth: { type: 'number', description: 'Max traversal depth (default 5)' }, }, handler: async (ctx, p) => { return ctx.engine.traverseGraph(p.slug as string, (p.depth as number) || 5); }, cliHints: { name: 'graph', positional: ['slug'] }, }; // --- Timeline --- const add_timeline_entry: Operation = { name: 'add_timeline_entry', description: 'Add timeline entry to a page', params: { slug: { type: 'string', required: true }, date: { type: 'string', required: true }, summary: { type: 'string', required: true }, detail: { type: 'string' }, source: { type: 'string' }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'add_timeline_entry', slug: p.slug }; await ctx.engine.addTimelineEntry(p.slug as string, { date: p.date as string, source: (p.source as string) || '', summary: p.summary as string, detail: (p.detail as string) || '', }); return { status: 'ok' }; }, cliHints: { name: 'timeline-add', positional: ['slug', 'date', 'summary'] }, }; const get_timeline: Operation = { name: 'get_timeline', description: 'Get timeline entries for a page', params: { slug: { type: 'string', required: true }, }, handler: async (ctx, p) => { return ctx.engine.getTimeline(p.slug as string); }, cliHints: { name: 'timeline', positional: ['slug'] }, }; // --- Admin --- const get_stats: Operation = { name: 'get_stats', description: 'Brain statistics (page count, chunk count, etc.)', params: {}, handler: async (ctx) => { return ctx.engine.getStats(); }, cliHints: { name: 'stats' }, }; const get_health: Operation = { name: 'get_health', description: 'Brain health dashboard (embed coverage, stale pages, orphans)', params: {}, handler: async (ctx) => { return ctx.engine.getHealth(); }, cliHints: { name: 'health' }, }; const get_versions: Operation = { name: 'get_versions', description: 'Page version history', params: { slug: { type: 'string', required: true }, }, handler: async (ctx, p) => { return ctx.engine.getVersions(p.slug as string); }, cliHints: { name: 'history', positional: ['slug'] }, }; const revert_version: Operation = { name: 'revert_version', description: 'Revert page to a previous version', params: { slug: { type: 'string', required: true }, version_id: { type: 'number', required: true }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'revert_version', slug: p.slug, version_id: p.version_id }; await ctx.engine.createVersion(p.slug as string); await ctx.engine.revertToVersion(p.slug as string, p.version_id as number); return { status: 'reverted' }; }, cliHints: { name: 'revert', positional: ['slug', 'version_id'] }, }; // --- Sync --- const sync_brain: Operation = { name: 'sync_brain', description: 'Sync git repo to brain (incremental)', params: { repo: { type: 'string', description: 'Path to git repo (optional if configured)' }, dry_run: { type: 'boolean', description: 'Preview changes without applying' }, full: { type: 'boolean', description: 'Full re-sync (ignore checkpoint)' }, no_pull: { type: 'boolean', description: 'Skip git pull' }, no_embed: { type: 'boolean', description: 'Skip embedding generation' }, }, mutating: true, handler: async (ctx, p) => { const { performSync } = await import('../commands/sync.ts'); return performSync(ctx.engine, { repoPath: p.repo as string | undefined, dryRun: ctx.dryRun || (p.dry_run as boolean) || false, noEmbed: (p.no_embed as boolean) || false, noPull: (p.no_pull as boolean) || false, full: (p.full as boolean) || false, }); }, cliHints: { name: 'sync' }, }; // --- Raw Data --- const put_raw_data: Operation = { name: 'put_raw_data', description: 'Store raw API response data for a page', params: { slug: { type: 'string', required: true }, source: { type: 'string', required: true, description: 'Data source (e.g., crustdata, happenstance)' }, data: { type: 'object', required: true, description: 'Raw data object' }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'put_raw_data', slug: p.slug, source: p.source }; await ctx.engine.putRawData(p.slug as string, p.source as string, p.data as object); return { status: 'ok' }; }, }; const get_raw_data: Operation = { name: 'get_raw_data', description: 'Retrieve raw data for a page', params: { slug: { type: 'string', required: true }, source: { type: 'string', description: 'Filter by source' }, }, handler: async (ctx, p) => { return ctx.engine.getRawData(p.slug as string, p.source as string | undefined); }, }; // --- Resolution & Chunks --- const resolve_slugs: Operation = { name: 'resolve_slugs', description: 'Fuzzy-resolve a partial slug to matching page slugs', params: { partial: { type: 'string', required: true }, }, handler: async (ctx, p) => { return ctx.engine.resolveSlugs(p.partial as string); }, }; const get_chunks: Operation = { name: 'get_chunks', description: 'Get content chunks for a page', params: { slug: { type: 'string', required: true }, }, handler: async (ctx, p) => { return ctx.engine.getChunks(p.slug as string); }, }; // --- Ingest Log --- const log_ingest: Operation = { name: 'log_ingest', description: 'Log an ingestion event', params: { source_type: { type: 'string', required: true }, source_ref: { type: 'string', required: true }, pages_updated: { type: 'array', required: true, items: { type: 'string' } }, summary: { type: 'string', required: true }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'log_ingest' }; await ctx.engine.logIngest({ source_type: p.source_type as string, source_ref: p.source_ref as string, pages_updated: p.pages_updated as string[], summary: p.summary as string, }); return { status: 'ok' }; }, }; const get_ingest_log: Operation = { name: 'get_ingest_log', description: 'Get recent ingestion log entries', params: { limit: { type: 'number', description: 'Max entries (default 20)' }, }, handler: async (ctx, p) => { return ctx.engine.getIngestLog({ limit: (p.limit as number) || 20 }); }, }; // --- File Operations --- const file_list: Operation = { name: 'file_list', description: 'List stored files', params: { slug: { type: 'string', description: 'Filter by page slug' }, }, handler: async (_ctx, p) => { const sql = db.getConnection(); const slug = p.slug as string | undefined; if (slug) { return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files WHERE page_slug = ${slug} ORDER BY filename`; } return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files ORDER BY page_slug, filename LIMIT 100`; }, }; const file_upload: Operation = { name: 'file_upload', description: 'Upload a file to storage', params: { path: { type: 'string', required: true, description: 'Local file path' }, page_slug: { type: 'string', description: 'Associate with page' }, }, mutating: true, handler: async (ctx, p) => { if (ctx.dryRun) return { dry_run: true, action: 'file_upload', path: p.path }; const { readFileSync, statSync } = await import('fs'); const { basename, extname } = await import('path'); const { createHash } = await import('crypto'); const filePath = p.path as string; const pageSlug = (p.page_slug as string) || null; const stat = statSync(filePath); const content = readFileSync(filePath); const hash = createHash('sha256').update(content).digest('hex'); const filename = basename(filePath); const storagePath = pageSlug ? `${pageSlug}/${filename}` : `unsorted/${hash.slice(0, 8)}-${filename}`; const MIME_TYPES: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', }; const mimeType = MIME_TYPES[extname(filePath).toLowerCase()] || null; const sql = db.getConnection(); const existing = await sql`SELECT id FROM files WHERE content_hash = ${hash} AND storage_path = ${storagePath}`; if (existing.length > 0) { return { status: 'already_exists', storage_path: storagePath }; } // Upload to storage backend if configured if (ctx.config.storage) { const { createStorage } = await import('./storage.ts'); const storage = await createStorage(ctx.config.storage as any); try { await storage.upload(storagePath, content, mimeType || undefined); } catch (uploadErr) { throw new OperationError('storage_error', `Upload failed: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`); } } try { await sql` INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata) VALUES (${pageSlug}, ${filename}, ${storagePath}, ${mimeType}, ${stat.size}, ${hash}, ${'{}'}::jsonb) ON CONFLICT (storage_path) DO UPDATE SET content_hash = EXCLUDED.content_hash, size_bytes = EXCLUDED.size_bytes, mime_type = EXCLUDED.mime_type `; } catch (dbErr) { // Rollback: clean up storage if DB write failed if (ctx.config.storage) { try { const { createStorage } = await import('./storage.ts'); const storage = await createStorage(ctx.config.storage as any); await storage.delete(storagePath); } catch { /* best effort cleanup */ } } throw dbErr; } return { status: 'uploaded', storage_path: storagePath, size_bytes: stat.size }; }, }; const file_url: Operation = { name: 'file_url', description: 'Get a URL for a stored file', params: { storage_path: { type: 'string', required: true }, }, handler: async (_ctx, p) => { const sql = db.getConnection(); const rows = await sql`SELECT storage_path, mime_type, size_bytes FROM files WHERE storage_path = ${p.storage_path as string}`; if (rows.length === 0) { throw new OperationError('storage_error', `File not found: ${p.storage_path}`); } // TODO: generate signed URL from Supabase Storage return { storage_path: rows[0].storage_path, url: `gbrain:files/${rows[0].storage_path}` }; }, }; // --- Exports --- export const operations: Operation[] = [ // Page CRUD get_page, put_page, delete_page, list_pages, // Search search, query, // Tags add_tag, remove_tag, get_tags, // Links add_link, remove_link, get_links, get_backlinks, traverse_graph, // Timeline add_timeline_entry, get_timeline, // Admin get_stats, get_health, get_versions, revert_version, // Sync sync_brain, // Raw data put_raw_data, get_raw_data, // Resolution & chunks resolve_slugs, get_chunks, // Ingest log log_ingest, get_ingest_log, // Files file_list, file_upload, file_url, ]; export const operationsByName = Object.fromEntries( operations.map(op => [op.name, op]), ) as Record;