mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
314f96152c |
@@ -0,0 +1,2 @@
|
||||
dist/
|
||||
node_modules/
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "gbrain",
|
||||
"name": "GBrain",
|
||||
"version": "0.1.0",
|
||||
"description": "Personal knowledge brain for OpenClaw — semantic search, entity resolution, relationship graph, and enrichment for markdown repos",
|
||||
"main": "dist/index.js",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"databaseUrl": {
|
||||
"type": "string",
|
||||
"description": "PostgreSQL connection URL (with pgvector extension)",
|
||||
"uiHints": { "sensitive": true }
|
||||
},
|
||||
"brainPath": {
|
||||
"type": "string",
|
||||
"description": "Path to the markdown knowledge repository",
|
||||
"default": "./brain"
|
||||
},
|
||||
"openaiApiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key for embeddings (falls back to OPENAI_API_KEY env)",
|
||||
"uiHints": { "sensitive": true }
|
||||
},
|
||||
"autoSync": {
|
||||
"type": "boolean",
|
||||
"description": "Watch brainPath for git changes and auto-reindex",
|
||||
"default": true
|
||||
},
|
||||
"syncIntervalSeconds": {
|
||||
"type": "number",
|
||||
"description": "Poll interval for git HEAD changes",
|
||||
"default": 30
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@garrytan/openclaw-gbrain",
|
||||
"version": "0.1.0",
|
||||
"description": "GBrain knowledge brain plugin for OpenClaw — semantic search, entity resolution, relationship graph for markdown repos",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --external:openclaw --external:postgres --external:openai --external:@electric-sql/pglite --external:pgvector --external:gray-matter --external:marked --external:@anthropic-ai/sdk --external:@aws-sdk/client-s3 --external:@modelcontextprotocol/sdk --minify-whitespace",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"files": [
|
||||
"dist/",
|
||||
"openclaw.plugin.json",
|
||||
"README.md"
|
||||
],
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "^0.34.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.0",
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": "*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": ["./dist/index.js"]
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Garry Tan"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* CLI commands registered under /gbrain.
|
||||
* Uses OpenClaw's CLI context (ctx.program is a Commander instance).
|
||||
*/
|
||||
|
||||
import { getEngine } from './engine-host.js';
|
||||
|
||||
export function registerGBrainCli() {
|
||||
return (ctx: { program: any; config: any; logger: any }) => {
|
||||
const { program } = ctx;
|
||||
|
||||
program
|
||||
.command('gbrain')
|
||||
.description('GBrain status — page count, health score, last sync')
|
||||
.action(async () => {
|
||||
try {
|
||||
const engine = getEngine();
|
||||
const stats = await engine.getStats();
|
||||
const health = await engine.getHealth();
|
||||
console.log(
|
||||
`GBrain: ${stats.page_count} pages | Score: ${health.brain_score}/100 | Stale: ${health.stale_pages}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('GBrain not connected:', (e as Error).message);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('gbrain-sync')
|
||||
.description('Trigger manual brain sync')
|
||||
.action(async () => {
|
||||
console.log('Manual sync triggered (use gbrain CLI for full sync)');
|
||||
});
|
||||
|
||||
program
|
||||
.command('gbrain-doctor')
|
||||
.description('Brain health check')
|
||||
.action(async () => {
|
||||
try {
|
||||
const engine = getEngine();
|
||||
const health = await engine.getHealth();
|
||||
console.log(`Brain Score: ${health.brain_score}/100`);
|
||||
console.log(` Embed coverage: ${health.embed_coverage_score}/35`);
|
||||
console.log(` Link density: ${health.link_density_score}/25`);
|
||||
console.log(` Timeline: ${health.timeline_coverage_score}/15`);
|
||||
console.log(` No orphans: ${health.no_orphans_score}/15`);
|
||||
console.log(` No dead links: ${health.no_dead_links_score}/10`);
|
||||
if (health.stale_pages > 0) {
|
||||
console.log(`\n⚠ ${health.stale_pages} stale pages need re-embedding`);
|
||||
}
|
||||
if (health.dead_links > 0) {
|
||||
console.log(`⚠ ${health.dead_links} dead links found`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('GBrain not connected:', (e as Error).message);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Plugin config resolution. Merges OpenClaw plugin config with env vars.
|
||||
*/
|
||||
|
||||
export interface GBrainPluginConfig {
|
||||
databaseUrl: string;
|
||||
brainPath: string;
|
||||
openaiApiKey: string;
|
||||
autoSync: boolean;
|
||||
syncIntervalSeconds: number;
|
||||
}
|
||||
|
||||
export function resolveConfig(raw: Record<string, unknown>): GBrainPluginConfig {
|
||||
const databaseUrl =
|
||||
(raw['databaseUrl'] as string) ||
|
||||
process.env['DATABASE_URL'] ||
|
||||
process.env['GBRAIN_DATABASE_URL'] ||
|
||||
'';
|
||||
|
||||
const brainPath =
|
||||
(raw['brainPath'] as string) ||
|
||||
process.env['GBRAIN_BRAIN_PATH'] ||
|
||||
'./brain';
|
||||
|
||||
const openaiApiKey =
|
||||
(raw['openaiApiKey'] as string) ||
|
||||
process.env['OPENAI_API_KEY'] ||
|
||||
'';
|
||||
|
||||
const autoSync = raw['autoSync'] !== false;
|
||||
|
||||
const syncIntervalSeconds =
|
||||
typeof raw['syncIntervalSeconds'] === 'number'
|
||||
? raw['syncIntervalSeconds']
|
||||
: 30;
|
||||
|
||||
return { databaseUrl, brainPath, openaiApiKey, autoSync, syncIntervalSeconds };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Engine host — manages the singleton BrainEngine lifecycle.
|
||||
*/
|
||||
|
||||
import type { BrainEngine } from '../../src/core/engine.ts';
|
||||
import { createEngine } from '../../src/core/engine-factory.ts';
|
||||
import type { GBrainPluginConfig } from './config.js';
|
||||
|
||||
let engine: BrainEngine | null = null;
|
||||
let config: GBrainPluginConfig | null = null;
|
||||
|
||||
export async function initEngine(cfg: GBrainPluginConfig): Promise<BrainEngine> {
|
||||
config = cfg;
|
||||
engine = await createEngine({
|
||||
database_url: cfg.databaseUrl,
|
||||
engine: 'postgres',
|
||||
});
|
||||
await engine.connect({ database_url: cfg.databaseUrl, engine: 'postgres' });
|
||||
return engine;
|
||||
}
|
||||
|
||||
export function getEngine(): BrainEngine {
|
||||
if (!engine) {
|
||||
throw new Error(
|
||||
'GBrain engine not initialized. Ensure the gbrain plugin service is running ' +
|
||||
'and databaseUrl is configured.',
|
||||
);
|
||||
}
|
||||
return engine;
|
||||
}
|
||||
|
||||
export function getConfig(): GBrainPluginConfig {
|
||||
if (!config) {
|
||||
throw new Error('GBrain plugin config not initialized.');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function shutdownEngine(): Promise<void> {
|
||||
if (engine) {
|
||||
await engine.disconnect();
|
||||
engine = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* GBrain OpenClaw Plugin — native tool registration for personal knowledge brains.
|
||||
*
|
||||
* Registers 7 tools that agents discover automatically:
|
||||
* gbrain_search — Hybrid search (keyword + semantic via RRF)
|
||||
* gbrain_get — Direct page read by slug
|
||||
* gbrain_resolve — Entity resolution (name → page)
|
||||
* gbrain_graph — Relationship traversal
|
||||
* gbrain_timeline — Temporal queries
|
||||
* gbrain_ingest — Create/update brain pages
|
||||
* gbrain_stats — Brain health and statistics
|
||||
*
|
||||
* Plus a background service for engine lifecycle and /gbrain CLI commands.
|
||||
*/
|
||||
|
||||
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
|
||||
import { resolveConfig } from './config.js';
|
||||
import { createSyncService } from './service.js';
|
||||
import { registerGBrainCli } from './cli.js';
|
||||
|
||||
// Tool schemas and executors
|
||||
import { gbrainSearchSchema, executeSearch } from './tools/search.js';
|
||||
import { gbrainGetSchema, executeGet } from './tools/get.js';
|
||||
import { gbrainResolveSchema, executeResolve } from './tools/resolve.js';
|
||||
import { gbrainGraphSchema, executeGraph } from './tools/graph.js';
|
||||
import { gbrainTimelineSchema, executeTimeline } from './tools/timeline.js';
|
||||
import { gbrainIngestSchema, executeIngest } from './tools/ingest.js';
|
||||
import { gbrainStatsSchema, executeStats } from './tools/stats.js';
|
||||
|
||||
export default definePluginEntry({
|
||||
id: 'gbrain',
|
||||
name: 'GBrain',
|
||||
description:
|
||||
'Personal knowledge brain — semantic search, entity resolution, ' +
|
||||
'relationship graph, and enrichment for markdown repos',
|
||||
|
||||
register(api) {
|
||||
const config = resolveConfig(api.config as Record<string, unknown>);
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────────────────
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_search',
|
||||
label: 'GBrain Search',
|
||||
description:
|
||||
'Search the knowledge brain using hybrid semantic + keyword search. ' +
|
||||
'Returns ranked page excerpts with source paths. Use for any question about ' +
|
||||
'people, companies, deals, meetings, projects, or concepts in the brain.',
|
||||
parameters: gbrainSearchSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeSearch(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_get',
|
||||
label: 'GBrain Get',
|
||||
description:
|
||||
'Read a brain page by its slug. Returns the full compiled truth section, ' +
|
||||
'optionally with timeline entries and link/backlink graph.',
|
||||
parameters: gbrainGetSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeGet(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_resolve',
|
||||
label: 'GBrain Resolve',
|
||||
description:
|
||||
'Resolve a name, company, or reference to its brain page. ' +
|
||||
'Uses exact slug match → keyword search cascade.',
|
||||
parameters: gbrainResolveSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeResolve(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_graph',
|
||||
label: 'GBrain Graph',
|
||||
description:
|
||||
'Traverse entity relationships in the knowledge brain. Returns connected ' +
|
||||
'pages with relationship types and context.',
|
||||
parameters: gbrainGraphSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeGraph(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_timeline',
|
||||
label: 'GBrain Timeline',
|
||||
description:
|
||||
'Query temporal changes for a brain entity. Returns dated timeline entries. ' +
|
||||
'Supports relative dates like "7d", "30d".',
|
||||
parameters: gbrainTimelineSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeTimeline(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_ingest',
|
||||
label: 'GBrain Ingest',
|
||||
description:
|
||||
'Create or update a brain page with automatic re-indexing. Can create new ' +
|
||||
'pages, prepend timeline entries, or replace compiled truth sections.',
|
||||
parameters: gbrainIngestSchema,
|
||||
async execute(_toolCallId: string, params: any) {
|
||||
return executeIngest(params as Record<string, unknown>);
|
||||
},
|
||||
} as any);
|
||||
|
||||
api.registerTool({
|
||||
name: 'gbrain_stats',
|
||||
label: 'GBrain Stats',
|
||||
description:
|
||||
'Get brain health statistics — page count, embed coverage, link density, ' +
|
||||
'brain score, most connected entities, and orphan/stale page counts.',
|
||||
parameters: gbrainStatsSchema,
|
||||
async execute(_toolCallId: string, _params: any) {
|
||||
return executeStats();
|
||||
},
|
||||
} as any);
|
||||
|
||||
// ── Background Service ──────────────────────────────────────────────
|
||||
|
||||
api.registerService(createSyncService(config) as any);
|
||||
|
||||
// ── CLI ─────────────────────────────────────────────────────────────
|
||||
|
||||
api.registerCli(registerGBrainCli() as any, { commands: ['gbrain', 'gbrain-sync', 'gbrain-doctor'] });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Background service — manages engine lifecycle and periodic sync.
|
||||
*/
|
||||
|
||||
import { initEngine, shutdownEngine } from './engine-host.js';
|
||||
import type { GBrainPluginConfig } from './config.js';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
let syncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastGitHead: string | null = null;
|
||||
|
||||
function readGitHead(brainPath: string): string | null {
|
||||
try {
|
||||
const headPath = join(brainPath, '.git', 'HEAD');
|
||||
if (!existsSync(headPath)) return null;
|
||||
const head = readFileSync(headPath, 'utf-8').trim();
|
||||
if (head.startsWith('ref: ')) {
|
||||
const refPath = join(brainPath, '.git', head.slice(5));
|
||||
if (existsSync(refPath)) {
|
||||
return readFileSync(refPath, 'utf-8').trim();
|
||||
}
|
||||
}
|
||||
return head;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSyncService(config: GBrainPluginConfig) {
|
||||
return {
|
||||
id: 'gbrain-sync',
|
||||
|
||||
async start() {
|
||||
if (!config.databaseUrl) {
|
||||
console.error('[gbrain] No databaseUrl configured — skipping engine init');
|
||||
return;
|
||||
}
|
||||
|
||||
await initEngine(config);
|
||||
console.error(`[gbrain] Engine connected. Brain path: ${config.brainPath}`);
|
||||
|
||||
lastGitHead = readGitHead(config.brainPath);
|
||||
|
||||
if (config.autoSync) {
|
||||
syncTimer = setInterval(async () => {
|
||||
try {
|
||||
const currentHead = readGitHead(config.brainPath);
|
||||
if (currentHead && currentHead !== lastGitHead) {
|
||||
console.error(
|
||||
`[gbrain] Git HEAD changed (${lastGitHead?.slice(0, 8)} → ${currentHead.slice(0, 8)}), sync needed`,
|
||||
);
|
||||
lastGitHead = currentHead;
|
||||
// Full sync integration uses the existing gbrain sync pipeline
|
||||
// For now we detect changes; sync orchestration is a follow-up
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[gbrain] Sync check error:', e);
|
||||
}
|
||||
}, config.syncIntervalSeconds * 1000);
|
||||
}
|
||||
},
|
||||
|
||||
async stop() {
|
||||
if (syncTimer) {
|
||||
clearInterval(syncTimer);
|
||||
syncTimer = null;
|
||||
}
|
||||
await shutdownEngine();
|
||||
console.error('[gbrain] Engine disconnected.');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shared tool result helpers. OpenClaw's AgentToolResult requires
|
||||
* both `content` (text/image blocks) and `details` (structured data).
|
||||
*/
|
||||
|
||||
export function textResult(text: string, details: Record<string, unknown> = {}) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text }],
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
export function truncate(s: string, max: number): string {
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max).replace(/\s\S*$/, '') + '…';
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* gbrain_get — Direct page read by slug.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainGetSchema = Type.Object({
|
||||
slug: Type.String({ description: 'Page slug (e.g. "people/garry-tan", "companies/brex")' }),
|
||||
includeTimeline: Type.Optional(
|
||||
Type.Boolean({ default: false, description: 'Include timeline entries' }),
|
||||
),
|
||||
includeLinks: Type.Optional(
|
||||
Type.Boolean({ default: false, description: 'Include links and backlinks' }),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeGet(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const slug = params['slug'] as string;
|
||||
const includeTimeline = params['includeTimeline'] as boolean ?? false;
|
||||
const includeLinks = params['includeLinks'] as boolean ?? false;
|
||||
|
||||
const page = await engine.getPage(slug);
|
||||
if (!page) {
|
||||
const candidates = await engine.resolveSlugs(slug);
|
||||
if (candidates.length > 0) {
|
||||
return textResult(
|
||||
`Page "${slug}" not found. Did you mean:\n` +
|
||||
candidates.slice(0, 5).map(c => ` - ${c}`).join('\n'),
|
||||
);
|
||||
}
|
||||
return textResult(`Page "${slug}" not found.`);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`# ${page.title}`);
|
||||
lines.push(`slug: ${page.slug} | type: ${page.type} | updated: ${page.updated_at.toISOString()}`);
|
||||
lines.push('');
|
||||
lines.push(page.compiled_truth);
|
||||
|
||||
if (includeTimeline) {
|
||||
const timeline = await engine.getTimeline(slug);
|
||||
if (timeline.length > 0) {
|
||||
lines.push('\n---\n## Timeline\n');
|
||||
for (const t of timeline) {
|
||||
lines.push(`**${t.date}** (${t.source}): ${t.summary}`);
|
||||
if (t.detail) lines.push(` ${t.detail}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeLinks) {
|
||||
const [links, backlinks] = await Promise.all([
|
||||
engine.getLinks(slug),
|
||||
engine.getBacklinks(slug),
|
||||
]);
|
||||
if (links.length > 0) {
|
||||
lines.push('\n## Links (outgoing)\n');
|
||||
for (const l of links) {
|
||||
lines.push(`- → ${l.to_slug} [${l.link_type}]${l.context ? ` — ${l.context}` : ''}`);
|
||||
}
|
||||
}
|
||||
if (backlinks.length > 0) {
|
||||
lines.push('\n## Backlinks (incoming)\n');
|
||||
for (const l of backlinks) {
|
||||
lines.push(`- ← ${l.from_slug} [${l.link_type}]${l.context ? ` — ${l.context}` : ''}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), { slug: page.slug, type: page.type });
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* gbrain_graph — Relationship traversal.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainGraphSchema = Type.Object({
|
||||
entity: Type.String({
|
||||
description: 'Entity slug or name to start from (e.g. "people/garry-tan", "Brex")',
|
||||
}),
|
||||
direction: Type.Optional(
|
||||
Type.Union(
|
||||
[Type.Literal('outgoing'), Type.Literal('incoming'), Type.Literal('both')],
|
||||
{ default: 'both', description: 'Edge direction to traverse' },
|
||||
),
|
||||
),
|
||||
depth: Type.Optional(
|
||||
Type.Number({ default: 1, minimum: 1, maximum: 3, description: 'Traversal depth (1-3)' }),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeGraph(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const entity = params['entity'] as string;
|
||||
const direction = (params['direction'] as string) ?? 'both';
|
||||
const depth = (params['depth'] as number) ?? 1;
|
||||
|
||||
// Resolve entity to slug
|
||||
let slug = entity;
|
||||
let rootPage = await engine.getPage(slug);
|
||||
if (!rootPage) {
|
||||
const candidates = await engine.resolveSlugs(slug.toLowerCase().replace(/\s+/g, '-'));
|
||||
if (candidates.length > 0) {
|
||||
slug = candidates[0];
|
||||
rootPage = await engine.getPage(slug);
|
||||
}
|
||||
if (!rootPage) {
|
||||
const search = await engine.searchKeyword(entity, { limit: 1 });
|
||||
if (search.length > 0) {
|
||||
slug = search[0].slug;
|
||||
rootPage = await engine.getPage(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!rootPage) return textResult(`No entity found matching "${entity}".`);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`## ${rootPage.title} (${rootPage.type})`);
|
||||
lines.push(`slug: ${rootPage.slug}\n`);
|
||||
|
||||
const visited = new Set<string>([slug]);
|
||||
const edges: Array<{ from: string; to: string; type: string; context: string; depth: number }> = [];
|
||||
|
||||
await traverse(engine, slug, direction, depth, 1, visited, edges);
|
||||
|
||||
if (edges.length === 0) {
|
||||
lines.push('No connected entities found.');
|
||||
} else {
|
||||
lines.push(`Found ${edges.length} connection(s):\n`);
|
||||
for (const e of edges) {
|
||||
const arrow = e.from === slug ? '→' : '←';
|
||||
const other = e.from === slug ? e.to : e.from;
|
||||
lines.push(`- ${arrow} **${other}** [${e.type}]${e.context ? ` — ${e.context}` : ''} (depth ${e.depth})`);
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), { edgeCount: edges.length });
|
||||
}
|
||||
|
||||
async function traverse(
|
||||
engine: ReturnType<typeof getEngine>,
|
||||
slug: string,
|
||||
direction: string,
|
||||
maxDepth: number,
|
||||
currentDepth: number,
|
||||
visited: Set<string>,
|
||||
edges: Array<{ from: string; to: string; type: string; context: string; depth: number }>,
|
||||
) {
|
||||
if (currentDepth > maxDepth) return;
|
||||
|
||||
const [outgoing, incoming] = await Promise.all([
|
||||
(direction === 'outgoing' || direction === 'both') ? engine.getLinks(slug) : Promise.resolve([]),
|
||||
(direction === 'incoming' || direction === 'both') ? engine.getBacklinks(slug) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const nextSlugs: string[] = [];
|
||||
|
||||
for (const link of outgoing) {
|
||||
edges.push({ from: link.from_slug, to: link.to_slug, type: link.link_type, context: link.context, depth: currentDepth });
|
||||
if (!visited.has(link.to_slug)) {
|
||||
visited.add(link.to_slug);
|
||||
nextSlugs.push(link.to_slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of incoming) {
|
||||
edges.push({ from: link.from_slug, to: link.to_slug, type: link.link_type, context: link.context, depth: currentDepth });
|
||||
if (!visited.has(link.from_slug)) {
|
||||
visited.add(link.from_slug);
|
||||
nextSlugs.push(link.from_slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (const next of nextSlugs) {
|
||||
await traverse(engine, next, direction, maxDepth, currentDepth + 1, visited, edges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* gbrain_ingest — Create or update brain pages with automatic re-indexing.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine, getConfig } from '../engine-host.js';
|
||||
import { parseMarkdown, serializeMarkdown } from '../../../src/core/markdown.ts';
|
||||
import type { PageType } from '../../../src/core/types.ts';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainIngestSchema = Type.Object({
|
||||
slug: Type.String({
|
||||
description: 'Brain-relative slug (e.g. "people/new-person", "companies/acme")',
|
||||
}),
|
||||
content: Type.Optional(
|
||||
Type.String({ description: 'Full page markdown content (for new pages)' }),
|
||||
),
|
||||
timelineEntry: Type.Optional(
|
||||
Type.String({ description: 'Text to prepend as a new timeline entry (date auto-added)' }),
|
||||
),
|
||||
compiledTruthUpdate: Type.Optional(
|
||||
Type.String({ description: 'New compiled truth body (replaces compiled truth section)' }),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeIngest(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const config = getConfig();
|
||||
const slug = params['slug'] as string;
|
||||
const content = params['content'] as string | undefined;
|
||||
const timelineEntry = params['timelineEntry'] as string | undefined;
|
||||
const compiledTruthUpdate = params['compiledTruthUpdate'] as string | undefined;
|
||||
|
||||
const filePath = join(config.brainPath, `${slug}.md`);
|
||||
const actions: string[] = [];
|
||||
|
||||
if (content) {
|
||||
// New page — write full content
|
||||
const dir = dirname(filePath);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, 'utf-8');
|
||||
actions.push('created');
|
||||
|
||||
// Index the new page
|
||||
const parsed = parseMarkdown(content, filePath);
|
||||
await engine.putPage(slug, {
|
||||
type: parsed.type,
|
||||
title: parsed.title,
|
||||
compiled_truth: parsed.compiled_truth,
|
||||
timeline: parsed.timeline,
|
||||
frontmatter: parsed.frontmatter,
|
||||
});
|
||||
actions.push('indexed');
|
||||
} else if (existsSync(filePath)) {
|
||||
const existing = readFileSync(filePath, 'utf-8');
|
||||
const parsed = parseMarkdown(existing, filePath);
|
||||
let newCompiledTruth = parsed.compiled_truth;
|
||||
let newTimeline = parsed.timeline;
|
||||
|
||||
if (compiledTruthUpdate) {
|
||||
newCompiledTruth = compiledTruthUpdate;
|
||||
actions.push('compiled_truth_updated');
|
||||
}
|
||||
|
||||
if (timelineEntry) {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const entry = `- **${today}**: ${timelineEntry}`;
|
||||
newTimeline = entry + '\n' + newTimeline;
|
||||
actions.push('timeline_prepended');
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
const serialized = serializeMarkdown(
|
||||
parsed.frontmatter,
|
||||
newCompiledTruth,
|
||||
newTimeline,
|
||||
{ type: parsed.type, title: parsed.title, tags: parsed.tags },
|
||||
);
|
||||
writeFileSync(filePath, serialized, 'utf-8');
|
||||
|
||||
// Re-index
|
||||
await engine.putPage(slug, {
|
||||
type: parsed.type,
|
||||
title: parsed.title,
|
||||
compiled_truth: newCompiledTruth,
|
||||
timeline: newTimeline,
|
||||
frontmatter: parsed.frontmatter,
|
||||
});
|
||||
actions.push('re-indexed');
|
||||
}
|
||||
} else {
|
||||
return textResult(`Page "${slug}" not found and no content provided for creation.`);
|
||||
}
|
||||
|
||||
return textResult(`Done: ${actions.join(', ')} for ${slug}`, { actions, slug });
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* gbrain_resolve — Entity resolution (name → page).
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import type { PageType } from '../../../src/core/types.ts';
|
||||
import { textResult, truncate } from '../tool-result.js';
|
||||
|
||||
export const gbrainResolveSchema = Type.Object({
|
||||
name: Type.String({
|
||||
description: 'Entity name to resolve (e.g. "Pedro", "Brex", "the Variant deal")',
|
||||
}),
|
||||
type: Type.Optional(
|
||||
Type.Union(
|
||||
[
|
||||
Type.Literal('person'),
|
||||
Type.Literal('company'),
|
||||
Type.Literal('deal'),
|
||||
Type.Literal('meeting'),
|
||||
Type.Literal('any'),
|
||||
],
|
||||
{ default: 'any', description: 'Expected entity type' },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeResolve(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const name = params['name'] as string;
|
||||
const type = (params['type'] as string) ?? 'any';
|
||||
|
||||
// 1. Try direct slug resolution
|
||||
const slugCandidates = await engine.resolveSlugs(name.toLowerCase().replace(/\s+/g, '-'));
|
||||
|
||||
const filtered = type !== 'any'
|
||||
? await filterByType(engine, slugCandidates, type as PageType)
|
||||
: slugCandidates;
|
||||
|
||||
if (filtered.length > 0) {
|
||||
const bestSlug = filtered[0];
|
||||
const page = await engine.getPage(bestSlug);
|
||||
if (page) {
|
||||
return textResult(
|
||||
`Resolved: **${page.title}**\n` +
|
||||
`slug: ${page.slug} | type: ${page.type}\n\n` +
|
||||
truncate(page.compiled_truth, 500),
|
||||
{ slug: page.slug, type: page.type, confidence: 1.0 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fall back to keyword search
|
||||
const searchOpts = type !== 'any' ? { type: type as PageType, limit: 5 } : { limit: 5 };
|
||||
const searchResults = await engine.searchKeyword(name, searchOpts);
|
||||
|
||||
if (searchResults.length > 0) {
|
||||
const best = searchResults[0];
|
||||
const page = await engine.getPage(best.slug);
|
||||
if (page && best.score > 0.3) {
|
||||
const others = searchResults.slice(1).map(r =>
|
||||
` - ${r.title} (${r.slug}, score: ${r.score.toFixed(2)})`,
|
||||
);
|
||||
return textResult(
|
||||
`Best match: **${page.title}** (score: ${best.score.toFixed(2)})\n` +
|
||||
`slug: ${page.slug} | type: ${page.type}\n\n` +
|
||||
truncate(page.compiled_truth, 400) +
|
||||
(others.length > 0 ? `\n\nOther candidates:\n${others.join('\n')}` : ''),
|
||||
{ slug: page.slug, type: page.type, confidence: best.score },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(`No confident match found for "${name}".`);
|
||||
}
|
||||
|
||||
async function filterByType(
|
||||
engine: ReturnType<typeof getEngine>,
|
||||
slugs: string[],
|
||||
type: PageType,
|
||||
): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
for (const slug of slugs) {
|
||||
const page = await engine.getPage(slug);
|
||||
if (page && page.type === type) result.push(slug);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* gbrain_search — Hybrid search (keyword + semantic via RRF fusion).
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { hybridSearch } from '../../../src/core/search/hybrid.ts';
|
||||
import type { SearchOpts, PageType } from '../../../src/core/types.ts';
|
||||
import { textResult, truncate } from '../tool-result.js';
|
||||
|
||||
const PAGE_TYPES = [
|
||||
'person', 'company', 'deal', 'meeting', 'project',
|
||||
'yc', 'civic', 'concept', 'source', 'media',
|
||||
] as const;
|
||||
|
||||
export const gbrainSearchSchema = Type.Object({
|
||||
query: Type.String({ description: 'Natural language search query' }),
|
||||
scope: Type.Optional(
|
||||
Type.Union(
|
||||
PAGE_TYPES.map(t => Type.Literal(t)),
|
||||
{ description: 'Limit search to a specific page type' },
|
||||
),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({ default: 10, minimum: 1, maximum: 50, description: 'Max results to return' }),
|
||||
),
|
||||
mode: Type.Optional(
|
||||
Type.Union(
|
||||
[Type.Literal('hybrid'), Type.Literal('keyword'), Type.Literal('semantic')],
|
||||
{ default: 'hybrid', description: 'Search mode' },
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeSearch(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const query = params['query'] as string;
|
||||
const scope = params['scope'] as PageType | undefined;
|
||||
const limit = (params['limit'] as number) ?? 10;
|
||||
const mode = (params['mode'] as string) ?? 'hybrid';
|
||||
|
||||
const opts: SearchOpts & { limit: number; type?: PageType } = { limit };
|
||||
if (scope) opts.type = scope;
|
||||
|
||||
let results;
|
||||
if (mode === 'keyword') {
|
||||
results = await engine.searchKeyword(query, opts);
|
||||
} else {
|
||||
results = await hybridSearch(engine, query, opts);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
return textResult(`No results found for "${query}".`);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`Found ${results.length} result(s) for "${query}":\n`);
|
||||
|
||||
for (const r of results) {
|
||||
lines.push(`## ${r.title}`);
|
||||
lines.push(`slug: ${r.slug} | type: ${r.type} | score: ${r.score.toFixed(3)}`);
|
||||
lines.push(truncate(r.chunk_text, 400));
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), { resultCount: results.length });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* gbrain_stats — Brain health and statistics.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainStatsSchema = Type.Object({});
|
||||
|
||||
export async function executeStats() {
|
||||
const engine = getEngine();
|
||||
|
||||
const [stats, health] = await Promise.all([
|
||||
engine.getStats(),
|
||||
engine.getHealth(),
|
||||
]);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('## GBrain Stats\n');
|
||||
lines.push(`**Pages:** ${stats.page_count}`);
|
||||
lines.push(`**Chunks:** ${stats.chunk_count} (${stats.embedded_count} embedded)`);
|
||||
lines.push(`**Links:** ${stats.link_count}`);
|
||||
lines.push(`**Timeline entries:** ${stats.timeline_entry_count}`);
|
||||
lines.push(`**Tags:** ${stats.tag_count}`);
|
||||
|
||||
lines.push('\n### Pages by Type\n');
|
||||
for (const [type, count] of Object.entries(stats.pages_by_type).sort((a, b) => b[1] - a[1])) {
|
||||
lines.push(`- ${type}: ${count}`);
|
||||
}
|
||||
|
||||
lines.push('\n### Health\n');
|
||||
lines.push(`**Brain Score:** ${health.brain_score}/100`);
|
||||
lines.push(`**Embed Coverage:** ${(health.embed_coverage * 100).toFixed(1)}%`);
|
||||
lines.push(`**Link Coverage:** ${(health.link_coverage * 100).toFixed(1)}%`);
|
||||
lines.push(`**Timeline Coverage:** ${(health.timeline_coverage * 100).toFixed(1)}%`);
|
||||
lines.push(`**Stale Pages:** ${health.stale_pages}`);
|
||||
lines.push(`**Orphan Pages:** ${health.orphan_pages}`);
|
||||
lines.push(`**Dead Links:** ${health.dead_links}`);
|
||||
|
||||
if (health.most_connected.length > 0) {
|
||||
lines.push('\n### Most Connected\n');
|
||||
for (const mc of health.most_connected) {
|
||||
lines.push(`- ${mc.slug}: ${mc.link_count} links`);
|
||||
}
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), {
|
||||
pageCount: stats.page_count,
|
||||
brainScore: health.brain_score,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* gbrain_timeline — Temporal queries for a specific entity.
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { getEngine } from '../engine-host.js';
|
||||
import { textResult } from '../tool-result.js';
|
||||
|
||||
export const gbrainTimelineSchema = Type.Object({
|
||||
slug: Type.String({
|
||||
description: 'Page slug to get timeline for (e.g. "people/garry-tan", "companies/brex")',
|
||||
}),
|
||||
since: Type.Optional(
|
||||
Type.String({ description: 'Only entries after this date (ISO or "7d", "30d")' }),
|
||||
),
|
||||
until: Type.Optional(
|
||||
Type.String({ description: 'Only entries before this date (ISO)' }),
|
||||
),
|
||||
limit: Type.Optional(
|
||||
Type.Number({ default: 20, minimum: 1, maximum: 100, description: 'Max entries to return' }),
|
||||
),
|
||||
});
|
||||
|
||||
export async function executeTimeline(params: Record<string, unknown>) {
|
||||
const engine = getEngine();
|
||||
const slug = params['slug'] as string;
|
||||
const since = params['since'] as string | undefined;
|
||||
const until = params['until'] as string | undefined;
|
||||
const limit = (params['limit'] as number) ?? 20;
|
||||
|
||||
const page = await engine.getPage(slug);
|
||||
if (!page) return textResult(`Page "${slug}" not found.`);
|
||||
|
||||
const after = since ? resolveDate(since) : undefined;
|
||||
const before = until ? resolveDate(until) : undefined;
|
||||
|
||||
const timeline = await engine.getTimeline(slug, { limit, after, before });
|
||||
|
||||
if (timeline.length === 0) {
|
||||
return textResult(`No timeline entries found for "${page.title}".`);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`## Timeline: ${page.title}`);
|
||||
lines.push(`${timeline.length} entries${after ? ` since ${after}` : ''}${before ? ` until ${before}` : ''}:\n`);
|
||||
|
||||
for (const t of timeline) {
|
||||
lines.push(`**${t.date}** (${t.source}): ${t.summary}`);
|
||||
if (t.detail) lines.push(` ${t.detail}`);
|
||||
}
|
||||
|
||||
return textResult(lines.join('\n'), { entryCount: timeline.length });
|
||||
}
|
||||
|
||||
function resolveDate(input: string): string {
|
||||
const relMatch = input.match(/^(\d+)d$/);
|
||||
if (relMatch) {
|
||||
const days = parseInt(relMatch[1], 10);
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - days);
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
return input;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"declaration": false,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user