mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* feat: contract-first operations.ts with OperationError, dry_run, importFromContent 30 shared operations as single source of truth for CLI and MCP. - OperationError with typed error codes (page_not_found, invalid_params, etc.) - dry_run support on all mutating operations - importFromContent split from importFile with transaction wrapping - Idempotency hash now includes ALL fields (title, type, frontmatter, tags) - Config env var fallback: GBRAIN_DATABASE_URL > DATABASE_URL > config file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: rewrite MCP server + CLI + tools-json from operations server.ts: 233 -> ~80 lines. Tool definitions and dispatch generated from operations[]. cli.ts: shared operations auto-registered, CLI-only commands kept as manual dispatch. tools-json: generated FROM operations[], eliminating the third contract surface. Parity test verifies structural contract between operations, CLI, and MCP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: delete 12 command files migrated to operations.ts Handler logic for get, put, delete, list, search, query, health, stats, tags, link, timeline, and version now lives in operations.ts. Kept: init, upgrade, import, export, files, embed, sync, serve, call, config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: init --non-interactive, upgrade verification, schema migration - gbrain init --non-interactive --url <url> for plugin mode (no TTY required) - Post-upgrade version verification in gbrain upgrade - Drop storage_url from files table (storage_path is the only identifier) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: tool-agnostic skills + new setup skill All 7 skills rewritten with intent-based language instead of CLI commands. Works with both CLI and MCP plugin contexts. New setup skill replaces install: auto-provision Supabase via CLI, AGENTS.md injection, target TTHW < 2 min. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: ClawHub bundle plugin, CI workflows, v0.3.0 - openclaw.plugin.json with configSchema, MCP server config, skill listing - GitHub Actions: test on push/PR, multi-platform release (macOS arm64 + Linux x64) - Version bump 0.3.0, CHANGELOG, README ClawHub section, CLAUDE.md updated Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: idempotency hash mismatch + MCP dry_run passthrough importFromContent now passes its all-fields hash through putPage via content_hash on PageInput, so the stored hash matches the computed hash. Previously the skip-if-unchanged check never fired because the hash formulas differed. MCP server now passes dry_run from tool params to OperationContext. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.3.0.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: schema loader handles PL/pgSQL $$ blocks Delete the semicolon-based SQL splitter in db.ts which broke on PL/pgSQL trigger functions containing semicolons inside $$ delimiter blocks. Use single conn.unsafe(schemaSql) call instead — the postgres driver handles multi-statement SQL natively. schema.sql already uses IF NOT EXISTS / CREATE OR REPLACE for idempotency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: E2E test infrastructure + realistic brain fixtures Add test infrastructure for running E2E tests against real Postgres+pgvector. Includes: - test/e2e/helpers.ts: DB lifecycle, fixture import, timing, diagnostics - 13 fixture files as a miniature realistic brain (people, companies, deals, meetings, concepts, projects, sources) following the compiled truth + timeline format from GBRAIN_RECOMMENDED_SCHEMA.md - docker-compose.test.yml: local pgvector convenience (port 5433) - .env.testing.example: template for test credentials - package.json: add test:e2e script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: E2E test suites + CI workflow Tier 1 (mechanical.test.ts): 14 test suites covering all operations against real Postgres — page CRUD, search with quality scoring, links, tags, timeline, versions, admin, chunks, resolution, ingest log, raw data, files, idempotency stress, setup journey (full CLI flow), init edge cases, schema idempotency, schema diff guard, performance baselines. Tier 1 (mcp.test.ts): MCP protocol test — spawns server, sends JSON-RPC, verifies tools/list matches operations count. Tier 2 (skills.test.ts): OpenClaw skill tests — ingest, query, health. Skips gracefully when dependencies missing. CI (.github/workflows/e2e.yml): Tier 1 on every PR (pgvector service), Tier 2 nightly/manual with API key secrets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: E2E test fixes + traverseGraph jsonb cast - Fix traverseGraph query: cast json_agg to jsonb_agg so SELECT DISTINCT works - Fix put_page tests to use importFromContent with noEmbed (no OpenAI key in Tier 1) - Fix get_health assertion (page_count not total_pages) - Fix raw_data test to handle JSONB string/object return - Simplify MCP test to verify tool generation directly - Add timeouts to CLI subprocess tests - Use port 5434 for docker-compose (5433 often in use) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: update all project docs for E2E test suite - CLAUDE.md: updated test count (9 unit + 3 E2E), added E2E test instructions, fixed skill count to 8 - CONTRIBUTING.md: updated project structure with test/e2e/, added E2E test instructions, rewrote "Adding a new command" to reflect contract-first architecture (add to operations.ts, done) - README.md: fixed table count (10 not 9), added recommended schema doc to Docs section, added E2E instructions to Contributing section - CHANGELOG.md: added E2E test suite, docker-compose, schema loader fix, and traverseGraph jsonb fix to v0.3.0 entry Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
644 lines
20 KiB
TypeScript
644 lines
20 KiB
TypeScript
/**
|
|
* 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<string, ParamDef>;
|
|
handler: (ctx: OperationContext, params: Record<string, unknown>) => Promise<unknown>;
|
|
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)' },
|
|
},
|
|
handler: async (ctx, p) => {
|
|
return ctx.engine.searchKeyword(p.query as string, { limit: (p.limit as number) || 20 });
|
|
},
|
|
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)' },
|
|
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,
|
|
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<string, string> = {
|
|
'.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 };
|
|
}
|
|
|
|
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
|
|
`;
|
|
|
|
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<string, Operation>;
|