Files
gbrain/src/core/operations.ts
T
b8e0a0eada v0.29.0 + v0.29.1 feat: salience + anomaly detection — brain surfaces what's hot without being asked (#730)
* v0.29 foundation: emotional_weight column + formula + anomaly stats

Migration v34 adds pages.emotional_weight REAL DEFAULT 0.0 (column-only,
no index — salience query orders by computed score, not raw weight).
Embedded DDL (schema.sql + pglite-schema.ts + schema-embedded.ts)
mirrors the column so fresh installs don't need migration replay.

types.ts gains: PageFilters.sort enum + PAGE_SORT_SQL whitelist (engines
hardcoded ORDER BY updated_at DESC; threading lands in the next commit);
SalienceOpts/SalienceResult, AnomaliesOpts/AnomalyResult,
EmotionalWeightInputRow/EmotionalWeightWriteRow contracts.

cycle/emotional-weight.ts: pure-function score in [0..1] from tags +
takes (anglocentric default seed list; user-overridable via config key
emotional_weight.high_tags). cycle/anomaly.ts: meanStddev + cohort
threshold helpers with zero-stddev fallback (count > mean + 1) so rare
cohorts don't produce NaN sigmas.

Test coverage: migrate v34 structural assertions + 14-case formula
unit + 13-case anomaly stats unit. Codex review fixes baked in:
formula clamped to [0,1]; per-take weight clamped to [0,1] before
averaging; zero-stddev fallback finite, never NaN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 engine: batch emotional-weight methods + listPages sort

BrainEngine adds 4 methods, both engines implement:

- batchLoadEmotionalInputs(slugs?): CTE-shaped read with per-table
  pre-aggregates. A page with N tags + M takes never produces N×M rows
  (codex C4#4) — page_tags + page_takes CTEs aggregate independently,
  then LEFT JOIN to pages.

- setEmotionalWeightBatch(rows): UPDATE FROM unnest($1::text[],
  $2::text[], $3::real[]) composite-keyed on (slug, source_id). Multi-
  source brains can't fan out (codex C4#3) — pages.slug is unique only
  within source_id. Same shape that v0.18 link batches use.

- getRecentSalience: time boundary computed in JS, bound as TIMESTAMPTZ.
  SQL identical across engines (codex C5/D5 — avoids dialect drift on
  $1::interval binding which has zero current uses on PGLite).

- findAnomalies: tag + type cohort baselines via generate_series-
  densified daily-count CTEs (codex C4#6). Sparse-day rare cohorts get
  correct (mean, stddev) instead of biased upward by zero-omission.
  Year cohort deferred to v0.30.

listPages threads the new PageFilters.sort enum through both engines.
Was hardcoded ORDER BY updated_at DESC; now PAGE_SORT_SQL whitelist
maps the 4 enum values to literal SQL fragments — no injection surface.
postgres.js uses sql.unsafe; PGLite splices the fragment directly.

Regression tests (PGLite, no DATABASE_URL needed):

- multi-source-emotional-weight: same slug under two source_ids,
  setEmotionalWeightBatch on one of them, asserts the other survives
  untouched. Direct codex C4#3 guard.

- list-pages-regression (IRON RULE): old call shape (type, tag, limit)
  still returns updated_desc default; new sort=updated_asc reverses;
  sort=created_desc orders by created_at; sort=slug alphabetical;
  unsupported sort enum falls back to default (defense in depth).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 cycle: new recompute_emotional_weight phase

Adds a 9th cycle phase between extract and embed. Sees the union of
syncPagesAffected + synthesizeWrittenSlugs for incremental mode (so
synthesize-written pages get their weight computed too — codex C2 caught
that the prior plan threaded only sync). Full mode (no incremental
anchors) walks every page; users hit this path on first upgrade via
gbrain dream --phase recompute_emotional_weight.

Phase orchestrator (cycle/recompute-emotional-weight.ts) is two SQL
round-trips total regardless of brain size:
  1. batchLoadEmotionalInputs(slugs?) → per-page tag/take inputs.
  2. computeEmotionalWeight in memory (pure function).
  3. setEmotionalWeightBatch(rows) → composite-keyed UPDATE FROM unnest.

Empty affectedSlugs short-circuits (no DB read, no write). Dry-run
computes weights and reports the would-write count without touching
the DB. Engine throw bubbles into status:fail with code
RECOMPUTE_EMOTIONAL_WEIGHT_FAIL — cycle continues to the next phase.

Plumbing:
- CyclePhase type adds 'recompute_emotional_weight'.
- ALL_PHASES + NEEDS_LOCK_PHASES include it.
- CycleReport.totals adds pages_emotional_weight_recomputed (additive,
  schema_version stays "1").
- runCycle's totals rollup + status derivation honor the new field.
- synthesize.ts emits writtenSlugs in details so cycle.ts can union
  with syncPagesAffected for incremental backfill.

Tests: 7-case unit (fake-engine), 3-case PGLite e2e (full mode + dry-
run + ALL_PHASES position), 1000-page perf budget (<5s on PGLite).

Codex C2 → A: clean separation. Phase doesn't modify runExtractCore;
runs on its own seam after the existing 8 phases plus synthesize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 ops: get_recent_salience + find_anomalies + get_recent_transcripts

Three new MCP operations + a transcripts library:

- get_recent_salience: pages ranked by emotional + activity salience.
  Subagent-allow-listed. params: days (default 14), limit (default 20,
  capped 100), slugPrefix (renamed from `kind` per codex C4#10 to
  avoid collision with PageKind/TakeKind).

- find_anomalies: cohort-level activity outliers (tag + type).
  Subagent-allow-listed. Year cohort deferred to v0.30.

- get_recent_transcripts: raw .txt transcripts from the dream-cycle
  corpus dirs. LOCAL-ONLY: rejects ctx.remote === true with
  permission_denied (codex C3). NOT in the subagent allow-list — all
  subagent calls run with remote=true, would always reject (footgun if
  visible). Cycle's synthesize phase calls discoverTranscripts
  directly, so subagents that need transcripts go through the library
  function, not the op.

Tool descriptions extracted to src/core/operations-descriptions.ts so
they're pinnable in tests and stable for the Tier-2 LLM routing eval.
Redirects on query/search/list_pages: personal/emotional questions
should reach the new ops, not semantic search. Anti-flattery hint on
query: "Do NOT assume words like crazy, notable, or big mean
impressive — they often mean difficult or emotionally charged."

list_pages gains updated_after (string ISO) and sort enum params,
surfacing the engine threading from the prior commit.

src/core/transcripts.ts: filesystem walk shared by the gated MCP op
and the (commit 5) CLI command. Reuses discoverTranscripts corpus-dir
resolution + isDreamOutput from cycle/transcript-discovery.ts. Trust
gate lives in the op handler, not the library — the library is
trusted by both the gated op and the local CLI.

Allow-list: 11 → 13 (add salience + anomalies; transcripts excluded
per codex C3, with a comment explaining why).

Tests: 21-case description pin (catches accidental edits that change
LLM-facing surface); 11-case transcripts unit covering trust gate,
mtime window, dream-output skip, summary truncation, no corpus_dir;
2-case salience type-contract smoke (full Garry-test fixture in commit
6's e2e suite).

Codex C1: routing-eval fixtures (skills/<x>/routing-eval.jsonl)
deliberately NOT shipped — routing-eval.ts is substring-match on
resolver triggers, not MCP tool routing. Real coverage lands as
test/e2e/salience-llm-routing.test.ts in commit 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 CLI: gbrain salience / anomalies / transcripts

Three new CLI commands wired into src/cli.ts dispatch + CLI_ONLY set +
help text:

- gbrain salience [--days N] [--limit N] [--kind PREFIX] [--json]
- gbrain anomalies [--since YYYY-MM-DD] [--lookback-days N] [--sigma N] [--json]
- gbrain transcripts recent [--days N] [--full] [--json]

Each command file mirrors src/commands/orphans.ts shape: pure data fn
+ JSON formatter + human formatter. Calls into engine.getRecentSalience
/ findAnomalies (already shipped) and src/core/transcripts.ts.

salience and anomalies show ranked rows with per-cohort
mean/stddev/sigma. transcripts honors `--full` (caps at 100KB/file)
vs default summary (first non-empty line + ~250 chars). All three
emit JSON with --json for agent consumption.

`--kind` is accepted as a slug-prefix shorthand on `gbrain salience`
even though the underlying op param is `slugPrefix` (kept the CLI
flag short; the MCP-facing param uses the more-explicit name to
align with PageKind/TakeKind/slugPrefix vocabulary).

CLI_ONLY set in src/cli.ts gains the three new command names so
they don't get forwarded to MCP-only routing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 e2e: Garry-test fixtures + Postgres parity + LLM routing eval

PGLite e2e (no DATABASE_URL needed):

- salience-pglite: the Garry test. 7 wedding-tagged pages updated today
  + 100 background pages backdated across 30 days via raw SQL UPDATE
  (codex C4#7 — engine.putPage stamps updated_at = now(), so seeding
  via the engine alone can't reproduce historical recency windows).
  Asserts wedding pages outrank random-tag noise in the 7-day window;
  slugPrefix filter narrows correctly; days=0 boundary case; limit cap.

- anomalies-pglite: same fixture shape (7 wedding pages today, 100
  background backdated). findAnomalies with sigma=3 returns the
  wedding-tag cohort with sigma_observed > 3 vs near-zero baseline;
  page_slugs sample carries the wedding pages; date with no activity
  returns []; high sigma threshold suppresses borderline cohorts
  (zero-stddev fallback stays finite — no NaN sigma).

Postgres-gated e2e:

- engine-parity-salience: PGLite ↔ Postgres parity for getRecentSalience
  and findAnomalies. Same fixture into both engines; top-result and
  cohort-set match. Closes the v0.22.0-style parity gap for the new
  v0.29 SQL idioms (EXTRACT(EPOCH ...), generate_series, CTE chain).

Tier-2 LLM routing eval (ANTHROPIC_API_KEY-gated):

- salience-llm-routing: calls Claude with v0.29 tool descriptions and
  12 personal-query phrasings ("anything crazy lately", "what's been
  going on with me", etc.). Asserts the chosen tool is in the v0.29
  set, not query() / search(). ~$0.10 per CI run on Haiku. Tests the
  ACTUAL ship criterion — replaces the discarded fake-coverage
  routing-eval.jsonl fixtures (codex C1 → B).

This is the only test that proves the description edits drive routing.
Without it, we'd ship description changes and only learn from
production behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.0: ship-prep — VERSION + CHANGELOG + CLAUDE Key Files

VERSION + package.json bump 0.28.0 → 0.29.0.

CHANGELOG.md adds a v0.29.0 release-summary in the GStack/Garry voice
plus the "To take advantage of v0.29.0" block. Headline two-liner:
"The brain tells you what's hot without being asked. Salience +
anomaly detection ship. Search rewards hypotheses; salience surfaces
them." Numbers-that-matter table covers engine surface delta, MCP op
delta, allow-list delta, cycle-phase delta, schema migration, list_pages
param surface, and test count. Itemized changes section lists the
schema migration + new cycle phase + new MCP ops + redirect
descriptions + subagent allow-list rules + new tests + a contributor
note clarifying that routing-eval.ts is not the right surface for
testing MCP tool routing (use the Tier-2 LLM eval pattern instead).

CLAUDE.md Key Files updated for the v0.29 surface:

- src/core/engine.ts: notes the 4 new methods + PageFilters.sort threading.
- src/core/migrate.ts: v34 (pages_emotional_weight) entry.
- src/core/cycle.ts: 8 → 9 phases, recompute_emotional_weight inserted
  between patterns and embed; totals.pages_emotional_weight_recomputed.
- src/core/cycle/emotional-weight.ts (NEW): formula + override path.
- src/core/cycle/anomaly.ts (NEW): stats helpers + zero-stddev fallback.
- src/core/cycle/recompute-emotional-weight.ts (NEW): phase orchestrator.
- src/core/transcripts.ts (NEW): library shared by gated MCP op + CLI.
- src/core/operations-descriptions.ts (NEW): pinned tool descriptions.
- src/core/minions/tools/brain-allowlist.ts: 11 → 13 entries; comment
  on why get_recent_transcripts is excluded.
- src/commands/salience.ts / anomalies.ts / transcripts.ts (NEW): CLI surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1 feat: recency + salience as two orthogonal options on query op (#696)

* feat: recency boost for search (v0.27.0) — temporal intent auto-detection, date filters, configurable decay

New search pipeline stage: keyword + vector → RRF → cosine re-score → backlink boost → recency boost → dedup

- applyRecencyBoost: hyperbolic decay, two strengths (moderate 30-day halflife, aggressive 7-day halflife)
- Auto-enabled when intent.ts detects temporal/event queries (detail='high')
- Manual override via SearchOpts.recencyBoost (0/1/2)
- Date filtering: afterDate/beforeDate on all three search paths (keyword, keywordChunks, vector)
- getPageTimestamps on both Postgres and PGLite engines
- 15 tests passing (boost math + intent classification)

* v0.29.1 schema: pages.{effective_date, effective_date_source, import_filename, salience_touched_at} + expression index

Migration v38 adds 4 nullable columns to pages and an expression index on
COALESCE(effective_date, updated_at) to support the new since/until date
filters. All additive — no behavior change in the default search path; only
consulted when callers opt into the new salience='on' / recency='on' axes
or pass since/until.

  effective_date         — content date (event_date / date / published /
                           filename-date / fallback). Read by recency boost
                           and date-filter paths only. Auto-link doesn't
                           touch it (immune to updated_at churn).
  effective_date_source  — sentinel for the doctor's effective_date_health
                           check ('event_date' | 'date' | 'published' |
                           'filename' | 'fallback').
  import_filename        — basename without extension, captured at import.
                           Used for filename-date precedence on daily/,
                           meetings/. Older rows leave it NULL.
  salience_touched_at    — bumped by recompute_emotional_weight when
                           emotional_weight changes. Salience window uses
                           GREATEST(updated_at, salience_touched_at) so
                           newly-salient old pages enter the recent salience
                           query.

Index strategy: a partial index on effective_date alone wouldn't help the
COALESCE expression in since/until filters (planner can't use it for the
negative side). The expression index ((COALESCE(effective_date, updated_at)))
is what actually accelerates the filter.

Postgres uses CONCURRENTLY + v14-style pg_index.indisvalid pre-drop guard
for prior failed CONCURRENTLY runs; PGLite uses plain CREATE INDEX. Mirror
of v34's pattern.

src/schema.sql + src/core/pglite-schema.ts updated for fresh installs;
src/core/schema-embedded.ts regenerated via bun run build:schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: computeEffectiveDate helper + putPage integration

Pure helper computing a page's effective_date from frontmatter precedence:
  1. event_date (meeting/event pages)
  2. date (dated essays)
  3. published (writing/)
  4. filename-date (leading YYYY-MM-DD in basename)
  5. updated_at (fallback)
  6. created_at (last resort)

Per-prefix override: for daily/ and meetings/ slugs, filename-date jumps
to position 1 — the filename is the user's primary signal there.

Returns {date, source}. The source label powers the doctor's
effective_date_health check to detect "fell back to updated_at" rows that
look populated but are functionally a NULL.

Range validation: parsed value must be in [1990-01-01, NOW + 1 year].
Out-of-range values drop to the next chain element.

Wired into importFromContent + importFromFile. The put_page MCP op derives
filename from slug-tail when no caller-supplied filename is available.

putPage SQL on both engines extended to write the new columns. ON CONFLICT
uses COALESCE(EXCLUDED.x, pages.x) so callers that don't know about the
new columns (auto-link, code reindex) preserve existing values rather than
blanking them. SELECT projection extended to return them; rowToPage threads
them through.

21 unit tests covering: precedence chain default order, per-prefix override,
parse failure fall-through, range validation [1990, NOW+1y], parseDateLoose
shape variants. All pass; typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: backfill orchestrator + library function for existing pages

src/core/backfill-effective-date.ts is the shared library function. Walks
pages in keyset-paginated batches (id > last_id ORDER BY id LIMIT 1000),
runs computeEffectiveDate per row, UPDATEs effective_date +
effective_date_source. Resumable via the `backfill.effective_date.last_id`
checkpoint key in the config table — a killed process can re-run and pick
up without re-doing rows. Idempotent: a full re-walk produces the same
writes.

Postgres-only: SET LOCAL statement_timeout = '600s' per batch. Doesn't
refuse the migration on low session settings (codex pass-2 #16).

src/commands/migrations/v0_29_1.ts is the orchestrator (4 phases mirroring
v0_12_2). Phase A schema (gbrain init --migrate-only), Phase B backfill
(via the library function), Phase C verify (count NULL effective_date),
Phase D record (handled by runner). The library function is reusable from
the gbrain reindex-frontmatter CLI command in the next commit.

import_filename stays NULL for backfilled rows — pre-v0.29.1 imports
didn't capture it. computeEffectiveDate uses the slug-tail when filename
is NULL; daily/2024-03-15 backfilled gets effective_date from the slug.

Registered in src/commands/migrations/index.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: gbrain reindex-frontmatter CLI command

Recovery / explicit-rebuild path for pages.effective_date. Used when:
  - User edited frontmatter dates after import
  - Post-upgrade backfill orchestrator finished but the user wants to
    re-walk a subset (e.g. just meetings/) after fixing some frontmatter
  - Precedence rules change between releases

Thin wrapper over backfillEffectiveDate from commit 3 — same code path
the v0_29_1 orchestrator uses; one source of truth.

Flags mirror reindex-code:
  --source <id>      Scope to one sources row (placeholder; library
                     library doesn't filter by source today, tracked v0.30+)
  --slug-prefix P    Scope to slugs starting with P (e.g. 'meetings/')
  --dry-run          Print what WOULD change, no DB writes
  --yes              Skip confirmation prompt (required for non-TTY non-JSON)
  --json             Machine-readable result envelope
  --force            Re-apply even when computed value matches existing

Wired into src/cli.ts. CLI handles its own engine lifecycle (creates +
disconnects).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: recency-decay map + buildRecencyComponentSql (pure, unused)

src/core/search/recency-decay.ts mirrors source-boost.ts in shape but
drives RECENCY ONLY (per D9 codex resolution). Salience is a separate
orthogonal axis; this map does not feed it.

DEFAULT_RECENCY_DECAY: 10 generic prefixes (no fork-specific names).
  - concepts/      evergreen (halflifeDays=0)
  - originals/     180d × 0.5 (long-tail decay; new essays nudged)
  - writing/       365d × 0.4
  - daily/         14d × 1.5  (aggressive — freshness IS the signal)
  - meetings/      60d × 1.0
  - chat/          7d × 1.0
  - media/x/       7d × 1.5
  - media/articles/ 90d × 0.5
  - people/companies/ 365d × 0.3
  - deals/         180d × 0.5

DEFAULT_FALLBACK: 90d × 0.5 for unmatched slugs.

Override priority: defaults < gbrain.yml recency: < env (GBRAIN_RECENCY_DECAY)
< per-call SearchOpts.recency_decay.

parseRecencyDecayEnv format: comma-separated prefix:halflifeDays:coefficient
triples. Refuses LOUD on parse error (RecencyDecayParseError) — codex
pass-2 #M3 finding. No silent fallback like source-boost's parser.

parseRecencyDecayYaml takes already-parsed YAML; throws on bad shape.

buildRecencyComponentSql in sql-ranking.ts emits a CASE expression with
longest-prefix-first ordering, evergreen short-circuit (literal 0 when
halflifeDays=0 or coefficient=0), and EXTRACT(EPOCH ...) for non-zero
branches. Output: ((CASE WHEN p.slug LIKE 'daily/%' THEN 1.5 * 14.0 /
(14.0 + EXTRACT(EPOCH FROM (NOW() - <dateExpr>))/86400.0) ... END))

Typed NowExpr enum prevents SQL injection (codex pass-1 #5). Tests pass
{ kind: 'fixed', isoUtc } for deterministic output; production NOW().
The 'fixed' branch escapes single quotes via escapeSqlLiteral.

25 unit tests covering: env parser shape, env error cases, yaml parser
shape, merge precedence (defaults < yaml < env < caller), CASE longest-
prefix-first ordering, evergreen short-circuit, NowExpr fixed/now,
single-quote injection defense, empty decayMap fallback path, default
map composition (no fork names, concepts/ evergreen, daily/ aggressive).

Pure module. Zero consumers in this commit; commit 6 wires it into
getRecentSalience, commit 10 wires it into the post-fusion stage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: refactor getRecentSalience to consume buildRecencyComponentSql

Both engines (Postgres + PGLite) now build the salience formula's third
term via buildRecencyComponentSql instead of inlining 1.0 / (1 + days_old).
Parameters: empty decayMap + fallback { halflifeDays: 1, coefficient: 1.0 }.
Math expands to 1 * 1.0 / (1.0 + days_old) = 1 / (1 + days_old) — same
numeric output as v0.29.0.

This is a no-behavior-change refactor preparing for commit 7's recency_bias
param. recency_bias='flat' (default) reproduces v0.29.0 exactly; 'on'
swaps in DEFAULT_RECENCY_DECAY for per-prefix decay.

Single source of truth for the recency math: same builder feeds the
salience query AND (in commit 10) the post-fusion applyRecencyBoost stage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: get_recent_salience gains recency_bias param (default 'flat')

SalienceOpts.recency_bias: 'flat' | 'on' added; default 'flat' preserves
v0.29.0 ranking verbatim. Pass 'on' to opt into per-prefix decay map
(concepts/originals/writing/ evergreen; daily/, media/x/, chat/ aggressive
decay).

When recency_bias='on', the salience query reads
COALESCE(p.effective_date, p.updated_at) instead of bare p.updated_at, so
the recency component is immune to auto-link updated_at churn — old
concepts/ pages just-touched by auto-link don't suddenly look fresh.

Both engines (Postgres + PGLite) wire the param through. resolveRecencyDecayMap()
honors gbrain.yml + GBRAIN_RECENCY_DECAY env at runtime.

MCP op surface: get_recent_salience gains the param with a load-bearing
description teaching the agent when to use 'on' vs 'flat' (current state →
on; mattering across all time → flat).

No silent v0.29.0 behavior change — opt-in only (per D11 codex resolution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: recompute_emotional_weight writes salience_touched_at; window picks up newly-salient pages

setEmotionalWeightBatch on both engines now bumps salience_touched_at to
NOW() ONLY when the new emotional_weight differs from the existing one
(IS DISTINCT FROM, NULL-safe). No-op writes (same weight) leave the
column alone — preserves "actual change" semantics.

getRecentSalience window changes from
  WHERE p.updated_at >= boundary
to
  WHERE GREATEST(p.updated_at, COALESCE(p.salience_touched_at, p.updated_at)) >= boundary

Closes codex pass-1 finding #4: pages whose emotional_weight just changed
in the dream cycle (because tags or takes shifted) but whose updated_at
is older than the salience window now correctly enter the recent-salience
results. Without this, "Garry just added a take to a 6-month-old page"
stayed invisible to get_recent_salience until the next content edit.

COALESCE(salience_touched_at, p.updated_at) handles pre-v0.29.1 rows
where salience_touched_at is NULL — they fall back to p.updated_at and
behave identically to v0.29.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: merge intent.ts → query-intent.ts; emit 3 suggestions per query

D1 + D4 + D6 + D8: single regex-pass classifier returning
{intent, suggestedDetail, suggestedSalience, suggestedRecency}.

intent + suggestedDetail are v0.29.0 behavior verbatim (legacy intent.ts
deleted; classifyQueryIntent + autoDetectDetail compat shims preserved).

NEW for v0.29.1 — two orthogonal recency-axis suggestions:

  suggestedSalience: 'off' | 'on' | 'strong'
  suggestedRecency:  'off' | 'on' | 'strong'

Resolution rules (per D6 narrow temporal-bound exception):
  - CANONICAL patterns (who is X / what is Y / code / graph) → both off
  - UNLESS an EXPLICIT_TEMPORAL_BOUND also matches (today / right now /
    this week / since X / last N days), in which case temporal-bound wins
  - STRONG_RECENCY (today / right now / this morning / just now) → strong
  - RECENCY_ON (latest / recent / this week / meeting prep / catch up
    / remind me / status update) → on
  - SALIENCE_ON (catch up / remind me / status update / prep me /
    what's going on / what matters) → on
  - default → off for both axes (v0.29.1 prime-directive: pure opt-in)

Salience and recency are TRULY orthogonal (per D9). A query like
"latest news on AI" → recency='on' but salience='off' (the user wants
fresh, not emotionally-weighted). "What's going on with widget-co" →
both on. "Who is X right now" → both 'strong'/'on' (temporal bound
beats canonical 'who is').

intent.ts deleted; test/intent.test.ts renamed → test/query-intent-legacy.test.ts
(unchanged behavior coverage). New test/query-intent.test.ts adds 21
cases covering all three axes' interactions: canonical wins on bare
'who is', temporal bound overrides, "catch me up" matches with up to 15
chars between, "today" → strong, intent vs recency independence.

Updated callers:
  - src/core/search/hybrid.ts (autoDetectDetail import)
  - test/recency-boost.test.ts (classifyQueryIntent import)
  - test/benchmark-search-quality.ts (autoDetectDetail import)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: applySalienceBoost + applyRecencyBoost + runPostFusionStages wrapper

D9 + codex pass-1 #2 + #3 + pass-2 #4: salience and recency are TRULY
ORTHOGONAL post-fusion stages, both running from ALL THREE hybridSearch
return paths (keyword-only, embed-failure-fallback, full-hybrid).

NEW src/core/search/hybrid.ts exports:
  - applySalienceBoost(results, scores, strength)
      score *= 1 + k * log(1 + score) where k = 0.15 (on) or 0.30 (strong)
      No time component. Pure mattering signal.
  - applyRecencyBoost(results, dates, strength, decayMap, fallback, nowMs?)
      Per-prefix decay factor: 1 + strengthMul * coefficient * halflife / (halflife + days_old)
      strengthMul: 1.0 (on) or 1.5 (strong)
      Evergreen prefixes (halflifeDays=0) skipped (factor 1.0).
      Pure recency signal. Independent of mattering.
  - runPostFusionStages(engine, results, opts)
      Wraps backlink + salience + recency. Called from EACH return path so
      keyless installs and embed failures get the same boost surface as
      the full hybrid path.

NEW engine methods (composite-keyed for multi-source isolation):
  - getEffectiveDates(refs: Array<{slug, source_id}>): Map<key, Date>
      Returns COALESCE(effective_date, updated_at, created_at). Key format:
      `${source_id}::${slug}`. Mirror of getBacklinkCounts shape.
  - getSalienceScores(refs: Array<{slug, source_id}>): Map<key, number>
      Returns emotional_weight × 5 + ln(1 + take_count). Composite key.

Deprecated (kept for back-compat through v0.29.x):
  - SearchOpts.afterDate / beforeDate (alias for since/until)
  - SearchOpts.recencyBoost: 0|1|2 (alias for recency: 'off'|'on'|'strong')
  - getPageTimestamps (use getEffectiveDates instead)

NEW SearchOpts fields:
  - salience: 'off' | 'on' | 'strong'
  - recency:  'off' | 'on' | 'strong'
  - since:    string (ISO-8601 or relative, replaces afterDate)
  - until:    string (replaces beforeDate)

Resolution: caller-explicit > legacy alias (recencyBoost) > heuristic
(classifyQuery's suggestedSalience / suggestedRecency).

Deleted: src/core/search/recency.ts (PR #618's, replaced) +
test/recency-boost.test.ts (its scope is replaced by query-intent.test.ts +
future post-fusion tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Wintermute <wintermute@garrytan.com>

* v0.29.1: query op gains salience + recency + since + until params; PGLite since/until parity

Combines commits 12 + 13 of the plan.

Query op surface (src/core/operations.ts):
  - salience: 'off' | 'on' | 'strong' (with load-bearing description)
  - recency:  'off' | 'on' | 'strong'
  - since:    string (ISO-8601 or relative; replaces deprecated afterDate)
  - until:    string (replaces deprecated beforeDate)

Tool descriptions teach the calling agent:
  - salience axis = mattering, no time component
  - recency axis = age decay, no mattering signal
  - omit either to let gbrain auto-detect from query text via classifyQuery

hybrid.ts maps since/until → afterDate/beforeDate at the engine call
boundary so PR #618's existing engine plumbing keeps working without
rename. Codex pass-1 #10 finding closed.

PGLite engine (codex pass-1 #10): since/until parity added to all three
search methods (searchKeyword, searchKeywordChunks, searchVector). SQL
filter against COALESCE(p.effective_date, p.updated_at, p.created_at)
so date filtering matches user content-date intent (a meeting was on
event_date, not when it got reimported). Filter is applied INSIDE the
HNSW inner CTE in searchVector so HNSW's candidate pool already
excludes out-of-range pages — preserves pagination contract.

This also closes existing cross-engine drift: pre-v0.29.1 Postgres had
afterDate/beforeDate from PR #618; PGLite had nothing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: migration v39 — eval_candidates capture columns for replay reproducibility

D11 codex pass-2 resolution: extend eval_candidates with 7 new nullable
columns so `gbrain eval replay` can reproduce captured runs of agent-explicit
salience + recency choices.

Without these columns, replays of the new axis params drift. The live
behavior depends on the resolved {salience, recency} values; v0.29.0's
schema doesn't capture them.

  as_of_ts            TIMESTAMPTZ  — brain's logical NOW at capture
                                     (replay uses this instead of wall-clock)
  salience_param      TEXT         — what the caller passed (NULL if omitted)
  recency_param       TEXT         — same
  salience_resolved   TEXT         — final value applied
  recency_resolved    TEXT         — same
  salience_source     TEXT         — 'caller' or 'auto_heuristic'
  recency_source      TEXT         — same

All nullable + additive. Pre-v0.29.1 rows stay valid. NDJSON
schema_version STAYS at 1 — consumers ignore unknown fields (codex
pass-1 #C2 dissolves; no cross-repo coordination needed).

ADD COLUMN with no DEFAULT is metadata-only on PG 11+ and PGLite —
instant on tables of any size.

src/schema.sql + src/core/pglite-schema.ts mirror the additions for fresh
installs; src/core/schema-embedded.ts regenerated. eval_capture.ts
populates the new fields in commit 16 (docs + ship).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: doctor checks — effective_date_health + salience_health

effective_date_health: sample-1000 scan detects three classes of
problems (codex pass-1 #5 resolution via the effective_date_source
sentinel column added in commit 1):

  fallback_with_fm_date  — page fell back to updated_at even though
                           frontmatter has parseable event_date / date /
                           published. The "wrong but populated" residual
                           that earlier review iterations missed.
  future_dated            — effective_date > NOW() + 1 year (corrupt
                            or typo'd century).
  pre_1990                — effective_date < 1990-01-01 (epoch math gone
                            wrong, bad parse).

Sample of last 1000 pages by default — fast on 200K-page brains. Fix
hint: gbrain reindex-frontmatter.

salience_health: detects pages with active takes whose emotional_weight
is still 0 (recompute_emotional_weight phase hasn't run since the
take landed). Reports the brain's non-zero emotional_weight count as
an informational baseline. Fix hint: gbrain dream --phase
recompute_emotional_weight.

Both checks gracefully skip on pre-v0.29.1 brains (column doesn't
exist → 42703) without surfacing as warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29.1: docs + skills convention + CHANGELOG + version bump

- VERSION 0.29.0 → 0.29.1
- package.json version bump
- CHANGELOG.md: full release-summary + itemized + "To take advantage"
  block per the project's voice rules. Two-line headline + concrete
  pathology framing (existing callers unchanged; new axes opt-in;
  agent in charge per the prime directive).
- skills/conventions/salience-and-recency.md: agent-readable decision
  rules. "Current state → on. Canonical truth → off." plus the narrow
  temporal-bound exception. Cross-cutting convention propagates to
  brain skills via RESOLVER.md.
- skills/migrations/v0.29.1.md: agent-readable upgrade instructions.
  Verify steps + behavior-change reference + recovery commands.

The build-time tool-description generator from D2 (extract decision
tables from skills/conventions/salience-and-recency.md, embed into
operations.ts at build time) is deferred to a follow-up commit. The
tool descriptions on the query op + get_recent_salience are inline in
operations.ts for v0.29.1; the auto-gen + CI staleness gate land in
v0.29.2 if drift becomes a problem in practice.

148 unit tests pass across the v0.29.1 surface (effective-date,
recency-decay, query-intent, migrate, salience, recompute-emotional-weight).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Wintermute <wintermute@garrytan.com>

---------

Co-authored-by: Wintermute <wintermute@garrytan.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 master-rebase fixups: renumber + drift cleanup

- v0.29.1 migrations renumber v38/v39 → v41/v42 (master shipped takes_table at
  v37 + access_tokens_permissions at v38; v0.27.1 took v39). My v0.29.0
  emotional_weight slots in at v40; v0.29.1's pages_recency_columns lands at
  v41 and eval_candidates_recency_capture at v42.
- src/core/utils.ts comment refs updated v37 → v40 (emotional_weight) and
  v38 → v41 (effective_date/etc).
- test/brain-allowlist.test.ts: size assertion 11 → 13 + the new
  get_recent_salience / find_anomalies positive checks + the explicit
  get_recent_transcripts negative check (v0.29 added the salience pair to
  the allow-list; transcripts are deliberately excluded because all
  subagent calls have remote=true and the v0.29 trust gate rejects them —
  visibility would be a footgun).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 CI fixups: privacy allow-list + cycle phase count + migration plan

Three CI test failures on PR #730, all caused by master-side state the
v0.29 cherry-picks didn't yet account for:

1. scripts/check-privacy.sh allow-lists test/recency-decay.test.ts
   The v0.29.1 recency-decay test asserts that DEFAULT_RECENCY_DECAY's
   keys do NOT include fork-specific path prefixes. Because the assertion
   has to name the banned tokens to assert their absence, the privacy
   guard flagged the literal occurrence. Same exception class as
   CHANGELOG.md, CLAUDE.md, and scripts/check-privacy.sh itself —
   meta-rule enforcement requires mentioning what the rule forbids.

2. test/core/cycle.serial.test.ts: 9 → 10 phases.
   The yieldBetweenPhases test was written for v0.26.5 (9 phases incl.
   purge). v0.29 added a 10th phase (recompute_emotional_weight)
   between patterns and embed; the test's expected hookCalls and
   report.phases.length needed bumping.

3. test/apply-migrations.test.ts: append '0.29.1' to skippedFuture lists.
   v0.29.1 added a new entry to src/commands/migrations/index.ts; the
   buildPlan test snapshots the exact ordered list of versions, so it
   needs the new entry in both the fresh-install case and the Codex H9
   regression case.

All three verified locally:
  - bash scripts/check-privacy.sh → exit 0
  - bun test test/apply-migrations.test.ts → 18/18 pass
  - bun test test/core/cycle.serial.test.ts → 28/28 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 CI fixup: regenerate llms-full.txt to match CLAUDE.md state

build-llms test asserts the committed llms.txt + llms-full.txt match
what the generator produces from the current source tree. CLAUDE.md
got new v0.29 Key Files entries (recompute_emotional_weight phase,
emotional-weight formula, anomaly stats, transcripts library, salience
ops, etc.) without a corresponding regen. `bun run build:llms` brings
llms-full.txt back in sync; llms.txt is byte-for-byte identical so
only the larger inline bundle changed.

Verified locally: bun test test/build-llms.test.ts → 7/7 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 e2e: cover tool-surfaces + MCP dispatch path

Two gaps were uncovered when reviewing v0.29 coverage against the new
contracts the cherry-picks landed onto master.

1. test/v0_29-tool-surfaces.test.ts (unit, 9 cases)

   Existing tests pin the description constants module and the
   BRAIN_TOOL_ALLOWLIST set membership, but nothing checked the two
   filters that ACT on those constants:

   - serve-http.ts:745 filters operations by !op.localOnly to build the
     HTTP MCP tool list. Without a test, anyone removing `localOnly: true`
     from get_recent_transcripts would silently expose it to remote
     callers — defense-in-depth on top of the in-handler ctx.remote check
     would be the only guard. Now pinned: get_recent_transcripts is
     hidden, salience + anomalies stay visible.

   - buildBrainTools surfaces the v0.29 ops as `brain_get_recent_salience`
     and `brain_find_anomalies`, and EXCLUDES `brain_get_recent_transcripts`
     (codex C3 footgun gate — all subagent calls are remote=true, the op
     would always reject). Now pinned.

   Both filters are pure functions; no DB / engine.connect needed.

2. test/e2e/v0_29-mcp-dispatch-pglite.test.ts (e2e, 5 cases)

   Existing v0.29 e2e tests call engine methods directly. None went
   through the full dispatchToolCall pipeline that stdio MCP and HTTP
   MCP both use. The new file covers:

   - get_recent_salience returns ranked rows via dispatch (top result
     is the wedding-tagged page from the seeded fixture).
   - find_anomalies returns the AnomalyResult shape via dispatch.
   - get_recent_transcripts rejects with permission_denied when
     ctx.remote === true (the in-handler trust gate is the last line if
     localOnly ever drops).
   - get_recent_transcripts succeeds with ctx.remote === false (CLI
     path) and returns [] when no corpus dir is configured.
   - Unknown tool name returns the standard isError + "Unknown tool"
     envelope (regression guard for dispatch shape).

Verified locally — all 14 cases pass:
  bun test test/v0_29-tool-surfaces.test.ts                          → 9 pass
  bun test test/e2e/v0_29-mcp-dispatch-pglite.test.ts                → 5 pass

Re-ran the full v0.29 PGLite e2e suite to confirm no regressions:
  salience-pglite.test.ts                       5 pass
  anomalies-pglite.test.ts                      4 pass
  cycle-recompute-emotional-weight-pglite.test  3 pass
  list-pages-regression.test.ts                 6 pass
  multi-source-emotional-weight-pglite.test     4 pass
  backfill-perf-pglite.test.ts                  1 pass
  v0_29-mcp-dispatch-pglite.test.ts             5 pass
  -----
  Total: 28 pass / 0 fail
  Postgres parity test (DATABASE_URL gated)     7 skip (correct)
  LLM routing eval (ANTHROPIC_API_KEY gated)   12 skip (correct)
  bun run typecheck                             clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.29 CI fixup: drop unused PGLiteEngine in tool-surfaces test

scripts/check-test-isolation.sh's R3 + R4 lints flagged the new
test/v0_29-tool-surfaces.test.ts for instantiating PGLiteEngine outside
a beforeAll() block (R3) and lacking the matching afterAll(disconnect)
(R4). The intent of those rules is to prevent engine leaks across the
shard process — every PGLiteEngine must follow the canonical
beforeAll(connect+initSchema) / afterAll(disconnect) pattern.

The fix here is upstream of the rule, not a workaround: this test never
needed an engine. buildBrainTools doesn't issue any SQL at registry-build
time — it only reads `engine.kind` for the put_page namespace-wrap
branch. A `{ kind: 'pglite' } as unknown as BrainEngine` fake-engine
literal keeps the test pure-function: no WASM cold-start, no connect
lifecycle, no test-isolation rule fired.

Verified locally:
  bash scripts/check-test-isolation.sh → OK (257 non-serial unit files)
  bun test test/v0_29-tool-surfaces.test.ts → 9 pass
  bun run typecheck → clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Wintermute <wintermute@garrytan.com>
2026-05-07 21:52:58 -07:00

2174 lines
86 KiB
TypeScript

/**
* Contract-first operation definitions. Single source of truth for CLI, MCP, and tools-json.
* Each operation defines its schema, handler, and optional CLI hints.
*/
import { lstatSync, realpathSync } from 'fs';
import { resolve, relative, sep } from 'path';
import type { BrainEngine } from './engine.ts';
import { clampSearchLimit } from './engine.ts';
import type { GBrainConfig } from './config.ts';
import type { PageType } from './types.ts';
import { importFromContent } from './import-file.ts';
import { hybridSearch } from './search/hybrid.ts';
import { expandQuery } from './search/expansion.ts';
import { dedupResults } from './search/dedup.ts';
import { captureEvalCandidate, isEvalCaptureEnabled, isEvalScrubEnabled } from './eval-capture.ts';
import type { HybridSearchMeta } from './types.ts';
import { extractPageLinks, isAutoLinkEnabled, isAutoTimelineEnabled, parseTimelineEntries, makeResolver, type UnresolvedFrontmatterRef } from './link-extraction.ts';
import * as db from './db.ts';
import {
GET_RECENT_SALIENCE_DESCRIPTION,
FIND_ANOMALIES_DESCRIPTION,
GET_RECENT_TRANSCRIPTS_DESCRIPTION,
LIST_PAGES_DESCRIPTION,
QUERY_DESCRIPTION,
SEARCH_DESCRIPTION,
} from './operations-descriptions.ts';
// --- Types ---
export type ErrorCode =
| 'page_not_found'
| 'invalid_params'
| 'embedding_failed'
| 'storage_error'
| 'bucket_not_found'
| 'database_error'
| 'permission_denied'
| 'unknown_transport'; // v0.28.1: whoami fail-closed for ambiguous transport
export class OperationError extends Error {
constructor(
public code: ErrorCode,
message: string,
public suggestion?: string,
public docs?: string,
) {
super(message);
this.name = 'OperationError';
}
toJSON() {
return {
error: this.code,
message: this.message,
suggestion: this.suggestion,
docs: this.docs,
};
}
}
// --- Upload validators (Fix 1 / B5 / H5 / M4) ---
/**
* Validate an upload path. Two modes:
* - strict (remote=true): confines the resolved path to `root` and rejects symlinks.
* Used when the caller is untrusted (MCP over stdio/HTTP, agent-facing).
* - loose (remote=false): only verifies the file exists and is not a symlink whose
* target escapes the filesystem (no path traversal protection). Used for local CLI
* where the user owns the filesystem.
*
* Either way: symlinks in the final component are always rejected (prevents
* transparent redirection to a different file than the user typed).
*
* @param filePath caller-supplied path
* @param root confinement root (only used when strict=true)
* @param strict true → enforce cwd confinement (B5 + H1). false → allow any accessible path.
* @throws OperationError(invalid_params) on symlink escape, traversal, or missing file
*/
export function validateUploadPath(filePath: string, root: string, strict = true): string {
let real: string;
try {
real = realpathSync(resolve(filePath));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('ENOENT')) {
throw new OperationError('invalid_params', `File not found: ${filePath}`);
}
throw new OperationError('invalid_params', `Cannot resolve path: ${filePath}`);
}
// Always reject final-component symlinks (basic safety for both modes).
try {
if (lstatSync(resolve(filePath)).isSymbolicLink()) {
throw new OperationError('invalid_params', `Symlinks are not allowed for upload: ${filePath}`);
}
} catch (e) {
if (e instanceof OperationError) throw e;
// lstat race with unlink — pass if realpath already succeeded.
}
if (!strict) return real;
// Strict mode: confine to root via realpath + path.relative (catches parent-dir symlinks per B5).
let realRoot: string;
try {
realRoot = realpathSync(root);
} catch {
throw new OperationError('invalid_params', `Confinement root not accessible: ${root}`);
}
const rel = relative(realRoot, real);
if (rel === '' || rel.startsWith('..') || rel.startsWith(`..${sep}`) || resolve(realRoot, rel) !== real) {
throw new OperationError('invalid_params', `Upload path must be within the working directory: ${filePath}`);
}
return real;
}
/**
* Allowlist validator for page slugs. Rejects URL-encoded traversal, backslashes,
* control chars, RTL overrides, Unicode lookalikes — anything outside the allowlist.
* Format: lowercase alphanumeric + hyphen segments separated by single forward slashes.
*/
export function validatePageSlug(slug: string): void {
if (typeof slug !== 'string' || slug.length === 0) {
throw new OperationError('invalid_params', 'page_slug must be a non-empty string');
}
if (slug.length > 255) {
throw new OperationError('invalid_params', 'page_slug exceeds 255 characters');
}
if (!/^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)*$/i.test(slug)) {
throw new OperationError('invalid_params', `Invalid page_slug: ${slug} (allowed: alphanumeric, hyphens, forward-slash separated segments)`);
}
}
/**
* Match a slug against a list of allow-list prefix globs.
*
* Glob form: `<prefix>/*` matches any slug starting with `<prefix>/` and
* having at least one more segment (single or multi). Bare `<prefix>` (no
* trailing `/*`) matches that exact slug only. The `*` is intentionally
* permissive — depth is unbounded, so `wiki/originals/*` matches both
* `wiki/originals/idea-x` and `wiki/originals/ideas/2026-04-25-idea-y`.
*
* Used by the v0.23 dream-cycle trusted-workspace path. Order doesn't
* matter; the first match wins (returns true on any match).
*/
export function matchesSlugAllowList(slug: string, prefixes: readonly string[]): boolean {
for (const p of prefixes) {
if (p.endsWith('/*')) {
const base = p.slice(0, -2);
if (slug === base) continue;
if (slug.startsWith(base + '/')) return true;
} else if (p === slug) {
return true;
}
}
return false;
}
/**
* Allowlist validator for uploaded file basenames. Rejects control chars, backslashes,
* RTL overrides (\u202E), leading dot (hidden files) and leading dash (CLI flag confusion).
* Allows extension dots and underscores. Max 255 chars.
*/
export function validateFilename(name: string): void {
if (typeof name !== 'string' || name.length === 0) {
throw new OperationError('invalid_params', 'Filename must be a non-empty string');
}
if (name.length > 255) {
throw new OperationError('invalid_params', 'Filename exceeds 255 characters');
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9._\-]*$/.test(name)) {
throw new OperationError('invalid_params', `Invalid filename: ${name} (allowed: alphanumeric, dot, underscore, hyphen — no leading dot/dash, no control chars or backslash)`);
}
}
export interface ParamDef {
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
required?: boolean;
description?: string;
default?: unknown;
enum?: string[];
items?: ParamDef;
}
export interface Logger {
info(msg: string): void;
warn(msg: string): void;
error(msg: string): void;
}
export interface AuthInfo {
token: string;
clientId: string;
/**
* Human-readable agent name resolved at token-verification time.
* For OAuth clients this is `oauth_clients.client_name`; for legacy
* bearer tokens it is `access_tokens.name`. Threading this through
* AuthInfo eliminates a per-request DB roundtrip in the /mcp handler
* (was: SELECT client_name FROM oauth_clients WHERE client_id = ?
* on every request — see PR #586 review note D14=B).
*/
clientName?: string;
scopes: string[];
expiresAt?: number;
}
export interface OperationContext {
engine: BrainEngine;
config: GBrainConfig;
logger: Logger;
dryRun: boolean;
/**
* OAuth auth info (v0.8+). Present when the caller authenticated via OAuth 2.1
* through `gbrain serve --http`. Contains clientId and granted scopes for
* per-operation scope enforcement.
*/
auth?: AuthInfo;
/**
* True when the caller is remote/untrusted (MCP over stdio/HTTP, or any agent-facing entry point).
* False for local CLI invocations by the owner of the machine.
*
* Security-sensitive operations (e.g., file_upload) tighten their filesystem
* confinement when remote=true and allow unrestricted local-filesystem access
* when remote=false.
*
* REQUIRED as of the F7b hardening — the type system is the first line of defense.
* Every transport (CLI / stdio MCP / HTTP MCP / subagent dispatcher) sets this
* explicitly. Consumers still treat anything that isn't strictly `false` as
* remote/untrusted (defense in depth in case the type is bypassed via cast).
*/
remote: boolean;
/**
* Subagent runtime context (v0.16+). Set by the subagent tool dispatcher when
* dispatching an op as a tool call from an LLM loop. Used to enforce per-op
* agent policy (e.g. put_page namespace rule).
*
* `viaSubagent` is the FAIL-CLOSED flag: when true, agent-facing policy MUST
* be enforced even if `subagentId` happens to be undefined (a bug in the
* dispatcher must not bypass the guard). `subagentId` is the owning subagent
* job id; `jobId` is the current Minion job id (aggregator or subagent).
*/
jobId?: number;
subagentId?: number;
viaSubagent?: boolean;
/**
* Trusted-workspace allow-list (v0.23 dream cycle). When the cycle's
* synthesize/patterns phases dispatch a subagent, they thread an
* explicit list of slug-prefix globs (e.g. "wiki/personal/reflections/*")
* through this field. put_page enforces it BEFORE the legacy
* `wiki/agents/<id>/...` namespace check.
*
* Trust comes from the SUBMITTER (subagent jobs are gated by
* PROTECTED_JOB_NAMES — MCP cannot submit them), not from `remote`.
* Every subagent tool call has `remote=true` for auto-link safety,
* so basing trust on `remote` is incoherent (would always reject).
*
* Empty / unset → fall back to the legacy namespace check (existing
* v0.15 behavior; pure addition, no regression).
*/
allowedSlugPrefixes?: string[];
/**
* Resolved global CLI options (--quiet / --progress-json / --progress-interval).
* CLI callers populate this from `getCliOptions()`. MCP / library callers
* may leave it undefined — consumers default to quiet/no-progress for
* background work.
*/
cliOpts?: { quiet: boolean; progressJson: boolean; progressInterval: number };
/**
* v0.28: per-token allow-list for the holder field on `takes`. Threaded
* by the MCP HTTP/stdio dispatch layer from `access_tokens.permissions.takes_holders`.
*
* When set (i.e., this OperationContext came from an MCP-bound token),
* `takes_list`, `takes_search`, and `query` (when it returns takes) MUST
* apply `WHERE holder = ANY($takesHoldersAllowList)`. This is the
* server-side filter that backs the v0.28 visibility model.
*
* Default behavior when unset: local CLI callers see all holders. v0.28
* MCP dispatch sets it to `['world']` for tokens with no permissions row
* (default-deny on private hunches).
*/
takesHoldersAllowList?: string[];
/**
* Connected-gbrains brain id (v0.19+ / v0.26 mounts). Identifies which brain
* this op is targeting. 'host' for the default brain configured in
* ~/.gbrain/config.json; otherwise a mount id registered in ~/.gbrain/mounts.json.
*
* `ctx.engine` is the resolved BrainEngine for this id (populated by
* BrainRegistry at dispatch time). `brainId` exists alongside for:
* - audit logging (mount-ops JSONL carries the id)
* - subagent inheritance (child jobs receive the parent's brainId)
* - cross-brain citation prefixes in agent output
*
* Orthogonal to v0.18.0's source_id, which scopes per-repo WITHIN a brain.
* See docs/architecture/brains-and-sources.md for the mental model.
*
* Omitted = 'host' (pre-v0.19 callers + single-brain deployments keep
* working without change).
*/
brainId?: string;
}
export interface Operation {
name: string;
description: string;
params: Record<string, ParamDef>;
handler: (ctx: OperationContext, params: Record<string, unknown>) => Promise<unknown>;
mutating?: boolean;
/**
* Capability scope required to invoke this op over an authenticated
* transport. v0.28 added `sources_admin` (manage federated sources) and
* `users_admin` (reserved). The hierarchy lives in src/core/scope.ts —
* `admin` implies all, `write` implies `read`, the two `*_admin` scopes
* are siblings (different axes; neither implies the other).
*
* Local CLI callers (ctx.remote === false) bypass scope enforcement
* because the trust boundary there is the OS, not OAuth scopes.
*/
scope?: 'read' | 'write' | 'admin' | 'sources_admin' | 'users_admin';
localOnly?: boolean;
cliHints?: {
name?: string;
positional?: string[];
stdin?: string;
hidden?: boolean;
};
}
// --- Page CRUD ---
const get_page: Operation = {
name: 'get_page',
description: 'Read a page by slug (supports optional fuzzy matching). Soft-deleted pages are hidden by default; pass include_deleted: true to surface them with deleted_at populated (see v0.26.5 recovery window).',
params: {
slug: { type: 'string', required: true, description: 'Page slug' },
fuzzy: { type: 'boolean', description: 'Enable fuzzy slug resolution (default: false)' },
include_deleted: { type: 'boolean', description: 'v0.26.5: surface soft-deleted pages with deleted_at populated (default: false). Used by restore workflows.' },
},
handler: async (ctx, p) => {
const slug = p.slug as string;
const fuzzy = (p.fuzzy as boolean) || false;
const includeDeleted = (p.include_deleted as boolean) === true;
let page = await ctx.engine.getPage(slug, { includeDeleted });
let resolved_slug: string | undefined;
if (!page && fuzzy) {
const candidates = await ctx.engine.resolveSlugs(slug);
if (candidates.length === 1) {
page = await ctx.engine.getPage(candidates[0], { includeDeleted });
resolved_slug = candidates[0];
} else if (candidates.length > 1) {
return { error: 'ambiguous_slug', candidates };
}
}
if (!page) {
throw new OperationError('page_not_found', `Page not found: ${slug}`, includeDeleted ? 'Check the slug or use fuzzy: true' : 'Page may be soft-deleted; pass include_deleted: true to verify');
}
const tags = await ctx.engine.getTags(page.slug);
return { ...page, tags, ...(resolved_slug ? { resolved_slug } : {}) };
},
scope: 'read',
cliHints: { name: 'get', positional: ['slug'] },
};
const put_page: Operation = {
name: 'put_page',
description: 'Write/update a page (markdown with frontmatter). Chunks, embeds, reconciles tags, and (when auto_link/auto_timeline are enabled) extracts + reconciles graph links and timeline entries.',
params: {
slug: { type: 'string', required: true, description: 'Page slug' },
content: { type: 'string', required: true, description: 'Full markdown content with YAML frontmatter' },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
const slug = p.slug as string;
// Subagent namespace enforcement (v0.15+). Runs BEFORE the dry-run
// short-circuit so preview calls surface the same rejection. Confines
// LLM-driven writes to wiki/agents/<subagentId>/... — no leading slash
// (slug grammar rejects that), anchored, slash-boundary to defeat prefix
// collisions like `wiki/agents/12evil/*` impersonating subagent 12.
//
// FAIL-CLOSED: `viaSubagent=true` enforces the check even if the
// dispatcher forgot to populate `subagentId`. Agent-originated writes
// without an owning subagent id are rejected outright.
if (ctx.viaSubagent === true) {
if (typeof ctx.subagentId !== 'number' || Number.isNaN(ctx.subagentId)) {
throw new OperationError('permission_denied', 'put_page via subagent requires ctx.subagentId');
}
const allowList = ctx.allowedSlugPrefixes;
if (allowList && allowList.length > 0) {
// Trusted-workspace path: explicit allow-list bounds writes.
// Set only by cycle.ts (synthesize/patterns) which submits subagent
// jobs under PROTECTED_JOB_NAMES — MCP cannot reach this branch.
if (!matchesSlugAllowList(slug, allowList)) {
throw new OperationError(
'permission_denied',
`put_page slug '${slug}' is not within the trusted-workspace allow-list (${allowList.join(', ')})`
);
}
} else {
// Legacy default: agent-namespace confinement.
const prefix = `wiki/agents/${ctx.subagentId}/`;
if (!slug.startsWith(prefix) || slug.length === prefix.length) {
throw new OperationError('permission_denied', `put_page via subagent must write under '${prefix}...'`);
}
}
}
if (ctx.dryRun) return { dry_run: true, action: 'put_page', slug: p.slug };
// Skip embedding when the AI gateway has no embedding provider configured.
// Checks all auth env vars for the resolved provider, not just OPENAI_API_KEY,
// so Gemini / Ollama / Voyage brains don't silently drop embeddings (Codex C2).
const { isAvailable } = await import('./ai/gateway.ts');
const noEmbed = !isAvailable('embedding');
const result = await importFromContent(ctx.engine, slug, p.content as string, { noEmbed });
// Auto-link post-hook: runs AFTER importFromContent (which is its own
// transaction). Runs even on status='skipped' so reconciliation catches drift
// between the page text and the links table. Failures are non-blocking.
//
// SECURITY: skipped for remote (MCP) callers. Auto-link's bare-slug regex
// matches `people/X` etc. anywhere in page text, including code fences,
// quoted strings, and prompt-injected content. An untrusted page can plant
// arbitrary outbound links by including `see meetings/board-q1` in its body.
// Combined with the backlink boost in hybridSearch, attacker-placed targets
// would surface higher in search. Local CLI users (ctx.remote=false) opt
// into this behavior; MCP/remote writes do not.
let autoLinks:
| { created: number; removed: number; errors: number; unresolved: UnresolvedFrontmatterRef[] }
| { error: string }
| { skipped: 'remote' }
| undefined;
let autoTimeline: { created: number } | { error: string } | { skipped: 'remote' } | undefined;
// Trusted-workspace path (v0.23 dream cycle) re-enables auto-link/timeline
// even though ctx.remote=true, because the allow-list bounds the slug and
// the synthesis prompt is itself the trusted dispatcher. Without this,
// the cycle's `extract` phase would have to recompute every edge, and
// patterns (which runs after extract) would still see the right graph
// but auto_timeline would never fire on synth output.
const trustedWorkspace = ctx.viaSubagent === true
&& Array.isArray(ctx.allowedSlugPrefixes)
&& ctx.allowedSlugPrefixes.length > 0;
if (ctx.remote !== false && !trustedWorkspace) {
autoLinks = { skipped: 'remote' };
autoTimeline = { skipped: 'remote' };
} else if (result.parsedPage) {
try {
const enabled = await isAutoLinkEnabled(ctx.engine);
if (enabled) {
autoLinks = await runAutoLink(ctx.engine, slug, result.parsedPage);
}
} catch (e) {
autoLinks = { error: e instanceof Error ? e.message : String(e) };
}
// Timeline extraction mirrors auto-link: runs post-write, best-effort,
// never blocks the write. ON CONFLICT DO NOTHING in
// addTimelineEntriesBatch keeps it idempotent across re-writes, so a
// page that's edited and re-written won't duplicate its own timeline.
try {
const enabled = await isAutoTimelineEnabled(ctx.engine);
if (enabled) {
const fullContent = result.parsedPage.compiled_truth + '\n' + result.parsedPage.timeline;
const entries = parseTimelineEntries(fullContent);
if (entries.length > 0) {
const batch = entries.map(e => ({
slug,
date: e.date,
summary: e.summary,
detail: e.detail || '',
}));
const created = await ctx.engine.addTimelineEntriesBatch(batch);
autoTimeline = { created };
} else {
autoTimeline = { created: 0 };
}
}
} catch (e) {
autoTimeline = { error: e instanceof Error ? e.message : String(e) };
}
}
// Post-write validator lint (PR 2.5): feature-flag-gated, non-blocking.
// When `writer.lint_on_put_page` is enabled, runs the BrainWriter's
// validators on the freshly-written page and logs findings to
// ingest_log + ~/.gbrain/validator-lint.jsonl. Does NOT reject the
// write — that's the deferred strict-mode flip after the 7-day soak.
let writerLint: { error_count: number; warning_count: number } | { skipped: string } | undefined;
try {
const { runPostWriteLint } = await import('./output/post-write.ts');
const lint = await runPostWriteLint(ctx.engine, result.slug);
if (lint.ran) {
writerLint = {
error_count: lint.findings.filter(f => f.severity === 'error').length,
warning_count: lint.findings.filter(f => f.severity === 'warning').length,
};
} else if (lint.skippedReason) {
writerLint = { skipped: lint.skippedReason };
}
} catch {
// Non-fatal; never blocks put_page.
}
return {
slug: result.slug,
status: result.status === 'imported' ? 'created_or_updated' : result.status,
chunks: result.chunks,
...(autoLinks ? { auto_links: autoLinks } : {}),
...(autoTimeline ? { auto_timeline: autoTimeline } : {}),
...(writerLint ? { writer_lint: writerLint } : {}),
};
},
cliHints: { name: 'put', positional: ['slug'], stdin: 'content' },
};
/**
* Extract entity refs from a freshly-written page, sync the links table to match.
* Creates new links via addLink, removes stale ones (links present in DB but no
* longer referenced in content) via removeLink. Returns counts.
*
* Runs OUTSIDE importFromContent's transaction so it doesn't block the page write
* or get rolled back if a single link operation fails. Per-link failures are
* counted; the overall function never throws (catch in put_page handler covers
* extraction errors).
*/
async function runAutoLink(
engine: BrainEngine,
slug: string,
parsed: { type: PageType; compiled_truth: string; timeline: string; frontmatter: Record<string, unknown> },
): Promise<{ created: number; removed: number; errors: number; unresolved: UnresolvedFrontmatterRef[] }> {
const fullContent = parsed.compiled_truth + '\n' + parsed.timeline;
// Live-mode resolver: per-put throwaway cache, pg_trgm + optional search.
const resolver = makeResolver(engine, { mode: 'live' });
const { candidates, unresolved } = await extractPageLinks(
slug, fullContent, parsed.frontmatter, parsed.type, resolver,
);
// Resolve which targets exist (skip refs to non-existent pages to avoid FK
// violation churn in addLink). One getAllSlugs call upfront, O(1) lookup.
const allSlugs = await engine.getAllSlugs();
const valid = candidates.filter(c =>
allSlugs.has(c.targetSlug) && (!c.fromSlug || allSlugs.has(c.fromSlug))
);
// Split candidates by direction. Outgoing (fromSlug === slug or unset) are
// this page's own edges, reconciled against getLinks(slug). Incoming
// (fromSlug !== slug — frontmatter with `direction: incoming`) are edges
// where this page is the TO side; reconciled against getBacklinks(slug)
// but SCOPED to the frontmatter edges this page authored via
// (link_source='frontmatter' AND origin_slug = slug). We never touch
// frontmatter edges authored by OTHER pages.
const out = valid.filter(c => !c.fromSlug || c.fromSlug === slug);
const inc = valid.filter(c => c.fromSlug && c.fromSlug !== slug);
// Run getLinks + addLink/removeLink loops inside a single transaction so that
// concurrent put_page calls on the same slug can't race the reconciliation:
// without this, two simultaneous writes both read stale `existingKeys` and
// re-create links the other side just removed (lost-update).
//
// Row-level locks alone aren't enough: both writers can read the same
// `existingKeys` set BEFORE either mutates a row, so the union-of-writes
// race survives. A transaction-scoped advisory lock keyed on the slug
// hash serializes the entire reconciliation across processes. Falls
// through on engines that don't support pg_advisory_xact_lock (PGLite is
// single-process so there's no cross-process concern there anyway).
const result = await engine.transaction(async (tx) => {
try {
await tx.executeRaw(`SELECT pg_advisory_xact_lock(hashtext($1)::bigint)`, [`auto_link:${slug}`]);
} catch {
// engine doesn't support advisory locks — fall through
}
const existingOut = await tx.getLinks(slug);
// Incoming: we only look at frontmatter edges WE authored (origin_slug=slug).
// Non-frontmatter and other-page frontmatter edges survive untouched.
const existingInRaw = await tx.getBacklinks(slug);
const existingIn = existingInRaw.filter(
l => l.link_source === 'frontmatter' && l.origin_slug === slug,
);
// Reconcilable outgoing edges: markdown + our own frontmatter edges.
// Manual edges (link_source='manual') are NEVER touched by reconciliation.
const reconcilableOut = existingOut.filter(
l => l.link_source === 'markdown' || l.link_source == null ||
(l.link_source === 'frontmatter' && l.origin_slug === slug),
);
const outKeys = new Set(out.map(c =>
`${c.targetSlug}\u0000${c.linkType}\u0000${c.linkSource ?? 'markdown'}`
));
const incKeys = new Set(inc.map(c =>
`${c.fromSlug}\u0000${c.linkType}`
));
let created = 0, removed = 0, errors = 0;
// Add outgoing edges.
for (const c of out) {
try {
await tx.addLink(
slug, c.targetSlug, c.context, c.linkType,
c.linkSource, c.originSlug, c.originField,
);
const existKey = `${c.targetSlug}\u0000${c.linkType}\u0000${c.linkSource ?? 'markdown'}`;
const exists = reconcilableOut.some(l =>
`${l.to_slug}\u0000${l.link_type}\u0000${l.link_source ?? 'markdown'}` === existKey
);
if (!exists) created++;
} catch {
errors++;
}
}
// Add incoming edges (other page → slug).
for (const c of inc) {
try {
await tx.addLink(
c.fromSlug!, c.targetSlug, c.context, c.linkType,
'frontmatter', c.originSlug, c.originField,
);
const existKey = `${c.fromSlug}\u0000${c.linkType}`;
const exists = existingIn.some(l =>
`${l.from_slug}\u0000${l.link_type}` === existKey
);
if (!exists) created++;
} catch {
errors++;
}
}
// Remove stale outgoing (markdown or our-frontmatter, not in desired set).
for (const l of reconcilableOut) {
const key = `${l.to_slug}\u0000${l.link_type}\u0000${l.link_source ?? 'markdown'}`;
if (!outKeys.has(key)) {
try {
await tx.removeLink(slug, l.to_slug, l.link_type, l.link_source ?? undefined);
removed++;
} catch {
errors++;
}
}
}
// Remove stale incoming (our frontmatter → slug, not in desired set).
for (const l of existingIn) {
const key = `${l.from_slug}\u0000${l.link_type}`;
if (!incKeys.has(key)) {
try {
await tx.removeLink(l.from_slug, slug, l.link_type, 'frontmatter');
removed++;
} catch {
errors++;
}
}
}
return { created, removed, errors };
});
return { ...result, unresolved };
}
const delete_page: Operation = {
name: 'delete_page',
description: 'Soft-delete a page. The row is hidden from search and from get_page/list_pages, but is recoverable via restore_page within 72h. The autopilot purge phase hard-deletes after the recovery window. Pass include_deleted: true to get_page to verify the soft-delete landed.',
params: {
slug: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
const slug = p.slug as string;
if (ctx.dryRun) return { dry_run: true, action: 'soft_delete_page', slug };
// v0.26.5: rewired from hard-delete to soft-delete. The hard-delete primitive
// (engine.deletePage) is now reserved for purgeDeletedPages and explicit
// tests. softDeletePage returns null when the slug is unknown OR already
// soft-deleted (idempotent-as-null) — preserve that as a clean no-op shape.
const result = await ctx.engine.softDeletePage(slug);
if (result === null) {
// Distinguish "not found" from "already soft-deleted" so the agent gets a
// clear signal. Probe once with include_deleted to disambiguate.
const existing = await ctx.engine.getPage(slug, { includeDeleted: true });
if (!existing) {
throw new OperationError('page_not_found', `Page not found: ${slug}`, 'Check the slug.');
}
return { status: 'already_soft_deleted', slug, deleted_at: existing.deleted_at };
}
return { status: 'soft_deleted', slug, recoverable_until: 'now + 72h via restore_page' };
},
cliHints: { name: 'delete', positional: ['slug'] },
};
const restore_page: Operation = {
name: 'restore_page',
description: 'v0.26.5 — restore a soft-deleted page (clear deleted_at). Returns success only if the page was actually soft-deleted. After this op, the page reappears in search and in get_page/list_pages without the include_deleted flag.',
params: {
slug: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
const slug = p.slug as string;
if (ctx.dryRun) return { dry_run: true, action: 'restore_page', slug };
const ok = await ctx.engine.restorePage(slug);
if (!ok) {
// Distinguish "not found" from "already active" (idempotent-as-false).
const existing = await ctx.engine.getPage(slug, { includeDeleted: true });
if (!existing) {
throw new OperationError('page_not_found', `Page not found: ${slug}`, 'Check the slug.');
}
return { status: 'already_active', slug };
}
return { status: 'restored', slug };
},
cliHints: { name: 'restore', positional: ['slug'] },
};
const purge_deleted_pages: Operation = {
name: 'purge_deleted_pages',
description: 'v0.26.5 — admin-only. Hard-deletes pages whose deleted_at is older than older_than_hours (default 72). Cascades through content_chunks, page_links, chunk_relations. Local CLI only (not exposed over HTTP MCP). Manual escape hatch alongside the autopilot purge phase.',
params: {
older_than_hours: { type: 'number', description: 'Age cutoff in hours. Default 72.' },
},
mutating: true,
scope: 'admin',
localOnly: true,
handler: async (ctx, p) => {
const olderThanHours = (p.older_than_hours as number | undefined) ?? 72;
if (ctx.dryRun) return { dry_run: true, action: 'purge_deleted_pages', older_than_hours: olderThanHours };
const result = await ctx.engine.purgeDeletedPages(olderThanHours);
return { status: 'purged', count: result.count, slugs: result.slugs };
},
cliHints: { name: 'purge-deleted' },
};
const LIST_PAGES_SORT_VALUES = ['updated_desc', 'updated_asc', 'created_desc', 'slug'] as const;
type ListPagesSort = typeof LIST_PAGES_SORT_VALUES[number];
const list_pages: Operation = {
name: 'list_pages',
description: LIST_PAGES_DESCRIPTION,
params: {
type: { type: 'string', description: 'Filter by page type' },
tag: { type: 'string', description: 'Filter by tag' },
limit: { type: 'number', description: 'Max results (default 50)' },
// v0.29 — surface filter that already exists on PageFilters.
updated_after: {
type: 'string',
description: 'ISO date (YYYY-MM-DD) or full timestamp. Returns pages with updated_at > value.',
},
sort: {
type: 'string',
enum: [...LIST_PAGES_SORT_VALUES],
description: 'Sort order. Default updated_desc (matches pre-v0.29). Options: updated_desc, updated_asc, created_desc, slug.',
},
include_deleted: { type: 'boolean', description: 'v0.26.5: include soft-deleted pages (default: false). Used by restore workflows and operator diagnostics.' },
},
handler: async (ctx, p) => {
// Whitelist the sort enum at the handler before passing to the engine.
// Engines also whitelist via PAGE_SORT_SQL but defending here keeps
// unsupported strings from reaching the SQL layer.
const rawSort = p.sort as string | undefined;
const sort = rawSort && (LIST_PAGES_SORT_VALUES as readonly string[]).includes(rawSort)
? (rawSort as ListPagesSort)
: undefined;
const pages = await ctx.engine.listPages({
type: p.type as any,
tag: p.tag as string,
limit: clampSearchLimit(p.limit as number | undefined, 50, 100),
includeDeleted: (p.include_deleted as boolean) === true,
updated_after: typeof p.updated_after === 'string' ? p.updated_after : undefined,
sort,
});
return pages.map(pg => ({
slug: pg.slug,
type: pg.type,
title: pg.title,
updated_at: pg.updated_at,
...(pg.deleted_at ? { deleted_at: pg.deleted_at } : {}),
}));
},
scope: 'read',
cliHints: { name: 'list' },
};
// --- Search ---
const search: Operation = {
name: 'search',
description: SEARCH_DESCRIPTION,
params: {
query: { type: 'string', required: true },
limit: { type: 'number', description: 'Max results (default 20)' },
offset: { type: 'number', description: 'Skip first N results (for pagination)' },
},
handler: async (ctx, p) => {
const startedAt = Date.now();
const queryText = p.query as string;
const raw = await ctx.engine.searchKeyword(queryText, {
limit: (p.limit as number) || 20,
offset: (p.offset as number) || 0,
});
const results = dedupResults(raw);
const latency_ms = Date.now() - startedAt;
// Op-layer capture (v0.25.0). Fire-and-forget — no await on the
// capture call so MCP response latency is unaffected. search has
// no expand/detail/vector semantics so meta fields are fixed.
if (isEvalCaptureEnabled(ctx.config)) {
void captureEvalCandidate(
ctx.engine,
{
tool_name: 'search',
query: queryText,
results,
meta: { vector_enabled: false, detail_resolved: null, expansion_applied: false },
latency_ms,
remote: ctx.remote ?? false,
expand_enabled: null,
detail: null,
job_id: ctx.jobId ?? null,
subagent_id: ctx.subagentId ?? null,
},
{ scrub_pii: isEvalScrubEnabled(ctx.config) },
);
}
return results;
},
scope: 'read',
cliHints: { name: 'search', positional: ['query'] },
};
const query: Operation = {
name: 'query',
description: QUERY_DESCRIPTION,
params: {
// v0.27.1: `query` is no longer strictly required — `--image <path>`
// is the alternative entry point for image-similarity search. The CLI
// validator at src/cli.ts honors `cliHints.altRequired` and admits the
// image-only invocation. MCP / programmatic callers must still pass
// `query` OR `image` (handler refuses if both are absent).
query: { type: 'string', required: false },
/** v0.27.1: image-similarity search. Path resolved on the CLI side
* before the op fires (the op receives raw bytes neither side; the
* CLI loads the file, base64-encodes, and passes through `image`). */
image: { type: 'string', description: 'Base64-encoded image bytes for image-similarity search (CLI: --image <path>).' },
image_mime: { type: 'string', description: 'MIME type for the image bytes (auto-derived from path on CLI; required when calling op directly).' },
limit: { type: 'number', description: 'Max results (default 20)' },
offset: { type: 'number', description: 'Skip first N results (for pagination)' },
expand: { type: 'boolean', description: 'Enable multi-query expansion (default: true)' },
detail: { type: 'string', description: 'Result detail level: low (compiled truth only), medium (default, all with dedup), high (all chunks)' },
// v0.20.0 Cathedral II Layer 10 C1/C2: language + symbol-kind filters.
lang: { type: 'string', description: 'Filter to chunks where content_chunks.language matches (e.g., typescript, python, ruby)' },
symbol_kind: { type: 'string', description: 'Filter to chunks where content_chunks.symbol_type matches (e.g., function, class, method, type, interface)' },
// v0.20.0 Cathedral II Layer 7 (A2) / Layer 10 C3: two-pass structural expansion.
near_symbol: { type: 'string', description: 'Anchor retrieval at this qualified symbol name (e.g., BrainEngine.searchKeyword). Enables A2 two-pass.' },
walk_depth: { type: 'number', description: 'Structural walk depth 1-2. Default 0 (off). Expands anchors through code_edges with 1/(1+hop) decay.' },
// v0.29.1 — orthogonal recency + salience axes. YOU (the agent) decide.
salience: {
type: 'string',
enum: ['off', 'on', 'strong'],
description:
"v0.29.1 salience boost — emotional_weight + take_count, NO time component.\n" +
" 'off' — default for entity / canonical / definitional queries\n" +
" 'on' — surface emotionally-weighted + take-rich pages\n" +
" 'strong' — aggressive mattering tilt\n" +
"Omit and gbrain auto-detects from query text. Independent of `recency`.",
},
recency: {
type: 'string',
enum: ['off', 'on', 'strong'],
description:
"v0.29.1 recency boost — per-prefix age decay, NO mattering signal.\n" +
" 'off' — default for canonical truth\n" +
" 'on' — daily/, media/x/, chat/ decay aggressively; concepts/, originals/, writing/ stay evergreen\n" +
" 'strong' — multiplies the recency factor by 1.5 (use for 'today' / 'right now')\n" +
"Omit and gbrain auto-detects. Independent of `salience` (orthogonal axes).",
},
since: {
type: 'string',
description:
"v0.29.1 — filter to pages whose effective_date is >= this. ISO-8601 (YYYY-MM-DD or full timestamp) OR relative ('7d', '2w', '1y'). Replaces deprecated `afterDate`.",
},
until: {
type: 'string',
description:
"v0.29.1 — filter to effective_date <= this. Same format as `since`. Replaces deprecated `beforeDate`. YYYY-MM-DD lands at end-of-day.",
},
},
handler: async (ctx, p) => {
const startedAt = Date.now();
const expand = p.expand !== false;
const detail = (p.detail as 'low' | 'medium' | 'high') || undefined;
const queryText = p.query as string | undefined;
const imageData = p.image as string | undefined;
const imageMime = (p.image_mime as string) || 'image/jpeg';
// v0.27.1: image-similarity branch. Bypasses hybridSearch (which is
// text-only); embeds the image via embedMultimodal and runs a direct
// vector search against the embedding_image column.
if (imageData) {
const { embedMultimodal } = await import('./ai/gateway.ts');
const [vec] = await embedMultimodal([
{ kind: 'image_base64', data: imageData, mime: imageMime },
]);
const results = await ctx.engine.searchVector(vec, {
limit: (p.limit as number) || 20,
offset: (p.offset as number) || 0,
embeddingColumn: 'embedding_image',
});
return results;
}
if (!queryText) {
throw new Error('query requires either `query` (text) or `image` (base64 bytes).');
}
// v0.25.0 — capture meta side-channel. hybridSearch's return contract
// stays SearchResult[] (Cathedral II callers depend on that); meta
// arrives via callback so eval capture can record what actually ran.
let capturedMeta: HybridSearchMeta | null = null;
const results = await hybridSearch(ctx.engine, queryText, {
limit: (p.limit as number) || 20,
offset: (p.offset as number) || 0,
expansion: expand,
expandFn: expand ? expandQuery : undefined,
detail,
language: (p.lang as string) || undefined,
symbolKind: (p.symbol_kind as string) || undefined,
nearSymbol: (p.near_symbol as string) || undefined,
walkDepth: typeof p.walk_depth === 'number' ? (p.walk_depth as number) : undefined,
// v0.29.1 — agent-explicit recency + salience. Omitted = heuristic defaults.
salience: p.salience as 'off' | 'on' | 'strong' | undefined,
recency: p.recency as 'off' | 'on' | 'strong' | undefined,
since: typeof p.since === 'string' ? p.since : undefined,
until: typeof p.until === 'string' ? p.until : undefined,
onMeta: (m) => { capturedMeta = m; },
});
const latency_ms = Date.now() - startedAt;
// Op-layer capture (v0.25.0). Fire-and-forget. meta tells gbrain-evals
// what hybridSearch *actually* did so replay can distinguish "with API
// key" from "keyword-only fallback" and "expansion fired" from
// "expansion requested + silently fell back."
if (isEvalCaptureEnabled(ctx.config)) {
const meta: HybridSearchMeta = capturedMeta ?? {
vector_enabled: false, detail_resolved: detail ?? null, expansion_applied: false,
};
void captureEvalCandidate(
ctx.engine,
{
tool_name: 'query',
query: queryText,
results,
meta,
latency_ms,
remote: ctx.remote ?? false,
expand_enabled: expand,
detail: detail ?? null,
job_id: ctx.jobId ?? null,
subagent_id: ctx.subagentId ?? null,
},
{ scrub_pii: isEvalScrubEnabled(ctx.config) },
);
}
return results;
},
scope: 'read',
cliHints: { name: 'query', positional: ['query'] },
};
// --- v0.28: Takes ---
const takes_list: Operation = {
name: 'takes_list',
description: 'List takes (typed/weighted/attributed claims) filtered by holder/kind/active/etc.',
scope: 'read',
params: {
page_slug: { type: 'string', description: 'Filter to this page' },
holder: { type: 'string', description: 'Filter to this holder (world|garry|brain|<slug>)' },
kind: { type: 'string', description: 'Filter to this kind (fact|take|bet|hunch)' },
active: { type: 'boolean', description: 'Active rows only (default true)' },
resolved: { type: 'boolean', description: 'true → only resolved bets; false → only unresolved' },
sort_by: { type: 'string', description: 'weight | since_date | created_at (default created_at)' },
limit: { type: 'number', description: 'Max rows (default 100, cap 500)' },
offset: { type: 'number', description: 'Skip first N rows' },
},
handler: async (ctx, p) => {
return ctx.engine.listTakes({
page_slug: p.page_slug as string | undefined,
holder: p.holder as string | undefined,
kind: p.kind as never,
active: p.active as boolean | undefined,
resolved: p.resolved as boolean | undefined,
sortBy: p.sort_by as never,
limit: p.limit as number | undefined,
offset: p.offset as number | undefined,
// Per-token allow-list — server-side filter for MCP-bound calls.
// Local CLI callers leave takesHoldersAllowList unset and see all holders.
takesHoldersAllowList: ctx.takesHoldersAllowList,
});
},
cliHints: { name: 'takes-list' },
};
const takes_search: Operation = {
name: 'takes_search',
description: 'Keyword search across takes (pg_trgm similarity over claim text)',
scope: 'read',
params: {
query: { type: 'string', required: true },
limit: { type: 'number', description: 'Max results (default 30, cap 100)' },
},
handler: async (ctx, p) => {
return ctx.engine.searchTakes(p.query as string, {
limit: p.limit as number | undefined,
takesHoldersAllowList: ctx.takesHoldersAllowList,
});
},
cliHints: { name: 'takes-search', positional: ['query'] },
};
const think: Operation = {
name: 'think',
description: 'Multi-hop synthesis across pages + takes + graph. Pulls relevant evidence and produces a cited answer with conflict + gap analysis.',
scope: 'write',
params: {
question: { type: 'string', required: true, description: 'The question to think about' },
anchor: { type: 'string', description: 'Pull the entity subgraph around this slug' },
rounds: { type: 'number', description: 'Multi-pass: 1 (default). Round-loop scaffolding is in place; gap-driven retrieval ships in v0.29.' },
save: { type: 'boolean', description: 'Persist a synthesis page (local-CLI only; ignored for MCP)' },
take: { type: 'boolean', description: 'Append a take row to the anchor page (requires anchor)' },
model: { type: 'string', description: 'Model override (alias or full id). Falls through models.think → models.default → GBRAIN_MODEL → opus.' },
since: { type: 'string', description: 'Start of temporal window (YYYY-MM-DD or YYYY-MM)' },
until: { type: 'string', description: 'End of temporal window' },
},
mutating: true,
handler: async (ctx, p) => {
const remote = ctx.remote ?? true;
// Codex P1 #7 + privacy: remote callers cannot persist via MCP.
const safeSave = remote ? false : Boolean(p.save);
const safeTake = remote ? false : Boolean(p.take);
const { runThink, persistSynthesis } = await import('./think/index.ts');
const result = await runThink(ctx.engine, {
question: String(p.question),
anchor: p.anchor ? String(p.anchor) : undefined,
rounds: typeof p.rounds === 'number' ? (p.rounds as number) : undefined,
save: safeSave,
take: safeTake,
model: p.model ? String(p.model) : undefined,
since: p.since ? String(p.since) : undefined,
until: p.until ? String(p.until) : undefined,
takesHoldersAllowList: ctx.takesHoldersAllowList,
});
// Persist if --save was passed locally
let savedSlug: string | undefined;
let evidenceInserted = 0;
if (safeSave) {
const persisted = await persistSynthesis(ctx.engine, result);
savedSlug = persisted.slug;
evidenceInserted = persisted.evidenceInserted;
for (const w of persisted.warnings) result.warnings.push(w);
}
return {
...result,
saved_slug: savedSlug ?? null,
evidence_inserted: evidenceInserted,
remote_persisted_blocked: remote && (Boolean(p.save) || Boolean(p.take)),
};
},
cliHints: { name: 'think', positional: ['question'] },
};
// --- Tags ---
const add_tag: Operation = {
name: 'add_tag',
description: 'Add tag to page',
params: {
slug: { type: 'string', required: true },
tag: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_tag', slug: p.slug, tag: p.tag };
await ctx.engine.addTag(p.slug as string, p.tag as string);
return { status: 'ok' };
},
cliHints: { name: 'tag', positional: ['slug', 'tag'] },
};
const remove_tag: Operation = {
name: 'remove_tag',
description: 'Remove tag from page',
params: {
slug: { type: 'string', required: true },
tag: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'remove_tag', slug: p.slug, tag: p.tag };
await ctx.engine.removeTag(p.slug as string, p.tag as string);
return { status: 'ok' };
},
cliHints: { name: 'untag', positional: ['slug', 'tag'] },
};
const get_tags: Operation = {
name: 'get_tags',
description: 'List tags for a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getTags(p.slug as string);
},
scope: 'read',
cliHints: { name: 'tags', positional: ['slug'] },
};
// --- Links ---
const add_link: Operation = {
name: 'add_link',
description: 'Create link between pages',
params: {
from: { type: 'string', required: true },
to: { type: 'string', required: true },
link_type: { type: 'string', description: 'Link type (e.g., invested_in, works_at)' },
context: { type: 'string', description: 'Context for the link' },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_link', from: p.from, to: p.to };
await ctx.engine.addLink(
p.from as string, p.to as string,
(p.context as string) || '', (p.link_type as string) || '',
);
return { status: 'ok' };
},
cliHints: { name: 'link', positional: ['from', 'to'] },
};
const remove_link: Operation = {
name: 'remove_link',
description: 'Remove link between pages',
params: {
from: { type: 'string', required: true },
to: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'remove_link', from: p.from, to: p.to };
await ctx.engine.removeLink(p.from as string, p.to as string);
return { status: 'ok' };
},
cliHints: { name: 'unlink', positional: ['from', 'to'] },
};
const get_links: Operation = {
name: 'get_links',
description: 'List outgoing links from a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getLinks(p.slug as string);
},
scope: 'read',
};
const get_backlinks: Operation = {
name: 'get_backlinks',
description: 'List incoming links to a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getBacklinks(p.slug as string);
},
scope: 'read',
cliHints: { name: 'backlinks', positional: ['slug'] },
};
/**
* Hard cap on traverse_graph depth from MCP callers. Each recursive CTE iteration
* grows a `visited` array per path; in `direction=both` the join is `OR`-based and
* fans out exponentially. Without a cap, a remote MCP caller can pass depth=1e6
* and burn memory/CPU on the database. 10 hops is well beyond any realistic
* relationship query (your OpenClaw's "people who attended meetings with Alice"
* is 2 hops; the deepest meaningful chain in our test data is 4).
*/
const TRAVERSE_DEPTH_CAP = 10;
const traverse_graph: Operation = {
name: 'traverse_graph',
description: 'Traverse link graph from a page. With link_type/direction, returns edges (GraphPath[]) instead of nodes.',
params: {
slug: { type: 'string', required: true },
depth: { type: 'number', description: `Max traversal depth (default 5, capped at ${TRAVERSE_DEPTH_CAP})` },
link_type: { type: 'string', description: 'Filter to one link type (per-edge filter, traversal only follows matching edges)' },
direction: { type: 'string', enum: ['in', 'out', 'both'], description: 'Traversal direction (default out)' },
},
handler: async (ctx, p) => {
const slug = p.slug as string;
const requestedDepth = (p.depth as number) || 5;
if (requestedDepth > TRAVERSE_DEPTH_CAP) {
ctx.logger.warn(`[gbrain] traverse_graph depth clamped from ${requestedDepth} to ${TRAVERSE_DEPTH_CAP}`);
}
const depth = Math.max(1, Math.min(requestedDepth, TRAVERSE_DEPTH_CAP));
const linkType = p.link_type as string | undefined;
const direction = p.direction as 'in' | 'out' | 'both' | undefined;
// Backward compat: when neither link_type nor direction is provided, return
// the legacy GraphNode[] shape. Once either is set, switch to GraphPath[].
if (linkType === undefined && direction === undefined) {
return ctx.engine.traverseGraph(slug, depth);
}
return ctx.engine.traversePaths(slug, { depth, linkType, direction });
},
scope: 'read',
cliHints: { name: 'graph', positional: ['slug'] },
};
// --- Timeline ---
const add_timeline_entry: Operation = {
name: 'add_timeline_entry',
description: 'Add timeline entry to a page',
params: {
slug: { type: 'string', required: true },
date: { type: 'string', required: true },
summary: { type: 'string', required: true },
detail: { type: 'string' },
source: { type: 'string' },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'add_timeline_entry', slug: p.slug };
const date = p.date as string;
// Reject anything that isn't a strict YYYY-MM-DD with year 1900-2199 and
// a real calendar day. PG DATE accepts year 5874897 silently — that's a
// semantic bug nobody actually wants.
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error(`Invalid date format "${date}" (expected YYYY-MM-DD)`);
}
const [y, m, d] = date.split('-').map(Number);
if (y < 1900 || y > 2199 || m < 1 || m > 12 || d < 1 || d > 31) {
throw new Error(`Invalid date "${date}" (year 1900-2199, month 1-12, day 1-31)`);
}
// Round-trip through Date to catch e.g. Feb 30.
const parsed = new Date(date);
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
throw new Error(`Invalid calendar date "${date}"`);
}
await ctx.engine.addTimelineEntry(p.slug as string, {
date,
source: (p.source as string) || '',
summary: p.summary as string,
detail: (p.detail as string) || '',
});
return { status: 'ok' };
},
cliHints: { name: 'timeline-add', positional: ['slug', 'date', 'summary'] },
};
const get_timeline: Operation = {
name: 'get_timeline',
description: 'Get timeline entries for a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getTimeline(p.slug as string);
},
scope: 'read',
cliHints: { name: 'timeline', positional: ['slug'] },
};
// --- Admin ---
const get_stats: Operation = {
name: 'get_stats',
description: 'Brain statistics (page count, chunk count, etc.)',
params: {},
handler: async (ctx) => {
return ctx.engine.getStats();
},
scope: 'admin',
cliHints: { name: 'stats' },
};
const get_health: Operation = {
name: 'get_health',
description: 'Brain health dashboard (embed coverage, stale pages, orphans)',
params: {},
handler: async (ctx) => {
return ctx.engine.getHealth();
},
scope: 'admin',
cliHints: { name: 'health' },
};
const get_versions: Operation = {
name: 'get_versions',
description: 'Page version history',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getVersions(p.slug as string);
},
scope: 'read',
cliHints: { name: 'history', positional: ['slug'] },
};
const revert_version: Operation = {
name: 'revert_version',
description: 'Revert page to a previous version',
params: {
slug: { type: 'string', required: true },
version_id: { type: 'number', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'revert_version', slug: p.slug, version_id: p.version_id };
await ctx.engine.createVersion(p.slug as string);
await ctx.engine.revertToVersion(p.slug as string, p.version_id as number);
return { status: 'reverted' };
},
cliHints: { name: 'revert', positional: ['slug', 'version_id'] },
};
// --- Sync ---
const sync_brain: Operation = {
name: 'sync_brain',
description: 'Sync git repo to brain (incremental)',
params: {
repo: { type: 'string', description: 'Path to git repo (optional if configured)' },
dry_run: { type: 'boolean', description: 'Preview changes without applying' },
full: { type: 'boolean', description: 'Full re-sync (ignore checkpoint)' },
no_pull: { type: 'boolean', description: 'Skip git pull' },
no_embed: { type: 'boolean', description: 'Skip embedding generation' },
},
mutating: true,
scope: 'admin',
localOnly: true,
handler: async (ctx, p) => {
const { performSync } = await import('../commands/sync.ts');
return performSync(ctx.engine, {
repoPath: p.repo as string | undefined,
dryRun: ctx.dryRun || (p.dry_run as boolean) || false,
noEmbed: (p.no_embed as boolean) || false,
noPull: (p.no_pull as boolean) || false,
full: (p.full as boolean) || false,
});
},
cliHints: { name: 'sync', hidden: true },
};
// --- Raw Data ---
const put_raw_data: Operation = {
name: 'put_raw_data',
description: 'Store raw API response data for a page',
params: {
slug: { type: 'string', required: true },
source: { type: 'string', required: true, description: 'Data source (e.g., crustdata, happenstance)' },
data: { type: 'object', required: true, description: 'Raw data object' },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'put_raw_data', slug: p.slug, source: p.source };
await ctx.engine.putRawData(p.slug as string, p.source as string, p.data as object);
return { status: 'ok' };
},
};
const get_raw_data: Operation = {
name: 'get_raw_data',
description: 'Retrieve raw data for a page',
params: {
slug: { type: 'string', required: true },
source: { type: 'string', description: 'Filter by source' },
},
handler: async (ctx, p) => {
return ctx.engine.getRawData(p.slug as string, p.source as string | undefined);
},
scope: 'read',
};
// --- Resolution & Chunks ---
const resolve_slugs: Operation = {
name: 'resolve_slugs',
description: 'Fuzzy-resolve a partial slug to matching page slugs',
params: {
partial: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.resolveSlugs(p.partial as string);
},
scope: 'read',
};
const get_chunks: Operation = {
name: 'get_chunks',
description: 'Get content chunks for a page',
params: {
slug: { type: 'string', required: true },
},
handler: async (ctx, p) => {
return ctx.engine.getChunks(p.slug as string);
},
scope: 'read',
};
// --- Ingest Log ---
const log_ingest: Operation = {
name: 'log_ingest',
description: 'Log an ingestion event',
params: {
source_type: { type: 'string', required: true },
source_ref: { type: 'string', required: true },
pages_updated: { type: 'array', required: true, items: { type: 'string' } },
summary: { type: 'string', required: true },
},
mutating: true,
scope: 'write',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'log_ingest' };
await ctx.engine.logIngest({
source_type: p.source_type as string,
source_ref: p.source_ref as string,
pages_updated: p.pages_updated as string[],
summary: p.summary as string,
});
return { status: 'ok' };
},
};
const get_ingest_log: Operation = {
name: 'get_ingest_log',
description: 'Get recent ingestion log entries',
params: {
limit: { type: 'number', description: 'Max entries (default 20)' },
},
handler: async (ctx, p) => {
return ctx.engine.getIngestLog({ limit: clampSearchLimit(p.limit as number | undefined, 20, 50) });
},
scope: 'read',
};
// --- File Operations ---
// Both branches need a LIMIT. Without one, the slug-filtered branch materializes
// every file for that slug — an MCP caller can force unbounded memory consumption
// by targeting a page with many attachments.
const FILE_LIST_LIMIT = 100;
const file_list: Operation = {
name: 'file_list',
description: 'List stored files',
params: {
slug: { type: 'string', description: 'Filter by page slug' },
},
scope: 'admin',
localOnly: true,
handler: async (_ctx, p) => {
const sql = db.getConnection();
const slug = p.slug as string | undefined;
if (slug) {
return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files WHERE page_slug = ${slug} ORDER BY filename LIMIT ${FILE_LIST_LIMIT}`;
}
return sql`SELECT id, page_slug, filename, storage_path, mime_type, size_bytes, content_hash, created_at FROM files ORDER BY page_slug, filename LIMIT ${FILE_LIST_LIMIT}`;
},
};
const file_upload: Operation = {
name: 'file_upload',
description: 'Upload a file to storage',
params: {
path: { type: 'string', required: true, description: 'Local file path' },
page_slug: { type: 'string', description: 'Associate with page' },
},
mutating: true,
scope: 'admin',
localOnly: true,
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'file_upload', path: p.path };
const { readFileSync, statSync } = await import('fs');
const { basename, extname } = await import('path');
const { createHash } = await import('crypto');
const filePath = p.path as string;
const pageSlug = (p.page_slug as string) || null;
// Fix 1 / B5 / H5 / M4: validate path, slug, filename before any filesystem read.
// Remote callers (MCP, agent) are confined to cwd (strict). Local CLI callers
// can upload from anywhere on the filesystem (loose) — the user owns the machine.
// Default is strict when ctx.remote is undefined (defense-in-depth).
const strict = ctx.remote !== false;
validateUploadPath(filePath, process.cwd(), strict);
if (pageSlug) validatePageSlug(pageSlug);
const filename = basename(filePath);
validateFilename(filename);
const stat = statSync(filePath);
const content = readFileSync(filePath);
const hash = createHash('sha256').update(content).digest('hex');
const storagePath = pageSlug ? `${pageSlug}/${filename}` : `unsorted/${hash.slice(0, 8)}-${filename}`;
const MIME_TYPES: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
'.pdf': 'application/pdf', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg',
};
const mimeType = MIME_TYPES[extname(filePath).toLowerCase()] || null;
const sql = db.getConnection();
const existing = await sql`SELECT id FROM files WHERE content_hash = ${hash} AND storage_path = ${storagePath}`;
if (existing.length > 0) {
return { status: 'already_exists', storage_path: storagePath };
}
// Upload to storage backend if configured
if (ctx.config.storage) {
const { createStorage } = await import('./storage.ts');
const storage = await createStorage(ctx.config.storage as any);
try {
await storage.upload(storagePath, content, mimeType || undefined);
} catch (uploadErr) {
throw new OperationError('storage_error', `Upload failed: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`);
}
}
try {
await sql`
INSERT INTO files (page_slug, filename, storage_path, mime_type, size_bytes, content_hash, metadata)
VALUES (${pageSlug}, ${filename}, ${storagePath}, ${mimeType}, ${stat.size}, ${hash}, ${'{}'}::jsonb)
ON CONFLICT (storage_path) DO UPDATE SET
content_hash = EXCLUDED.content_hash,
size_bytes = EXCLUDED.size_bytes,
mime_type = EXCLUDED.mime_type
`;
} catch (dbErr) {
// Rollback: clean up storage if DB write failed
if (ctx.config.storage) {
try {
const { createStorage } = await import('./storage.ts');
const storage = await createStorage(ctx.config.storage as any);
await storage.delete(storagePath);
} catch { /* best effort cleanup */ }
}
throw dbErr;
}
return { status: 'uploaded', storage_path: storagePath, size_bytes: stat.size };
},
};
const file_url: Operation = {
name: 'file_url',
description: 'Get a URL for a stored file',
params: {
storage_path: { type: 'string', required: true },
},
scope: 'admin',
localOnly: true,
handler: async (_ctx, p) => {
const sql = db.getConnection();
const rows = await sql`SELECT storage_path, mime_type, size_bytes FROM files WHERE storage_path = ${p.storage_path as string}`;
if (rows.length === 0) {
throw new OperationError('storage_error', `File not found: ${p.storage_path}`);
}
// TODO: generate signed URL from Supabase Storage
return { storage_path: rows[0].storage_path, url: `gbrain:files/${rows[0].storage_path}` };
},
};
// --- Jobs (Minions) ---
const submit_job: Operation = {
name: 'submit_job',
description: 'Submit a background job to the Minions queue. Built-in types: sync, embed, lint, import, extract, backlinks, autopilot-cycle. The `shell` type is CLI-only and rejected over MCP.',
params: {
name: { type: 'string', required: true, description: 'Job type (sync, embed, lint, import, extract, backlinks, autopilot-cycle; shell is CLI-only)' },
data: { type: 'object', description: 'Job payload (JSON)' },
queue: { type: 'string', description: 'Queue name (default: "default")' },
priority: { type: 'number', description: 'Priority (0 = highest, default: 0)' },
max_attempts: { type: 'number', description: 'Max retry attempts (default: 3)' },
delay: { type: 'number', description: 'Delay in ms before eligible' },
timeout_ms: { type: 'number', description: 'Per-job wall-clock timeout in ms; aborted job goes to dead' },
},
mutating: true,
scope: 'admin',
handler: async (ctx, p) => {
const name = typeof p.name === 'string' ? p.name.trim() : '';
if (ctx.dryRun) return { dry_run: true, action: 'submit_job', name };
// Submit-side MCP guard: reject protected job names from untrusted callers
// BEFORE we touch the DB. This is the first of the two security layers
// (the second is MinionQueue.add's check). Independent of the worker-side
// GBRAIN_ALLOW_SHELL_JOBS env flag — even if that flag is on, MCP callers
// cannot submit protected-type jobs.
const { isProtectedJobName } = await import('./minions/protected-names.ts');
// F7b fail-closed: anything that is not strictly false (i.e., remote=true OR
// the field somehow leaks in undefined despite the required type) rejects
// protected job submissions. Closes the HTTP MCP shell-job RCE that surfaced
// when the HTTP transport's OperationContext literal forgot to set remote.
if (ctx.remote !== false && isProtectedJobName(name)) {
throw new OperationError('permission_denied', `'${name}' jobs cannot be submitted over MCP (CLI-only for security)`);
}
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
// Trusted flag fires ONLY for an explicit local CLI submission of a protected
// name. Strict `=== false` so an untyped/cast context can't escalate.
const trusted = ctx.remote === false && isProtectedJobName(name) ? { allowProtectedSubmit: true } : undefined;
return queue.add(name, (p.data as Record<string, unknown>) || {}, {
queue: (p.queue as string) || 'default',
priority: (p.priority as number) || 0,
max_attempts: (p.max_attempts as number) || 3,
delay: (p.delay as number) || undefined,
timeout_ms: (p.timeout_ms as number) || undefined,
}, trusted);
},
};
const get_job: Operation = {
name: 'get_job',
description: 'Get job status and details by ID',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
scope: 'admin',
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.getJob(p.id as number);
if (!job) throw new OperationError('invalid_params', `Job not found: ${p.id}`);
return job;
},
};
const list_jobs: Operation = {
name: 'list_jobs',
description: 'List jobs with optional filters',
params: {
status: { type: 'string', description: 'Filter by status (waiting, active, completed, failed, delayed, dead, cancelled)' },
queue: { type: 'string', description: 'Filter by queue name' },
name: { type: 'string', description: 'Filter by job type' },
limit: { type: 'number', description: 'Max results (default: 50)' },
},
scope: 'admin',
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
return queue.getJobs({
status: p.status as string | undefined,
queue: p.queue as string | undefined,
name: p.name as string | undefined,
limit: (p.limit as number) || 50,
} as Parameters<typeof queue.getJobs>[0]);
},
};
const cancel_job: Operation = {
name: 'cancel_job',
description: 'Cancel a waiting, active, or delayed job',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
mutating: true,
scope: 'admin',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'cancel_job', id: p.id };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const cancelled = await queue.cancelJob(p.id as number);
if (!cancelled) throw new OperationError('invalid_params', `Cannot cancel job ${p.id} (may already be in terminal status)`);
return cancelled;
},
};
const retry_job: Operation = {
name: 'retry_job',
description: 'Re-queue a failed or dead job for retry',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
mutating: true,
scope: 'admin',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'retry_job', id: p.id };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const retried = await queue.retryJob(p.id as number);
if (!retried) throw new OperationError('invalid_params', `Cannot retry job ${p.id} (must be failed or dead)`);
return retried;
},
};
const get_job_progress: Operation = {
name: 'get_job_progress',
description: 'Get structured progress for a running job',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
scope: 'admin',
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.getJob(p.id as number);
if (!job) throw new OperationError('invalid_params', `Job not found: ${p.id}`);
return { id: job.id, name: job.name, status: job.status, progress: job.progress };
},
};
const pause_job: Operation = {
name: 'pause_job',
description: 'Pause a waiting, active, or delayed job',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
scope: 'admin',
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.pauseJob(p.id as number);
if (!job) throw new OperationError('invalid_params', `Job not found or not pausable: ${p.id}`);
return { id: job.id, status: job.status };
},
};
const resume_job: Operation = {
name: 'resume_job',
description: 'Resume a paused job back to waiting',
params: {
id: { type: 'number', required: true, description: 'Job ID' },
},
scope: 'admin',
handler: async (ctx, p) => {
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.resumeJob(p.id as number);
if (!job) throw new OperationError('invalid_params', `Job not found or not paused: ${p.id}`);
return { id: job.id, status: job.status };
},
};
const replay_job: Operation = {
name: 'replay_job',
description: 'Replay a completed/failed/dead job, optionally with modified data',
params: {
id: { type: 'number', required: true, description: 'Source job ID to replay' },
data_overrides: { type: 'object', required: false, description: 'Data fields to override (merged with original)' },
},
scope: 'admin',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'replay_job', id: p.id };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const job = await queue.replayJob(p.id as number, p.data_overrides as Record<string, unknown> | undefined);
if (!job) throw new OperationError('invalid_params', `Job not found or not in terminal state: ${p.id}`);
return { id: job.id, name: job.name, status: job.status, source_id: p.id };
},
};
const send_job_message: Operation = {
name: 'send_job_message',
description: 'Send a sidechannel message to a running job\'s inbox',
params: {
id: { type: 'number', required: true, description: 'Job ID to message' },
payload: { type: 'object', required: true, description: 'Message payload (arbitrary JSON)' },
sender: { type: 'string', required: false, description: 'Sender identity (default: admin)' },
},
scope: 'admin',
handler: async (ctx, p) => {
if (ctx.dryRun) return { dry_run: true, action: 'send_job_message', id: p.id };
const { MinionQueue } = await import('./minions/queue.ts');
const queue = new MinionQueue(ctx.engine);
const msg = await queue.sendMessage(p.id as number, p.payload, (p.sender as string) ?? 'admin');
if (!msg) throw new OperationError('invalid_params', `Job not found, not messageable, or sender unauthorized: ${p.id}`);
return { sent: true, message_id: msg.id, job_id: p.id };
},
};
// --- Orphans ---
const find_orphans: Operation = {
name: 'find_orphans',
description: 'Find pages with no inbound wikilinks. Essential for content enrichment cycles.',
params: {
include_pseudo: {
type: 'boolean',
description: 'Include auto-generated and pseudo pages (default: false)',
},
},
scope: 'read',
handler: async (ctx, p) => {
const { findOrphans } = await import('../commands/orphans.ts');
return findOrphans(ctx.engine, { includePseudo: (p.include_pseudo as boolean) || false });
},
cliHints: { name: 'orphans', hidden: true },
};
// --- v0.29: Salience + Anomaly Detection ---
const get_recent_salience: Operation = {
name: 'get_recent_salience',
description: GET_RECENT_SALIENCE_DESCRIPTION,
scope: 'read',
params: {
days: { type: 'number', description: 'Window in days. Default 14.' },
limit: { type: 'number', description: 'Max results (default 20, capped at 100).' },
slugPrefix: {
type: 'string',
description: "Optional slug-prefix filter, e.g. 'personal' or 'wiki/people'.",
},
recency_bias: {
type: 'string',
enum: ['flat', 'on'],
description:
"v0.29.1: how to weight recency in the salience score.\n" +
" 'flat' (DEFAULT) — v0.29.0 behavior. Every page gets 1/(1+days_old).\n" +
" Stable, predictable; what most callers want.\n" +
" 'on' — Per-prefix decay map. concepts/originals/writing/\n" +
" become evergreen (recency component = 0); daily/,\n" +
" media/x/, chat/ decay aggressively. Use when the\n" +
" user explicitly biases for recency-aware salience\n" +
" ('what's been salient lately' vs 'what matters\n" +
" in this brain regardless of when').",
},
},
handler: async (ctx, p) => {
const recencyBias = p.recency_bias === 'on' ? 'on' : 'flat';
return ctx.engine.getRecentSalience({
days: typeof p.days === 'number' ? p.days : undefined,
limit: typeof p.limit === 'number' ? p.limit : undefined,
slugPrefix: typeof p.slugPrefix === 'string' ? p.slugPrefix : undefined,
recency_bias: recencyBias,
});
},
cliHints: { name: 'salience' },
};
const find_anomalies: Operation = {
name: 'find_anomalies',
description: FIND_ANOMALIES_DESCRIPTION,
scope: 'read',
params: {
since: {
type: 'string',
description: 'ISO date YYYY-MM-DD. Default = today (UTC).',
},
lookback_days: {
type: 'number',
description: 'Days of history for the baseline. Default 30.',
},
sigma: {
type: 'number',
description: 'Sigma threshold. Default 3.0.',
},
},
handler: async (ctx, p) => {
return ctx.engine.findAnomalies({
since: typeof p.since === 'string' ? p.since : undefined,
lookback_days: typeof p.lookback_days === 'number' ? p.lookback_days : undefined,
sigma: typeof p.sigma === 'number' ? p.sigma : undefined,
});
},
cliHints: { name: 'anomalies' },
};
const get_recent_transcripts: Operation = {
name: 'get_recent_transcripts',
description: GET_RECENT_TRANSCRIPTS_DESCRIPTION,
scope: 'read',
// Local-only: rejects HTTP-borne MCP traffic at tool-list time
// (serve-http.ts filters on `localOnly`) AND at runtime via the in-handler
// ctx.remote check. Defense in depth: hidden + rejected.
localOnly: true,
params: {
days: { type: 'number', description: 'Window in days. Default 7.' },
summary: {
type: 'boolean',
description: 'When true (default), return first ~300 chars per transcript. When false, full content (capped at 100 KB per file).',
},
limit: { type: 'number', description: 'Max transcripts (default 50).' },
},
handler: async (ctx, p) => {
// Trust gate (eng review D2 + codex C3): MCP / HTTP callers (`remote=true`)
// are blocked. Local CLI callers (`remote=false`) and the trusted-workspace
// dream cycle pass through. This op is intentionally NOT in the subagent
// allow-list (subagents always run with remote=true; they would always be
// rejected, which is a footgun if the op is visible).
if (ctx.remote === true) {
throw new OperationError(
'permission_denied',
'get_recent_transcripts is local-only — call via the gbrain CLI.',
);
}
const { listRecentTranscripts } = await import('./transcripts.ts');
return listRecentTranscripts(ctx.engine, {
days: typeof p.days === 'number' ? p.days : undefined,
summary: typeof p.summary === 'boolean' ? p.summary : undefined,
limit: typeof p.limit === 'number' ? p.limit : undefined,
});
},
cliHints: { name: 'transcripts', hidden: true },
};
// --- v0.28: whoami + sources management ---
const whoami: Operation = {
name: 'whoami',
description:
'Introspect the calling identity. Returns one of three transport shapes: ' +
'{transport: "oauth", client_id, client_name, scopes, expires_at}, ' +
'{transport: "legacy", token_name, scopes, expires_at: null}, or ' +
'{transport: "local", scopes: []}. Throws unknown_transport when the ' +
'context is ambiguous (remote=true without auth) — fail-closed posture ' +
'mirroring the v0.26.9 trust-boundary contract.',
params: {},
scope: 'read',
handler: async (ctx) => {
// Trust boundary: ctx.remote === false is the trusted local CLI surface.
// Returning OAuth-shaped scopes here would resurrect the v0.26.9 footgun
// where code conditionally trusted on `scopes.includes('admin')` instead
// of `ctx.remote === false`. Empty scopes array forces clients to
// special-case `transport: 'local'` explicitly.
if (ctx.remote === false) {
return { transport: 'local', scopes: [] };
}
if (!ctx.auth) {
throw new OperationError(
'unknown_transport',
'whoami called over a remote transport that did not thread ctx.auth. ' +
'This is a transport bug — every remote call site must populate ctx.auth ' +
'or set ctx.remote === false.',
);
}
// OAuth tokens have client_id starting with 'gbrain_cl_'; legacy
// access_tokens reuse `name` as both clientId and clientName (verifyAccessToken
// at oauth-provider.ts:417-430). Detect by inspecting the prefix.
const isOauth = ctx.auth.clientId.startsWith('gbrain_cl_');
if (isOauth) {
return {
transport: 'oauth',
client_id: ctx.auth.clientId,
client_name: ctx.auth.clientName ?? ctx.auth.clientId,
scopes: ctx.auth.scopes,
expires_at: ctx.auth.expiresAt ?? null,
};
}
return {
transport: 'legacy',
token_name: ctx.auth.clientName ?? ctx.auth.clientId,
scopes: ctx.auth.scopes,
expires_at: null,
};
},
cliHints: { name: 'whoami' },
};
const sources_add: Operation = {
name: 'sources_add',
description:
'Register a new source. Supports either --path (existing v0.17 behavior) ' +
'or --url (v0.28 federated remote-clone path: parses the URL through the ' +
'SSRF gate, clones into $GBRAIN_HOME/clones/<id>/ via temp-dir + rename ' +
'atomicity, and stores remote_url in sources.config). Pre-flight collision ' +
'check on id; rollback on either-side failure.',
params: {
id: {
type: 'string',
required: true,
description: 'Source id ([a-z0-9-]{1,32}). Immutable citation key.',
},
name: { type: 'string', description: 'Display name (defaults to id).' },
path: { type: 'string', description: 'Local path. Mutually optional with url.' },
url: {
type: 'string',
description:
'HTTPS git URL. Cloned into $GBRAIN_HOME/clones/<id>/. SSRF-guarded.',
},
federated: {
type: 'boolean',
description: 'true → cross-source default search. false → isolated.',
},
clone_dir: {
type: 'string',
description:
'Override clone destination (only valid with url). Default: $GBRAIN_HOME/clones/<id>/.',
},
},
mutating: true,
scope: 'sources_admin',
handler: async (ctx, p) => {
const { addSource } = await import('./sources-ops.ts');
// v0.28.1 codex finding (CRITICAL + HIGH): a `sources_admin` token over
// HTTP MCP must not be able to plant content at arbitrary host paths.
//
// - `path` lets a remote caller register `/etc/` (or any host dir) as a
// "source"; later `gbrain sync --all` walks every sources.local_path,
// which exfiltrates host content into the brain.
// - `clone_dir` lets a remote caller name the destination directly;
// addSource's renameSync places the cloned tree there with no
// confinement, AND validateRepoState's degraded-state recovery later
// does rm -rf on src.local_path, so the same primitive doubles as
// arbitrary-delete.
//
// Both fields are CLI-only (the operator runs `gbrain sources add --path
// /home/me/notes`). For HTTP MCP, ignore overrides — clone_dir defaults
// to $GBRAIN_HOME/clones/<id>/ and path is rejected. Local CLI callers
// (ctx.remote === false, per F7b fail-closed contract) keep the override.
const isLocal = ctx.remote === false;
const remotePath = isLocal ? (p.path as string | undefined) ?? null : null;
const remoteCloneDir = isLocal ? (p.clone_dir as string | undefined) : undefined;
if (!isLocal && (p.path !== undefined || p.clone_dir !== undefined)) {
ctx.logger.warn(
'[sources_add] ignoring path/clone_dir overrides on HTTP MCP transport ' +
'(remote callers can only register a remote --url; the clone path is ' +
'fixed under $GBRAIN_HOME/clones/).',
);
}
const row = await addSource(ctx.engine, {
id: p.id as string,
name: p.name as string | undefined,
localPath: remotePath,
remoteUrl: p.url as string | undefined,
federated:
p.federated === undefined ? null : (p.federated as boolean),
cloneDir: remoteCloneDir,
});
return row;
},
cliHints: { name: 'sources_add', hidden: true },
};
const sources_list: Operation = {
name: 'sources_list',
description:
'List registered sources with page counts and remote_url. v0.28 surfaces ' +
'the new remote_url field so a remote MCP caller can confirm a source is ' +
'managed by clone+pull rather than user-supplied path.',
params: {
include_archived: { type: 'boolean', description: 'Include soft-deleted sources.' },
},
scope: 'read',
handler: async (ctx, p) => {
const { listSources } = await import('./sources-ops.ts');
return {
sources: await listSources(ctx.engine, {
includeArchived: (p.include_archived as boolean) === true,
}),
};
},
cliHints: { name: 'sources_list', hidden: true },
};
const sources_remove: Operation = {
name: 'sources_remove',
description:
'Hard-remove a source (cascades pages/chunks/embeddings). Refuses to ' +
'delete the auto-managed clone dir unless its resolved path is confined ' +
'under $GBRAIN_HOME/clones/ (realpath+lstat — symlink-safe). For most ' +
'workflows prefer sources_archive for the soft-delete path.',
params: {
id: { type: 'string', required: true },
confirm_destructive: {
type: 'boolean',
description:
'Required when the source has data (pages, chunks). Without it the op refuses.',
},
dry_run: { type: 'boolean', description: 'Preview impact without side effects.' },
keep_storage: {
type: 'boolean',
description: 'Skip clone-dir cleanup even when the source is auto-managed.',
},
},
mutating: true,
scope: 'sources_admin',
handler: async (ctx, p) => {
const { removeSource } = await import('./sources-ops.ts');
return removeSource(ctx.engine, {
id: p.id as string,
confirmDestructive: (p.confirm_destructive as boolean) === true,
dryRun: (p.dry_run as boolean) === true || ctx.dryRun,
keepStorage: (p.keep_storage as boolean) === true,
});
},
cliHints: { name: 'sources_remove', hidden: true },
};
const sources_status: Operation = {
name: 'sources_status',
description:
'Per-source diagnostic. Returns clone_state ("healthy" | "missing" | ' +
'"not-a-dir" | "no-git" | "url-drift" | "corrupted" | "not-applicable") ' +
'so a remote MCP caller can diagnose whether the on-disk clone is ' +
'syncable without SSH access to the brain host.',
params: {
id: { type: 'string', required: true },
},
scope: 'read',
handler: async (ctx, p) => {
const { getSourceStatus } = await import('./sources-ops.ts');
return getSourceStatus(ctx.engine, p.id as string);
},
cliHints: { name: 'sources_status', hidden: true },
};
// --- Exports ---
export const operations: Operation[] = [
// Page CRUD
get_page, put_page, delete_page, list_pages,
// v0.26.5 destructive-guard ops (page-level soft-delete + recovery + admin purge)
restore_page, purge_deleted_pages,
// Search
search, query,
// Tags
add_tag, remove_tag, get_tags,
// Links
add_link, remove_link, get_links, get_backlinks, traverse_graph,
// Timeline
add_timeline_entry, get_timeline,
// Admin
get_stats, get_health, get_versions, revert_version,
// Sync
sync_brain,
// Raw data
put_raw_data, get_raw_data,
// Resolution & chunks
resolve_slugs, get_chunks,
// Ingest log
log_ingest, get_ingest_log,
// Files
file_list, file_upload, file_url,
// Jobs (Minions)
submit_job, get_job, list_jobs, cancel_job, retry_job, get_job_progress,
pause_job, resume_job, replay_job, send_job_message,
// Orphans
find_orphans,
// v0.28: Takes + think
takes_list, takes_search, think,
// v0.28: whoami + scoped sources management
whoami, sources_add, sources_list, sources_remove, sources_status,
// v0.29: Salience + anomalies + recent transcripts
get_recent_salience, find_anomalies, get_recent_transcripts,
];
export const operationsByName = Object.fromEntries(
operations.map(op => [op.name, op]),
) as Record<string, Operation>;