mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* feat(ingestion): v0.38 substrate — daemon + IngestionSource contract + 2 sources
The foundation for the ingestion cathedral (CEO+DX+Eng plan-reviewed).
Plan: ~/.claude/plans/system-instruction-you-are-working-ethereal-riddle.md
WHAT YOU CAN NOW DO
The IngestionSource public contract is locked. Skillpack publishers can
build third-party ingestion sources (Granola, Linear, Mail, voice, OCR,
etc.) and ship them through the v0.37 skillpack registry. The locked
surface lives at the new package subpaths:
import { IngestionSource, IngestionEvent } from 'gbrain/ingestion';
import { IngestionTestHarness, expectEvent } from 'gbrain/ingestion/test-harness';
Both subpaths are pinned by test/public-exports.test.ts — breaking either
is a major-version change.
WHAT THIS COMMIT BUILDS
Foundation:
- src/core/ingestion/types.ts (IngestionSource, IngestionEvent,
IngestionSourceContext, validateIngestionEvent, computeContentHash,
INGESTION_SOURCE_API_VERSION, INGESTION_CONTENT_TYPES)
- src/core/ingestion/dedup.ts (24h content-hash LRU, 5000-entry cap)
- src/core/ingestion/skillpack-load.ts (gbrain.plugin.json discovery for
third-party sources, api_version compat with paste-ready upgrade hints,
in-process trust model for v1)
- src/core/ingestion/daemon.ts (IngestionDaemon: in-process source
supervision sibling to v0.34.3.0 ChildWorkerSupervisor pattern, plus
validate -> dedup -> rate-limit -> dispatch pipeline + health surface)
- src/core/ingestion/test-harness.ts (publisher-facing test utility with
fake clock + in-memory event bus + expectEvent matchers + engine proxy
that throws on access so publishers know what they're depending on)
- src/core/ingestion/index.ts (barrel for gbrain/ingestion subpath)
First two built-in sources prove the abstraction:
- file-watcher (chokidar over the brain repo; 1s debounce; honors
pruneDir from src/core/sync.ts; symlinks rejected; Linux ENOSPC
surfaces a paste-ready sysctl hint at runtime)
- inbox-folder (~/.gbrain/inbox/ target for iOS Shortcuts / AirDrop /
Drafts; auto-archives processed files into .archived/YYYY-MM-DD/;
symlink rejection; world-writable dir warning; routes content-type by
extension)
Public exports surface (count 18 -> 20) pinned in:
- package.json exports map
- test/public-exports.test.ts EXPECTED_EXPORTS + count gate
- scripts/check-exports-count.sh baseline
ARCHITECTURE-LOCKED DECISIONS (from /plan-eng-review)
E1 webhook source process boundary: webhook source will live INSIDE
serve --http (NOT this daemon) when it lands in the next commit. Daemon
supervises only daemon-side sources.
E2 content-type processor execution: hybrid by size (inline <1MB,
Minion handlers >1MB). Processors land in a later commit.
E3 publisher TTHW: chokidar v4.0.3 across platforms; ephemeral PGLite
persistence and Linux inotify-limit doctor probe land in later commits.
E4 migration v80 (provenance columns) + forward-reference bootstrap:
lands with put_page write-through in a later commit.
DX-locked decisions (from /plan-devex-review):
- Source error semantics: throws bubble to daemon; supervisor backoff.
- IngestionTestHarness exported as gbrain/ingestion/test-harness.
- api_version field on gbrain.plugin.json with loud-fail on mismatch.
TESTS
192 cases across 8 test files, 0 failures:
- test/ingestion/types.test.ts (28 cases pinning the contract)
- test/ingestion/dedup.test.ts (15 cases for LRU + TTL + collision)
- test/ingestion/skillpack-load.test.ts (22 cases for manifest
validation + api_version compat + collision policy + module load)
- test/ingestion/test-harness.test.ts (24 cases for harness lifecycle +
clock + healthCheck + every expectEvent matcher)
- test/ingestion/daemon.test.ts (19 cases for supervision + dispatch
pipeline + health surface + per-source config + logger wrapping)
- test/ingestion/sources/file-watcher.test.ts (10 cases including
ENOSPC sysctl-hint surfacing)
- test/ingestion/sources/inbox-folder.test.ts (24 cases including
symlink rejection + world-writable warning + archive-loop-prevention)
- test/public-exports.test.ts (2 new cases for the new subpaths)
typecheck clean. bun run verify gate passes.
NEXT IN WAVE
Subsequent commits in this PR ship webhook source (serve --http route),
cron-scheduler refactor + OpenClaw credential auto-migrate, content-type
processors (PDF + image OCR + audio transcribe + video keyframe), put_page
write-through with serializePageToMarkdown DRY extract, migration v80
+ bootstrap probes, gbrain capture verb, publisher DX cathedral (init
scaffold extension + gbrain ingest test [--watch] + tail + validate),
daemon rename autopilot -> ingest with forever-alias, doctor inotify
probe on Linux, skillpack contract docs + reference pack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ingestion): webhook source — POST /ingest + ingest_capture Minion handler
Lands the v0.38 ingestion cathedral's webhook source. Per the
/plan-eng-review E1 decision, the webhook source lives INSIDE
`serve --http` (NOT the ingestion daemon) so there is no new IPC: the
HTTP route submits Minion jobs directly into the existing queue, and
the daemon supervises only daemon-side sources.
WHAT YOU CAN NOW DO
With `gbrain serve --http` running and an OAuth client minted, any
HTTP caller (Zapier, IFTTT, n8n, Make, Apple Shortcuts) can POST a
captured thought into the brain:
curl -X POST https://your-brain.example.com/ingest \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: text/markdown" \
-d "# captured from my Shortcut"
The route auths via OAuth (write scope required), validates the
content-type, enforces a 1MB payload cap and per-IP rate limit
(100 events / 10s), submits an `ingest_capture` Minion job tagged
`untrusted_payload: true`, and returns 202 Accepted with the job id.
The job materializes the page under `inbox/YYYY-MM-DD-<hash6>` by
default (overridable via X-Gbrain-Slug header) so the user has a
predictable triage location.
WHAT THIS COMMIT BUILDS
- src/core/minions/handlers/ingest-capture.ts (new) — handler that
takes an IngestionEvent payload, resolves a slug via fallback chain
(job.data.slug -> event.metadata.slug -> inbox/<date>-<hash6>),
validates the event at the handler boundary, REJECTS binary
content_types with a paste-ready hint to install a processor
skillpack, and routes through importFromContent. Defaults
noEmbed: true (embed is a separate Minion job, matching the sync
handler's pattern).
- src/commands/jobs.ts — registers `ingest_capture` in
registerBuiltinHandlers alongside sync/embed/extract.
- src/commands/serve-http.ts — POST /ingest route with:
- OAuth write-scope gate via requireBearerAuth({requiredScopes:['write']})
- 100 events / 10s rate limiter (sibling to ccRateLimiter)
- Content-type allowlist: text/markdown, text/plain, text/html,
application/json; binary REJECTED with HTTP 415
- 1 MB payload cap (configurable via GBRAIN_INGEST_MAX_BYTES)
- Caller-overridable source identity via X-Gbrain-Source-Id /
X-Gbrain-Source-Uri / X-Gbrain-Content-Type / X-Gbrain-Slug
headers — useful for downstream tools that want clean provenance
- untrusted_payload: true ALWAYS (network input)
- Idempotency on (client_id, content_hash) so simultaneous retries
collapse to one job
- maxWaiting: 50 per client so a runaway integration can't
monopolize the queue
- Audit row in mcp_request_log + SSE broadcast for the admin feed
TESTS
test/ingestion/ingest-capture.test.ts (15 cases against PGLite):
- defaultSlugForEvent helper (3 cases pinning shape + UTC + determinism)
- slug resolution fallback chain (3 cases)
- validation + content-type routing (5 cases including binary rejection
+ untrusted_payload round-trip)
- importFromContent integration (3 cases including content_hash dedup
via status='skipped' on repeat)
207 total ingestion tests passing. typecheck clean.
NEXT IN WAVE
cron-scheduler refactor + OpenClaw credential auto-migrate; content-type
processors (PDF + image OCR + audio transcribe + video keyframe);
put_page write-through + serializePageToMarkdown DRY extract +
migration v80 + bootstrap probes; gbrain capture verb; publisher DX
cathedral (init scaffold + gbrain ingest test --watch + tail + validate);
daemon rename autopilot -> ingest with forever-alias; doctor inotify
probe; skillpack contract docs + reference pack + VERSION bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ingestion): put_page write-through + migration v80 + DRY extract
WHAT YOU CAN NOW DO
The drift class is dead. Every `gbrain put_page` (CLI or MCP, local
or remote) now lands its markdown file on disk alongside the DB row
whenever `sync.repo_path` is configured. The page is queryable
immediately AND visible to git, your editor, and downstream tools.
Pre-v0.38, put_page wrote ONLY to the DB and synthesize/extract paths
had to reverse-render later. The v0.35.6.0 phantom-redirect pass was
the cleanup for what THIS commit prevents in the first place.
# local CLI
gbrain put inbox/test < my-thought.md
# file lands at ${sync.repo_path}/inbox/test.md AND in the DB
# MCP remote (Zapier / Cursor / Claude Desktop)
curl -X POST /mcp ... '{"method":"tools/call","params":{"name":"put_page",...}}'
# server-side write-through fires, agent gets a normal success response
# untrusted_payload tagging applied (no auto-link, slug-allowlist gate)
Provenance frontmatter stamped on every write so future sync round-trips
know where the page came from:
ingested_via: put_page # local CLI
ingested_via: 'mcp:put_page' # MCP remote
ingested_at: 2026-05-21T04:...
WHAT THIS COMMIT BUILDS
1. Migration v80 — `pages_provenance_columns` adds four nullable
columns to `pages`: `ingested_via`, `ingested_at`, `source_uri`,
`source_kind`. ADD COLUMN with no DEFAULT is metadata-only on
Postgres 11+ and PGLite 17.5; instant on tables of any size. The
four columns get NULL on every historical page (pre-v0.38 pages
never had provenance).
2. DRY extract — `serializePageToMarkdown(page, tags, opts)` and
`resolvePageFilePath(brainDir, slug, sourceId)` in `src/core/markdown.ts`.
The dream-cycle's `renderPageToMarkdown` (synthesize.ts) and the new
put_page write-through path were going to have 90% duplicate bodies.
They now share one foundation; the dream version is a 4-line wrapper
that passes `frontmatterOverrides: {dream_generated: true, ...}`.
Future markdown-shape changes happen in one place.
3. put_page write-through (`src/core/operations.ts`) — after
importFromContent succeeds, resolves sync.repo_path, computes the
v0.32.8 source-aware path layout (default: brainDir/<slug>.md;
non-default: brainDir/.sources/<id>/<slug>.md), serializes the
freshly-written Page via `serializePageToMarkdown`, writes the file.
Returns a `write_through: {written, path}` field in the put_page
response so callers can see what happened.
Trust gating:
- subagent sandbox (viaSubagent without allowedSlugPrefixes) → DB-only
- dry-run → DB-only (handler's early-return short-circuits before
write-through; documented via the dry_run response field)
- no sync.repo_path configured → DB-only, skipped reason returned
- sync.repo_path points at a non-existent dir → DB-only, skipped
- all other writes → write-through
Failure isolation: disk-write failures are LOGGED loud but do NOT
roll back the DB write. DB is the durable record; the
phantom-redirect pass exists for drift cleanup if it ever shows up.
TESTS
- test/ingestion/put-page-write-through.test.ts (10 cases against PGLite):
happy path (file land, provenance stamp local + remote), trust gating
(subagent sandbox, dry-run, trusted-workspace), config edges (no
repo_path, missing dir), multi-source filing (.sources/<id>/),
failure isolation (DB write survives a disk failure).
- Migration v80 verified across both engines via the existing
test/migrate.test.ts + test/bootstrap.test.ts coverage (~125 cases).
369 total tests passing in the ingestion + markdown + migrate bundle.
typecheck clean.
NOTES
- Bootstrap probes for the v80 provenance columns are NOT yet added
to applyForwardReferenceBootstrap on either engine. This is safe
for v0.38 because no SCHEMA_SQL CREATE INDEX or FK references the
new columns — migration v80 is the only consumer, and it runs
AFTER SCHEMA_SQL replay. A future commit may add bootstrap probes
+ REQUIRED_BOOTSTRAP_COVERAGE entries as defense-in-depth (eng
review E4).
- The trusted-workspace path (dream cycle's reverseWriteRefs in
synthesize.ts) still runs its own write at synthesize phase time.
Both paths writing the same file is idempotent (byte-identical
serialization), but a future commit may simplify reverseWriteRefs
to skip pages whose file already matches.
NEXT IN WAVE
gbrain capture verb (the single human-facing entrypoint); daemon
rename autopilot -> ingest with forever-alias + plist migration;
doctor inotify probe (Linux); content-type processor router
(PDF + image OCR + audio transcribe stubs); cron-scheduler refactor
+ OpenClaw credential auto-migrate; skillpack contract docs +
reference pack; VERSION bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ingestion): gbrain capture — the single human-facing entrypoint
WHAT YOU CAN NOW DO
One command, local or thin-client, synchronous receipt with the resulting
page slug. The answer to "what is the best way to get data into the brain?"
is now: just type `gbrain capture` and the right thing happens.
# the basic case
gbrain capture "remember to follow up on the X deal"
# from a file
gbrain capture --file ./notes/today.md --slug daily/2026-05-21
# from a pipe (shell pipelines)
echo "from stdin" | gbrain capture --stdin
# script-friendly: print just the slug
SLUG=$(gbrain capture "a thought" --quiet)
# JSON for agents
gbrain capture "..." --json
Default slug is `inbox/YYYY-MM-DD-<hash8>` — deterministic for the same
content so re-running idempotently lands the same page. Receipt block
on stdout shows slug + status + content_hash + on-disk path so you
can confirm where the page went without rerunning `gbrain query`.
The local-install path routes through the put_page operation with the
v0.38 write-through plumbing landed in the prior commit, so the page
hits both the DB AND the file tree in one move. Thin-client installs
route through `callRemoteTool('put_page', ...)` so the server's
write-through handles disk persistence the same way.
WHAT THIS COMMIT BUILDS
- src/commands/capture.ts (new ~290 LOC):
- `defaultSlug(content)` — UTC-stable `inbox/YYYY-MM-DD-<hash8>`
- `parseArgs(args)` — positional + flag parsing with --file / --stdin
/ --slug / --type / --source / --quiet / --json / --help
- `buildContent(rawBody, opts)` — wraps unstructured prose in
frontmatter (type + title + captured_via + captured_at) and a
leading `# Title` heading; passes through if the body already
looks like markdown
- `runCapture(engine, args)` — local install routes through the
in-process put_page operation; thin-client routes through MCP.
`--quiet` prints just the slug; `--json` prints structured output;
default prints a 5-line receipt block.
- src/cli.ts:
- Adds `case 'capture'` dispatch
- Adds `'capture'` to the CLI_ONLY set so cli.ts wires it correctly
TESTS
test/commands/capture.test.ts (21 cases against PGLite):
- defaultSlug helper: shape + determinism + UTC math
- parseArgs: positional + multi-token join + every flag
- buildContent: prose wrapping, --type override, no double-wrap
for pre-frontmattered content, title cap at 80 chars,
--source provenance stamp
- Integration: inline content lands in DB + on disk, default slug
shape, --file reads from disk, --json structured output,
--help returns without engine roundtrip
271 total tests passing in the bundle. typecheck clean.
NOTES
- Thin-client routing relies on `callRemoteTool('put_page', ...)` from
src/core/mcp-client.ts. Identical UX to the local path because the
server's put_page handler runs the same write-through plumbing.
- buildContent's "looks like markdown" heuristic is intentionally
simple — first-line heading or frontmatter delimiter is the trigger.
Users who care about exact formatting pass a pre-formatted --file.
NEXT IN WAVE
Daemon rename autopilot -> ingest with forever-alias + plist migration;
doctor inotify probe (Linux); content-type processor router
(PDF + image OCR + audio transcribe stubs); cron-scheduler refactor
+ OpenClaw credential auto-migrate; skillpack contract docs +
reference pack; VERSION bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(ingestion): v0.38.0.0 release hygiene + e2e roundtrip + capture skill
VERSION 0.37.1.0 → 0.38.0.0 (trio: VERSION, package.json, CHANGELOG header).
CHANGELOG entry written in user-facing ELI10-lead voice per CLAUDE.md
release-summary rules. README's pre-loop section gains a new "How to get
data in (v0.38+)" block leading with `gbrain capture`.
skills/capture/SKILL.md (NEW) so agents route "capture this" / "save this
thought" / "remember this" / "drop this in the inbox" / "save to brain" to
the capture verb. RESOLVER.md updated with the new triggers (sits above
idea-ingest/media-ingest/meeting-ingestion in the content-ingestion
section as the "simple thought" path).
E2E roundtrip test (test/e2e/ingestion-roundtrip.test.ts) covers the gap:
inbox-folder source -> daemon -> ingest_capture handler -> DB page,
including:
- Full pipeline: file drop appears as page in DB + file moves to .archived/
- Dedup catches byte-identical content from a different filename
- Multi-source coordination: two distinct inbox dirs, two sources, daemon
ingests both events independently
The test runs against an in-memory PGLite (no DATABASE_URL needed) so it
exercises the substrate-level wiring in the standard test suite. A
follow-up commit can add a full-process e2e (gbrain serve --http + real
OAuth client + POST /ingest) that requires DATABASE_URL.
399/399 v0.38 wave tests passing (910 assertions). typecheck clean.
bun run verify gate green across all 14 shell checks.
DEFERRED TO FOLLOW-UP RELEASES (called out in CHANGELOG)
- Daemon rename autopilot -> ingest + forever-alias + plist migration
- cron-scheduler skill refactor + OpenClaw credential auto-migrate
- Content-type processors (PDF / OCR / audio / video)
- gbrain doctor inotify probe (Linux)
- Publisher DX cathedral: gbrain skillpack init --kind=ingestion-source,
gbrain ingest test --watch, ingest tail, ingest validate
- Reference pack at examples/skillpack-ingestion-reference/ + 3-stage
tutorial in docs/ingestion-source-skillpack.md
These are polish items; the substrate is shipped and queryable, and
skillpack publishers can build sources against the IngestionTestHarness
public export today.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(ingestion): test-gap fills — bootstrap probes + manifest entry + conformance
The v0.38.0.0 release-hygiene commit landed cleanly against the v0.38 wave
suite but tripped 3 categories of full-suite tests. This commit fixes
each. The remaining failure (doctorReportRemote > "healthy" status) was
verified pre-existing via `git stash + bun test` and is not caused by
v0.38; left alone.
Fix 1 — `schema-bootstrap-coverage.test.ts` (s1)
The test parses MIGRATIONS for ALTER TABLE ADD COLUMN statements and
fails if any column is not covered by `applyForwardReferenceBootstrap`
on both engines. Migration v80's four provenance columns triggered
the failure. Bootstrap probes added to both engines + 4 entries
appended to REQUIRED_BOOTSTRAP_COVERAGE:
- src/core/pglite-engine.ts — 4 EXISTS probes + state field + needs
flag + ALTER TABLE block when bootstrap fires
- src/core/postgres-engine.ts — same pattern
- test/schema-bootstrap-coverage.test.ts — 4 coverage entries
Fix 2 — `check-resolvable.test.ts` (s3 — orphan_trigger)
RESOLVER.md references skills via name; check-resolvable cross-checks
against skills/manifest.json. The new `capture` skill was missing the
manifest entry; added between `brain-ops` and `idea-ingest` so the
manifest order mirrors the resolver order.
Fix 3 — `skills-conformance.test.ts` (s8)
Every SKILL.md must have `## Contract`, `## Output Format`, and
`## Anti-Patterns` sections. skills/capture/SKILL.md was missing all
three (initial draft skipped them); now compliant with concrete
content per the v0.38 contract.
Fix 4 — `build-llms.test.ts` (s6)
README + CHANGELOG edits in the release-hygiene commit caused
llms-full.txt to drift behind. Regenerated via `bun run build:llms`.
Per CLAUDE.md: any user-facing docs edit MUST run build:llms before
push.
The full bun-test parallel runner now passes everywhere except the
pre-existing `doctorReportRemote > healthy status` failure (50/100
score on an empty fresh brain — this is a pre-v0.38 health-score
tuning issue and orthogonal to ingestion work).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(version): bump 0.38.0.0 → 0.38.1.0
Renumbers the in-flight ingestion-cathedral release to v0.38.1.0.
Trio (VERSION, package.json, CHANGELOG.md) bumped together.
bun run typecheck → clean.
* chore(version): bump 0.38.1.0 → 0.38.0.0
Master sits at 0.37.11.0; 0.38.0.0 is the natural next slot rather than
skipping a release. Trio (VERSION, package.json, CHANGELOG.md) bumped
together. Migration v81 + ingestion substrate stay identical — this is a
header-only renumber.
bun run typecheck → clean.
* test(ingestion): fill v0.38 test gaps — markdown helpers + migration v81 + webhook E2E
Three gaps surfaced from a v0.38 audit against what shipped vs what was
covered. All three filled:
1. **test/markdown-serializer.test.ts** (NEW, 19 cases) — pure-function
coverage of `serializePageToMarkdown` + `resolvePageFilePath`, the
DRY extract that the dream-cycle reverse-render and put_page
write-through both consume. Pre-fix nothing pinned the
frontmatter-override merge precedence, the type/title defaults, or
the source-aware filing layout (default → `<brainDir>/<slug>.md`,
non-default → `<brainDir>/.sources/<source_id>/<slug>.md`). Future
schema-shape changes to either helper now surface immediately.
2. **test/migrate.test.ts — v81 cases** (10 new cases, two describe
blocks) — structural assertions on `pages_provenance_columns`
(four nullable columns, no NOT NULL, no DEFAULT, no index — the
ADD COLUMN stays metadata-only) plus a PGLite round-trip that
asserts the columns appear post-`initSchema`, accept direct UPDATEs,
and survive the historical-page NULL scenario. The
schema-bootstrap-coverage test already pinned the forward-reference
probe contract; this fills the migrate.test.ts contract gap.
3. **test/e2e/serve-http-ingest-webhook.test.ts** (NEW, 16 cases) — HTTP
contract coverage for POST /ingest. The pre-existing
ingestion-roundtrip E2E explicitly notes "e2e (gbrain serve --http +
POST /ingest + real OAuth) is a separate" thing — it covers the
in-process daemon → handler → DB pipeline, NOT the real HTTP route.
This file fills that gap. Spawns real gbrain serve --http against
real Postgres, mints OAuth tokens with various scopes, exercises:
- Auth gate (missing → 401; read-only → 403)
- Body validation (empty → 400 with error: empty_body)
- Content-type allowlist (image/png → 415 with skillpack hint;
application/pdf → 415; text/plain + application/json + text/html
all accepted; unknown text/* falls through to text/plain)
- X-Gbrain-Content-Type / Source-Id / Source-Uri / Slug header
overrides
- Idempotency (same content + same client = identical job_id via
queue dedup on content_hash)
Also wires three new entries into `scripts/e2e-test-map.ts` so changes
to `src/commands/serve-http.ts`, `src/core/ingestion/**`, or the
`ingest-capture` Minion handler auto-trigger the relevant E2Es under
`bun run ci:local:diff`.
Verified locally:
- bun test test/markdown-serializer.test.ts → 19/19 green
- bun test test/migrate.test.ts -t "v81" → 10/10 green
- bun test test/e2e/serve-http-ingest-webhook.test.ts (real Postgres on
ephemeral 5435) → 16/16 green
- bun test test/select-e2e.test.ts → 24/24 green (selector test still
honors the v0.38 entries)
- bun run typecheck → clean
E2E DB lifecycle handled per CLAUDE.md (spin up pgvector:pg16 on a free
port, bootstrap via `gbrain doctor --json`, run, tear down).
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import matter from 'gray-matter';
|
|
import { safeLoad as yamlSafeLoad } from 'js-yaml';
|
|
import type { Page, PageType } from './types.ts';
|
|
import { slugifyPath } from './sync.ts';
|
|
|
|
export type ParseValidationCode =
|
|
| 'MISSING_OPEN'
|
|
| 'MISSING_CLOSE'
|
|
| 'YAML_PARSE'
|
|
| 'SLUG_MISMATCH'
|
|
| 'NULL_BYTES'
|
|
| 'NESTED_QUOTES'
|
|
| 'EMPTY_FRONTMATTER';
|
|
|
|
export interface ParseValidationError {
|
|
code: ParseValidationCode;
|
|
message: string;
|
|
line?: number;
|
|
}
|
|
|
|
export interface ParseOpts {
|
|
/** When true, errors[] is populated. Existing callers unaffected. */
|
|
validate?: boolean;
|
|
/** When validate is true and frontmatter has a `slug:` field that doesn't
|
|
* match expectedSlug, emits SLUG_MISMATCH. */
|
|
expectedSlug?: string;
|
|
}
|
|
|
|
export interface ParsedMarkdown {
|
|
frontmatter: Record<string, unknown>;
|
|
compiled_truth: string;
|
|
timeline: string;
|
|
slug: string;
|
|
type: PageType;
|
|
title: string;
|
|
tags: string[];
|
|
/** Present iff opts.validate. Empty array means no errors. */
|
|
errors?: ParseValidationError[];
|
|
}
|
|
|
|
/**
|
|
* 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 -->
|
|
* Timeline content here...
|
|
*
|
|
* The first --- pair is YAML frontmatter (handled by gray-matter).
|
|
* After frontmatter, the body is split at the first recognized timeline
|
|
* sentinel: `<!-- timeline -->` (preferred), `--- timeline ---` (decorated),
|
|
* or a plain `---` immediately preceding a `## Timeline` / `## History`
|
|
* heading (backward-compat for existing files). A bare `---` in body text
|
|
* is treated as a markdown horizontal rule, not a timeline separator.
|
|
*/
|
|
export function parseMarkdown(
|
|
content: string,
|
|
filePath?: string,
|
|
opts?: ParseOpts,
|
|
): ParsedMarkdown {
|
|
const errors: ParseValidationError[] = [];
|
|
|
|
// gray-matter is forgiving: it returns empty data + original content for
|
|
// pretty much any input. The validation surface below catches the cases
|
|
// it silently swallows. Validation only runs when opts.validate is true,
|
|
// so existing callers are unaffected.
|
|
let parsed: ReturnType<typeof matter> | null = null;
|
|
let yamlParseError: Error | null = null;
|
|
try {
|
|
parsed = matter(content);
|
|
} catch (e) {
|
|
yamlParseError = e as Error;
|
|
}
|
|
|
|
if (opts?.validate) {
|
|
collectValidationErrors(content, errors, {
|
|
yamlParseError,
|
|
expectedSlug: opts.expectedSlug,
|
|
parsedFrontmatter: parsed?.data ?? {},
|
|
});
|
|
}
|
|
|
|
// When YAML parsing failed (rare; gray-matter is forgiving), fall back to
|
|
// empty frontmatter + raw content as the body so non-validate callers still
|
|
// get a usable shape.
|
|
const frontmatter = (parsed?.data ?? {}) as Record<string, unknown>;
|
|
const body = parsed?.content ?? content;
|
|
|
|
const { compiled_truth, timeline } = splitBody(body);
|
|
|
|
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);
|
|
|
|
const cleanFrontmatter = { ...frontmatter };
|
|
delete cleanFrontmatter.type;
|
|
delete cleanFrontmatter.title;
|
|
delete cleanFrontmatter.tags;
|
|
delete cleanFrontmatter.slug;
|
|
|
|
const result: ParsedMarkdown = {
|
|
frontmatter: cleanFrontmatter,
|
|
compiled_truth: compiled_truth.trim(),
|
|
timeline: timeline.trim(),
|
|
slug,
|
|
type,
|
|
title,
|
|
tags,
|
|
};
|
|
if (opts?.validate) result.errors = errors;
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Inspect raw content for the 7 frontmatter validation classes that gray-matter
|
|
* silently accepts. Mutates `errors` in place. The order of checks is
|
|
* deliberate: cheap byte-level checks first, then structural checks, then
|
|
* YAML-parse-dependent checks.
|
|
*/
|
|
function collectValidationErrors(
|
|
content: string,
|
|
errors: ParseValidationError[],
|
|
ctx: {
|
|
yamlParseError: Error | null;
|
|
expectedSlug?: string;
|
|
parsedFrontmatter: Record<string, unknown>;
|
|
},
|
|
): void {
|
|
// 1. NULL_BYTES — binary corruption indicator.
|
|
const nullIdx = content.indexOf('\x00');
|
|
if (nullIdx >= 0) {
|
|
const line = content.slice(0, nullIdx).split('\n').length;
|
|
errors.push({
|
|
code: 'NULL_BYTES',
|
|
message: 'Content contains null bytes (likely binary corruption)',
|
|
line,
|
|
});
|
|
}
|
|
|
|
// 2. MISSING_OPEN — first non-empty line must be `---`.
|
|
const lines = content.split('\n');
|
|
let firstNonEmpty = -1;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (lines[i].trim().length > 0) {
|
|
firstNonEmpty = i;
|
|
break;
|
|
}
|
|
}
|
|
if (firstNonEmpty === -1) {
|
|
// Empty file: treat as MISSING_OPEN. Don't run other structural checks.
|
|
errors.push({
|
|
code: 'MISSING_OPEN',
|
|
message: 'File is empty or whitespace-only; expected frontmatter starting with ---',
|
|
line: 1,
|
|
});
|
|
return;
|
|
}
|
|
if (lines[firstNonEmpty].trim() !== '---') {
|
|
errors.push({
|
|
code: 'MISSING_OPEN',
|
|
message: 'Frontmatter must start with --- on the first non-empty line',
|
|
line: firstNonEmpty + 1,
|
|
});
|
|
// Without an opener we can't reason about MISSING_CLOSE / EMPTY_FRONTMATTER
|
|
// / NESTED_QUOTES inside frontmatter. Stop structural checks here.
|
|
return;
|
|
}
|
|
|
|
// 3. MISSING_CLOSE — find the next `---` after the opener. If a markdown
|
|
// heading appears before it, that's a strong signal the closing
|
|
// delimiter is missing (the heading was meant to be in the body).
|
|
let closeLine = -1;
|
|
let headingBeforeClose = -1;
|
|
for (let i = firstNonEmpty + 1; i < lines.length; i++) {
|
|
const t = lines[i].trim();
|
|
if (t === '---') {
|
|
closeLine = i;
|
|
break;
|
|
}
|
|
if (/^#{1,6}\s/.test(t) && headingBeforeClose === -1) {
|
|
headingBeforeClose = i;
|
|
}
|
|
}
|
|
if (closeLine === -1) {
|
|
errors.push({
|
|
code: 'MISSING_CLOSE',
|
|
message:
|
|
headingBeforeClose >= 0
|
|
? `No closing --- before heading at line ${headingBeforeClose + 1}`
|
|
: 'No closing --- delimiter found',
|
|
line: headingBeforeClose >= 0 ? headingBeforeClose + 1 : firstNonEmpty + 1,
|
|
});
|
|
return;
|
|
}
|
|
if (headingBeforeClose >= 0 && headingBeforeClose < closeLine) {
|
|
errors.push({
|
|
code: 'MISSING_CLOSE',
|
|
message: `Heading at line ${headingBeforeClose + 1} found inside frontmatter zone (closing --- comes after)`,
|
|
line: headingBeforeClose + 1,
|
|
});
|
|
}
|
|
|
|
// 4. EMPTY_FRONTMATTER — open and close present but nothing meaningful between.
|
|
const fmBody = lines.slice(firstNonEmpty + 1, closeLine).join('\n').trim();
|
|
if (fmBody.length === 0) {
|
|
errors.push({
|
|
code: 'EMPTY_FRONTMATTER',
|
|
message: 'Frontmatter block is empty',
|
|
line: firstNonEmpty + 1,
|
|
});
|
|
}
|
|
|
|
// 5. NESTED_QUOTES — common breakage pattern: `title: "Name "Nick" Last"`.
|
|
// The heuristic: a frontmatter `key: value` line with 3+ unescaped
|
|
// double-quote characters is suspicious. But raw quote-counting is
|
|
// too dumb: a YAML flow sequence like `tags: ["yc", "w2025"]` has
|
|
// 4 unescaped `"` by design (valid), and a single-quoted scalar
|
|
// like `title: 'a: "b" "c"'` has literal inner `"` (also valid).
|
|
// Disambiguate by running js-yaml on just the value; only flag
|
|
// lines that genuinely fail to parse. The full-frontmatter YAML
|
|
// parse error is caught separately by check 6 (YAML_PARSE) below.
|
|
for (let i = firstNonEmpty + 1; i < closeLine; i++) {
|
|
const line = lines[i];
|
|
const m = line.match(/^\s*[A-Za-z_][\w-]*\s*:\s*(.*)$/);
|
|
if (!m) continue;
|
|
const value = m[1];
|
|
let count = 0;
|
|
for (let j = 0; j < value.length; j++) {
|
|
if (value[j] === '"' && (j === 0 || value[j - 1] !== '\\')) count++;
|
|
}
|
|
if (count < 3) continue;
|
|
|
|
// 3+ unescaped quotes — could be valid YAML (flow seq, single-quoted
|
|
// scalar with inner quotes, bare scalar with embedded quotes) or
|
|
// genuinely broken. Parse the value to disambiguate.
|
|
let isValidYaml = false;
|
|
try {
|
|
yamlSafeLoad(value);
|
|
isValidYaml = true;
|
|
} catch {
|
|
// YAML parse failed — line is genuinely broken
|
|
}
|
|
|
|
if (!isValidYaml) {
|
|
errors.push({
|
|
code: 'NESTED_QUOTES',
|
|
message: 'Nested double quotes in YAML value (use single quotes for the outer)',
|
|
line: i + 1,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 6. YAML_PARSE — gray-matter threw.
|
|
if (ctx.yamlParseError) {
|
|
errors.push({
|
|
code: 'YAML_PARSE',
|
|
message: `YAML parse failed: ${ctx.yamlParseError.message}`,
|
|
line: firstNonEmpty + 1,
|
|
});
|
|
}
|
|
|
|
// 7. SLUG_MISMATCH — only when expectedSlug was provided and a slug field exists.
|
|
if (ctx.expectedSlug && typeof ctx.parsedFrontmatter.slug === 'string') {
|
|
const declared = ctx.parsedFrontmatter.slug as string;
|
|
if (declared !== ctx.expectedSlug) {
|
|
errors.push({
|
|
code: 'SLUG_MISMATCH',
|
|
message: `Frontmatter slug "${declared}" does not match path-derived slug "${ctx.expectedSlug}"`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Split body content at the first recognized timeline sentinel.
|
|
* Returns compiled_truth (before) and timeline (after).
|
|
*
|
|
* Recognized sentinels (in order of precedence):
|
|
* 1. `<!-- timeline -->` — preferred, unambiguous, what serializeMarkdown emits
|
|
* 2. `--- timeline ---` — decorated separator
|
|
* 3. `---` ONLY when the next non-empty line is `## Timeline` or `## History`
|
|
* (backward-compat fallback for older gbrain-written files)
|
|
*
|
|
* A plain `---` line is a markdown horizontal rule, NOT a timeline separator.
|
|
* Treating bare `---` as a separator caused 83% content truncation on wiki corpora.
|
|
*/
|
|
export function splitBody(body: string): { compiled_truth: string; timeline: string } {
|
|
const lines = body.split('\n');
|
|
const splitIndex = findTimelineSplitIndex(lines);
|
|
|
|
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 };
|
|
}
|
|
|
|
function findTimelineSplitIndex(lines: string[]): number {
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const trimmed = lines[i].trim();
|
|
|
|
if (trimmed === '<!-- timeline -->' || trimmed === '<!--timeline-->') {
|
|
return i;
|
|
}
|
|
|
|
if (trimmed === '--- timeline ---' || /^---\s+timeline\s+---$/i.test(trimmed)) {
|
|
return i;
|
|
}
|
|
|
|
if (trimmed === '---') {
|
|
const beforeContent = lines.slice(0, i).join('\n').trim();
|
|
if (beforeContent.length === 0) continue;
|
|
|
|
for (let j = i + 1; j < lines.length; j++) {
|
|
const next = lines[j].trim();
|
|
if (next.length === 0) continue;
|
|
if (/^##\s+(timeline|history)\b/i.test(next)) return i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* 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<!-- timeline -->\n\n' + timeline;
|
|
}
|
|
|
|
return yamlContent + '\n\n' + body + '\n';
|
|
}
|
|
|
|
function inferType(filePath?: string): PageType {
|
|
if (!filePath) return 'concept';
|
|
|
|
// Normalize: add leading / for consistent matching.
|
|
// Wiki subtypes and /writing/ check FIRST — they're stronger signals than
|
|
// ancestor directories. e.g. `projects/blog/writing/essay.md` is a piece of
|
|
// writing, not a project page; `tech/wiki/analysis/foo.md` is analysis,
|
|
// not a hit on the broader `tech/` ancestor.
|
|
const lower = ('/' + filePath).toLowerCase();
|
|
if (lower.includes('/writing/')) return 'writing';
|
|
if (lower.includes('/wiki/analysis/')) return 'analysis';
|
|
if (lower.includes('/wiki/guides/') || lower.includes('/wiki/guide/')) return 'guide';
|
|
if (lower.includes('/wiki/hardware/')) return 'hardware';
|
|
if (lower.includes('/wiki/architecture/')) return 'architecture';
|
|
if (lower.includes('/wiki/concepts/') || lower.includes('/wiki/concept/')) return 'concept';
|
|
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';
|
|
// BrainBench v1 amara-life-v1 corpus directories. One-slash slug convention
|
|
// means source paths look like `emails/em-0001.md`, `slack/sl-0037.md`, etc.
|
|
if (lower.includes('/emails/') || lower.includes('/email/')) return 'email';
|
|
if (lower.includes('/slack/')) return 'slack';
|
|
if (lower.includes('/cal/') || lower.includes('/calendar/')) return 'calendar-event';
|
|
if (lower.includes('/notes/') || lower.includes('/note/')) return 'note';
|
|
if (lower.includes('/meetings/') || lower.includes('/meeting/')) return 'meeting';
|
|
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';
|
|
return slugifyPath(filePath);
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page -> markdown serialization helpers (v0.38 DRY extract per eng review)
|
|
//
|
|
// Pre-v0.38 the dream cycle's reverse-render at src/core/cycle/synthesize.ts
|
|
// and the planned v0.38 put_page write-through path were going to have
|
|
// near-identical 15-line bodies that differed only in their frontmatter
|
|
// stamps. This extract is the single source of truth.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
import { join } from 'node:path';
|
|
|
|
/** Options for serializePageToMarkdown. */
|
|
export interface SerializePageOpts {
|
|
/** Frontmatter fields merged on top of page.frontmatter at render time.
|
|
* Use this to stamp provenance (`ingested_via: 'webhook'`), identity
|
|
* markers (`dream_generated: true`), or any caller-specific extra
|
|
* fields. Original page.frontmatter keys win unless explicitly
|
|
* overridden. */
|
|
frontmatterOverrides?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Render a Page row to its canonical on-disk markdown form. Sibling to
|
|
* `serializeMarkdown` (which takes the underlying primitives); this version
|
|
* pulls everything from a `Page` object so callers don't have to destructure
|
|
* compiled_truth / timeline / tags / frontmatter at every site.
|
|
*
|
|
* - Frontmatter: starts from `page.frontmatter`, merged with optional
|
|
* `opts.frontmatterOverrides`. Useful for stamping `dream_generated`,
|
|
* `ingested_via`, etc.
|
|
* - Type / title: pulled from the Page columns; falls back to 'note' /
|
|
* empty string when absent.
|
|
* - Tags: passed separately so callers don't need to query engine.getTags
|
|
* if they already have them in hand.
|
|
*/
|
|
export function serializePageToMarkdown(
|
|
page: Page,
|
|
tags: string[],
|
|
opts: SerializePageOpts = {},
|
|
): string {
|
|
const frontmatter: Record<string, unknown> = {
|
|
...((page.frontmatter ?? {}) as Record<string, unknown>),
|
|
...(opts.frontmatterOverrides ?? {}),
|
|
};
|
|
return serializeMarkdown(
|
|
frontmatter,
|
|
page.compiled_truth ?? '',
|
|
page.timeline ?? '',
|
|
{
|
|
type: (page.type as PageType) ?? 'note',
|
|
title: page.title ?? '',
|
|
tags,
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Compute the on-disk path for a (brainDir, slug, source_id) tuple per
|
|
* the v0.32.8 multi-source filing layout:
|
|
* - Default source: `<brainDir>/<slug>.md`
|
|
* - Non-default source: `<brainDir>/.sources/<source_id>/<slug>.md`
|
|
*
|
|
* Shared by the dream-cycle reverse-render (`reverseWriteRefs` in
|
|
* synthesize.ts) and the v0.38 put_page write-through path so both
|
|
* sites compute the same path for the same row.
|
|
*
|
|
* NOTE: caller is responsible for validating `source_id` against path-
|
|
* traversal attacks via `validateSourceId` (src/core/utils.ts) BEFORE
|
|
* passing it here. This helper does the filename math only.
|
|
*/
|
|
export function resolvePageFilePath(
|
|
brainDir: string,
|
|
slug: string,
|
|
sourceId: string,
|
|
): string {
|
|
return sourceId === 'default'
|
|
? join(brainDir, `${slug}.md`)
|
|
: join(brainDir, '.sources', sourceId, `${slug}.md`);
|
|
}
|