mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
* chore: add CLAUDE.md with project context and gstack skill routing rules Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: initialize project with Bun + TypeScript package.json with dependencies (postgres, pgvector, openai, anthropic, MCP SDK, gray-matter). TypeScript config targeting ESNext with bundler module resolution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add foundation layer — engine interface, Postgres engine, schema BrainEngine pluggable interface with full PostgresEngine: CRUD, search (keyword + vector), links, tags, timeline, versions, stats, health, ingest log, config. Trigger-based tsvector spanning pages + timeline_entries. Markdown parser with frontmatter, compiled_truth / timeline splitting, and round-trip serialization. 19 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add 3-tier chunking and embedding service Recursive delimiter-aware chunker (5-level hierarchy, 300-word chunks, 50-word overlap). Semantic chunker with Savitzky-Golay boundary detection and recursive fallback. LLM-guided chunker via Claude Haiku with sliding window topic detection. OpenAI embedding service with batch support, exponential backoff, and rate limit handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add hybrid search with RRF fusion, expansion, and 4-layer dedup Hybrid search merges vector (pgvector HNSW) + keyword (tsvector) via Reciprocal Rank Fusion. Multi-query expansion via Claude Haiku generates 2 alternative phrasings. 4-layer dedup pipeline: by source, cosine similarity, type diversity (60% cap), per-page cap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add GBRAIN_V0 spec, pluggable engine architecture, SQLite engine plan GBRAIN_V0.md: full product spec with architecture decisions, CLI commands, schema, search architecture, chunking strategies, first-time experience, and future plans. ENGINES.md: pluggable engine interface, capability matrix, how to add new backends. SQLITE_ENGINE.md: complete SQLite implementation plan with schema, FTS5 setup, vector search options, and contributor guide. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add CLI with all commands Full CLI dispatcher with 25+ commands: init (Supabase wizard), get, put, delete, list, search, query (hybrid RRF), import (bulk with progress bar), export (round-trip), embed, stats, health, tag/untag/tags, link/unlink/ backlinks/graph, timeline/timeline-add, history/revert, config, upgrade, serve, call. Smart slug resolution on reads. Version snapshots on updates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add MCP stdio server with all brain tools 20 MCP tools mirroring CLI operations: get/put/delete/list pages, search (keyword), query (hybrid RRF + expansion), tags, links with graph traversal, timeline, stats, health, version history, and revert. Auto-chunks and embeds on put_page. CLI and MCP share the same engine. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add 6 skill files and ClawHub manifest Fat markdown skills for AI agents: ingest (meetings/docs/articles with timeline merge), query (3-layer search + synthesis + citations), maintain (health checks, stale detection, orphan audit), enrich (external API enrichment), briefing (daily briefing compilation), migrate (universal migration from Obsidian/Notion/Logseq/markdown/CSV/JSON/Roam). ClawHub manifest for skill distribution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add README, CONTRIBUTING, update CLAUDE.md test references README with quickstart, commands, architecture, library usage, MCP setup, and links to design docs. CONTRIBUTING with setup, project structure, and guides for adding commands and engines. CLAUDE.md updated to reference actual test files instead of planned-but-unwritten import test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address adversarial review findings — 5 critical/high fixes - revertToVersion: add page_id check to prevent cross-page data corruption - traverseGraph: use UNION instead of UNION ALL for cycle safety - embedAll: preserve all chunks when embedding stale subset only - embedding: throw on retry exhaustion instead of returning zero vectors - putPage: validate slugs to prevent path traversal on export Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.1.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: expand README with schema, install, search architecture, and motivation Why it exists, how search works (with ASCII diagram), full database schema with all 9 tables and index details, chunking strategies explained, storage estimates, setup wizard walkthrough, knowledge model with example page, library usage with more examples, expanded skills table. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: add MIT license (Copyright 2026 Garry Tan) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add OpenClaw install flow as primary option in README OpenClaw users just say "install gbrain" and the orchestrator handles everything: package install, Supabase setup wizard, skill registration. Shows the conversational interface for querying, ingesting, and briefings. ClawHub and standalone CLI paths follow as alternatives. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add prerequisites and explicit OpenClaw install instructions Prerequisites table listing Supabase, OpenAI, and Anthropic dependencies with links. Environment variable setup. Explicit step-by-step prompt for OpenClaw users showing exactly what to tell the orchestrator. Note that search degrades gracefully without API keys (keyword-only without OpenAI, no expansion without Anthropic). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: scrub named references, add PG essay demo section to README Replace all Pedro/Brex/Jensen Huang/River AI examples with Paul Graham essay examples using the kindling corpus. Add "Try it" section to README showing the power of hybrid search on PG essays in 90 seconds. Update test fixtures to use concept pages instead of person pages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
import matter from 'gray-matter';
|
|
import type { PageType } from './types.ts';
|
|
|
|
export interface ParsedMarkdown {
|
|
frontmatter: Record<string, unknown>;
|
|
compiled_truth: string;
|
|
timeline: string;
|
|
slug: string;
|
|
type: PageType;
|
|
title: string;
|
|
tags: string[];
|
|
}
|
|
|
|
/**
|
|
* Parse a markdown file with YAML frontmatter into its components.
|
|
*
|
|
* Structure:
|
|
* ---
|
|
* type: concept
|
|
* title: Do Things That Don't Scale
|
|
* tags: [startups, growth]
|
|
* ---
|
|
* Compiled truth content here...
|
|
* ---
|
|
* Timeline content here...
|
|
*
|
|
* The first --- pair is YAML frontmatter (handled by gray-matter).
|
|
* After frontmatter, the body is split at the first standalone ---
|
|
* (a line containing only --- with optional whitespace).
|
|
* Everything before is compiled_truth, everything after is timeline.
|
|
* If no body --- exists, all content is compiled_truth.
|
|
*/
|
|
export function parseMarkdown(content: string, filePath?: string): ParsedMarkdown {
|
|
const { data: frontmatter, content: body } = matter(content);
|
|
|
|
// Split body at first standalone ---
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
|
|
// Extract metadata from frontmatter
|
|
const type = (frontmatter.type as PageType) || inferType(filePath);
|
|
const title = (frontmatter.title as string) || inferTitle(filePath);
|
|
const tags = extractTags(frontmatter);
|
|
const slug = (frontmatter.slug as string) || inferSlug(filePath);
|
|
|
|
// Remove processed fields from frontmatter (they're stored as columns)
|
|
const cleanFrontmatter = { ...frontmatter };
|
|
delete cleanFrontmatter.type;
|
|
delete cleanFrontmatter.title;
|
|
delete cleanFrontmatter.tags;
|
|
delete cleanFrontmatter.slug;
|
|
|
|
return {
|
|
frontmatter: cleanFrontmatter,
|
|
compiled_truth: compiled_truth.trim(),
|
|
timeline: timeline.trim(),
|
|
slug,
|
|
type,
|
|
title,
|
|
tags,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Split body content at first standalone --- separator.
|
|
* Returns compiled_truth (before) and timeline (after).
|
|
*/
|
|
export function splitBody(body: string): { compiled_truth: string; timeline: string } {
|
|
// Match a line that is only --- (with optional whitespace)
|
|
// Must not be at the very start (that would be frontmatter)
|
|
const lines = body.split('\n');
|
|
let splitIndex = -1;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const trimmed = lines[i].trim();
|
|
if (trimmed === '---') {
|
|
// Skip if this is the very first non-empty line (leftover from frontmatter parsing)
|
|
const beforeContent = lines.slice(0, i).join('\n').trim();
|
|
if (beforeContent.length > 0) {
|
|
splitIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (splitIndex === -1) {
|
|
return { compiled_truth: body, timeline: '' };
|
|
}
|
|
|
|
const compiled_truth = lines.slice(0, splitIndex).join('\n');
|
|
const timeline = lines.slice(splitIndex + 1).join('\n');
|
|
return { compiled_truth, timeline };
|
|
}
|
|
|
|
/**
|
|
* Serialize a page back to markdown format.
|
|
* Produces: frontmatter + compiled_truth + --- + timeline
|
|
*/
|
|
export function serializeMarkdown(
|
|
frontmatter: Record<string, unknown>,
|
|
compiled_truth: string,
|
|
timeline: string,
|
|
meta: { type: PageType; title: string; tags: string[] },
|
|
): string {
|
|
// Build full frontmatter including type, title, tags
|
|
const fullFrontmatter: Record<string, unknown> = {
|
|
type: meta.type,
|
|
title: meta.title,
|
|
...frontmatter,
|
|
};
|
|
if (meta.tags.length > 0) {
|
|
fullFrontmatter.tags = meta.tags;
|
|
}
|
|
|
|
const yamlContent = matter.stringify('', fullFrontmatter).trim();
|
|
|
|
let body = compiled_truth;
|
|
if (timeline) {
|
|
body += '\n\n---\n\n' + timeline;
|
|
}
|
|
|
|
return yamlContent + '\n\n' + body + '\n';
|
|
}
|
|
|
|
function inferType(filePath?: string): PageType {
|
|
if (!filePath) return 'concept';
|
|
|
|
// Normalize: add leading / for consistent matching
|
|
const lower = ('/' + filePath).toLowerCase();
|
|
if (lower.includes('/people/') || lower.includes('/person/')) return 'person';
|
|
if (lower.includes('/companies/') || lower.includes('/company/')) return 'company';
|
|
if (lower.includes('/deals/') || lower.includes('/deal/')) return 'deal';
|
|
if (lower.includes('/yc/')) return 'yc';
|
|
if (lower.includes('/civic/')) return 'civic';
|
|
if (lower.includes('/projects/') || lower.includes('/project/')) return 'project';
|
|
if (lower.includes('/sources/') || lower.includes('/source/')) return 'source';
|
|
if (lower.includes('/media/')) return 'media';
|
|
return 'concept';
|
|
}
|
|
|
|
function inferTitle(filePath?: string): string {
|
|
if (!filePath) return 'Untitled';
|
|
|
|
// Extract filename without extension, convert dashes/underscores to spaces
|
|
const parts = filePath.split('/');
|
|
const filename = parts[parts.length - 1]?.replace(/\.md$/i, '') || 'Untitled';
|
|
return filename.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
}
|
|
|
|
function inferSlug(filePath?: string): string {
|
|
if (!filePath) return 'untitled';
|
|
|
|
// Remove leading path components that are just the import root
|
|
// Keep the type directory + filename structure
|
|
let slug = filePath
|
|
.replace(/\.md$/i, '')
|
|
.replace(/\\/g, '/');
|
|
|
|
// Remove leading ./
|
|
if (slug.startsWith('./')) slug = slug.slice(2);
|
|
|
|
return slug.toLowerCase();
|
|
}
|
|
|
|
function extractTags(frontmatter: Record<string, unknown>): string[] {
|
|
const tags = frontmatter.tags;
|
|
if (!tags) return [];
|
|
if (Array.isArray(tags)) return tags.map(String);
|
|
if (typeof tags === 'string') return tags.split(',').map(t => t.trim()).filter(Boolean);
|
|
return [];
|
|
}
|