* fix: security hardening — search DoS, slug hijack, symlink traversal, content bombs, stdin guard 4 security vulnerabilities closed: - Search limit clamped to 100 (MAX_SEARCH_LIMIT) with statement_timeout 8s - Frontmatter slug authority enforced (path-derived, mismatch rejected) - Symlink traversal blocked (lstatSync in walker + importFromFile) - Content size guard on importFromContent (Buffer.byteLength, 5MB) - Stdin size guard in parseOpArgs (5MB cap) Search pagination added (--offset param on search + query operations). Clamp warning emitted when limit is capped. Co-Authored-By: garagon <garagon@users.noreply.github.com> * fix: PGLite concurrent access lock — prevent Aborted() crash File-based advisory lock using atomic mkdir with PID tracking and 5-minute stale detection. Clear error messages show which process holds the lock and how to recover. Co-Authored-By: danbr <danbr@users.noreply.github.com> * fix: 12 data integrity fixes + stale embedding prevention CTE searchKeyword rewrite (SQL-level LIMIT, not JS splice). Write validation on addLink/addTag/addTimelineEntry/putRawData/createVersion. Health metrics now measure real problems (stale_pages, orphan_pages, dead_links). Orphan chunk cleanup on empty pages. Embedding error logging. contentHash now covers all PageInput fields. Stale embedding NULL'd when chunk_text changes (prevents wrong vector on new text). hybridSearch stops double-embedding query. MCP param validation. type/exclude_slugs search filters now work. pgcrypto extension for Postgres <13. Co-Authored-By: win4r <win4r@users.noreply.github.com> * perf: 30x embedAll speedup + O(n²) fix + ask alias Sliding worker pool (concurrency 20, tunable via GBRAIN_EMBED_CONCURRENCY). O(n²) chunk lookup in embedPage replaced with Map. gbrain ask alias for query (CLI-only, not in MCP tools-json). .idea added to .gitignore. Co-Authored-By: stephenhungg <stephenhungg@users.noreply.github.com> Co-Authored-By: sharziki <sharziki@users.noreply.github.com> Co-Authored-By: hnshah <hnshah@users.noreply.github.com> Co-Authored-By: doguabaris <doguabaris@users.noreply.github.com> * chore: bump version and changelog (v0.9.1) Community fix wave: 10 PRs, 7 contributors. 4 security fixes, PGLite crash fix, 12 data integrity fixes, 30x embed speedup, search pagination, ask alias. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: garagon <garagon@users.noreply.github.com> Co-authored-by: danbr <danbr@users.noreply.github.com> Co-authored-by: win4r <win4r@users.noreply.github.com> Co-authored-by: stephenhungg <stephenhungg@users.noreply.github.com> Co-authored-by: sharziki <sharziki@users.noreply.github.com> Co-authored-by: hnshah <hnshah@users.noreply.github.com> Co-authored-by: doguabaris <doguabaris@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
673 lines
21 KiB
TypeScript
673 lines
21 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)' },
|
|
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<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 };
|
|
}
|
|
|
|
// 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<string, Operation>;
|