diff --git a/CHANGELOG.md b/CHANGELOG.md index 8aa3c42a5..4c1ba6748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,70 @@ All notable changes to GBrain will be documented in this file. +## [0.42.10.0] - 2026-06-02 + +**Wikilinks like `[[struktura]]` that point at pages in another folder finally connect.** Until now, if you wrote `[[struktura]]` in `concepts/knowledge-graph.md` and the actual page lived at `projects/struktura.md`, GBrain silently dropped the link from its graph. Obsidian users saw a dense web of connections in their vault and a thin, broken graph inside GBrain. The issue reporter had 71 wikilinks across 20 pages — GBrain captured 12. + +This release adds an **opt-in mode** that resolves bare wikilinks by basename match. Turn it on with one command and the missing edges show up. + +``` +gbrain config set link_resolution.global_basename true +gbrain extract links +``` + +`gbrain doctor` scans your brain on every run and tells you what you'd gain. If five or more bare wikilinks would resolve under the new mode, you'll see a paste-ready hint: + +``` +link_resolution_opportunity: 47 of 60 bare wikilinks (78%) would resolve to +24 distinct page(s) under global_basename mode. +Enable with: gbrain config set link_resolution.global_basename true +``` + +The mode covers every place wikilinks get resolved: `gbrain extract links` (both the filesystem-walk path that the issue reporter hit by default, and the database path that the autopilot cycle uses), and the auto-link step that runs every time an agent writes a page through `put_page`. Same flag, all three surfaces. + +### How it handles ambiguity + +If `[[struktura]]` matches two pages — say `projects/struktura.md` and `archive/struktura.md` — GBrain emits **one edge to each**, both tagged with a new `wikilink_basename` link type. No silent winner-takes-all, no arbitrary first-wins choice. You can audit and prune the dual-match cases via: + +``` +gbrain graph-query concepts/knowledge-graph --type wikilink_basename +``` + +This is a deliberate departure from the issue's original proposal (which suggested dropping ambiguous matches with a warning). The graph layer is supposed to capture relationships, not editorialize about them. + +### Why opt-in (and why this default is safe) + +The default stays off, so existing brains get zero behavior change on upgrade. The bug class this fix opens — a `[[review]]` accidentally linking to some `topics/review` page that exists for unrelated reasons — is real, and turning it on globally would change every existing user's graph in ways they didn't ask for. Doctor surfaces the opportunity; the user decides. + +For brains that flip the flag on, all newly extracted edges carry a distinct `link_source = 'wikilink-resolved'` provenance tag, so you can later distinguish basename-resolved edges from your hand-written markdown links. + +### What this means + +If you write in the Obsidian convention (flat-namespace, globally-unique basenames, bare `[[wikilinks]]` everywhere), your GBrain graph has been wrong. Flip the flag and run extract once — the missing edges show up immediately. + +If you don't use bare wikilinks, doctor will tell you so (`No bare wikilinks found`) and you can keep ignoring this knob. + +Closes https://github.com/garrytan/gbrain/issues/972. + +### Itemized changes + +- `src/core/link-extraction.ts` — three new exports: + - `WIKILINK_BASENAME_LINK_TYPE = 'wikilink_basename'` — the edge-type constant used by every path that emits basename-resolved edges. Adapted from PR #1233's regex (which was the kernel of the resolver-side fix). + - `isGlobalBasenameEnabled(engine)` — single source of truth for the + flag read. Order: env `GBRAIN_LINK_RESOLUTION_GLOBAL_BASENAME` → DB + config `link_resolution.global_basename` → default false. + - `WIKILINK_GENERIC_RE` — generic Obsidian-shape wikilink regex for the `[[bare-name]]` pass that falls outside `DIR_PATTERN`. `EntityRef` gains an optional `needsResolution` flag tagged onto refs from this pass so downstream callers know to route them through the resolver instead of treating them as canonical slugs. +- `SlugResolver` interface gains an optional `resolveBasenameMatches(name): Promise` method. Returns ALL slugs whose basename matches — multi-match by design. `makeResolver(engine)` implements this with a lazy-built basename → `string[]` index (single `engine.getAllSlugs()` call per resolver instance) covering raw + lowercase + slugified key variants. +- `extractPageLinks` gains two options. `opts.globalBasename` routes `needsResolution: true` refs through `resolveBasenameMatches`, emitting one candidate per match tagged `linkType: 'wikilink_basename'` + `linkSource: 'wikilink-resolved'`. `opts.skipFrontmatter` replaces the pre-issue-#972 `nullResolver` pattern — callers can now pass the real resolver everywhere AND independently gate the frontmatter pass, which is necessary because the bare-wikilink path needs `resolveBasenameMatches` on the real resolver. +- `src/commands/extract.ts` — DB-source `extractLinksFromDB` and FS-source `extractLinksFromFile` / `extractLinksFromDir` / `extractLinksForSlugs` thread `globalBasename` (read once per extract run via `isGlobalBasenameEnabled`). FS-source adds two new exported helpers — `resolveBasenameMatchesFromSlugs(name, allSlugs): string[]` and `resolveSlugAll(fileDir, relTarget, allSlugs, opts): string[]`. `resolveSlug` stays single-match for back-compat; `resolveSlugAll` wraps it with the basename-fallback. Stable sort: shorter slug first, then lexical. +- `src/core/operations.ts:put_page` — auto-link path now reads the flag and passes `globalBasename` through to `extractPageLinks`. Newly-written pages get basename edges when the flag is on. +- `src/commands/doctor.ts` — new `link_resolution_opportunity` check. Full scan with a 60s budget, heartbeat-reported. Severity: skipped when flag already on; ok when no bare wikilinks; ok with explanation when bare wikilinks present but none match; **warn with paste-ready hint when ≥5 would resolve AND ≥20% of bare wikilinks have matches**. Threshold tuned to surface real opportunities without flagging brains that only have a handful of unresolvable refs. Wired into both `buildChecks` (local doctor) and `doctorReportRemote` (thin-client) for parity. +- Migration v113 (`links_link_source_widen_for_wikilink_basename`) widens the `links_link_source_check` constraint to accept `'wikilink-resolved'` as a valid `link_source`. Idempotent — DROP IF EXISTS + ADD CONSTRAINT. +- `src/schema.sql` + regenerated `src/core/schema-embedded.ts` + `src/core/pglite-schema.ts` carry the widened constraint for fresh installs so first-init brains start with the right shape. +- New CHECK constraint value adds `'wikilink-resolved'` alongside `'markdown'`, `'frontmatter'`, `'manual'`. +- `KNOWN_CONFIG_KEYS` (in `src/core/config.ts`) adds `'link_resolution'` and `'link_resolution.global_basename'` so `gbrain config set ...` accepts the new key without `--force`. +- Tests: 38 new cases pinning the contract. `test/link-extraction.test.ts` adds 17 cases covering `WIKILINK_GENERIC_RE` shape (anchor / display / strip / escape paths), the `extractEntityRefs` pass-2c no-double-emit invariant, `resolveBasenameMatches` multi-match + index-built-once + missing-`getAllSlugs` degradation, and the `extractPageLinks` opt routing under both flag states. `test/extract-fs.test.ts` adds 11 cases for the pure-function helpers (`resolveBasenameMatchesFromSlugs`, `resolveSlugAll`) plus 3 round-trip tests of the issue's exact repro inside a PGLite brain. `test/doctor.test.ts` adds 7 cases for the new doctor check (skip / ok / warn paths + the cross-surface wiring source-grep). `test/e2e/global-basename-pglite.test.ts` adds 7 end-to-end cases against an in-memory PGLite brain covering FS-source, DB-source, and put_page auto-link paths under both flag states. +- PR #1233 from @rayers contributed the kernel of the resolver-side approach (the generic wikilink regex + slug-tail index pattern). This PR keeps that mechanism, makes it opt-in via the new config flag, replaces the first-write-wins lookup with multi-match return, and extends the coverage to the FS-source path that the issue's repro actually hits. ## [0.42.8.0] - 2026-06-01 **Scraped junk stops landing in your brain as if it were real content, and when something looks off, your agent gets told instead of being left to guess.** @@ -1535,6 +1599,7 @@ behavior for an automated caller, set `gbrain config set search.mcp_keyword_only - A page represented by its weakest chunk in vector search (the incident). - Same-slug pages in different sources collapsing in per-page pooling and the alias hop (source-isolation). + ## [0.41.33.0] - 2026-05-29 **`gbrain search` and `query` can now return a tight, intent-sized set of @@ -1775,6 +1840,7 @@ doctor` warns about a partial migration: `test/doctor.test.ts` (T1 untracked-folders headline bug, T2 remote-never-shells-out trust boundary), `test/sync-all-parallel.test.ts` (column-path staleness). - **CI tooling** — `scripts/ship-remote-tests.sh` + `workflow_dispatch` on `test.yml`. + ## [0.41.31.0] - 2026-05-30 **Your nightly `gbrain sync --all` cron stops getting blocked. It used to diff --git a/CLAUDE.md b/CLAUDE.md index 213ec1227..7de9b210f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,8 @@ strict behavior when unset. - `src/commands/doctor.ts` extension (v0.40.9.0) — three new checks wired into `runDoctor()` and the JSON envelope: `oversized_pages` (warns on pages exceeding `content_sanity.bytes_warn`), `scraper_junk_pages` (warns on pages that match any junk pattern despite being live in the DB — these escaped pre-v0.40.9.0 ingest), and `content_sanity_audit_recent` (reads the last 7 days of audit events, aggregates by pattern+source). Default scans the 1000 most-recent pages; new `--content-audit` flag opts into a full scan for the cleanup wave. All three are warn-only with paste-ready fix hints (junk page → `gbrain sources audit ` + `git rm` source-of-truth, oversize → split or accept). - `src/commands/lint.ts` extension (v0.40.9.0) — two new lint rules: `huge-page` (flags pages exceeding `content_sanity.bytes_warn` threshold) and `scraper-junk` (flags pages matching any junk pattern). Both reuse `assessContent()` from `src/core/content-sanity.ts` so lint, doctor, and ingest share one assessor — adding a junk pattern automatically covers all three surfaces. `lint.ts` lifts DB config when `~/.gbrain/` is reachable (matches what `gbrain config set` writes); falls back to file/env on CI. Pinned by `test/lint-content-sanity.test.ts` (161 lines). - `src/commands/embed.ts` extension (v0.40.9.0) — applies the `embed-skip` filter at all 5 stale-chunk sites: `runEmbedCore --stale`, `runEmbedCore --all`, the `embed-stale` Minion helper, plus both engines' `listStaleChunks` + `countStaleChunks` via `EMBED_SKIP_SQL_FRAGMENT`. A soft-blocked page is queryable by title and slug but its chunks never enter the embed sweep. The shared helper from `src/core/embed-skip.ts` is the regression guard — no per-site ad-hoc filter is allowed. Pinned by `test/embed-skip.test.ts`. -- `src/core/import-file.ts` extension (v0.40.9.0) — `importFromContent` is the narrow waist that every ingest path passes through (`gbrain import`, `gbrain sync`, `put_page` MCP, `/ingest` webhook). It now calls `assessContent()` BEFORE chunking; verdict `hard_block_junk_pattern` throws `ContentSanityBlockError` (which every wrapper site already catches via its exception flow); verdict `warn_oversize OR oversize-without-junk` sets `frontmatter.embed_skip` via `buildEmbedSkipMarker()` AND deletes any pre-existing chunks for the page in the same transaction so search can't surface stale chunks against content that's now soft-blocked. `gbrain import` honors `errors > 0` for non-zero exit (was silently exit-0 on failed files). `classifyErrorCode` in `src/core/sync.ts` recognizes the new `PAGE_JUNK_PATTERN` code so sync-failures.jsonl grouping bins these correctly. Pinned by `test/import-file-content-sanity.test.ts` (206 lines). **v0.42.8.0 (#1699 content-quality gate):** `importFromContent` (the narrow waist for `gbrain import` / `gbrain sync` / `put_page` MCP / `/ingest`) now runs a three-tier disposition via `assessContentSanity` from `src/core/content-sanity.ts`. (1) High-confidence junk (built-in Cloudflare/CAPTCHA interstitial patterns + operator literals) → QUARANTINE: stamps the `quarantine` frontmatter marker, writes ZERO chunks, hides the page from search; OR REJECT (throw → sync-failure) when `content_sanity.junk_disposition` is `reject`. (2) Fuzzy markup-heavy (prose-vs-markup ratio above `content_sanity.max_markup_ratio`, warn-tier byte window, code pages exempt) → `content_flag:markup_heavy` marker — the page stays fully searchable, the marker rides search results + get_page to warn the agent. (3) Oversize → existing `embed_skip` soft-block PLUS a `content_flag:oversized` marker. Gate-owned markers (`quarantine`, `content_flag`) are STRIPPED from untrusted (remote MCP, `ctx.remote !== false`) frontmatter so a write-scoped client can't hide pages or forge the warning channel; markers are excluded from `content_hash` so a flagged page doesn't re-embed every sync. Pinned by `test/import-file-content-sanity.test.ts`. — shared library for the v0.12.0 graph layer. extractEntityRefs (canonical, replaces backlinks.ts duplicate) matches both `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks as of v0.12.3. extractPageLinks, inferLinkType heuristics (attended/works_at/invested_in/founded/advises/source/mentions), parseTimelineEntries, isAutoLinkEnabled config helper. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts. +- `src/core/link-extraction.ts` — shared library for the v0.12.0 graph layer. extractEntityRefs (canonical, replaces backlinks.ts duplicate) matches both `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks as of v0.12.3. extractPageLinks, inferLinkType heuristics (attended/works_at/invested_in/founded/advises/source/mentions), parseTimelineEntries, isAutoLinkEnabled config helper. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts. **v0.42.10.0 (issue #972):** opt-in global-basename wikilink resolution. New `WIKILINK_GENERIC_RE` catches bare `[[name]]` wikilinks outside `DIR_PATTERN` (third pass `2c` in extractEntityRefs); `EntityRef.needsResolution: true` tags refs from this pass (the ref's `slug` is the wikilink TARGET, `name` the optional display alias). `SlugResolver` gains optional `resolveBasenameMatches(name): Promise`. The ONE shared basename matcher is `buildBasenameIndex(slugs)` + `queryBasenameIndex(index, name)` + `normalizeBasename` (keys raw/lower/slugified tail, stable-sorted shorter-first then lexical) — used by `makeResolver`, the FS `resolveBasenameMatchesFromSlugs`, AND the doctor check so they cannot drift. `makeResolver(engine, {mode, sourceId})` builds the index lazily via `engine.getAllSlugs({sourceId})` — **source-scoped** so a bare `[[name]]` never resolves to a same-tail page in a DIFFERENT source (no cross-source edges; #972 is across-folders, not cross-source federation). `extractPageLinks` gains `opts.globalBasename` (routes `needsResolution` refs through `resolveBasenameMatches` keyed on `ref.slug` — the TARGET, not the display alias — emits one candidate per match tagged `linkType: 'wikilink_basename'` + `linkSource: 'wikilink-resolved'`, skips self-loops) and `opts.skipFrontmatter` (replaces the pre-#972 `nullResolver` ternary). All three surfaces tag provenance: FS edges carry `link_source='wikilink-resolved'` via the new `ExtractedLink.link_source` field; `put_page` auto-link includes `wikilink-resolved` in its reconcilable-edge set so stale basename edges are removed when the wikilink (or the flag) goes away. A wikilink inside a markdown-link label (`[see [[acme]]](path)`) is masked out of pass 2c; the FS path strips code fences before scanning. New exports `WIKILINK_BASENAME_LINK_TYPE` + `isGlobalBasenameEnabled(engine)` (resolution order: env `GBRAIN_LINK_RESOLUTION_GLOBAL_BASENAME` → DB config `link_resolution.global_basename` → default false). Multi-match by design. The mode is opt-in (default false); `gbrain doctor`'s `link_resolution_opportunity` check (bounded scan: 1000 most-recent pages in one query, not a per-page walk) surfaces a paste-ready enable hint when ≥5 bare wikilinks would resolve AND ≥20% ratio. Migration v113 widens the `links_link_source_check` constraint to admit the `'wikilink-resolved'` provenance value (full union with master's `'mentions'`). Pinned by `test/link-extraction.test.ts` + `test/extract-fs.test.ts` + `test/doctor.test.ts` + `test/e2e/global-basename-pglite.test.ts` (incl. the issue's exact `mkdir -p /tmp/vault/{concepts,projects}` repro, alias-target resolution, cross-source non-leak, put_page stale-edge removal). PR #1233 from @rayers contributed the regex shape + slug-tail index pattern; PR #1388 from @ukd1 (Russell Smith) made it opt-in + multi-match + FS coverage; the v0.42.10.0 wave added the codex source-correctness fixes (alias-by-target, stale-edge reconciliation, source-scoping) + the shared matcher. +- `src/core/import-file.ts` extension (v0.40.9.0) — `importFromContent` is the narrow waist that every ingest path passes through (`gbrain import`, `gbrain sync`, `put_page` MCP, `/ingest` webhook). It now calls `assessContent()` BEFORE chunking; verdict `hard_block_junk_pattern` throws `ContentSanityBlockError` (which every wrapper site already catches via its exception flow); verdict `warn_oversize OR oversize-without-junk` sets `frontmatter.embed_skip` via `buildEmbedSkipMarker()` AND deletes any pre-existing chunks for the page in the same transaction so search can't surface stale chunks against content that's now soft-blocked. `gbrain import` honors `errors > 0` for non-zero exit (was silently exit-0 on failed files). `classifyErrorCode` in `src/core/sync.ts` recognizes the new `PAGE_JUNK_PATTERN` code so sync-failures.jsonl grouping bins these correctly. Pinned by `test/import-file-content-sanity.test.ts` (206 lines). **v0.42.8.0 (#1699 content-quality gate):** `importFromContent` (the narrow waist for `gbrain import` / `gbrain sync` / `put_page` MCP / `/ingest`) now runs a three-tier disposition via `assessContentSanity` from `src/core/content-sanity.ts`. (1) High-confidence junk (built-in Cloudflare/CAPTCHA interstitial patterns + operator literals) → QUARANTINE: stamps the `quarantine` frontmatter marker, writes ZERO chunks, hides the page from search; OR REJECT (throw → sync-failure) when `content_sanity.junk_disposition` is `reject`. (2) Fuzzy markup-heavy (prose-vs-markup ratio above `content_sanity.max_markup_ratio`, warn-tier byte window, code pages exempt) → `content_flag:markup_heavy` marker — the page stays fully searchable, the marker rides search results + get_page to warn the agent. (3) Oversize → existing `embed_skip` soft-block PLUS a `content_flag:oversized` marker. Gate-owned markers (`quarantine`, `content_flag`) are STRIPPED from untrusted (remote MCP, `ctx.remote !== false`) frontmatter so a write-scoped client can't hide pages or forge the warning channel; markers are excluded from `content_hash` so a flagged page doesn't re-embed every sync. Pinned by `test/import-file-content-sanity.test.ts`. - `src/core/quarantine.ts` (v0.42.8.0, NEW, #1699) — the two frontmatter markers the content-quality gate writes, sibling of `src/core/embed-skip.ts` (same marker-as-JSONB-object pattern, same JSONB `?` existence check that works on Postgres AND PGLite; no schema migration — both are frontmatter JSONB keys). `quarantine` (key `QUARANTINE_KEY`) HIDES: set ONLY for high-confidence junk, writes zero chunks, excluded from search via `quarantineFilterFragment(pageAlias)` / `QUARANTINE_FILTER_FRAGMENT` (the `p`-aliased constant), the single source of truth `buildVisibilityClause` calls so the search filter and the marker key can't drift. `content_flag` (key `CONTENT_FLAG_KEY`) WARNS, does NOT hide: set for fuzzy markup-heavy / oversize, the page stays searchable, the marker is READ INTO search/get_page output — deliberately NO SQL filter fragment. Three distinct markers, three reasons (never overloaded): `embed_skip` = oversized-but-clean, `quarantine` = junk hidden, `content_flag` = odd-examine-still-here; a page can carry more than one (oversize → embed_skip + content_flag:oversized) and each clears independently. Exports `buildQuarantineMarker` / `isQuarantined` / `filterOutQuarantined`, `buildContentFlagMarker` / `getContentFlag` / `hasContentFlag`, plus the two key constants. Pinned by `test/quarantine.test.ts`. - `src/core/content-sanity.ts` extension (v0.42.8.0, #1699) — new `assessContentSanity(opts): SanityAssessment` returns the three-tier disposition (`shouldQuarantine` / `shouldFlag` + reason/detail) consumed by `importFromContent` and `gbrain quarantine scan`. Adds the fuzzy prose-vs-markup ratio pass (markup chars / total chars above `max_markup_ratio`, default 0.85; code pages exempt; gated by `prose_check_enabled`, default true) on top of the v0.40.9.0 byte + junk-pattern passes. Three new config knobs: `content_sanity.junk_disposition` (`quarantine` default | `reject`; no env override — a destructive flip belongs in explicit config), `content_sanity.max_markup_ratio` (default 0.85, env `GBRAIN_MAX_MARKUP_RATIO`, clamped `(0,1]`), `content_sanity.prose_check_enabled` (default true). Same env > file > DB > defaults resolution chain as the v0.40.9.0 knobs. Pinned by `test/content-sanity.test.ts`. - `src/commands/quarantine.ts` (v0.42.8.0, NEW, #1699) — `gbrain quarantine ` operator surface for the content-quality gate. `list [--json] [--include-flagged]` paginates `listPages` and reports quarantined (HIDDEN) pages, optionally also `content_flag` (FLAGGED, searchable) pages. `clear [--force] [--no-embed] [--json]` drops both markers and re-imports through the normal pipeline so the page re-chunks + re-embeds and becomes searchable; the gate re-runs on import so genuinely-junk pages re-quarantine (exit 1) unless `--force` sets `GBRAIN_NO_SANITY=1` for that one import. `scan [--limit N] [--apply] [--no-embed] [--json]` re-assesses already-ingested pages through the gate so junk that predates the gate gets marked (unchanged content short-circuits normal sync, so it never re-assesses otherwise); dry-run uses the SAME effective `content_sanity` config thresholds `--apply` will use, idempotent (skips already-marked pages), `--apply` re-imports with `forceRechunk` to set markers + (for quarantine) drop chunks. Dispatched in `cli.ts`. Pinned by `test/quarantine-cli.test.ts`. diff --git a/INSTALL_FOR_AGENTS.md b/INSTALL_FOR_AGENTS.md index 4974a0eb6..421d2ff09 100644 --- a/INSTALL_FOR_AGENTS.md +++ b/INSTALL_FOR_AGENTS.md @@ -161,6 +161,29 @@ After this step: If a user has a very large brain (>10K pages), `extract --source db` is idempotent and supports `--since YYYY-MM-DD` for incremental runs. +### Obsidian-style bare wikilinks (opt-in) + +If the user imported an Obsidian or Notion vault that uses **bare** `[[note-name]]` +wikilinks — where `[[struktura]]` written in one folder means the page that lives +at `projects/struktura.md` in another — GBrain does NOT connect those by default. +Out of the box it only resolves path-qualified refs like `[[projects/struktura]]`, +so a vault full of bare links shows up as a thin, broken graph. Turn on basename +resolution so the cross-folder links connect: + +```bash +gbrain config set link_resolution.global_basename true +gbrain extract links --source db # re-run so the new edges land +``` + +`gbrain doctor` surfaces a `link_resolution_opportunity` hint with the exact count +("47 of 60 bare wikilinks would resolve") so you know whether it's worth enabling +before you flip it. When a bare name matches more than one page (`[[struktura]]` → +both `projects/struktura` and `archive/struktura`), GBrain emits one edge to each +rather than guessing a winner — review and prune the duplicates with +`gbrain graph-query `. The mode is also honored on the filesystem-walk path +(`gbrain extract links` with no `--source db`) and by auto-link on every future +`put_page`. + ## Step 5: Load Skills If you're running an agent platform (OpenClaw, Hermes, or any repo with a workspace), diff --git a/README.md b/README.md index 91d66798a..c3ea8086c 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec **Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). Default: `balanced` with ZeroEntropy reranker on. Per-query graph signals notice when a top result is a hub for THAT query (adjacency boost), is corroborated across team brains (cross-source boost), or is being crowded out by weak chunks from a chatty session (session demote). Run `gbrain search "" --explain` to see per-stage attribution: base score, every boost that fired, what it multiplied. `gbrain doctor` ships a `graph_signals_coverage` check; `gbrain search stats` shows fire counts and failure breakdowns. Vector retrieval pools the best chunk per page, so a page surfaces on its strongest evidence instead of losing to a neighbor on one weak chunk. Queries that match a page's title phrase or a declared free-text alias (`gbrain reindex --aliases` backfills existing pages) get boosted to the page they name. Every result carries an `evidence` tag (why it matched) and a `create_safety` hint (`exists` / `probable` / `unknown`) so an agent decides whether a page already exists instead of guessing from a raw score. `gbrain search diagnose "" --target ` traces which retrieval layer surfaces (or misses) a page. -**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. +**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. **Obsidian-style vaults:** bare `[[note-name]]` wikilinks that point across folders — you wrote `[[struktura]]` but the page lives at `projects/struktura.md` — resolve by basename once you opt in with `gbrain config set link_resolution.global_basename true`. Off by default; `gbrain doctor` tells you how many edges you'd gain before you flip it. See [migrating an Obsidian vault](INSTALL_FOR_AGENTS.md#step-45-wire-the-knowledge-graph). **Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything. diff --git a/VERSION b/VERSION index 7fb2c43bc..19f6e33f9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.42.8.0 \ No newline at end of file +0.42.10.0 \ No newline at end of file diff --git a/llms-full.txt b/llms-full.txt index 19dfcb4b4..5a2c087c2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -363,7 +363,8 @@ strict behavior when unset. - `src/commands/doctor.ts` extension (v0.40.9.0) — three new checks wired into `runDoctor()` and the JSON envelope: `oversized_pages` (warns on pages exceeding `content_sanity.bytes_warn`), `scraper_junk_pages` (warns on pages that match any junk pattern despite being live in the DB — these escaped pre-v0.40.9.0 ingest), and `content_sanity_audit_recent` (reads the last 7 days of audit events, aggregates by pattern+source). Default scans the 1000 most-recent pages; new `--content-audit` flag opts into a full scan for the cleanup wave. All three are warn-only with paste-ready fix hints (junk page → `gbrain sources audit ` + `git rm` source-of-truth, oversize → split or accept). - `src/commands/lint.ts` extension (v0.40.9.0) — two new lint rules: `huge-page` (flags pages exceeding `content_sanity.bytes_warn` threshold) and `scraper-junk` (flags pages matching any junk pattern). Both reuse `assessContent()` from `src/core/content-sanity.ts` so lint, doctor, and ingest share one assessor — adding a junk pattern automatically covers all three surfaces. `lint.ts` lifts DB config when `~/.gbrain/` is reachable (matches what `gbrain config set` writes); falls back to file/env on CI. Pinned by `test/lint-content-sanity.test.ts` (161 lines). - `src/commands/embed.ts` extension (v0.40.9.0) — applies the `embed-skip` filter at all 5 stale-chunk sites: `runEmbedCore --stale`, `runEmbedCore --all`, the `embed-stale` Minion helper, plus both engines' `listStaleChunks` + `countStaleChunks` via `EMBED_SKIP_SQL_FRAGMENT`. A soft-blocked page is queryable by title and slug but its chunks never enter the embed sweep. The shared helper from `src/core/embed-skip.ts` is the regression guard — no per-site ad-hoc filter is allowed. Pinned by `test/embed-skip.test.ts`. -- `src/core/import-file.ts` extension (v0.40.9.0) — `importFromContent` is the narrow waist that every ingest path passes through (`gbrain import`, `gbrain sync`, `put_page` MCP, `/ingest` webhook). It now calls `assessContent()` BEFORE chunking; verdict `hard_block_junk_pattern` throws `ContentSanityBlockError` (which every wrapper site already catches via its exception flow); verdict `warn_oversize OR oversize-without-junk` sets `frontmatter.embed_skip` via `buildEmbedSkipMarker()` AND deletes any pre-existing chunks for the page in the same transaction so search can't surface stale chunks against content that's now soft-blocked. `gbrain import` honors `errors > 0` for non-zero exit (was silently exit-0 on failed files). `classifyErrorCode` in `src/core/sync.ts` recognizes the new `PAGE_JUNK_PATTERN` code so sync-failures.jsonl grouping bins these correctly. Pinned by `test/import-file-content-sanity.test.ts` (206 lines). **v0.42.8.0 (#1699 content-quality gate):** `importFromContent` (the narrow waist for `gbrain import` / `gbrain sync` / `put_page` MCP / `/ingest`) now runs a three-tier disposition via `assessContentSanity` from `src/core/content-sanity.ts`. (1) High-confidence junk (built-in Cloudflare/CAPTCHA interstitial patterns + operator literals) → QUARANTINE: stamps the `quarantine` frontmatter marker, writes ZERO chunks, hides the page from search; OR REJECT (throw → sync-failure) when `content_sanity.junk_disposition` is `reject`. (2) Fuzzy markup-heavy (prose-vs-markup ratio above `content_sanity.max_markup_ratio`, warn-tier byte window, code pages exempt) → `content_flag:markup_heavy` marker — the page stays fully searchable, the marker rides search results + get_page to warn the agent. (3) Oversize → existing `embed_skip` soft-block PLUS a `content_flag:oversized` marker. Gate-owned markers (`quarantine`, `content_flag`) are STRIPPED from untrusted (remote MCP, `ctx.remote !== false`) frontmatter so a write-scoped client can't hide pages or forge the warning channel; markers are excluded from `content_hash` so a flagged page doesn't re-embed every sync. Pinned by `test/import-file-content-sanity.test.ts`. — shared library for the v0.12.0 graph layer. extractEntityRefs (canonical, replaces backlinks.ts duplicate) matches both `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks as of v0.12.3. extractPageLinks, inferLinkType heuristics (attended/works_at/invested_in/founded/advises/source/mentions), parseTimelineEntries, isAutoLinkEnabled config helper. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts. +- `src/core/link-extraction.ts` — shared library for the v0.12.0 graph layer. extractEntityRefs (canonical, replaces backlinks.ts duplicate) matches both `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks as of v0.12.3. extractPageLinks, inferLinkType heuristics (attended/works_at/invested_in/founded/advises/source/mentions), parseTimelineEntries, isAutoLinkEnabled config helper. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts. **v0.42.10.0 (issue #972):** opt-in global-basename wikilink resolution. New `WIKILINK_GENERIC_RE` catches bare `[[name]]` wikilinks outside `DIR_PATTERN` (third pass `2c` in extractEntityRefs); `EntityRef.needsResolution: true` tags refs from this pass (the ref's `slug` is the wikilink TARGET, `name` the optional display alias). `SlugResolver` gains optional `resolveBasenameMatches(name): Promise`. The ONE shared basename matcher is `buildBasenameIndex(slugs)` + `queryBasenameIndex(index, name)` + `normalizeBasename` (keys raw/lower/slugified tail, stable-sorted shorter-first then lexical) — used by `makeResolver`, the FS `resolveBasenameMatchesFromSlugs`, AND the doctor check so they cannot drift. `makeResolver(engine, {mode, sourceId})` builds the index lazily via `engine.getAllSlugs({sourceId})` — **source-scoped** so a bare `[[name]]` never resolves to a same-tail page in a DIFFERENT source (no cross-source edges; #972 is across-folders, not cross-source federation). `extractPageLinks` gains `opts.globalBasename` (routes `needsResolution` refs through `resolveBasenameMatches` keyed on `ref.slug` — the TARGET, not the display alias — emits one candidate per match tagged `linkType: 'wikilink_basename'` + `linkSource: 'wikilink-resolved'`, skips self-loops) and `opts.skipFrontmatter` (replaces the pre-#972 `nullResolver` ternary). All three surfaces tag provenance: FS edges carry `link_source='wikilink-resolved'` via the new `ExtractedLink.link_source` field; `put_page` auto-link includes `wikilink-resolved` in its reconcilable-edge set so stale basename edges are removed when the wikilink (or the flag) goes away. A wikilink inside a markdown-link label (`[see [[acme]]](path)`) is masked out of pass 2c; the FS path strips code fences before scanning. New exports `WIKILINK_BASENAME_LINK_TYPE` + `isGlobalBasenameEnabled(engine)` (resolution order: env `GBRAIN_LINK_RESOLUTION_GLOBAL_BASENAME` → DB config `link_resolution.global_basename` → default false). Multi-match by design. The mode is opt-in (default false); `gbrain doctor`'s `link_resolution_opportunity` check (bounded scan: 1000 most-recent pages in one query, not a per-page walk) surfaces a paste-ready enable hint when ≥5 bare wikilinks would resolve AND ≥20% ratio. Migration v113 widens the `links_link_source_check` constraint to admit the `'wikilink-resolved'` provenance value (full union with master's `'mentions'`). Pinned by `test/link-extraction.test.ts` + `test/extract-fs.test.ts` + `test/doctor.test.ts` + `test/e2e/global-basename-pglite.test.ts` (incl. the issue's exact `mkdir -p /tmp/vault/{concepts,projects}` repro, alias-target resolution, cross-source non-leak, put_page stale-edge removal). PR #1233 from @rayers contributed the regex shape + slug-tail index pattern; PR #1388 from @ukd1 (Russell Smith) made it opt-in + multi-match + FS coverage; the v0.42.10.0 wave added the codex source-correctness fixes (alias-by-target, stale-edge reconciliation, source-scoping) + the shared matcher. +- `src/core/import-file.ts` extension (v0.40.9.0) — `importFromContent` is the narrow waist that every ingest path passes through (`gbrain import`, `gbrain sync`, `put_page` MCP, `/ingest` webhook). It now calls `assessContent()` BEFORE chunking; verdict `hard_block_junk_pattern` throws `ContentSanityBlockError` (which every wrapper site already catches via its exception flow); verdict `warn_oversize OR oversize-without-junk` sets `frontmatter.embed_skip` via `buildEmbedSkipMarker()` AND deletes any pre-existing chunks for the page in the same transaction so search can't surface stale chunks against content that's now soft-blocked. `gbrain import` honors `errors > 0` for non-zero exit (was silently exit-0 on failed files). `classifyErrorCode` in `src/core/sync.ts` recognizes the new `PAGE_JUNK_PATTERN` code so sync-failures.jsonl grouping bins these correctly. Pinned by `test/import-file-content-sanity.test.ts` (206 lines). **v0.42.8.0 (#1699 content-quality gate):** `importFromContent` (the narrow waist for `gbrain import` / `gbrain sync` / `put_page` MCP / `/ingest`) now runs a three-tier disposition via `assessContentSanity` from `src/core/content-sanity.ts`. (1) High-confidence junk (built-in Cloudflare/CAPTCHA interstitial patterns + operator literals) → QUARANTINE: stamps the `quarantine` frontmatter marker, writes ZERO chunks, hides the page from search; OR REJECT (throw → sync-failure) when `content_sanity.junk_disposition` is `reject`. (2) Fuzzy markup-heavy (prose-vs-markup ratio above `content_sanity.max_markup_ratio`, warn-tier byte window, code pages exempt) → `content_flag:markup_heavy` marker — the page stays fully searchable, the marker rides search results + get_page to warn the agent. (3) Oversize → existing `embed_skip` soft-block PLUS a `content_flag:oversized` marker. Gate-owned markers (`quarantine`, `content_flag`) are STRIPPED from untrusted (remote MCP, `ctx.remote !== false`) frontmatter so a write-scoped client can't hide pages or forge the warning channel; markers are excluded from `content_hash` so a flagged page doesn't re-embed every sync. Pinned by `test/import-file-content-sanity.test.ts`. - `src/core/quarantine.ts` (v0.42.8.0, NEW, #1699) — the two frontmatter markers the content-quality gate writes, sibling of `src/core/embed-skip.ts` (same marker-as-JSONB-object pattern, same JSONB `?` existence check that works on Postgres AND PGLite; no schema migration — both are frontmatter JSONB keys). `quarantine` (key `QUARANTINE_KEY`) HIDES: set ONLY for high-confidence junk, writes zero chunks, excluded from search via `quarantineFilterFragment(pageAlias)` / `QUARANTINE_FILTER_FRAGMENT` (the `p`-aliased constant), the single source of truth `buildVisibilityClause` calls so the search filter and the marker key can't drift. `content_flag` (key `CONTENT_FLAG_KEY`) WARNS, does NOT hide: set for fuzzy markup-heavy / oversize, the page stays searchable, the marker is READ INTO search/get_page output — deliberately NO SQL filter fragment. Three distinct markers, three reasons (never overloaded): `embed_skip` = oversized-but-clean, `quarantine` = junk hidden, `content_flag` = odd-examine-still-here; a page can carry more than one (oversize → embed_skip + content_flag:oversized) and each clears independently. Exports `buildQuarantineMarker` / `isQuarantined` / `filterOutQuarantined`, `buildContentFlagMarker` / `getContentFlag` / `hasContentFlag`, plus the two key constants. Pinned by `test/quarantine.test.ts`. - `src/core/content-sanity.ts` extension (v0.42.8.0, #1699) — new `assessContentSanity(opts): SanityAssessment` returns the three-tier disposition (`shouldQuarantine` / `shouldFlag` + reason/detail) consumed by `importFromContent` and `gbrain quarantine scan`. Adds the fuzzy prose-vs-markup ratio pass (markup chars / total chars above `max_markup_ratio`, default 0.85; code pages exempt; gated by `prose_check_enabled`, default true) on top of the v0.40.9.0 byte + junk-pattern passes. Three new config knobs: `content_sanity.junk_disposition` (`quarantine` default | `reject`; no env override — a destructive flip belongs in explicit config), `content_sanity.max_markup_ratio` (default 0.85, env `GBRAIN_MAX_MARKUP_RATIO`, clamped `(0,1]`), `content_sanity.prose_check_enabled` (default true). Same env > file > DB > defaults resolution chain as the v0.40.9.0 knobs. Pinned by `test/content-sanity.test.ts`. - `src/commands/quarantine.ts` (v0.42.8.0, NEW, #1699) — `gbrain quarantine ` operator surface for the content-quality gate. `list [--json] [--include-flagged]` paginates `listPages` and reports quarantined (HIDDEN) pages, optionally also `content_flag` (FLAGGED, searchable) pages. `clear [--force] [--no-embed] [--json]` drops both markers and re-imports through the normal pipeline so the page re-chunks + re-embeds and becomes searchable; the gate re-runs on import so genuinely-junk pages re-quarantine (exit 1) unless `--force` sets `GBRAIN_NO_SANITY=1` for that one import. `scan [--limit N] [--apply] [--no-embed] [--json]` re-assesses already-ingested pages through the gate so junk that predates the gate gets marked (unchanged content short-circuits normal sync, so it never re-assesses otherwise); dry-run uses the SAME effective `content_sanity` config thresholds `--apply` will use, idempotent (skips already-marked pages), `--apply` re-imports with `forceRechunk` to set markers + (for quarantine) drop chunks. Dispatched in `cli.ts`. Pinned by `test/quarantine-cli.test.ts`. @@ -2396,6 +2397,29 @@ After this step: If a user has a very large brain (>10K pages), `extract --source db` is idempotent and supports `--since YYYY-MM-DD` for incremental runs. +### Obsidian-style bare wikilinks (opt-in) + +If the user imported an Obsidian or Notion vault that uses **bare** `[[note-name]]` +wikilinks — where `[[struktura]]` written in one folder means the page that lives +at `projects/struktura.md` in another — GBrain does NOT connect those by default. +Out of the box it only resolves path-qualified refs like `[[projects/struktura]]`, +so a vault full of bare links shows up as a thin, broken graph. Turn on basename +resolution so the cross-folder links connect: + +```bash +gbrain config set link_resolution.global_basename true +gbrain extract links --source db # re-run so the new edges land +``` + +`gbrain doctor` surfaces a `link_resolution_opportunity` hint with the exact count +("47 of 60 bare wikilinks would resolve") so you know whether it's worth enabling +before you flip it. When a bare name matches more than one page (`[[struktura]]` → +both `projects/struktura` and `archive/struktura`), GBrain emits one edge to each +rather than guessing a winner — review and prune the duplicates with +`gbrain graph-query `. The mode is also honored on the filesystem-walk path +(`gbrain extract links` with no `--source db`) and by auto-link on every future +`put_page`. + ## Step 5: Load Skills If you're running an agent platform (OpenClaw, Hermes, or any repo with a workspace), @@ -2967,7 +2991,7 @@ The whole loop is described in [`docs/architecture/topologies.md`](docs/architec **Hybrid search.** Vector (HNSW on pgvector) + BM25 keyword + reciprocal-rank fusion + source-tier boost + intent-aware query rewriting. Three named search modes (`conservative`, `balanced`, `tokenmax`) bundle the cost/quality knobs into a single config key. Live cost/recall comparisons in [`docs/eval/SEARCH_MODE_METHODOLOGY.md`](docs/eval/SEARCH_MODE_METHODOLOGY.md). Default: `balanced` with ZeroEntropy reranker on. Per-query graph signals notice when a top result is a hub for THAT query (adjacency boost), is corroborated across team brains (cross-source boost), or is being crowded out by weak chunks from a chatty session (session demote). Run `gbrain search "" --explain` to see per-stage attribution: base score, every boost that fired, what it multiplied. `gbrain doctor` ships a `graph_signals_coverage` check; `gbrain search stats` shows fire counts and failure breakdowns. Vector retrieval pools the best chunk per page, so a page surfaces on its strongest evidence instead of losing to a neighbor on one weak chunk. Queries that match a page's title phrase or a declared free-text alias (`gbrain reindex --aliases` backfills existing pages) get boosted to the page they name. Every result carries an `evidence` tag (why it matched) and a `create_safety` hint (`exists` / `probable` / `unknown`) so an agent decides whether a page already exists instead of guessing from a raw score. `gbrain search diagnose "" --target ` traces which retrieval layer surfaces (or misses) a page. -**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. +**Self-wiring knowledge graph.** Every `put_page` extracts entity refs from markdown/wikilinks/typed-link syntax and writes edges with zero LLM calls. Typed edges (`attended`, `works_at`, `invested_in`, `founded`, `advises`, `mentions`, …). Multi-hop traversal via `gbrain graph-query`. The graph is what produces the +31.4 P@5 lift over vector-only RAG. **Obsidian-style vaults:** bare `[[note-name]]` wikilinks that point across folders — you wrote `[[struktura]]` but the page lives at `projects/struktura.md` — resolve by basename once you opt in with `gbrain config set link_resolution.global_basename true`. Off by default; `gbrain doctor` tells you how many edges you'd gain before you flip it. See [migrating an Obsidian vault](INSTALL_FOR_AGENTS.md#step-45-wire-the-knowledge-graph). **Job queue (Minions).** BullMQ-shaped, Postgres-native job queue. Durable subagents (LLM tool loops that survive crashes via two-phase pending→done persistence), shell jobs with audit, child jobs with cascading timeouts, rate leases for outbound providers, attachments via S3/Supabase storage. Replaces "spawn subagent as fire-and-forget Promise" with something that recovers from anything. diff --git a/package.json b/package.json index d3a449b94..6abf53c75 100644 --- a/package.json +++ b/package.json @@ -142,5 +142,5 @@ "bun": ">=1.3.10" }, "license": "MIT", - "version": "0.42.8.0" + "version": "0.42.10.0" } diff --git a/scripts/llms-config.ts b/scripts/llms-config.ts index ee718679e..ba676dc02 100644 --- a/scripts/llms-config.ts +++ b/scripts/llms-config.ts @@ -255,12 +255,13 @@ export const INLINE_TIPS = [ "`gbrain upgrade` runs post-upgrade + apply-migrations.", ]; -// Target ~750KB so llms-full.txt fits in ~190k-token contexts with room to spare. -// Bumped 600KB→700KB in v0.41.9.0, then 700KB→750KB once CLAUDE.md crossed 700KB: -// it's ~540KB (77% of the bundle) and grows ~5-15KB per release with each feature's -// Key Files annotation. Both master (v0.41.34-38 waves) and this branch (skillopt -// wave) independently hit the 700KB line and bumped to the same 750KB. CLAUDE.md is -// the whole point of the one-fetch bundle, so it stays inlined; the budget tracks -// its legitimate growth. Still fits comfortably in 200k+ context models. +// Target ~800KB so llms-full.txt fits in ~200k-token contexts with room to spare. +// Bumped 600KB→700KB in v0.41.9.0, then 700KB→750KB once CLAUDE.md crossed 700KB, +// then 750KB→800KB in v0.42.10.0 when the #972 global-basename Key Files annotation +// (landing alongside master's #1696/#1699 waves) crossed the 750KB line. CLAUDE.md +// is ~540KB+ (the bulk of the bundle) and grows ~5-15KB per release with each +// feature's Key Files annotation. CLAUDE.md is the whole point of the one-fetch +// bundle, so it stays inlined; the budget tracks its legitimate growth. Still fits +// comfortably in 200k+ context models. // Generator prints a WARN if exceeded; ship with includeInFull=false exclusions. -export const FULL_SIZE_BUDGET = 750_000; +export const FULL_SIZE_BUDGET = 800_000; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3ccbe16a9..64911bbbe 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -27,6 +27,12 @@ import { gbrainPath } from '../core/config.ts'; import { dirname, isAbsolute, join, resolve as resolvePath } from 'path'; import { fileURLToPath } from 'url'; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; +import { + extractEntityRefs, + isGlobalBasenameEnabled, + buildBasenameIndex, + queryBasenameIndex, +} from '../core/link-extraction.ts'; import { isSourceUnchangedSinceSync } from '../core/git-head.ts'; // v0.41.32.0: remote staleness reads the stored newest_content_at column via // this pure comparator (no git subprocess on the HTTP MCP doctor path). @@ -739,6 +745,11 @@ export async function doctorReportRemote(engine: BrainEngine): Promise