Files
gbrain/src/core/markdown.ts
T
b22cbd349a feat: GBrain v0.1.0 — Postgres-native personal knowledge brain (#1)
* 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>
2026-04-05 12:48:10 -07:00

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 [];
}