Compare commits

..
Author SHA1 Message Date
Garry TanandClaude Opus 4.7 670520608c docs: rewrite v0.13.0 + v0.13.1 CHANGELOG entries in builder voice
Both entries were dense — full of jargon a non-contributor would
bounce off. "Typed abstractions," "FOR UPDATE serializes concurrent
reserves," "FNV-1a → 0–59 deterministic," "per CEO plan." That's
insider baseball, not release notes.

Rewrite both with the CLAUDE.md voice rules in mind: lead with what
the user can DO, concrete commands, short paragraphs, kill
abbreviations and AI-review vocabulary. Keep the tables of numbers
(they're the honest part) and keep the itemized section for agents
that need implementation detail, but trim the itemized jargon too.

v0.13.1 headline reframed around the four things users actually get:
self-repairing citations, a budget wall, quiet-hours on Minions,
validators that catch bad writes. v0.13.0 headline stays — "your
YAML frontmatter is now a graph" was already good.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 07:41:17 +08:00
Garry Tan f53f37e56b Merge remote-tracking branch 'origin/master' into garrytan/knowledge-runtime
# Conflicts:
#	CHANGELOG.md
#	VERSION
#	package.json
#	src/commands/migrations/index.ts
#	src/core/migrate.ts
#	src/core/operations.ts
#	test/apply-migrations.test.ts
2026-04-20 07:17:54 +08:00
Garry Tan c5f1a2f71f Merge remote-tracking branch 'origin/master' into garrytan/knowledge-runtime
# Conflicts:
#	CHANGELOG.md
#	VERSION
#	package.json
#	src/cli.ts
#	src/commands/doctor.ts
2026-04-19 18:37:08 +08:00
Garry TanandClaude Opus 4.7 ecedbcd869 docs(bench): add v0.13 knowledge runtime benchmark deltas
Two new benchmark scripts + one consolidated markdown comparing this
branch against master (c0b6219, v0.12.1):

benchmark-put-page-latency.ts — 200 put_page ops, measures the
per-write cost of Step B's auto-timeline extraction. Branch adds
~0.5ms mean latency and produces 300 timeline entries for free;
master produces zero and requires a separate 'gbrain extract timeline'
pass.

benchmark-knowledge-runtime.ts — three measurements in one script:
time-to-queryable (branch 40/40 vs master 0/40 on post-ingest
timeline queries), integrity repair rate (70/20/10 three-bucket
split via mocked resolver), doctor completeness (surfaces 100% of
real issues after Step A, respects grandfathered pages).

docs/benchmarks/2026-04-19-knowledge-runtime-v0.13.md — consolidated
report. Covers the four moved benchmarks plus side-by-side runs of
graph-quality and search-quality showing they're identical across
master and branch. Proof of no regression on the retrieval hot path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 18:28:11 +08:00
Garry TanandClaude Opus 4.7 422c36c5e4 feat(migrate): verify target health after engine migration
After a PGLite↔Postgres migration, the user was left to run 'gbrain
doctor' themselves to confirm the target is good. Not great, because
the failure modes (partial copy, missing embeddings, schema drift)
all surface at next CLI use when the migration itself looks like it
succeeded.

Add verifyTarget() — inline doctor-lite that checks page count
matches the source, embedding coverage is above 90%, and schema
version is at latest. Prints a 3-line status table at the end of
migrate and points at 'gbrain doctor' for the full check. Non-fatal:
warns on discrepancies instead of failing the command so the user
sees the full picture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:45:44 +08:00
Garry TanandClaude Opus 4.7 cf28c7e8ef feat(put_page): auto-extract timeline entries alongside auto-link
put_page already chunks, embeds, reconciles tags, and extracts
auto-links on every write. Timeline extraction has lived in a
separate command (gbrain extract timeline) that users had to remember
to run. Fold it into the write path: after the page commits, parse
timeline entries from compiled_truth + timeline body and insert via
addTimelineEntriesBatch. ON CONFLICT DO NOTHING keeps it idempotent
across re-writes.

Mirrors auto-link shape: best-effort post-hook, skipped for remote
(MCP) callers, gated by auto_timeline config (default TRUE). Response
includes auto_timeline: { created } alongside auto_links.

Side effect: a one-shot `gbrain put` now produces a complete page —
chunks, embeddings, links, AND timeline — instead of three commands
the user has to chain manually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:43:26 +08:00
Garry TanandClaude Opus 4.7 2e79a1b5e2 feat(doctor): fold integrity sample scan into default health check
Expose scanIntegrity(engine, opts) as a pure library function — same
logic cmdCheck uses — and call it from doctor in non-fast mode with
a 500-page sampling limit. Surfaces bare-tweet phrase count and
external-link count as an 'integrity' check, warn-status when bare
tweets are present with a one-liner pointing at 'gbrain integrity
check' for the full report and 'integrity auto' for repair.

Read-only: no network, no writes, no resolver calls. Pages with
validate:false frontmatter are skipped (grandfathered). --fast mode
skips it entirely so the existing health-snapshot contract holds.

Users no longer need to remember three separate commands (doctor,
lint, integrity check) to audit brain health — doctor surfaces the
integrity signal by default, full scan stays available for deep dives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:20:50 +08:00
Garry TanandClaude Opus 4.7 2c74065527 test: expand coverage on abort-signal threading + integrity CLI dispatch
fail-improve: four new AbortSignal cases — pre-start abort, between
deterministic and LLM, signal forwarded into both callbacks, and
LLM-thrown AbortError propagates without logging a failure entry.

integrity: three new CLI dispatch cases — --help, no-subcommand (help),
and unknown subcommand (stderr + exit 1). Non-engine paths so they
exercise routing without spinning up a DB.

Coverage-only; no source changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:04:07 +08:00
Garry TanandClaude Opus 4.7 9ab830eed3 fix(auto-link): advisory lock serializes concurrent reconciliation
runAutoLink wraps getLinks + addLink/removeLink in a transaction, but
row-level locks alone don't prevent the union-of-writes race: two
concurrent put_page calls on the same slug can both read the same
existingKeys BEFORE either mutates a row, then proceed to add links
the other side's rewrite no longer mentions.

Take a transaction-scoped advisory lock on hashtext("auto_link:" ||
slug) at the start of the reconciliation. Concurrent writers on the
same slug now fully serialize; writers on different slugs still run
in parallel. No-op on engines without advisory locks (PGLite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:04:00 +08:00
Garry TanandClaude Opus 4.7 a3beba949c fix(validators): empty [Source:] no longer satisfies citation check
Regex /\[Source:[^\]]*\]/ matched decorative markers like [Source:]
and [Source:   ] that carry zero provenance. Tighten to require at
least one non-whitespace character before the closing bracket. The
inline URL form ](https://...) already requires a scheme+host so it
stays as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:03:53 +08:00
Garry TanandClaude Opus 4.7 53c0216554 fix(writer): advisory lock on desiredSlug prevents cross-process TOCTOU
BrainWriter's createEntity checks engine.getPage(slug) and falls back
to putPage(), which upserts. Two putPage('people/alice') calls from
separate processes (a Claude Code session + a Minions worker, say) can
both read "free" from SlugRegistry and both call putPage, silently
overwriting each other with no disambiguation.

Take a transaction-scoped advisory lock keyed on hashtext(desiredSlug)
before the registry check. Concurrent writers for the same slug now
serialize at the DB level: the second observes the first's commit and
disambiguates to alice-2. PGLite is single-process so this is a
harmless no-op there. Wrapped in try/catch so engines/test doubles
that don't support advisory locks fall through to the existing
within-process check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:03:48 +08:00
Garry TanandClaude Opus 4.7 874fa166cd fix(resolvers): DNS-rebinding defense + X rate-limit header parity
Two non-blocking codex findings on PR #210 rolled into one bisectable
commit because their tests share an import line.

url_reachable: hostname-string SSRF guard is vulnerable to DNS rebinding
(attacker-controlled DNS returns a public IP at validate time and
169.254.169.254 at fetch time). Add checkDnsRebinding() that resolves
the hostname via dns.lookup({all:true}) and rejects any result whose
A/AAAA record lands in a private range (v4 via isPrivateIpv4, v6
loopback/link-local/unique-local/IPv4-mapped). Applied on the initial
URL and on every redirect target. Null on DNS failure so genuine
network problems surface via fetch.

x_handle_to_tweet: rate-limit backoff only honored Retry-After and
ignored X's proprietary x-rate-limit-reset header. computeBackoffMs()
parses both (Retry-After = seconds or HTTP-date; x-rate-limit-reset =
epoch seconds), takes MAX, and clamps to [2s, 60s]. Exported for
testability; callers use it uniformly on every 429.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 14:03:39 +08:00
Garry TanandClaude Opus 4.7 858484ea50 chore: bump version and changelog (v0.13.0.0)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:28 +08:00
Garry TanandClaude Opus 4.7 2fad71dcb0 fix(integrity): --dry-run no longer writes progress, poisoning resume
Codex caught that 'gbrain integrity auto --dry-run' appended progress
entries (status='repaired', 'reviewed', 'skipped', 'error') despite doing
no actual writes. The follow-on real run with default --resume would then
skip those slugs — the dry-run silently consumed the work queue.

Fix: gate every appendProgress() call in cmdAuto on !dryRun. Dry-run
still logs to the skip log / review queue (so the user sees what WOULD
happen), but the progress file stays untouched.

Behavior:
  --dry-run            → buckets counted + summary printed + review-queue
                         + log populated, but progress file unchanged.
  (default)            → progress file tracks every processed slug, so
                         Ctrl-C + re-run resumes from the right place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:13 +08:00
Garry TanandClaude Opus 4.7 8e90e39408 fix(budget): commit() re-checks cap + rejects negative actuals
Codex caught two cap-bypass bugs in BudgetLedger.commit():

1. reserve({estimateUsd: 0.01, capUsd: 1.0}) + commit(id, 100) silently
   charged $100 to a $1-cap bucket. Cap is an advertised invariant that
   the code was not enforcing.

2. Negative actuals (commit(id, -5)) were accepted, letting callers
   artificially reduce committed_usd below the real spend. Refunds need
   a dedicated API, not a side-channel on commit.

Fix:
- Reject non-finite AND negative actualUsd at entrypoint.
- Lock the ledger row FOR UPDATE during commit (same serialization as
  reserve).
- Compute effective cap headroom = cap - other_committed - other_reserved
  (excluding this reservation from the reserved pool since we're about to
  finalize it).
- When actualUsd would exceed available, clamp committed_usd to max
  available and throw BudgetError with the overage reported. The
  reservation is still marked 'committed' (API call already happened;
  don't retry-loop), but the cap is honored.

After this, a $1/day cap actually means $1/day.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:13 +08:00
Garry TanandClaude Opus 4.7 7083f01ae2 fix(minions): route quiet-hours 'skip' through cancelJob to rollup parents
Codex flagged that handleQuietHoursDefer with verdict='skip' directly set
status='cancelled' via raw UPDATE — bypassing MinionQueue.cancelJob, which
means:
  - Parent jobs in 'waiting-children' never get rolled up.
  - Descendant jobs don't cascade-cancel.
  - Child-done inbox notification is skipped.

Result: a parent waiting on a child that fell inside quiet hours with
policy='skip' stays stuck forever.

Fix: release the lock, then delegate to queue.cancelJob(job.id) which
handles the recursive CTE + parent rollup + inbox posting correctly.
Falls back to a direct UPDATE only if cancelJob errors — even then, the
status transition is status-guarded to avoid stomping terminal states.

Defer path unchanged (no parent rollup needed since the job hasn't reached
a terminal state).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:13 +08:00
Garry TanandClaude Opus 4.7 53d4414e21 fix(minions): wire quiet_hours + stagger_key into MinionJobInput + queue.add
Codex adversarial review caught that PR 5 (claim-time quiet-hours gate) was
cosmetic: the schema v12 column existed, the worker read it via
`readQuietHoursConfig(job)`, but `MinionJobInput` never accepted it,
`queue.add()` never inserted it, and `rowToMinionJob()` never mapped it out.
Result: every scheduled job saw `quiet_hours: null`, so the gate was a
no-op. Stagger_key had the same broken wiring.

- MinionJob (types.ts): add `quiet_hours` and `stagger_key` fields.
- MinionJobInput: add matching optional fields so callers can submit them.
- rowToMinionJob: parse both columns (JSONB handled the same way as `data`).
- MinionQueue.add: include both columns in the INSERT (idempotent + normal
  paths), bound as $19/$20. The `$19::jsonb` cast matches the JSONB column
  shape; the wire format is the same native-JS object path that fixed the
  JSONB double-encode bug in v0.12.1.

After this, `await queue.add('x', {}, { quiet_hours: {start:22,end:7,
tz:"America/Los_Angeles",policy:"defer"} })` actually stores the window
and the worker's claim-time gate defers the job inside it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 08:47:13 +08:00
Garry Tan 6756325532 Merge remote-tracking branch 'origin/master' into garrytan/knowledge-runtime
# Conflicts:
#	src/cli.ts
#	src/commands/migrations/index.ts
#	test/apply-migrations.test.ts
2026-04-19 08:39:13 +08:00
Garry TanandClaude Opus 4.7 f332a8fe76 test(migrations-v0_13_0): drop flaky no-config assertion
The 'does not succeed when no brain is configured' test assumed loadConfig
would return null when HOME is empty, but it also reads DATABASE_URL from
the environment. When .env.testing sources DATABASE_URL into the shell
(normal E2E lifecycle), the orchestrator connects successfully and runs
to completion — the test's assertion was unreachable.

The dry-run path is still covered by the remaining test in the same
describe block; registry integration and semver ordering are covered by
the sibling describe.

Full suite with DATABASE_URL live: 1574 pass / 0 fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:29:05 +08:00
Garry TanandClaude Opus 4.7 c5555dcac1 feat(output): post-write validator lint hook — PR 2.5
Minimal integration of BrainWriter validators into the main write path,
feature-flag-gated and non-blocking. The CEO plan explicitly scoped PR 2.5
as a pre-soak landing step: the hook plugs in now, observability lands,
but strict-mode rejection is deferred to a follow-on release gated on the
7-day soak + BrainBench regression ≤1pt.

src/core/output/post-write.ts
  runPostWriteLint(engine, slug, opts?) invokes the four BrainWriter
  validators (citation, link, back-link, triple-hr) against a freshly
  written page and returns a PostWriteLintResult. Skips cleanly when:
    - config `writer.lint_on_put_page` is not truthy (default OFF; opts.force overrides)
    - the page is not found (shouldn't happen in normal put_page flow)
    - the page has frontmatter.validate === false (grandfathered)
  Findings are logged to:
    - ~/.gbrain/validator-lint.jsonl (capped at 20 findings per line)
    - engine.logIngest (ingest_log table) for durable agent-inspectable history
  Validator-level exceptions are swallowed so a buggy validator never
  breaks put_page.

src/core/operations.ts put_page handler
  After importFromContent + runAutoLink, imports runPostWriteLint and
  invokes it. Result returns writer_lint: {error_count, warning_count} or
  {skipped: reason}. Try/catch wraps the whole hook so an import or
  runtime error never blocks the main write.

Enable locally:
  gbrain config set writer.lint_on_put_page true
Then every put_page emits a writer_lint summary + appends structured
findings to the ingest log for analysis before the strict-mode flip.

test/post-write-lint.test.ts — 11 tests:
  Flag reader (default off, true/1/on, other values false, explicit false)
  Hook behavior (flag-off skip, page-not-found skip, validate:false
  grandfather skip, force=true overrides flag, dirty page yields citation
  error, clean page yields zero findings).

Full suite: 1485 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 07:11:10 +08:00
Garry TanandClaude Opus 4.7 1aab3ee62b feat(minions): quiet-hours + stagger + claim-time gate — PR 5
Closes the scheduler gap per CEO plan: Minions v7 shipped a durable
runtime but nothing about when jobs should NOT run. This wires
quiet-hours enforcement at claim time (the codex correction — dispatch-
time is wrong because a queued job can become claimable after its window
opens) plus deterministic stagger slots to prevent cron-boundary storms.

Schema migration v12 adds two columns to minion_jobs:
  quiet_hours JSONB    — {start, end, tz, policy} window config
  stagger_key TEXT     — partitioning key for deterministic offset
Plus a partial index on stagger_key for later slot-assignment queries.

src/core/minions/quiet-hours.ts
  evaluateQuietHours(cfg, now?) → 'allow' | 'skip' | 'defer'. Pure,
  deterministic, no engine. Handles straight-line and wrap-around windows
  (e.g. 22→7 spans midnight). IANA timezone via Intl.DateTimeFormat;
  unknown tz fails open (allow) — safer than hard-blocking every job.
  'skip' policy drops the event; 'defer' (default) re-queues for later.

src/core/minions/stagger.ts
  staggerMinuteOffset(key) → 0–59, FNV-1a hash. Same key → same slot.
  Pure; no module-level state. Used by scheduled resolvers that want to
  avoid cron-boundary collisions ("10 jobs all fire at minute 0").

src/core/minions/worker.ts
  MinionWorker.tick now consults evaluateQuietHours on every claimed job.
  Verdict 'defer' → UPDATE status='delayed', delay_until = now() + 15m
  (prevents immediate re-claim loops when the claim query re-runs).
  Verdict 'skip' → UPDATE status='cancelled', error_text='skipped_quiet_hours'.
  Both paths clear lock_token and require lock_token match in the WHERE
  clause so a concurrent stall recovery can't race us.

test/minions-quiet-hours.test.ts — 25 tests:
  evaluateQuietHours: null/undefined/invalid config paths (allow fail-open),
  straight-line in/out + exclusive-end, wrap-around in (before midnight +
  after), skip vs defer policy, timezone-offset propagation (winter PST
  vs summer PDT), localHour parity with Date.getUTCHours.
  staggerMinuteOffset: deterministic same key → same offset, different
  keys spread across buckets (10 keys → ≥5 unique buckets), empty/non-
  string edge cases.
  Schema v12: quiet_hours and stagger_key columns exist on minion_jobs,
  idx_minion_jobs_stagger_key index present.

Full suite: 1474 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:37:31 +08:00
Garry TanandClaude Opus 4.7 42f98a07a0 feat(enrichment): BudgetLedger + CompletenessScorer — PR 4
Two layer-2 primitives that slot under the resolver SDK and BrainWriter:
cost-aware spend caps and evidence-weighted per-page completeness scoring.

Schema migration v11 adds two tables:
  budget_ledger (scope, resolver_id, local_date) PK — midnight rollover by
    date column means a new calendar day upserts a new row; no rollover
    thread, no race.
  budget_reservations (reservation_id) — TTL-bounded held reservations
    (default 60s) so process death between reserve() and commit() doesn't
    strand money.

Rollback plan: DROP TABLE. Budget data is regenerable from resolver call
logs; no durable product value lives in the ledger.

src/core/enrichment/budget.ts
  BudgetLedger.reserve({resolverId, estimateUsd, capUsd?, ttlSeconds?})
  serializes concurrent reserves on {scope, resolver_id, local_date} via
  SELECT ... FOR UPDATE. Returns {kind:'held', reservationId, ...} or
  {kind:'exhausted', reason, spent, pending, cap} — never over-spends.

  commit(id, actualUsd) moves money from reserved_usd to committed_usd and
  marks the reservation status='committed'. rollback(id) zeros out the
  reservation without touching committed. Commit-after-commit throws
  already_finalized; rollback-after-commit is a no-op (callers don't need
  to guard). commit-unknown-id throws reservation_not_found.

  cleanupExpired() sweeps held reservations past expires_at and rolls them
  back; reserve() opportunistically reclaims the target row's expired
  reservations before acquiring its own lock.

  IANA timezone config via opts.tz (default America/Los_Angeles); midnight
  rollover is naturally expressed as a date column + Intl.DateTimeFormat
  with en-CA locale (YYYY-MM-DD). DST is handled by the formatter.

src/core/enrichment/completeness.ts
  Seven per-type rubrics (person, company, project, deal, concept, source,
  media) + default. Each rubric's dimension weights sum to 1.0, checked at
  module load. scorePage(page) returns {score, dimensionScores, rubric}
  where score is 0.000–1.000.

  Person rubric dimensions: has_role_and_company, has_source_urls,
  has_timeline_entries, has_citations, has_backlinks, recency_score,
  non_redundancy. The last two are the explicit fix for the two pathologies
  called out in the codex review of the earlier design: stale pages that
  never decay (30-day re-enrich forever) and Wilco-style repeated blocks
  that pass Wintermute's length heuristic.

  Pure functions. No engine calls — BrainWriter invokes scorePage after a
  transaction and caches the result in frontmatter.completeness.

test/enrichment.test.ts — 23 tests:
  BudgetLedger: under-cap held, over-cap exhausted, commit moves money,
  rollback clears, commit-rollback no-op, commit-commit throws, commit-
  unknown throws, invalid input, empty state null, scope isolation,
  parallel reserves respect cap (10 parallel, cap 1.0, est 0.3 each →
  ≤ 3 held; state.reservedUsd ≤ 1.0), cleanupExpired reclaims TTL=0.

  CompletenessScorer: all 8 rubrics sum to 1.0, empty person scores <0.3,
  fully-enriched person >0.8, dimension scores exposed, role detection,
  company/concept/source/media/default routing, recency decay with age,
  non_redundancy penalizes repeated lines.

Full suite: 1449 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:33:57 +08:00
Garry TanandClaude Opus 4.7 079ea814b0 feat(integrity): gbrain integrity — bare-tweet repair + dead-link scan (PR 3)
Ships the user-visible milestone for the Knowledge Runtime delta: a
command that finds brain-integrity issues and repairs them through the
BrainWriter + Resolver SDK infrastructure from PRs 1 and 2.

Targets the two quantified pain points from brain/CITATIONS.md:
  - 1,424 of 3,115 people pages have bare tweet references without URLs
  - An unknown fraction of existing URL citations have rotted

Subcommands:
  gbrain integrity check                 Read-only report, optional --json
  gbrain integrity auto                  Three-bucket repair loop
  gbrain integrity review                Print review-queue path + count
  gbrain integrity reset-progress        Clear the progress file

Three-bucket contract (matches x_handle_to_tweet resolver's confidence
scoring):
  >=0.8 → auto-repair via BrainWriter transaction. Appends a timeline
          entry on the page with a Scaffolder-built tweet citation (URL
          from the API response, never from LLM text).
  0.5-0.8 → append to ~/.gbrain/integrity-review.md with all candidates
            sorted by match score, for batch human review.
  <0.5 → log reason to ~/.gbrain/integrity.log.jsonl and skip.

Resumable: every processed slug hits ~/.gbrain/integrity-progress.jsonl
so an interrupted run resumes from the last slug. --fresh clears it.

Bare-tweet detection patterns (regex, deterministic, skip code fences
and already-cited lines):
  - "tweeted about"
  - "in/on a (recent|viral) tweet"
  - "wrote a tweet/post"
  - "posted on X"
  - "via X" (but not "via X/handle" — already cited)
  - possessive "his/her/their tweet"

External-link detection extracts all [text](https?://...) pairs (code
fences skipped) for optional dead-link probing via url_reachable.

Dead links are surfaced, not auto-repaired — no "correct" replacement
exists without human judgment.

Wiring: runIntegrity dispatches subcommands, registers builtin resolvers
into the default registry, connects to the brain engine, and uses
BrainWriter in strict-off mode (integrity is the repair path, not the
write-gate path).

Unit tests: 21 cover bare-tweet regex (all 9 phrase shapes + code-fence
skip + URL-already-present skip + per-line dedup), external-link
extraction (http+https, line numbers, fenced skip), frontmatter handle
extraction (x_handle, twitter, twitter_handle, x; preference order;
leading @ strip; null paths). End-to-end auto flow verified manually
via the resolver SDK tests + BrainWriter tests it composes.

src/cli.ts wires `integrity` into CLI_ONLY + dispatches to runIntegrity.

Full suite: 1426 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:29:35 +08:00
Garry TanandClaude Opus 4.7 f56946ee88 feat(migrations): v0.13.0 grandfather validate:false — PR 2 pass 3/4
Adds the TS migration that makes BrainWriter's strict-mode rollout safe:
every existing page gets `validate: false` in frontmatter so the new
citation / link / back-link / triple-HR validators skip legacy content.
gbrain integrity --auto (PR 3) clears the flag per-page once real citations
are repaired.

src/commands/migrations/v0_13_0_add_validate_false.ts
  Four-phase orchestrator following the v0_12_0 pattern:
    A. connect   — loadConfig + createEngine. Does NOT write config (prior
                   learning: gbrain init --migrate-only semantics; never
                   flip Postgres users to PGLite via bare init).
    B. snapshot  — engine.getAllSlugs() upfront (prior learning:
                   listpages-pagination-mutation; OFFSET iteration is
                   self-invalidating when each write bumps updated_at).
    C. grandfather — per slug, skip if frontmatter.validate already set,
                   else append-log pre-mutation snapshot to
                   ~/.gbrain/migrations/v0_13_0-rollback.jsonl and
                   putPage with validate:false merged in. Batched 100
                   at a time so interruption losses are bounded.
    D. verify    — SQL count of pages with validate=false ≥ expectedTouched.
  Idempotent: second run is a no-op. Reversible: rollback log is
  append-only JSONL; future `gbrain apply-migrations --rollback v0.13.0`
  replays it. Safe on empty brains (returns complete with 0 touched).

src/commands/migrations/index.ts
  Registers v0_13_0 after v0_12_0 in semver order.

test/migrations-v0_13_0.test.ts
  Registry integration (v0.13.0 present, semver-after-v0.12.0, pitch
  metadata well-formed), orchestrator handles no-config gracefully,
  dryRun skips the connect phase.

test/apply-migrations.test.ts
  Updated two assertions that hard-coded the v0.12.0 skippedFuture list
  to also include v0.13.0 (now skippedFuture when installed < 0.13.0).

Full suite: 1405 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:26:04 +08:00
Garry TanandClaude Opus 4.7 65126eb4e7 feat(output): 4 pre-commit validators + tests — PR 2 pass 2/4
Lands the validator suite that BrainWriter runs before committing a
transaction. Paragraph-level deterministic checks, markdown-aware, skip
legacy pages via validate:false frontmatter.

src/core/output/validators/citation.ts
  Every factual paragraph in compiled_truth carries at least one citation
  marker: [Source: ...] or a linked URL. Splits paragraphs on blank lines,
  strips fenced code / inline code / HTML comments before checking.
  Ignores headings, key-value lines ("**Status:** Active"), table rows,
  pure wikilink bullets (## See Also), and short labels without a factual
  verb. Deterministic — no LLM, no semantic judgment.

src/core/output/validators/link.ts
  Every [text](path) wikilink resolves to a page that exists (unless it's
  an external http(s) URL, which this validator doesn't check; that's
  url_reachable's job in PR 3). Strips relative prefix and .md extension.
  Batches engine.getPage lookups per unique target. mailto/anchor/other
  schemes flagged as warning. Links inside fenced code blocks are skipped.

src/core/output/validators/back-link.ts
  Iron Law: if page X → page Y, then Y → X. Reads engine.getLinks(ctx.slug),
  and for each target checks engine.getLinks(target) for a reverse edge.
  Missing reverses flagged as warning (runAutoLink is the authoritative
  enforcer on put_page; this is defense-in-depth for pages edited outside
  the main write path).

src/core/output/validators/triple-hr.ts
  Catches hygiene issues on the compiled_truth / timeline split: bare `---`
  in compiled_truth would re-split on round-trip through parseMarkdown;
  headings in the timeline section signal authoring mistakes. Both warn
  (not error) — legacy pages legitimately use thematic breaks.

src/core/output/validators/index.ts
  registerBuiltinValidators(writer) wires all four.

test/writer.test.ts
  57 tests: Scaffolder (all 5 helpers + error paths), SlugRegistry (create,
  disambiguator, collision throw, invalid-slug, isFree, suggestDisambiguators),
  BrainWriter (happy path, disambiguate, addLink + reverse, strict rollback,
  lint proceeds with report, off skips validators, validate:false grandfather,
  setCompiledTruth, setFrontmatterField merge, registered validators list),
  citation validator (all 11 shape cases), link validator (normalizeToSlug
  including ../../, external URL skip, mailto warning, code-fence skip),
  back-link validator (no outbound, missing reverse → warning, bidirectional
  clean), triple-hr validator (clean, bare --- warning, fenced --- skipped,
  heading in timeline warning, ## Timeline header allowed).

Full suite: 1400 pass / 0 fail / 141 skip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:21:35 +08:00
Garry TanandClaude Opus 4.7 1cc818ed43 feat(output): BrainWriter + Scaffolder + SlugRegistry — PR 2 pass 1/4
Lands the transactional writer library that the rest of the Knowledge
Runtime sits on top of. No callers routed through it yet — publish.ts /
backlinks.ts / put_page migrations are pass 4 and PR 2.5.

src/core/output/scaffold.ts
  Deterministic URL / citation / link builders. Callers pass typed inputs
  (handle + tweetId, account + messageId, slug + display text) and get
  canonical markdown bytes out. LLM-generated URLs never touch disk.
  - tweetCitation({handle, tweetId, dateISO?})
  - emailCitation({account, messageId, subject, dateISO?})
  - sourceCitation(resolverResult, {url?, label?})
  - entityLink({slug, displayText, relativePrefix?})
  - timelineLine({dateISO, summary, citation?})
  ScaffoldError with codes for invalid_handle / invalid_tweet_id /
  invalid_slug / invalid_message_id / invalid_date / empty.

src/core/output/slug-registry.ts
  Solves the "Marc Benioff vs Marc-Benioff both slug to marc-benioff" bug.
  create() probes engine.getPage and either returns the desired slug or
  disambiguates (alice-smith → alice-smith-2). isFree() + suggestDisambiguators()
  for interactive UX. Errors: collision, disambiguator_exhausted, invalid_slug.

src/core/output/writer.ts
  BrainWriter.transaction(fn, ctx) wraps engine.transaction. The `fn`
  callback receives a WriteTx with createEntity / appendTimeline /
  setCompiledTruth / setFrontmatterField / putRawData / addLink (the last
  creates both forward + reverse back-link atomically). On commit, per-page
  validators run against all touchedSlugs. Strict mode throws on
  error-severity findings, rolling back the outer tx. Lint mode (default for
  PR 2 rollout) returns the report but commits regardless. Pages with
  `validate: false` frontmatter skip validators entirely (grandfather hook
  for PR 2 migration).

Integration smoke against PGLite: createEntity → disambiguator (2nd call
with same desired slug), addLink writes both forward + back-link,
strict-mode validator failure rolls back the transaction bit-identically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:15:34 +08:00
Garry TanandClaude Opus 4.7 6e17d19c85 feat(resolvers): tests + gbrain resolvers CLI — SDK pass 4 (PR 1/5 complete)
Closes out PR 1. 43 new tests in test/resolvers.test.ts covering registry
contract, both reference builtins, all three confidence buckets, and every
ResolverError subcode.

test/resolvers.test.ts
  - ResolverRegistry: register, duplicate-id rejection, get/has, list with
    cost+backend filters, resolve, unavailable propagation, clear, default
    singleton lifecycle.
  - url_reachable: available(), SSRF guard on localhost + RFC1918 + 169.254
    metadata + file:// scheme, empty-url schema error, 200/404 status
    propagation, HEAD→GET fallback on 405, redirect chain, per-hop SSRF
    re-validation, network failure → reachable=false, AbortSignal mid-flight.
  - x_handle_to_tweet: token gate via env AND via ctx.config, invalid/long
    handle schema errors, zero-candidate + single-strong + single-weak +
    many-ambiguous confidence buckets (gates >=0.5 url emission), 401/403
    auth error, 500 upstream error, 429 retry-then-rate_limited, X operator
    stripping (prompt injection defense).

src/commands/resolvers.ts
  - `gbrain resolvers list [--cost | --backend | --json]` pretty table
    or JSON.
  - `gbrain resolvers describe <id>` schema + availability detail.
  - registerBuiltinResolvers() is idempotent; ready to be called from
    future entry points (gbrain integrity, MCP server).

src/cli.ts wires `resolvers` into CLI_ONLY + dispatches to runResolvers.

Full suite: 1343 pass / 0 fail / 141 skip (E2E without DATABASE_URL).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 06:11:54 +08:00
Garry TanandClaude Opus 4.7 a9c312b3a3 feat(resolvers): url_reachable + x_handle_to_tweet — SDK pass 3 (PR 1/5)
Two reference resolver implementations that validate the interface against
real-world requirements: a deterministic free-cost check and a rate-limited
paid-backend lookup.

src/core/resolvers/builtin/url-reachable.ts
  HEAD-check a URL, follow redirects (max 5), detect dead links. Reused
  isInternalUrl() from the wave-3 SSRF hardening; re-validates every redirect
  hop against the same filter. Falls back from HEAD to GET on 405/501.
  Composes caller's AbortSignal with a per-request timeout via
  AbortSignal.any (with manual-propagation fallback). Confidence=1 when the
  backend answers; confidence=0 only on transport failure (DNS/connect/timeout).

src/core/resolvers/builtin/x-api/handle-to-tweet.ts
  Find a tweet by handle + free-text keyword hint. Used by the upcoming
  `gbrain integrity --auto` loop to repair the 1,424 bare-tweet citations
  in Garry's brain. Confidence buckets align with the three-bucket contract:
    - >=0.8 auto-repair (single strong match, or dominant in small candidate set)
    - 0.5-0.8 review queue (ambiguous but promising)
    - <0.5 skip (many candidates or weak match)
  Scoring: normalized keyword-token overlap against tweet text, with margin
  boost for dominant matches. Strict handle regex (X's username rules).
  Retries on 429 up to 2x with Retry-After honor. Terminal 401/403 surfaces
  as auth ResolverError so the caller stops hammering. Bearer token read
  from ctx.config.x_api_bearer_token or X_API_BEARER_TOKEN env — never logged.

Smoke: registry accepts both, SSRF blocks localhost + file://, available()
returns false when token missing, schema validator rejects bad handles.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 05:46:16 +08:00
Garry TanandClaude Opus 4.7 c529f5c0cf feat(fail-improve): optional AbortSignal — Resolver SDK pass 2 (PR 1/5)
Extends FailImproveLoop.execute with an optional `opts.signal` that threads
through the deterministic-first / LLM-fallback flow. Needed by the Resolver
SDK so long-running lookups can be cooperatively cancelled when a caller
aborts (deadline hit, Minion job timeout, user ctrl-c).

Additive and backwards-compatible:
- execute() signature widens callbacks to (input, signal?) => ...; existing
  two-arg callbacks are structurally compatible and ignore the extra arg.
- opts is optional; callers that omit it get pre-extension behavior.
- Aborts throw a DOM-style AbortError (name='AbortError'), matching what
  fetch() throws, so downstream `err.name === 'AbortError'` branches work
  unchanged.
- Aborted runs are NOT logged to the failure JSONL — not informative and
  would pollute pattern analysis.

Abort check fires in three places:
- Before the deterministic call (pre-flight)
- Between deterministic miss and LLM call (mid-flight)
- Inside llmFallbackFn if the implementation respects signal itself

Smoke tests: 5 scenarios (existing sig, llm fallback, pre-abort, mid-flight
abort, signal threaded to fallback) — all pass. Existing test/fail-improve.test.ts
(13 tests, 27 expects) unchanged and passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 05:43:59 +08:00
Garry TanandClaude Opus 4.7 7e7630fcc8 feat(resolvers): Resolver SDK pass 1 — interface + registry (PR 1/5)
Adds the typed plugin interface that unifies external-lookup calls (X API,
Perplexity, HEAD check, brain-local slug resolution) behind a single shape:

    registry.resolve('x_handle_to_tweet', { handle, keywords }, ctx)
      → { value, confidence, source, fetchedAt, raw? }

Zero behavior change — the registry is empty by default. Builtins
(url_reachable, x_handle_to_tweet) land in the next pass. ScheduledResolver
wrapping via Minions lands in PR 5.

New files:
- src/core/resolvers/interface.ts — Resolver<I,O>, ResolverResult<O>,
  ResolverContext (engine, storage, config, logger, requestId, remote,
  deadline, signal), ResolverError (not_found, already_registered,
  unavailable, timeout, rate_limited, auth, schema, aborted, upstream)
- src/core/resolvers/registry.ts — ResolverRegistry (register/get/has/
  list/resolve/clear/size) + getDefaultRegistry() for process-wide use
- src/core/resolvers/index.ts — barrel export

Design rules enforced by types:
- Every result carries confidence (0.0-1.0) + source attribution
- LLM-backed resolvers return confidence<1.0 by convention
- ctx.remote propagates the trust boundary (mirrors OperationContext.remote)
- AbortSignal threads through for cooperative cancellation

Smoke: imports + runs, list()/get()/resolve() behave as typed.
Dependency-free beyond types and storage/engine type imports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:47:35 +08:00
Garry Tan bdc4f62307 Merge remote-tracking branch 'origin/master' into garrytan/knowledge-runtime 2026-04-18 23:10:02 +08:00
Garry TanandClaude Opus 4.7 ad6d58458f docs: Knowledge Runtime design doc (draft) — 4-layer architecture + reduced-scope delta
Captures the Knowledge Runtime design thinking from the CEO review session:
Resolver SDK, Enrichment Orchestrator, Scheduler, Deterministic Output Builder.

The original 7-phase plan was drafted before v0.12.0 (knowledge graph layer)
and v0.11.x (Minions agent runtime) shipped. Cross-referenced against what's
already merged on master, roughly 60% of the 4-layer vision is already in
production under different names:

  - Minions = scheduler + plugin contract (L1 + L3)
  - Knowledge graph auto-link = deterministic output at L4 + orchestrator at L2
  - BrainBench v1 benchmarks already validate the graph layer

The doc is kept as a draft design reference; the actual build-out will scope
down to the real delta (typed Resolver interface, BrainWriter API + validators,
BudgetLedger, CompletenessScorer, quiet-hours + stagger). See the CEO review
notes for the reduced plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:09:58 +08:00
216 changed files with 974 additions and 30400 deletions
+1 -1
View File
@@ -28,4 +28,4 @@ jobs:
with:
bun-version: latest
- run: bun install
- run: bun run test
- run: bun test
+2 -3
View File
@@ -5,9 +5,8 @@ bin/
.env
.env.*
!.env.*.example
# Bun --compile temp artifacts. Each build emits a new hash-named .bun-build
# file in cwd; glob catches all of them.
*.bun-build
.18a49dfd730ff378-00000000.bun-build
.18a49f9dfb996f70-00000000.bun-build
.gstack/
supabase/.temp/
.claude/skills/
-59
View File
@@ -1,59 +0,0 @@
# Agents working on GBrain
This is your install + operating protocol. Claude Code reads `./CLAUDE.md` automatically.
Everyone else (Codex, Cursor, OpenClaw, Aider, Continue, or an LLM fetching via URL):
start here.
## Install (5 min)
1. Clone: `git clone https://github.com/garrytan/gbrain ~/gbrain && cd ~/gbrain`
2. Install: `bun install`
3. Init the brain: `gbrain init` (defaults to PGLite, zero-config). For 1000+ files or
multi-machine sync, init suggests Postgres + pgvector via Supabase.
4. Read [`./INSTALL_FOR_AGENTS.md`](./INSTALL_FOR_AGENTS.md) for the full 9-step flow
(API keys, identity, cron, verification).
## Read this order
1. `./AGENTS.md` (this file) — install + operating protocol.
2. [`./CLAUDE.md`](./CLAUDE.md) — architecture reference, key files, trust boundaries,
test layout.
3. [`./skills/RESOLVER.md`](./skills/RESOLVER.md) — skill dispatcher. Read before any task.
## Trust boundary (critical)
GBrain distinguishes **trusted local CLI callers** (`OperationContext.remote = false`,
set by `src/cli.ts`) from **untrusted agent-facing callers** (`remote = true`, set by
`src/mcp/server.ts`). Security-sensitive operations like `file_upload` tighten filesystem
confinement when `remote = true` and default to strict behavior when unset. If you are
writing or reviewing an operation, consult `src/core/operations.ts` for the contract.
## Common tasks
- **Configure:** [`docs/ENGINES.md`](./docs/ENGINES.md),
[`docs/guides/live-sync.md`](./docs/guides/live-sync.md),
[`docs/mcp/DEPLOY.md`](./docs/mcp/DEPLOY.md).
- **Debug:** [`docs/GBRAIN_VERIFY.md`](./docs/GBRAIN_VERIFY.md),
[`docs/guides/minions-fix.md`](./docs/guides/minions-fix.md), `gbrain doctor --fix`.
- **Migrate:** [`docs/UPGRADING_DOWNSTREAM_AGENTS.md`](./docs/UPGRADING_DOWNSTREAM_AGENTS.md),
[`skills/migrations/`](./skills/migrations/), `gbrain apply-migrations`.
- **Everything else:** [`./llms.txt`](./llms.txt) is the full documentation map.
[`./llms-full.txt`](./llms-full.txt) is the same map with core docs inlined for
single-fetch ingestion.
## Before shipping
Run `bun test` plus the E2E lifecycle described in `./CLAUDE.md` (spin up the test
Postgres container, run `bun run test:e2e`, tear it down). Ship via the `/ship` skill,
not by hand.
## Privacy
Never commit real names of people, companies, or funds into public artifacts. See the
Privacy rule in `./CLAUDE.md`. GBrain pages reference real contacts; public docs must
use generic placeholders (`alice-example`, `acme-example`, `fund-a`).
## Forks
If you are a fork, regenerate `llms.txt` + `llms-full.txt` with your own URL base before
publishing: `LLMS_REPO_BASE=https://raw.githubusercontent.com/your-org/your-fork/main bun run build:llms`.
+97 -1141
View File
File diff suppressed because it is too large Load Diff
+16 -107
View File
@@ -23,9 +23,9 @@ strict behavior when unset.
## Key files
- `src/core/operations.ts` — Contract-first operation definitions (the foundation). Also exports upload validators: `validateUploadPath`, `validatePageSlug`, `validateFilename`. `OperationContext.remote` flags untrusted callers.
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`). As of v0.13.1, `BrainEngine` has a `readonly kind: 'postgres' | 'pglite'` discriminator so migrations (`src/core/migrate.ts`) and other consumers can branch on engine without `instanceof` + dynamic imports.
- `src/core/engine.ts` — Pluggable engine interface (BrainEngine). `clampSearchLimit(limit, default, cap)` takes an explicit cap so per-operation caps can be tighter than `MAX_SEARCH_LIMIT`. Exports `LinkBatchInput` / `TimelineBatchInput` for the v0.12.1 bulk-insert API (`addLinksBatch` / `addTimelineEntriesBatch`).
- `src/core/engine-factory.ts` — Engine factory with dynamic imports (`'pglite'` | `'postgres'`)
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders. As of v0.13.1, `connect()` wraps `PGlite.create()` in a try/catch that emits an actionable error naming the macOS 26.3 WASM bug (#223) and pointing at `gbrain doctor`; the lock is released on failure so the next process can retry cleanly.
- `src/core/pglite-engine.ts` — PGLite (embedded Postgres 17.5 via WASM) implementation, all 40 BrainEngine methods. `addLinksBatch` / `addTimelineEntriesBatch` use multi-row `unnest()` with manual `$N` placeholders.
- `src/core/pglite-schema.ts` — PGLite-specific DDL (pgvector, pg_trgm, triggers)
- `src/core/postgres-engine.ts` — Postgres + pgvector implementation (Supabase / self-hosted). `addLinksBatch` / `addTimelineEntriesBatch` use `INSERT ... SELECT FROM unnest($1::text[], ...) JOIN pages ON CONFLICT DO NOTHING RETURNING 1` — 4-5 array params regardless of batch size, sidesteps the 65535-parameter cap. As of v0.12.3, `searchKeyword` / `searchVector` scope `statement_timeout` via `sql.begin` + `SET LOCAL` so the GUC dies with the transaction instead of leaking across the pooled postgres.js connection (contributed by @garagon). `getEmbeddingsByChunkIds` uses `tryParseEmbedding` so one corrupt row skips+warns instead of killing the query.
- `src/core/utils.ts` — Shared SQL utilities extracted from postgres-engine.ts. Exports `parseEmbedding(value)` (throws on unknown input, used by migration + ingest paths where data integrity matters) and as of v0.12.3 `tryParseEmbedding(value)` (returns `null` + warns once per process, used by search/rescore paths where availability matters more than strictness).
@@ -42,10 +42,7 @@ strict behavior when unset.
- `src/core/search/eval.ts` — Retrieval eval harness: P@k, R@k, MRR, nDCG@k metrics + runEval() orchestrator
- `src/commands/eval.ts``gbrain eval` command: single-run table + A/B config comparison
- `src/core/embedding.ts` — OpenAI text-embedding-3-large, batch, retry, backoff
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`.
- `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic.
- `src/commands/check-resolvable.ts` — Standalone CLI wrapper (v0.16.4) over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag — honors README:259. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error paths. `--fix` path runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `deferred[]` array surfaces pending Checks 5 (trigger routing eval) and 6 (brain filing) with issue URLs. `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing — no silent false-pass.
- `src/core/dry-fix.ts``gbrain doctor --fix` engine. `autoFixDryViolations(fixes, {dryRun})` rewrites inlined rules to `> **Convention:** see [path](path).` callouts via three shape-aware expanders (bullet / blockquote / paragraph). Five guards: working-tree-dirty (`getWorkingTreeStatus()` returns 3-state `'clean' | 'dirty' | 'not_a_repo'`), no-git-backup, inside-code-fence, already-delegated (40-line proximity, consistent with detector), ambiguous-multi-match, block-is-callout. `execFileSync` array args (no shell — no injection surface). EOF newline preserved.
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects
- `src/core/backoff.ts` — Adaptive load-aware throttling: CPU/memory checks, exponential backoff, active hours multiplier
- `src/core/fail-improve.ts` — Deterministic-first, LLM-fallback loop with JSONL failure logging and auto-test generation
- `src/core/transcription.ts` — Audio transcription: Groq Whisper (default), OpenAI fallback, ffmpeg segmentation for >25MB
@@ -54,46 +51,22 @@ strict behavior when unset.
- `src/commands/extract.ts``gbrain extract links|timeline|all [--source fs|db]`: batch link/timeline extraction. fs walks markdown files, db walks pages from the engine (mutation-immune snapshot iteration; use this for live brains with no local checkout). As of v0.12.1 there is no in-memory dedup pre-load — candidates are buffered 100 at a time and flushed via `addLinksBatch` / `addTimelineEntriesBatch`; `ON CONFLICT DO NOTHING` enforces uniqueness at the DB layer, and the `created` counter returns real rows inserted (truthful on re-runs).
- `src/commands/graph-query.ts``gbrain graph-query <slug> [--type T] [--depth N] [--direction in|out|both]`: typed-edge relationship traversal (renders indented tree)
- `src/core/link-extraction.ts` — shared library for the v0.12.0 graph layer. extractEntityRefs (canonical, replaces backlinks.ts duplicate) matches both `[Name](people/slug)` markdown links and Obsidian `[[people/slug|Name]]` wikilinks as of v0.12.3. extractPageLinks, inferLinkType heuristics (attended/works_at/invested_in/founded/advises/source/mentions), parseTimelineEntries, isAutoLinkEnabled config helper. `DIR_PATTERN` covers `people`, `companies`, `deals`, `topics`, `concepts`, `projects`, `entities`, `tech`, `finance`, `personal`, `openclaw`. Used by extract.ts, operations.ts auto-link post-hook, and backlinks.ts.
- `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types, protected-names, quiet-hours, stagger, handlers/shell).
- `src/core/minions/queue.ts` — MinionQueue class (submit, claim, complete, fail, stall detection, parent-child, depth/child-cap, per-job timeouts, cascade-kill, attachments, idempotency keys, child_done inbox, removeOnComplete/Fail). `add()` takes a 4th `trusted` arg (separate from `opts` to prevent spread leakage); protected names in `PROTECTED_JOB_NAMES` require `{allowProtectedSubmit: true}` and the check runs trim-normalized (whitespace-bypass safe). v0.14.1 #219: `add()` plumbs `max_stalled` through with a `[1, 100]` clamp; omitted values let the schema DEFAULT (5) kick in.
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net). v0.14.0 abort-path fix: aborted jobs now call `failJob` with reason (`timeout`/`cancel`/`lock-lost`/`shutdown`) instead of returning silently. `shutdownAbort` (instance field) fires on process SIGTERM/SIGINT and propagates to `ctx.shutdownSignal` — shell handler listens to it; non-shell handlers don't.
- `src/core/minions/types.ts``MinionJobInput` + `MinionJobStatus` + handler context types. `MinionJobInput.max_stalled` (new in v0.14.1) is optional; omitted values let the schema DEFAULT (5) kick in, provided values are clamped to `[1, 100]`.
- `src/core/minions/protected-names.ts` — side-effect-free constant module exporting `PROTECTED_JOB_NAMES` + `isProtectedJobName()`. Kept pure so queue core can import without loading handler modules.
- `src/core/minions/handlers/shell.ts``shell` job handler. Spawns `/bin/sh -c cmd` (absolute path, PATH-override-safe) or `argv[0] argv[1..]` (no shell). Env allowlist: `PATH, HOME, USER, LANG, TZ, NODE_ENV` + caller `env:` overrides. UTF-8-safe stdout/stderr tail via `string_decoder.StringDecoder`. Abort (either `ctx.signal` or `ctx.shutdownSignal`) fires SIGTERM → 5s grace → SIGKILL on child. Requires `GBRAIN_ALLOW_SHELL_JOBS=1` on worker (gated by `registerBuiltinHandlers`).
- `src/core/minions/handlers/shell-audit.ts` — per-submission JSONL audit trail at `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl` (ISO-week rotation; override via `GBRAIN_AUDIT_DIR`). Best-effort: `mkdirSync(recursive)` + `appendFileSync`; failures logged to stderr, submission not blocked. Logs cmd (first 80 chars) or argv (JSON array). Never logs env values.
- `src/core/minions/handlers/subagent.ts` (v0.15) — LLM-loop handler. Two-phase tool persistence (pending → complete/failed), replay reconciliation for mid-dispatch crashes, dual-signal abort (`ctx.signal` + `ctx.shutdownSignal`), Anthropic prompt caching on system + tool defs. `makeSubagentHandler({engine, client?, ...})` factory; `MessagesClient` is an injectable interface the real SDK implements structurally. Throws `RateLeaseUnavailableError` (renewable) when rate-lease capacity is full.
- `src/core/minions/handlers/subagent-aggregator.ts` (v0.15) — `subagent_aggregator` handler. Claims AFTER all children resolve (queue changes guarantee every terminal child posts a `child_done` inbox message with outcome). Reads inbox via `ctx.readInbox()`, builds deterministic mixed-outcome markdown summary. No LLM call in v0.15.
- `src/core/minions/handlers/subagent-audit.ts` (v0.15) — JSONL audit + heartbeat writer at `~/.gbrain/audit/subagent-jobs-YYYY-Www.jsonl`. Events: `submission` (one line per submit) + `heartbeat` (per turn boundary: `llm_call_started | llm_call_completed | tool_called | tool_result | tool_failed`). Never logs prompts or tool inputs. `readSubagentAuditForJob(jobId, {sinceIso})` is the readback path for `gbrain agent logs`.
- `src/core/minions/rate-leases.ts` (v0.15) — lease-based concurrency cap for outbound providers (default key `anthropic:messages`, max via `GBRAIN_ANTHROPIC_MAX_INFLIGHT`). Owner-tagged rows with `expires_at` auto-prune on acquire; `pg_advisory_xact_lock` guards check-then-insert; CASCADE on owning job deletion. `renewLeaseWithBackoff` retries 3x (250/500/1000ms).
- `src/core/minions/wait-for-completion.ts` (v0.15) — poll-until-terminal helper for CLI callers. `TimeoutError` does NOT cancel the job; `AbortSignal` exits without throwing. Default `pollMs`: 1000 on Postgres, 250 on PGLite inline.
- `src/core/minions/transcript.ts` (v0.15) — renders `subagent_messages` + `subagent_tool_executions` to markdown. Tool rows splice under their owning assistant `tool_use` by `tool_use_id`. UTF-8-safe truncation; unknown block types fall through to fenced JSON.
- `src/core/minions/plugin-loader.ts` (v0.15) — `GBRAIN_PLUGIN_PATH` discovery. Absolute paths only, left-wins collision, `gbrain.plugin.json` with `plugin_version: "gbrain-plugin-v1"`, plugins ship DEFS only (no new tools), `allowed_tools:` validated at load time against the derived registry.
- `src/core/minions/tools/brain-allowlist.ts` (v0.15) — derives subagent tool registry from `src/core/operations.ts`. 11-name allow-list: `query`, `search`, `get_page`, `list_pages`, `file_list`, `file_url`, `get_backlinks`, `traverse_graph`, `resolve_slugs`, `get_ingest_log`, `put_page`. `put_page` schema is namespace-wrapped per subagent (`^wiki/agents/<subagentId>/.+`); the `put_page` op's server-side check is the authoritative gate via `ctx.viaSubagent` fail-closed.
- `src/mcp/tool-defs.ts` (v0.15) — extracted `buildToolDefs(ops)` helper. MCP server + subagent tool registry both call it; byte-for-byte equivalence pinned by `test/mcp-tool-defs.test.ts`.
- `src/core/minions/` — Minions job queue: BullMQ-inspired, Postgres-native (queue, worker, backoff, types)
- `src/core/minions/queue.ts` — MinionQueue class (submit, claim, complete, fail, stall detection, parent-child, depth/child-cap, per-job timeouts, cascade-kill, attachments, idempotency keys, child_done inbox, removeOnComplete/Fail)
- `src/core/minions/worker.ts` — MinionWorker class (handler registry, lock renewal, graceful shutdown, timeout safety net)
- `src/core/minions/attachments.ts` — Attachment validation (path traversal, null byte, oversize, base64, duplicate detection)
- `src/commands/agent.ts` (v0.16)`gbrain agent run <prompt> [flags]` CLI. Submits `subagent` (or N children + 1 aggregator) under `{allowProtectedSubmit: true}`. Single-entry `--fanout-manifest` short-circuits. Children get `on_child_fail: 'continue'` + `max_stalled: 3`. `--follow` is the default on TTY; streams logs + polls `waitForCompletion` in parallel. Ctrl-C detaches, does not cancel.
- `src/commands/agent-logs.ts` (v0.16) — `gbrain agent logs <job> [--follow] [--since]`. Merges JSONL heartbeat audit + `subagent_messages` into a chronological timeline. `parseSince` accepts ISO-8601 or relative (`5m`, `1h`, `2d`). Transcript tail renders only for terminal jobs.
- `src/commands/jobs.ts``gbrain jobs` CLI subcommands + `gbrain jobs work` daemon. v0.13.1 surfaces the full `MinionJobInput` retry/backoff/timeout/idempotency surface as first-class CLI flags on `jobs submit`: `--max-stalled`, `--backoff-type fixed|exponential`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key`. `jobs smoke --sigkill-rescue` is the opt-in regression guard for #219. v0.16 wires `registerBuiltinHandlers` to always register `subagent` + `subagent_aggregator` (no env flag — `ANTHROPIC_API_KEY` is the natural cost gate, trust is via `PROTECTED_JOB_NAMES`) and loads `GBRAIN_PLUGIN_PATH` plugins at worker startup with a loud startup-line per plugin. `shell` handler still gated by `GBRAIN_ALLOW_SHELL_JOBS=1` (RCE surface, separate concern).
- `src/commands/jobs.ts``gbrain jobs` CLI subcommands + `gbrain jobs work` daemon
- `src/commands/features.ts``gbrain features --json --auto-fix`: usage scan + feature adoption salesman
- `src/commands/autopilot.ts``gbrain autopilot --install`: self-maintaining brain daemon (sync+extract+embed)
- `src/mcp/server.ts` — MCP stdio server (generated from operations)
- `src/commands/auth.ts` — Standalone token management (create/list/revoke/test)
- `src/commands/upgrade.ts` — Self-update CLI. `runPostUpgrade()` enumerates migrations from the TS registry (src/commands/migrations/index.ts) and tail-calls `runApplyMigrations(['--yes', '--non-interactive'])` so the mechanical side of every outstanding migration runs unconditionally.
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). `v0_14_0.ts` = shell-jobs + autopilot cooperative (2 phases: schema ALTER minion_jobs.max_stalled SET DEFAULT 3 — superseded by v0.14.3's schema-level DEFAULT 5 + UPDATE backfill; pending-host-work ping for skills/migrations/v0.14.0.md). All orchestrators are idempotent and resumable from `partial` status. As of v0.14.2 (Bug 3), the RUNNER owns all ledger writes — orchestrators return `OrchestratorResult` and `apply-migrations.ts` persists a canonical `{version, status, phases}` shape after return. Orchestrators no longer call `appendCompletedMigration` directly. `statusForVersion` prefers `complete` over `partial` (never regresses). 3 consecutive partials → wedged → `--force-retry <version>` writes a `'retry'` reset marker. v0.14.3 (fix wave) ships schema-only migrations v14 (`pages_updated_at_index`) + v15 (`minion_jobs_max_stalled_default_5` with UPDATE backfill) via the `MIGRATIONS` array in `src/core/migrate.ts` — no orchestrator phases needed.
- `src/commands/migrations/` — TS migration registry (compiled into the binary; no filesystem walk of `skills/migrations/*.md` needed at runtime). `index.ts` lists migrations in semver order. `v0_11_0.ts` = Minions adoption orchestrator (8 phases). `v0_12_0.ts` = Knowledge Graph auto-wire orchestrator (5 phases: schema → config check → backfill links → backfill timeline → verify). `phaseASchema` has a 600s timeout (bumped from 60s in v0.12.1 for duplicate-heavy brains). `v0_12_2.ts` = JSONB double-encode repair orchestrator (4 phases: schema → repair-jsonb → verify → record). All orchestrators are idempotent and resumable from `partial` status.
- `src/commands/repair-jsonb.ts``gbrain repair-jsonb [--dry-run] [--json]`: rewrites `jsonb_typeof='string'` rows in place across 5 affected columns (pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata, page_versions.frontmatter). Fixes v0.12.0 double-encode bug on Postgres; PGLite no-ops. Idempotent.
- `src/commands/orphans.ts``gbrain orphans [--json] [--count] [--include-pseudo]`: surfaces pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. Shipped in v0.12.3 (contributed by @knee5).
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix] [--dry-run] [--index-audit]`: health checks. v0.12.3 added `jsonb_integrity` + `markdown_body_completeness` reliability checks. v0.14.1: `--fix` delegates inlined cross-cutting rules to `> **Convention:** see [path](path).` callouts (pipes DRY violations into `src/core/dry-fix.ts`); `--fix --dry-run` previews without writing. v0.14.2: `schema_version` check fails loudly when `version=0` (migrations never ran — the #218 `bun install -g` signature) and routes users to `gbrain apply-migrations --yes`; new opt-in `--index-audit` flag (Postgres-only) reports zero-scan indexes from `pg_stat_user_indexes` (informational only, no auto-drop). v0.15.2: every DB check is wrapped in a progress phase; `markdown_body_completeness` runs under a 1s heartbeat timer so 10+ min scans are observable on 50K-page brains. Fix hints point at `gbrain repair-jsonb`, `gbrain sync --force`, and `gbrain apply-migrations`.
- `src/core/migrate.ts` — schema-migration runner. Owns the `MIGRATIONS` array (source of truth for schema DDL). v0.14.2 extended the `Migration` interface with `sqlFor?: { postgres?, pglite? }` (engine-specific SQL overrides `sql`) and `transaction?: boolean` (set to false for `CREATE INDEX CONCURRENTLY`, which Postgres refuses inside a transaction; ignored on PGLite since it has no concurrent writers). Migration v14 (fix wave) uses a handler branching on `engine.kind` to run CONCURRENTLY on Postgres (with a pre-drop of any invalid remnant via `pg_index.indisvalid`) and plain `CREATE INDEX` on PGLite. v15 bumps `minion_jobs.max_stalled` default 1→5 and backfills existing non-terminal rows.
- `src/core/progress.ts` — Shared bulk-action progress reporter. Writes to stderr. Modes: `auto` (TTY: `\r`-rewriting; non-TTY: plain lines), `human`, `json` (JSONL), `quiet`. Rate-gated by `minIntervalMs` and `minItems`. `startHeartbeat(reporter, note)` helper for single long queries. `child()` composes phase paths. Singleton SIGINT/SIGTERM coordinator emits `abort` events for every live phase. EPIPE defense on both sync throws and stream `'error'` events. Zero dependencies. Introduced in v0.15.2.
- `src/core/cli-options.ts` — Global CLI flag parser. `parseGlobalFlags(argv)` returns `{cliOpts, rest}` with `--quiet` / `--progress-json` / `--progress-interval=<ms>` stripped. `getCliOptions()` / `setCliOptions()` expose a module-level singleton so commands reach the resolved flags without parameter threading. `cliOptsToProgressOptions()` maps to reporter options. `childGlobalFlags()` returns the flag suffix to append to `execSync('gbrain ...')` calls in migration orchestrators. `OperationContext.cliOpts` extends shared-op dispatch for MCP callers.
- `src/core/cycle.ts` — v0.17 brain maintenance cycle primitive. `runCycle(engine: BrainEngine | null, opts: CycleOpts): Promise<CycleReport>` composes 6 phases in semantically-driven order (lint → backlinks → sync → extract → embed → orphans). Three callers: `gbrain dream` CLI, `gbrain autopilot` daemon's inline path, and the Minions `autopilot-cycle` handler (`src/commands/jobs.ts`). One source of truth for what the brain does overnight. Coordination via `gbrain_cycle_locks` DB table (TTL-based; works through PgBouncer transaction pooling, unlike session-scoped `pg_try_advisory_lock`) + `~/.gbrain/cycle.lock` file lock with PID-liveness for PGLite / engine=null mode. `CycleReport.schema_version: "1"` is the stable agent-consumable shape. `PhaseResult.error: { class, code, message, hint?, docs_url? }` is Stripe-API-tier structured failure info. `yieldBetweenPhases` hook awaited between every phase — Minions handler uses this to renew its job lock and prevent v0.14 stall-death regression. Engine nullable: filesystem phases (lint, backlinks) run without DB; DB phases skip with `status: "skipped", reason: "no_database"`. Lock-skip: read-only phase selections (`--phase orphans`) bypass the cycle lock.
- `src/commands/dream.ts` — v0.17 `gbrain dream` CLI. ~80-line thin alias over `runCycle`. brainDir resolution requires explicit `--dir` OR `sync.repo_path` config (no more walk-up-cwd-for-.git footgun). Flags: `--dry-run`, `--json`, `--phase <name>`, `--pull`, `--dir <path>`. Exit code 1 on status=failed (partial/warn not fatal — don't page on warnings).
- `scripts/check-progress-to-stdout.sh` — CI guard against regressing to `\r`-on-stdout progress. Wired into `bun run test` via `scripts/check-progress-to-stdout.sh && bun test` in package.json.
- `docs/progress-events.md` — Canonical JSON event schema reference. Stable from v0.15.2, additive only.
- `src/commands/doctor.ts``gbrain doctor [--json] [--fast] [--fix]`: health checks. v0.12.3 adds two reliability detection checks: `jsonb_integrity` (scans pages.frontmatter, raw_data.data, ingest_log.pages_updated, files.metadata for `jsonb_typeof='string'` rows left over from v0.12.0) and `markdown_body_completeness` (flags pages whose compiled_truth is <30% of raw source when raw has multiple H2/H3 boundaries). Fix hints point at `gbrain repair-jsonb` and `gbrain sync --force`.
- `src/core/markdown.ts` — Frontmatter parsing + body splitter. `splitBody` requires an explicit timeline sentinel (`<!-- timeline -->`, `--- timeline ---`, or `---` immediately before `## Timeline`/`## History`). Plain `---` in body text is a markdown horizontal rule, not a separator. `inferType` auto-types `/wiki/analysis/` → analysis, `/wiki/guides/` → guide, `/wiki/hardware/` → hardware, `/wiki/architecture/` → architecture, `/writing/` → writing (plus the existing people/companies/deals/etc heuristics).
- `scripts/check-jsonb-pattern.sh` — CI grep guard. Fails the build if anyone reintroduces (a) the `${JSON.stringify(x)}::jsonb` interpolation pattern (postgres.js v3 double-encodes it), or (b) `max_stalled INTEGER NOT NULL DEFAULT 1` in any schema source file (v0.15.1 #219 regression guard — must be DEFAULT 5 to preserve SIGKILL-rescue). Wired into `bun test`.
- `scripts/llms-config.ts` + `scripts/build-llms.ts` — Generator for `llms.txt` (llmstxt.org-spec web index) + `llms-full.txt` (inlined single-fetch bundle). Curated config drives both. Run `bun run build:llms` after adding a new doc. `LLMS_REPO_BASE` env var lets forks regenerate with their own URL base. `FULL_SIZE_BUDGET` (600KB) caps the inline bundle; generator WARNs if exceeded. Committed output is not analogous to `schema-embedded.ts` (no runtime consumer); we commit for GitHub browsing and fork-safe fetching.
- `AGENTS.md` — Local-clone entry point for non-Claude agents (Codex, Cursor, OpenClaw, Aider). Mirrors `CLAUDE.md` intent via relative links. Claude Code keeps using `CLAUDE.md`.
- `scripts/check-jsonb-pattern.sh` — CI grep guard. Fails the build if anyone reintroduces the `${JSON.stringify(x)}::jsonb` interpolation pattern (which postgres.js v3 double-encodes). Wired into `bun test`.
- `docs/UPGRADING_DOWNSTREAM_AGENTS.md` — Patches for downstream agent skill forks to apply when upgrading. Each release appends a new section. v0.10.3 includes diffs for brain-ops, meeting-ingestion, signal-detector, enrich.
- `src/core/schema-embedded.ts` — AUTO-GENERATED from schema.sql (run `bun run build:schema`)
- `src/schema.sql` — Full Postgres + pgvector DDL (source of truth, generates schema-embedded.ts)
@@ -153,13 +126,12 @@ Key commands added in v0.7:
- `gbrain migrate --to supabase` / `gbrain migrate --to pglite` — bidirectional engine migration
Key commands added for Minions (job queue):
- `gbrain jobs submit <name> [--params JSON] [--follow] [--dry-run]` — submit a background job. v0.13.1 adds first-class flags for every `MinionJobInput` tuning knob: `--max-stalled N`, `--backoff-type fixed|exponential`, `--backoff-delay Nms`, `--backoff-jitter 0..1`, `--timeout-ms N`, `--idempotency-key K`.
- `gbrain jobs submit <name> [--params JSON] [--follow] [--dry-run]` — submit a background job
- `gbrain jobs list [--status S] [--queue Q]` — list jobs with filters
- `gbrain jobs get <id>` — job details with attempt history
- `gbrain jobs cancel/retry/delete <id>` — manage job lifecycle
- `gbrain jobs prune [--older-than 30d]` — clean old completed/dead jobs
- `gbrain jobs stats` — job health dashboard
- `gbrain jobs smoke [--sigkill-rescue]` — health smoke test. `--sigkill-rescue` is the v0.13.1 regression guard for #219: simulates a killed worker and asserts the stalled job is requeued instead of dead-lettered on first stall.
- `gbrain jobs work [--queue Q] [--concurrency N]` — start worker daemon (Postgres only)
Key commands added in v0.12.2:
@@ -169,19 +141,6 @@ Key commands added in v0.12.3:
- `gbrain orphans [--json] [--count] [--include-pseudo]` — surface pages with zero inbound wikilinks, grouped by domain. Auto-generated/raw/pseudo pages filtered by default. Also exposed as `find_orphans` MCP operation. The natural consumer of the v0.12.0 knowledge graph layer: once edges are captured, find the gaps.
- `gbrain doctor` gains two new reliability detection checks: `jsonb_integrity` (v0.12.0 Postgres double-encode damage) and `markdown_body_completeness` (pages truncated by the old splitBody bug). Detection only; fix hints point at `gbrain repair-jsonb` and `gbrain sync --force`.
Key commands added in v0.14.2:
- `gbrain sync --skip-failed` — acknowledge the current set of failed-parse files recorded in `~/.gbrain/sync-failures.jsonl` so the sync bookmark advances past them. Doctor's `sync_failures` check shows previously-skipped as "all acknowledged" instead of warning.
- `gbrain sync --retry-failed` — re-walk the unacknowledged failures and re-attempt parsing. If the files now succeed, they clear from the set and the bookmark advances naturally.
- `gbrain apply-migrations --force-retry <version>` — reset a wedged migration (3 consecutive partials with no completion) by appending a `'retry'` marker. Next `apply-migrations --yes` treats the version as fresh. `complete` status never regresses to `partial` either before or after a retry marker.
- `GBRAIN_POOL_SIZE` env var — honored by both the singleton pool (`src/core/db.ts`) and the parallel-import worker pool (`src/commands/import.ts`). Default is 10; lower to 2 for Supabase transaction pooler to avoid MaxClients crashes during `gbrain upgrade` subprocess spawns. Read at call time via `resolvePoolSize()`.
- `gbrain doctor` gains two new checks: `sync_failures` (surfaces unacknowledged parse failures with exact paths + fix hints) and `brain_score` (renders the 5-component breakdown when score < 100: embed coverage / 35, link density / 25, timeline coverage / 15, orphans / 15, dead links / 10 — sum equals total).
Key commands added in v0.14.3 (fix wave):
- `gbrain doctor --index-audit` — opt-in Postgres-only check reporting zero-scan indexes from `pg_stat_user_indexes`. Informational only; never auto-drops.
- `gbrain doctor` schema_version check fails loudly when `version=0` — catches `bun install -g github:...` postinstall failures (#218) and routes users to `gbrain apply-migrations --yes`.
- `gbrain jobs submit` gains `--max-stalled`, `--backoff-type`, `--backoff-delay`, `--backoff-jitter`, `--timeout-ms`, `--idempotency-key` — exposing existing `MinionJobInput` fields as first-class CLI flags.
- `gbrain jobs smoke --sigkill-rescue` — opt-in regression smoke case simulating a killed worker; asserts the v0.14.3 schema default (`max_stalled=5`) actually rescues on first stall.
## Testing
`bun test` runs all tests. After the v0.12.1 release: ~75 unit test files + 8 E2E test files (1412 unit pass, 119 E2E when `DATABASE_URL` is set — skip gracefully otherwise). Unit tests run
@@ -193,11 +152,11 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/files.test.ts` (MIME/hash), `test/import-file.test.ts` (import pipeline),
`test/upgrade.test.ts` (schema migrations),
`test/file-migration.test.ts` (file migration), `test/file-resolver.test.ts` (file resolution),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix + v0.13.1 assertions on v12/v13 SQL shape, `sqlFor` + `transaction:false` runner semantics, and the `max_stalled DEFAULT 1` regression guard),
`test/import-resume.test.ts` (import checkpoints), `test/migrate.test.ts` (migration; v8/v9 helper-btree-index SQL structural assertions + 1000-row wall-clock fixtures that guard the O(n²)→O(n log n) fix),
`test/setup-branching.test.ts` (setup flow), `test/slug-validation.test.ts` (slug validation),
`test/storage.test.ts` (storage backends), `test/supabase-admin.test.ts` (Supabase admin),
`test/yaml-lite.test.ts` (YAML parsing), `test/check-update.test.ts` (version check + update CLI),
`test/pglite-engine.test.ts` (PGLite engine, all 40 BrainEngine methods including 11 cases for `addLinksBatch` / `addTimelineEntriesBatch`: empty batch, missing optionals, within-batch dedup via ON CONFLICT, missing-slug rows dropped by JOIN, half-existing batch, batch of 100 + v0.13.1 `connect()` error-wrap assertion (original error nested, #223 link in message, lock released)),
`test/pglite-engine.test.ts` (PGLite engine, all 40 BrainEngine methods including 11 cases for `addLinksBatch` / `addTimelineEntriesBatch`: empty batch, missing optionals, within-batch dedup via ON CONFLICT, missing-slug rows dropped by JOIN, half-existing batch, batch of 100),
`test/engine-factory.test.ts` (engine factory + dynamic imports),
`test/integrations.test.ts` (recipe parsing, CLI routing, recipe validation),
`test/publish.test.ts` (content stripping, encryption, password generation, HTML output),
@@ -210,15 +169,13 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/dedup.test.ts` (source-aware dedup, compiled truth guarantee, layer interactions),
`test/intent.test.ts` (query intent classification: entity/temporal/event/general),
`test/eval.test.ts` (retrieval metrics: precisionAtK, recallAtK, mrr, ndcgAtK, parseQrels),
`test/check-resolvable.test.ts` (resolver reachability, MECE overlap, gap detection, DRY checks + v0.14.1 proximity-based DRY detection + `extractDelegationTargets` coverage — 13 DRY cases),
`test/dry-fix.test.ts` (v0.14.1 auto-fix: three shape-aware expander pure-function tests, five guards — working-tree-dirty, no-git-backup, inside-code-fence, already-delegated within 40 lines, ambiguous-multi-match, block-is-callout — 28 cases),
`test/doctor-fix.test.ts` (v0.14.1 `gbrain doctor --fix` CLI integration: dry-run preview, apply path, JSON output shape — 3 cases),
`test/check-resolvable.test.ts` (resolver reachability, MECE overlap, gap detection, DRY checks),
`test/backoff.test.ts` (load-aware throttling, concurrency limits, active hours),
`test/fail-improve.test.ts` (deterministic/LLM cascade, JSONL logging, test generation, rotation),
`test/transcription.test.ts` (provider detection, format validation, API key errors),
`test/enrichment-service.test.ts` (entity slugification, extraction, tier escalation),
`test/data-research.test.ts` (recipe validation, MRR/ARR extraction, dedup, tracker parsing, HTML stripping),
`test/minions.test.ts` (Minions job queue v7: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, child_done inbox, attachments, removeOnComplete/Fail + v0.13.1 `max_stalled` clamp/default/plumbing coverage),
`test/minions.test.ts` (Minions job queue v7: CRUD, state machine, backoff, stall detection, dependencies, worker lifecycle, lock management, claim mechanics, depth/child-cap, timeouts, cascade kill, idempotency, child_done inbox, attachments, removeOnComplete/Fail),
`test/extract.test.ts` (link extraction, timeline extraction, frontmatter parsing, directory type inference),
`test/extract-db.test.ts` (gbrain extract --source db: typed link inference, idempotency, --type filter, --dry-run JSON output),
`test/extract-fs.test.ts` (gbrain extract --source fs: first-run inserts + second-run reports zero, dry-run dedups candidates across files, second-run perf regression guard — the v0.12.1 N+1 dedup bug),
@@ -235,8 +192,7 @@ parity), `test/cli.test.ts` (CLI structure), `test/config.test.ts` (config redac
`test/postgres-engine.test.ts` (v0.12.3 statement_timeout scoping: `sql.begin` + `SET LOCAL` shape, source-level grep guardrail against reintroduced bare `SET statement_timeout`),
`test/sync.test.ts` (sync logic + v0.12.3 regression guard asserting top-level `engine.transaction` is not called),
`test/doctor.test.ts` (doctor command + v0.12.3 assertions that `jsonb_integrity` scans the four v0.12.0 write sites and `markdown_body_completeness` is present),
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics),
`test/build-llms.test.ts` (llms.txt/llms-full.txt generator: path resolution, idempotence, spec shape, regen-drift guard, content contract, AGENTS.md install-path mirror, size-budget enforcement — 7 cases).
`test/utils.test.ts` (shared SQL utilities + `tryParseEmbedding` null-return and single-warn semantics).
E2E tests (`test/e2e/`): Run against real Postgres+pgvector. Require `DATABASE_URL`.
- `bun run test:e2e` runs Tier 1 (mechanical, all operations, no API keys). Includes 9 dedicated cases for the postgres-engine `addLinksBatch` / `addTimelineEntriesBatch` bind path — postgres-js's `unnest()` binding is structurally different from PGLite's and gets its own coverage.
@@ -312,38 +268,6 @@ testing, soul-audit, webhook-transforms, data-research, minion-orchestrator.
model-routing, test-before-bulk, cross-modal). `skills/_brain-filing-rules.md` and
`skills/_output-rules.md` are shared references.
## Bulk-action progress reporting
All bulk commands (doctor, embed, import, export, sync, extract, migrate,
repair-jsonb, orphans, check-backlinks, lint, integrity auto, eval, files
sync, and apply-migrations) stream progress through the shared reporter
at `src/core/progress.ts`. Agents get heartbeats within 1 second of every
iteration regardless of how slow the underlying work is.
Rules:
- Progress always writes to **stderr**. Stdout stays clean for data output
(`--json` payloads, final summaries, JSON action events from `extract`).
- Non-TTY default: plain one-line-per-event human text. JSON requires the
explicit `--progress-json` flag.
- Global flags (`--quiet`, `--progress-json`, `--progress-interval=<ms>`)
are parsed by `src/core/cli-options.ts` BEFORE command dispatch.
- Phase names are machine-stable `snake_case.dot.path` (e.g.
`doctor.db_checks`, `sync.imports`). Documented in
`docs/progress-events.md`; additive changes only.
- `scripts/check-progress-to-stdout.sh` is a CI guard that fails the build
if any new code writes `\r` progress to stdout. Wired into `bun run test`.
- Minion handlers pass `job.updateProgress` as the `onProgress` callback
to core functions (DB-backed primary progress channel); stderr from
`jobs work` stays coarse for daemon liveness only.
When wiring a new bulk command: `import { createProgress } from '../core/progress.ts'`
and `import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts'`.
Create a reporter with `createProgress(cliOptsToProgressOptions(getCliOptions()))`,
`start(phase, total?)` before the loop, `tick()` inside it, `finish()` after.
For single long-running queries, use `startHeartbeat(reporter, note)` with a
try/finally to guarantee cleanup. Never call `process.stdout.write('\r...')`
in bulk paths, the CI guard will fail the build.
## Build
`bun build --compile --outfile bin/gbrain src/cli.ts`
@@ -572,21 +496,6 @@ search engines, surfaced in cross-references, and distributed with every release
- Example meeting → `meetings/2026-04-03` (generic date is fine)
- Example user → `you` or `the user`, never a proper name
**Specific rule: never say `Wintermute` in any CHANGELOG, README, doc, PR, or
commit message.** When the temptation is to illustrate with the real fork name:
- Reader-facing copy → `your OpenClaw` (covers Wintermute, Hermes, AlphaClaw,
and any other downstream OpenClaw deployment in one term the reader already
recognizes).
- First-person / origin-story copy → `Garry's OpenClaw` (honest that this is
the production deployment driving the feature, without exposing the private
agent's name).
`Wintermute` may appear in private artifacts (scratch plans under
`~/.gstack/projects/…`, memory files, conversation transcripts, CEO-review
plans) — those aren't distributed. Anything checked into this repo or shipped
in a release must use the OpenClaw phrasing above. Sweeping a stale reference
is a small clean-up PR, not a debate.
**When in doubt, ask yourself:** "Would this query reveal private information
about the user's contacts, investments, or portfolio if it were read by a
stranger?" If yes, replace with generic placeholders.
+1 -17
View File
@@ -3,17 +3,6 @@
Read this entire file, then follow the steps. Ask the user for API keys when needed.
Target: ~30 minutes to a fully working brain.
## Step 0: If you are not Claude Code
Read `AGENTS.md` at the repo root first. It's the non-Claude-agent operating
protocol (install, read order, trust boundary, common tasks). Claude Code reads
`CLAUDE.md` automatically and can skip ahead.
If you fetched this file by URL without cloning yet, the companion files live at:
- `https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` — start here
- `https://raw.githubusercontent.com/garrytan/gbrain/master/llms.txt` — full doc map
- `https://raw.githubusercontent.com/garrytan/gbrain/master/llms-full.txt` — same map, inlined
## Step 1: Install GBrain
```bash
@@ -26,11 +15,6 @@ bun install && bun link
Verify: `gbrain --version` should print a version number. If `gbrain` is not found,
restart the shell or add the PATH export to the shell profile.
> **Do NOT use `bun install -g github:garrytan/gbrain`.** Bun blocks the top-level
> postinstall hook on global installs, so schema migrations never run and the CLI
> aborts with `Aborted()` when it opens PGLite. Use the `git clone + bun link` path
> above. Tracking issue: [#218](https://github.com/garrytan/gbrain/issues/218).
## Step 2: API Keys
Ask the user for these:
@@ -149,7 +133,7 @@ actually works) is the most important.
## Upgrade
```bash
cd ~/gbrain && git pull origin master && bun install
cd ~/gbrain && git pull origin main && bun install
gbrain init # apply schema migrations (idempotent)
gbrain post-upgrade # show migration notes for the version range
```
+1 -34
View File
@@ -10,8 +10,6 @@ GBrain is those patterns, generalized. 26 skills. Install in 30 minutes. Your ag
> **~30 minutes to a fully working brain.** Database ready in 2 seconds (PGLite, no server). You just answer questions about API keys.
> **LLMs:** fetch [`llms.txt`](llms.txt) for the documentation map, or [`llms-full.txt`](llms-full.txt) for the same map with core docs inlined in one fetch. **Agents:** start with [`AGENTS.md`](AGENTS.md) (or [`CLAUDE.md`](CLAUDE.md) if you're Claude Code).
## Install
### On an agent platform (recommended)
@@ -30,11 +28,6 @@ https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md
That's it. The agent clones the repo, installs GBrain, sets up the brain, loads 26 skills, and configures recurring jobs. You answer a few questions about API keys. ~30 minutes.
If your agent doesn't auto-read `AGENTS.md`, point it at that file first:
`https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md` is the non-Claude
agent operating protocol (install, read order, trust boundary, common tasks). For
the full doc map, use `llms.txt` at the same URL root.
### Standalone CLI (no agent)
```bash
@@ -44,11 +37,6 @@ gbrain import ~/notes/ # index your markdown
gbrain query "what themes show up across my notes?"
```
**Do NOT use `bun install -g github:garrytan/gbrain`.** Bun blocks the top-level
postinstall hook on global installs, so schema migrations never run and the CLI
aborts with `Aborted()` the first time it opens PGLite. Use `git clone + bun install
&& bun link` as shown above. See [#218](https://github.com/garrytan/gbrain/issues/218).
```
3 results (hybrid search, 0.12s):
@@ -228,27 +216,6 @@ gbrain skillpack-check | jq # full JSON: {healthy, summary, actions[], doc
If anything's off, `actions[]` tells you the exact command to run. For deeper troubleshooting: [`docs/guides/minions-fix.md`](docs/guides/minions-fix.md).
Moving gateway crons to Minions (deterministic scripts, zero LLM tokens per fire): [`docs/guides/minions-shell-jobs.md`](docs/guides/minions-shell-jobs.md).
## Durable agents: `gbrain agent` (v0.15)
Your subagent runs survive crashes now. OpenClaw died mid-run? The worker re-claims on restart and replays from the last committed turn. Fan-out across 50 shards, one shard crashes — the aggregator still claims after every child reaches a terminal state and writes a mixed-outcome summary. Tool calls persist as a two-phase ledger (`pending``complete | failed`) so replay is safe by construction, not by hope.
```bash
# Submit a single-subagent run
gbrain agent run "summarize my last 10 journal pages"
# Fan out N prompts across N subagent children + 1 aggregator
gbrain agent run "analyze every page" \
--fanout-manifest manifests/pages.json \
--subagent-def analyzer
# Tail a running job (heartbeat per turn + full transcript on completion)
gbrain agent logs 1247 --follow --since 5m
```
Durability is the point: every Anthropic turn commits to `subagent_messages`, every tool call to `subagent_tool_executions`. Worker kills, OpenClaw crashes, timeouts — all resumable. Host repos (your OpenClaw, etc.) ship their own subagent definitions via `GBRAIN_PLUGIN_PATH` + a `gbrain.plugin.json` manifest: see [`docs/guides/plugin-authors.md`](docs/guides/plugin-authors.md). Requires `ANTHROPIC_API_KEY` on the worker.
## Skillify: your skills tree stops being a black box
Hermes and similar agent frameworks auto-create skills as a background behavior. Fine until you don't know what the agent shipped. Checklists decay. Tests drift. Resolver entries get stale. Six months later you've got an opaque pile of "skills" that nobody has read, nobody has tested, and nobody is sure still work.
@@ -563,7 +530,7 @@ JOBS (Minions)
ADMIN
gbrain doctor [--json] [--fast] Health checks (resolver, skills, DB, embeddings)
gbrain doctor --fix [--dry-run] Auto-fix DRY violations (delegate inlined rules to conventions)
gbrain doctor --fix Auto-fix resolver issues
gbrain stats Brain statistics
gbrain serve MCP server (stdio)
gbrain integrations Integration recipe dashboard
+1 -106
View File
@@ -1,21 +1,5 @@
# TODOS
## check-resolvable
### File tracking issues for Checks 5 + 6 (deferred in PR #325)
**Priority:** P2
**What:** `src/commands/check-resolvable.ts` currently points `DEFERRED[].issue` at GitHub issue search URLs (`?q=TBD-check-5`, `?q=TBD-check-6`). File real tracking issues and grep-replace both placeholders with the real URLs.
**Why:** v0.16.4 shipped `gbrain check-resolvable` with 4 of the 6 checks from the original spec. Checks 5 (trigger routing eval) and 6 (brain filing) were explicitly deferred during plan-ceo-review because they each need new detection logic. The CLI's `deferred[]` JSON field is meant to surface these to agents so they know the coverage boundary — the TBD placeholders do the right thing mechanically but aren't clickable.
**How:**
1. `gh issue create -t "check-resolvable Check 5: trigger routing eval" -b "..."` — detection: every skill's own frontmatter trigger should match the RESOLVER.md entry pointing at that skill. Needs new issue type (e.g. `mis_route`).
2. `gh issue create -t "check-resolvable Check 6: brain filing validation" -b "..."` — detection: scan SKILL.md body for brain paths (e.g., `brain/people/`, `brain/companies/`), cross-reference with `skills/_brain-filing-rules.md`. Flag mutating skills missing entries.
3. Replace `TBD-check-5` and `TBD-check-6` in `src/commands/check-resolvable.ts` with the real issue URLs.
**Effort:** ~15 min mechanical (issue filing + grep-replace). Implementation of the checks themselves is a separate, larger piece of work — the TODO here is just the issue filing + URL swap.
## P1 (BrainBench v1.1 — categories deferred from PR #188)
### BrainBench Cat 5: Source Attribution / Provenance
@@ -100,30 +84,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
## P1
### Minions shell jobs — Phase 2 scheduling (deferred from v0.13.0)
**What:** `minion_schedules` table + autopilot-cycle scanner that submits due shell jobs.
**Why:** v0.13.0 moves shell scripts to Minions but still leaves scheduling in the host crontab. Your OpenClaw's `scripts/service-manager.sh` + crontab is the only piece left on the host side. A DB-driven scheduler would mean a single `gbrain autopilot --install` replaces the host crontab entirely, scheduling is visible via `gbrain jobs list --scheduled`, and downtime-on-one-machine tolerance improves (schedule is shared DB state, not per-host crontab).
**Pros:** Canonical host-agnostic deployment. No more host-specific crontab.
**Cons:** Cross-engine migration complexity (new table on both PGLite + Postgres). Autopilot-cycle scanner needs to handle missed-schedule semantics (fire-once-on-startup or skip-if-past-now), and this is where every other cron-like system has historically accrued bugs.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### `gbrain crontab-to-minions <file>` migration helper (deferred from v0.13.0)
**What:** Parse an existing crontab file, emit a proposed rewrite using `gbrain jobs submit shell ...` for each deterministic entry, keep LLM-requiring entries as-is.
**Why:** Hand-rewriting ~14 OpenClaw cron entries is error-prone and one-shot. A helper would make the migration reversible and auditable (diff the before/after crontab, dry-run the first N, commit).
**Pros:** Removes the "rewrite 14 lines by hand" tax every agent operator pays on adoption.
**Cons:** Crontab parsing is historically fiddly (5-field vs 6-field, `@hourly` aliases, Vixie extensions, env vars in crontab). Could misrewrite entries with shell substitution.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Batch the DB-source extract read path (deferred from v0.12.1)
**What:** `extractLinksFromDB` and `extractTimelineFromDB` at `src/commands/extract.ts:447, 504` issue one `engine.getPage(slug)` per slug after `engine.getAllSlugs()`. On a 47K-page brain that's still 47K serial reads over the Supabase pooler.
@@ -189,7 +149,7 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
**Cons:** Requires adding `sender_id` or `access_tier` to `OperationContext`. Each mutating operation needs a permission check. Medium implementation effort.
**Context:** From CEO review + Codex outside voice (2026-04-13). Prompt-layer access control works in practice (same model as Garry's OpenClaw) but is not sufficient for remote MCP where direct tool calls bypass the agent's prompt.
**Context:** From CEO review + Codex outside voice (2026-04-13). Prompt-layer access control works in practice (same model as Wintermute) but is not sufficient for remote MCP where direct tool calls bypass the agent's prompt.
**Depends on:** v0.10.0 GStackBrain skill layer (shipped).
@@ -244,50 +204,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
## P2
### Minions: `gbrain jobs stats --orphaned` (deferred from v0.13.0)
**What:** New CLI flag / output column surfacing jobs that are waiting with no registered handler on any live worker.
**Why:** v0.13.0 adds shell jobs that require `GBRAIN_ALLOW_SHELL_JOBS=1` on the worker. If an operator submits a shell job but no worker with the flag is running, the row sits in `waiting` silently. The CLI's starvation warning + docs help at submit time; this TODO surfaces the problem at operational-check time.
**Pros:** Closes the "did my cron actually run" ambiguity for multi-machine deployments.
**Cons:** Knowing "no worker has this handler registered" requires worker heartbeat tracking, which Minions doesn't have yet (it's stateless at DB level beyond `lock_token`). Could be approximated by "no jobs of this name have completed in last N minutes AND count of waiting is > 0."
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: AbortReason plumbing on MinionJobContext (deferred from v0.13.0)
**What:** Handlers today can't distinguish whether `ctx.signal.aborted` fired due to timeout, cancel, or lock-loss. v0.13.0 derives this at worker-catch-time from `abort.signal.reason`, but the handler can't see it directly. Expose `ctx.abortReason?: 'timeout' | 'cancel' | 'lock-lost' | 'shutdown'` on the context.
**Why:** Shell handler's kill-sequence today can't decide "retry this" (lock-lost) vs "don't retry, user cancelled" (cancel) — they look the same. A typed AbortReason lets handlers make that decision for themselves.
**Pros:** Handlers get richer signals.
**Cons:** Small surface-area addition to the handler API. Not strictly required since the worker already makes the retry/dead decision for them.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: blocking-mode audit log for true forensic integrity (deferred from v0.13.0)
**What:** Opt-in mode for `shell-audit` where `appendFileSync` failures DO block submission instead of logging-and-continuing.
**Why:** v0.13.0 ships the audit log in best-effort mode, which means a disk-full attacker can silently disable the forensic trail. Acceptable for v0.13.0 because the primary use is operational ("what did this cron do last Tuesday"), not security forensics. Operators who want fail-closed semantics should have a flag.
**Pros:** Enables true forensic integrity for deployments that need it.
**Cons:** Fail-closed means a transient disk issue blocks shell submissions, which can be worse than a missing log line for most operators. Opt-in is the right shape but adds surface area.
**Depends on:** v0.13.0 shell jobs shipped. ✅
### Minions: configurable per-job output buffer sizes (deferred from v0.13.0)
**What:** Add `max_stdout_bytes` / `max_stderr_bytes` to ShellJobParams; override the 64KB/16KB defaults.
**Why:** 64KB/16KB covers typical OpenClaw scripts today but a verbose benchmark or a debug-dump script could need more.
**Depends on:** First shell-job author who actually needs it. Don't pre-build the flag.
### Security hardening follow-ups (deferred from security-wave-3)
**What:** Close remaining security gaps identified during the v0.9.4 Codex outside-voice review that didn't make the wave's in-scope cut.
@@ -380,27 +296,6 @@ board" — likely an advisor-role page prior plus verb-pattern combinations.
**Priority:** P2
**Depends on:** Nothing.
### Doctor --fix polish from v0.14.1 adversarial review
**What:** Six deferred findings from v0.14.1 ship-time adversarial review on `src/core/dry-fix.ts`:
1. **TOCTOU between read and write.** `attemptFix` reads once, writes later. Concurrent editor saves silently overwritten. Fix: re-read immediately before write and compare snapshot, or `O_EXCL` tempfile + rename.
2. **Fence detection misses 4-backtick and `~~~` fences.** `isInsideCodeFence` only catches `^```$`. CommonMark-legal alternates slip through.
3. **`expandBullet` walk-up is dead code.** Loop breaks immediately because `baseIndent` matches the current line. Remove or make it actually walk up.
4. **Multi-match guard too strict.** Skills with the pattern in a table-of-contents AND body get `ambiguous_multiple_matches` forever. Consider: fix first, re-scan, repeat until fixed-point.
5. **Subprocess spam.** `getWorkingTreeStatus` spawns `git status` N×M times per `doctor --fix`. Cache per-skill per-invocation.
6. **`doctor --fix --json` swallows the auto-fix report.** `printAutoFixReport` returns early on `jsonOutput`; agents don't see fix outcomes. Emit `auto_fix` as a top-level key.
**Why:** None are ship-blockers; all surfaced during v0.14.1 Codex adversarial review. Bundle into one follow-up PR.
**Pros:** Closes the adversarial findings loop. Better correctness under concurrent edits and JSON-consumer agents.
**Cons:** Concurrent-edit test is finicky.
**Context:** v0.14.1 shipped with the 4 critical fixes (shell-injection via execFileSync, no-git-backup detection, EOF newline preservation, proximity-window consistency). These six are the deferred remainder.
**Effort estimate:** M (CC: ~45min for all six + tests).
**Priority:** P2
**Depends on:** Nothing.
## Completed
### Implement AWS Signature V4 for S3 storage backend
+1 -1
View File
@@ -1 +1 @@
0.17.0
0.13.1
+2 -8
View File
@@ -7,7 +7,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@electric-sql/pglite": "0.4.3",
"@electric-sql/pglite": "^0.4.4",
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"marked": "^18.0.0",
@@ -17,13 +17,9 @@
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.6.0",
},
},
},
"trustedDependencies": [
"@electric-sql/pglite",
],
"packages": {
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.30.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-nuKvp7wOIz6BFei8WrTdhmSsx5mwnArYyJgh4+vYu3V4J0Ltb8Xm3odPm51n1aSI0XxNCrDl7O88cxCtUdAkaw=="],
@@ -107,7 +103,7 @@
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="],
"@electric-sql/pglite": ["@electric-sql/pglite@0.4.4", "", {}, "sha512-g/6CWAJ4XOkObWCWAQ2IReZD8VvsDy3poRHSKvpRR2F96F8WJ3HVbjpso3gN7l0q6QPPgvxSSpl/qo5k8a7mkQ=="],
"@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="],
@@ -457,8 +453,6 @@
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
-6
View File
@@ -1,6 +0,0 @@
[test]
# PGLite initialization can be slow under parallel test execution.
# Default 5s is too short when many test files boot PGLite instances at once.
# 60s is the empirical ceiling we observed before the first file's beforeAll
# completed on a loaded machine.
timeout = 60_000
-1
View File
@@ -52,7 +52,6 @@ Running a production brain.
| [Cron via Minions](../skills/conventions/cron-via-minions.md) | Why scheduled work runs as Minion jobs, not `agentTurn`. Auto-applied by v0.11.0 migration for built-in handlers; host-specific handlers use the plugin contract below. |
| [Plugin Handlers](guides/plugin-handlers.md) | Registering host-specific Minion handlers via code (no data-file exec surface). |
| [Minions fix](guides/minions-fix.md) | Repairing a half-migrated v0.11.0 install. |
| [Shell jobs (v0.14.0+)](guides/minions-shell-jobs.md) | Move deterministic crons (API fetch, token refresh, scrape+write) off the LLM gateway. Zero tokens per fire, ~60% gateway headroom. Follow `skills/migrations/v0.14.0.md` for the adoption playbook. |
| [Quiet Hours & Timezone](guides/quiet-hours.md) | Hold notifications during sleep, timezone-aware delivery |
| [Executive Assistant Pattern](guides/executive-assistant.md) | Email triage, meeting prep, scheduling |
| [Operational Disciplines](guides/operational-disciplines.md) | Signal detection, brain-first, sync-after-write, heartbeat, dream cycle |
-136
View File
@@ -319,142 +319,6 @@ v0.13 edges carry new `link_type` values. If your fork has graph-query skills th
### Type normalization NOT in v0.13
Legacy rows with `link_type='attendee'` or `link_type='mention'` coexist with new `'attended'` / `'mentions'` rows. Your queries filtering on old type names keep working. A separate opt-in `gbrain normalize-types` command in v0.14 handles the rename.
## v0.14.0 shell jobs (optional adoption, no skill edits)
Adds a `shell` job type to Minions so deterministic cron scripts (API fetch, token
refresh, scrape + write) move off the LLM gateway. Zero tokens per fire. ~60%
gateway CPU headroom at typical scale. Feature is **off by default**, existing
installs keep running exactly as they did before. Nothing breaks.
To adopt, follow `skills/migrations/v0.14.0.md`. The short version:
1. Set `GBRAIN_ALLOW_SHELL_JOBS=1` on the worker process, then `gbrain jobs work`
(Postgres). On PGLite, every crontab invocation uses `--follow` for inline
execution; no persistent worker.
2. Classify each of your host's cron entries: LLM-requiring (keep on gateway) vs
deterministic (candidate for shell). Typical splits:
- **Deterministic → shell:** `ycli-token-refresh`, `x-oauth2-refresh`,
`x-garrytan-unified`, `calendar-sync-to-brain`, `github-pulse`,
`frameio-scan`, `flight-tracker`, `x-raw-json-backfill`.
- **LLM-requiring → stay:** `social-radar`, `content-ideas`, `adversary-vacuum`,
`ea-inbox-sweep`, `morning-briefing`, `brain-maintenance`.
3. For each deterministic cron, rewrite as:
```cron
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node scripts/your-script.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
4. Watch `gbrain jobs get <id>` for exit_code / stdout_tail / stderr_tail on each
fire. Compare against pre-migration behavior before approving the next batch.
**No skill edits required.** The handler runs worker-side; skill files don't
change. If your host exposed custom handlers via the plugin contract (v0.11.0),
they still work the same way.
Iron rule: **never auto-rewrite the operator's crontab.** Every rewrite is
per-cron, human-approved, with a diff. If you want automation later, the
upcoming `gbrain crontab-to-minions <file>` helper is P1 in TODOS.
---
## v0.16.0: durable agent runtime
v0.15 ships `gbrain agent run` / `gbrain agent logs`, a new `subagent` handler
type in Minions, and a plugin contract for host-repo subagent defs. None of the
existing skills need surgery. The question for downstream agents is *how* to
adopt the new runtime, not how to patch around a breaking change.
### 1. Run a worker with an Anthropic key
The subagent handlers (`subagent` and `subagent_aggregator`) are always
registered on the worker. No separate opt-in flag — `ANTHROPIC_API_KEY` is
the natural cost gate (no key, the SDK call fails on the first turn), and
who-can-submit is already protected (`PROTECTED_JOB_NAMES` + trusted-submit:
MCP callers get `permission_denied`; only `gbrain agent run` can insert
these rows).
```bash
ANTHROPIC_API_KEY=sk-ant-... gbrain jobs work
```
Worker startup prints:
```
[minion worker] subagent handlers enabled
```
### 2. Ship your subagents as a plugin (OpenClaw + similar)
Move your custom subagent definitions out of your gbrain fork and into your own
repo as a plugin. Concretely:
```
~/<your-agent>/gbrain-plugin/
├── gbrain.plugin.json
└── subagents/
├── meeting-ingestion.md
├── signal-detector.md
└── daily-task-prep.md
```
`gbrain.plugin.json`:
```json
{
"name": "your-openclaw",
"version": "2026.4.20",
"plugin_version": "gbrain-plugin-v1"
}
```
Each `subagents/*.md` is a plain-text agent definition — YAML frontmatter +
body-as-system-prompt. Recognized frontmatter fields: `name`, `model`,
`max_turns`, `allowed_tools` (must subset the derived brain-tool registry).
Turn it on:
```bash
export GBRAIN_PLUGIN_PATH="$HOME/<your-agent>/gbrain-plugin"
```
Worker startup prints `[plugin-loader] loaded '<name>' v<ver> (N subagents)`
per plugin; any rejection (bad manifest, unknown tool in `allowed_tools`,
version mismatch) shows up as a loud warning at startup, not a silent dispatch-
time failure. See `docs/guides/plugin-authors.md` for the full contract.
### 3. Replace ephemeral subagent runs with durable ones
If your agent currently spawns ephemeral subagents (OpenClaw `Agent()`, ad-hoc
Anthropic API calls, etc.) for work that should survive crashes, sleeps, or
worker restarts, migrate those to `gbrain agent run`. The durability is free:
```bash
gbrain agent run "analyze my last 50 journal pages for recurring themes" \
--subagent-def analyzer --fanout-manifest manifests/journal-pages.json
```
Every turn persists to `subagent_messages`, every tool call is a two-phase
ledger, and `gbrain agent logs <job>` shows where it died + what the last
successful call returned. No more "re-run from scratch because the session
context evaporated."
### 4. `put_page` from subagents writes under an agent namespace
If you adopted the v0.15 subagent runtime, note that `put_page` calls
originating from a subagent's tool dispatch MUST target
`wiki/agents/<subagent_id>/...`. The schema shown to the model enforces this
on first try; a server-side fail-closed check rejects anything else. This
does NOT affect your skill files, CLI put_page calls, or MCP put_page —
only tool-dispatched writes from inside an LLM loop.
Aggregation output (the final "here's what all N children found" brain page)
goes via a separate trusted CLI path, not through a subagent tool call, so
it can write anywhere you want.
Iron rule: **never grant an agent write access beyond its namespace**. The
server-side check exists because dispatcher bugs happen; treat it as defense
in depth, not the primary boundary.
---
@@ -1,7 +1,7 @@
# Production Benchmark: Minions vs OpenClaw Sub-agents (Real Deployment)
**Date:** 2026-04-18
**Environment:** Garry's OpenClaw on Render (ephemeral container, Supabase Postgres)
**Environment:** Wintermute on Render (ephemeral container, Supabase Postgres)
**GBrain:** v0.11.0 (minions-jobs branch)
**OpenClaw:** 2026.4.10
**Brain:** 45,798 pages, 98K chunks, 25K links, 79K timeline entries
+27 -27
View File
@@ -8,9 +8,9 @@
## 0. Context
During a CEO review of a narrow two-feature plan (bare-tweet citation repair + completeness score, borrowed from Feynman), the scope was reframed. The narrow plan duplicated work Garry's OpenClaw already does and missed the real leverage point: **the bespoke abstractions hiding inside OpenClaw — resolvers, enrichment orchestration, scheduling, deterministic output — should live in GBrain as first-class primitives.**
During a CEO review of a narrow two-feature plan (bare-tweet citation repair + completeness score, borrowed from Feynman), the scope was reframed. The narrow plan duplicated work Wintermute already does and missed the real leverage point: **the bespoke abstractions hiding inside Wintermute — resolvers, enrichment orchestration, scheduling, deterministic output — should live in GBrain as first-class primitives.**
North star: *"When Garry's OpenClaw's Claw upgrades to this version of GBrain, it should immediately recognize brilliance and completeness and say 'It's time to switch to these abstractions.'"*
North star: *"When Wintermute's Claw upgrades to this version of GBrain, it should immediately recognize brilliance and completeness and say 'It's time to switch to these abstractions.'"*
That is the test this document is designed against. Everything else is downstream.
@@ -67,7 +67,7 @@ An earlier implementation could ship L1 + L4 first (the two "purest" layers) and
### 3.1 What's broken today
Garry's OpenClaw has **69 distinct external-lookup patterns** across X API (14 shapes), Perplexity, Mistral OCR, Gmail, Calendar, Slack, GitHub, YouTube, Diarize.io, YC tools, OSINT collectors, and brain-local lookups. Each one is a bespoke script under `scripts/` with its own error handling, retry logic, and output shape. GBrain has 3 ad-hoc wrappers (`embedding.ts`, `transcription.ts`, `enrichment-service.ts`) that don't share an interface.
Wintermute has **69 distinct external-lookup patterns** across X API (14 shapes), Perplexity, Mistral OCR, Gmail, Calendar, Slack, GitHub, YouTube, Diarize.io, YC tools, OSINT collectors, and brain-local lookups. Each one is a bespoke script under `scripts/` with its own error handling, retry logic, and output shape. GBrain has 3 ad-hoc wrappers (`embedding.ts`, `transcription.ts`, `enrichment-service.ts`) that don't share an interface.
Common consequences:
- No uniform retry/backoff strategy (some scripts retry, most don't)
@@ -187,7 +187,7 @@ Existing `src/core/fail-improve.ts` is the deterministic-first/LLM-fallback patt
### 3.7 Reference implementations to ship
The OpenClaw survey inventoried 69 resolver shapes. Shipping all of them is wrong (over-scoped); shipping zero is under-scoped. The dogfood set:
The Wintermute survey inventoried 69 resolver shapes. Shipping all of them is wrong (over-scoped); shipping zero is under-scoped. The dogfood set:
| # | Resolver | Purpose | Used by |
|---|---|---|---|
@@ -198,7 +198,7 @@ The OpenClaw survey inventoried 69 resolver shapes. Shipping all of them is wron
| 5 | `perplexity_query` | Query → synthesis + citations | Enrichment Orchestrator |
| 6 | `text_to_entities` | LLM entity extraction (structured JSON) | Enrichment Orchestrator |
The remaining 63 OpenClaw patterns port incrementally, driven by user need. Each port is a new YAML + module under `recipes/` or `~/.gbrain/resolvers/` with no framework changes.
The remaining 63 Wintermute patterns port incrementally, driven by user need. Each port is a new YAML + module under `recipes/` or `~/.gbrain/resolvers/` with no framework changes.
---
@@ -206,7 +206,7 @@ The remaining 63 OpenClaw patterns port incrementally, driven by user need. Each
### 4.1 What's broken today
Garry's OpenClaw's enrichment is **polished at the data layer, hacky at the control layer**:
Wintermute's enrichment is **polished at the data layer, hacky at the control layer**:
- **Completeness = "length > 500 chars + no `needs-enrichment` tag"** (`lib/enrich.mjs:351-355`). Naïve. A rich page of repetitive Perplexity summaries (see `brain/people/0interestrates.md` — 38 repeating blocks) passes this check.
- **30-day auto-re-enrichment** runs forever. No "done" state. A person met once in 2023 still gets re-researched monthly.
@@ -342,9 +342,9 @@ await writer.transaction(async (tx) => {
### 5.1 What's broken today
Garry's OpenClaw's cron is **externally-driven JSON** (`cron/jobs.json`) with ~30 jobs manually stagger-offset at different minutes. GBrain has **zero native scheduling**`src/commands/autopilot.ts` is a single daemon loop, and `docs/guides/cron-schedule.md` is architectural guidance, not code.
Wintermute's cron is **externally-driven JSON** (`cron/jobs.json`) with ~30 jobs manually stagger-offset at different minutes. GBrain has **zero native scheduling**`src/commands/autopilot.ts` is a single daemon loop, and `docs/guides/cron-schedule.md` is architectural guidance, not code.
Failures observed in Garry's OpenClaw's actual state:
Failures observed in Wintermute's actual state:
- `X OAuth2 Token Refresh`: 11 consecutive timeouts (critical-path silent failure)
- `flight-tracker daily scan`: 5 consecutive timeouts
- `morning-briefing`: 4 consecutive timeouts
@@ -378,9 +378,9 @@ export interface ScheduledResolver extends Resolver<void, ScheduledResult> {
}
```
### 5.3 Enforcement vs convention (the key delta from Garry's OpenClaw)
### 5.3 Enforcement vs convention (the key delta from Wintermute)
| Concern | Garry's OpenClaw today | Knowledge Runtime |
| Concern | Wintermute today | Knowledge Runtime |
|---|---|---|
| Quiet hours | Checked inside each skill (trust-based) | Enforced at scheduler, skill cannot override |
| Staggering | Manual minute-offset in `jobs.json` | Scheduler assigns slots via hashed staggerKey |
@@ -405,7 +405,7 @@ Every scheduled run emits structured events: `started`, `skipped-quiet-hours`, `
- `engine.logIngest` (audit trail in brain DB)
- Optional webhook (Slack/Telegram for the user)
`gbrain doctor` reads the event log and reports: current circuit-breaker state, any resolver with > 3 consecutive failures, any resolver that hasn't fired within 3× its interval (freshness SLA like Garry's OpenClaw's `freshness-check.mjs` but built-in).
`gbrain doctor` reads the event log and reports: current circuit-breaker state, any resolver with > 3 consecutive failures, any resolver that hasn't fired within 3× its interval (freshness SLA like Wintermute's `freshness-check.mjs` but built-in).
---
@@ -415,9 +415,9 @@ Every scheduled run emits structured events: `started`, `skipped-quiet-hours`, `
**Iron Law: LLM picks WHAT. Code guarantees WHERE and HOW.**
Garry's OpenClaw's existing `lib/enrich.mjs:buildTweetEntry` is close to this — tweet URLs are built from `tweet.id` returned by the X API, never from LLM memory. But:
Wintermute's existing `lib/enrich.mjs:buildTweetEntry` is close to this — tweet URLs are built from `tweet.id` returned by the X API, never from LLM memory. But:
- A past incident: *"Sub-agent test #2 FAILED — hallucinated 'Philip Leung' entity links across all daily files. LLM rewriting of daily files is too error-prone."* (Garry's OpenClaw memory log, 2026-04-13.)
- A past incident: *"Sub-agent test #2 FAILED — hallucinated 'Philip Leung' entity links across all daily files. LLM rewriting of daily files is too error-prone."* (Wintermute memory log, 2026-04-13.)
- Back-links depend on `appendTimeline` being called everywhere; skips are silent.
- Slug collisions are unchecked (no conflict detection on `slugify`).
- Citation format is post-hoc linted weekly, not pre-write enforced.
@@ -461,7 +461,7 @@ export class Scaffolder {
// "[Source: [X/garrytan, 2026-04-18](https://x.com/garrytan/status/123456)]"
}
emailCitation(account: string, messageId: string, subject: string): string {
// deterministic Gmail URL per OpenClaw pattern
// deterministic Gmail URL per Wintermute pattern
}
sourceCitation(resolverResult: ResolverResult<unknown>): string {
// pulls .source, .fetchedAt, .raw from the result
@@ -563,7 +563,7 @@ Each phase ships independently, passes full E2E, is feature-flagged, and is reve
- L4 core: `BrainWriter.transaction`, `Scaffolder`, `SlugRegistry` with conflict detection.
- Pre-write validators: citation, link, back-link, triple-HR.
- Migrate `src/commands/publish.ts` + `src/commands/backlinks.ts` to route through BrainWriter.
- **Now** Garry's OpenClaw's "Philip Leung" hallucination is structurally impossible — LLM output passes through JSON-Schema validator before reaching Scaffolder.
- **Now** Wintermute's "Philip Leung" hallucination is structurally impossible — LLM output passes through JSON-Schema validator before reaching Scaffolder.
### Phase 3 — `gbrain integrity` command (human: ~0.5 wk / CC: ~2 h)
- Ship the originally-scoped user-facing feature on top of the new foundation.
@@ -582,14 +582,14 @@ Each phase ships independently, passes full E2E, is feature-flagged, and is reve
- Migrate `src/commands/autopilot.ts` to a ScheduledResolver set.
- Ship `gbrain schedule list|run|pause|tail` CLI for observability.
### Phase 6 — Port 58 OpenClaw resolvers (human: ~1.5 wk / CC: ~6 h)
### Phase 6 — Port 58 Wintermute resolvers (human: ~1.5 wk / CC: ~6 h)
- `perplexity_query`, `text_to_entities`, `mistral_ocr_pdf`, `x_search_all`, `x_user_to_tweets`, `gmail_query_to_threads`, `calendar_date_to_events`.
- Each ships as YAML + TS module under `resolvers/builtin/` — **proof of the plugin format.**
### Phase 7 — OpenClaw Adoption Integration (human: ~1 wk / CC: ~4 h)
- Write `docs/openclaw/ADOPTION.md` showing your OpenClaw how to replace its 69 bespoke scripts with calls to `gbrain registry.resolve(...)`.
- Ship a `gbrain claw-bridge` subcommand that proxies Garry's OpenClaw's current script invocations to the resolver registry — zero-edit adoption path.
- **This is the test of the north star.** If your OpenClaw can stand up a 1-line shim and drop `scripts/x-api-client.mjs`, the abstraction succeeded.
### Phase 7 — Wintermute Claw Adoption Integration (human: ~1 wk / CC: ~4 h)
- Write `docs/wintermute/ADOPTION.md` showing Wintermute how to replace its 69 bespoke scripts with calls to `gbrain registry.resolve(...)`.
- Ship a `gbrain claw-bridge` subcommand that proxies Wintermute's current script invocations to the resolver registry — zero-edit adoption path.
- **This is the test of the north star.** If Wintermute can stand up a 1-line shim and drop `scripts/x-api-client.mjs`, the abstraction succeeded.
Total: human: ~10 weeks / CC: ~42 hours / calendar with single implementer: ~34 weeks.
@@ -649,7 +649,7 @@ src/commands/
integrity.ts # ships in Phase 3, replaces Feynman Phase A/B
schedule.ts # gbrain schedule list|run|pause|tail (Phase 5)
docs/openclaw/
docs/wintermute/
ADOPTION.md # written in Phase 7
```
@@ -685,19 +685,19 @@ Every Resolver implementation tested against the interface spec. Table-driven: r
- Simulate API timeout mid-transaction; transaction must roll back completely.
- Corrupted state file; scheduler must escalate, not silently skip.
### Regression tests vs. Garry's OpenClaw behavior
For each OpenClaw pattern we port (e.g. X-handle → tweet URL), a regression test proves the new resolver produces the same answer on real-world inputs from the brain audit. This is the "your OpenClaw would adopt" proof.
### Regression tests vs. Wintermute behavior
For each Wintermute pattern we port (e.g. X-handle → tweet URL), a regression test proves the new resolver produces the same answer on real-world inputs from the brain audit. This is the "Wintermute would adopt" proof.
---
## 11. Open Questions (flagged for CEO re-review)
1. **Scope shape.** Is this the right four-layer decomposition, or are some layers better left to OpenClaw (e.g. Scheduling lives above GBrain, not in it)?
1. **Scope shape.** Is this the right four-layer decomposition, or are some layers better left to Wintermute (e.g. Scheduling lives above GBrain, not in it)?
2. **Phase 3 user-value break.** Does Phase 3 (user-visible `gbrain integrity`) ship early enough, or do we need an even smaller MVP?
3. **LLM-as-resolver.** Should `text_to_entities` be a Resolver, or does that blur the "code vs LLM" line the invariant relies on?
4. **Plugin format.** YAML + TS module (§3.5) vs. pure TS module with decorator-style metadata. Latter is more type-safe; former is more discoverable.
5. **Cross-resolver transactions.** Do we support "atomic fetch-from-Perplexity + write-to-brain" at the L2 layer? Current design says yes; implementation is tricky (Perplexity call isn't rollbackable).
6. **OpenClaw bridge scope.** Phase 7 `gbrain claw-bridge` — is that worth a phase of its own, or should adoption be documentation-only?
6. **Wintermute bridge scope.** Phase 7 `gbrain claw-bridge` — is that worth a phase of its own, or should adoption be documentation-only?
7. **Completeness rubric coverage.** Do we define rubrics for all 9 PageTypes upfront, or ship people/company/meeting first and extend incrementally?
8. **Budget config UX.** Hard daily cap is strict; should we also expose a soft-cap warning mode, and how is the cap set (env var? config file? prompt on first use?)
9. **Backwards compat.** `src/commands/publish.ts` and `src/commands/backlinks.ts` have been running cleanly for weeks. Refactoring through BrainWriter carries migration risk. Acceptable?
@@ -705,12 +705,12 @@ For each OpenClaw pattern we port (e.g. X-handle → tweet URL), a regression te
---
## 12. Verification (the "your OpenClaw would adopt" test)
## 12. Verification (the "Wintermute would adopt" test)
The design succeeds iff:
- [ ] A user can add a new resolver by dropping a YAML + TS module in `~/.gbrain/resolvers/` without editing GBrain source.
- [ ] Your OpenClaw can delete `scripts/x-api-client.mjs` and replace all callers with 1-line `await registry.resolve('x_handle_to_tweet', ...)`.
- [ ] Wintermute can delete `scripts/x-api-client.mjs` and replace all callers with 1-line `await registry.resolve('x_handle_to_tweet', ...)`.
- [ ] No brain page can be written with a bare tweet reference, a missing back-link, or an unverified URL (validators catch it pre-commit).
- [ ] Running `gbrain integrity --auto --confidence 0.8` over a real brain fixes ≥1,000 of the 1,424 known bare-tweet citations without human review.
- [ ] Full E2E test suite passes on both PGLite + Postgres engines.
@@ -1,10 +0,0 @@
# Procfile — Render / Railway / Heroku.
#
# Fly.io users: see fly.toml.partial instead.
#
# Set secrets via the platform's env UI or CLI (e.g. `heroku config:set`,
# `render env:set`, `railway variables set`). At minimum:
# DATABASE_URL=postgresql://...
# GBRAIN_ALLOW_SHELL_JOBS=1 # only if submitting shell jobs
worker: gbrain jobs work --concurrency 2
@@ -1,22 +0,0 @@
# fly.toml — partial. Merge into your existing fly.toml.
#
# Set secrets once (never commit them):
# fly secrets set DATABASE_URL='postgresql://user:pass@host:6543/db?prepare=false'
# fly secrets set GBRAIN_ALLOW_SHELL_JOBS=1 # only if submitting shell jobs
# fly secrets set ANTHROPIC_API_KEY=... # optional
#
# Fly.io auto-restarts the process on crash — no watchdog needed.
[processes]
worker = "gbrain jobs work --concurrency 2"
# Scale the worker process to 1 machine (job queue serializes work; more
# machines means higher concurrency but also more Postgres connections).
# fly scale count worker=1
# If you want the worker in its own VM size:
# [[vm]]
# processes = ["worker"]
# memory = "512mb"
# cpu_kind = "shared"
# cpus = 1
@@ -1,35 +0,0 @@
# /etc/gbrain.env — secrets + env for the gbrain worker.
#
# Install:
# sudo install -m 600 -o $GBRAIN_WORKER_USER -g $GBRAIN_WORKER_USER \
# gbrain.env.example /etc/gbrain.env
# sudoedit /etc/gbrain.env # fill in real values
#
# Referenced from crontab via BASH_ENV=/etc/gbrain.env, or from systemd
# via EnvironmentFile=/etc/gbrain.env. Never commit real secrets.
# --- Required ---------------------------------------------------------------
# Postgres connection string. For Supabase transaction pooler, include
# prepare=false (see CLAUDE.md #284/#286).
DATABASE_URL=postgresql://user:pass@host:6543/db?prepare=false
# --- Required if you submit `shell` jobs ------------------------------------
# Only the worker process needs this. Submitters do not.
GBRAIN_ALLOW_SHELL_JOBS=1
# --- Optional ---------------------------------------------------------------
# LLM provider keys (needed for `subagent` handler, transcription, enrichment).
# ANTHROPIC_API_KEY=
# OPENAI_API_KEY=
# Custom handler plugins (see docs/guides/plugin-handlers.md).
# GBRAIN_PLUGIN_PATH=/etc/gbrain/plugins
# Pool size tuning for Supabase transaction pooler (default 10; drop to 2
# if you hit MaxClients during upgrade subprocess spawns).
# GBRAIN_POOL_SIZE=2
# Connection-level concurrency cap for Anthropic Messages API.
# GBRAIN_ANTHROPIC_MAX_INFLIGHT=4
@@ -1,68 +0,0 @@
#!/bin/bash
# minion-watchdog.sh — restart gbrain jobs work if the process is dead or
# has logged a shutdown marker since its last start.
#
# Fixes the v0.16.1 restart-loop bug: old shutdown lines from previous
# restarts stayed in the unrotated log and every tick re-matched them
# forever. This version writes a restart epoch to line 2 of the PID file
# and only considers log lines newer than that epoch.
#
# Run every 5 minutes from crontab. See docs/guides/minions-deployment.md.
set -u
PID_FILE="${GBRAIN_WORKER_PID_FILE:-/tmp/gbrain-worker.pid}"
LOG_FILE="${GBRAIN_WORKER_LOG_FILE:-/tmp/gbrain-worker.log}"
GBRAIN="${GBRAIN_BIN:-/usr/local/bin/gbrain}"
CONCURRENCY="${GBRAIN_WORKER_CONCURRENCY:-2}"
start_worker() {
# stderr merged so banner lines ("[minion worker] shell handler enabled",
# "worker shutting down") all land in $LOG_FILE.
nohup "$GBRAIN" jobs work --concurrency "$CONCURRENCY" \
> "$LOG_FILE" 2>&1 &
local pid=$!
# Line 1: PID. Line 2: restart epoch (seconds since 1970).
# Readers that want just PID use `head -n1 "$PID_FILE"`.
printf '%s\n%s\n' "$pid" "$(date +%s)" > "$PID_FILE"
}
shutdown_since_restart() {
# Only match shutdown lines logged AFTER the most recent restart epoch.
# Worker log lines start with ISO-8601 UTC timestamps ("2026-04-21T19:05:12Z ...").
local restart_epoch
restart_epoch=$(sed -n '2p' "$PID_FILE" 2>/dev/null || echo 0)
[ -z "$restart_epoch" ] && restart_epoch=0
# POSIX-portable regex (no {n} intervals — mawk on Debian/Ubuntu rejects them).
awk -v since="$restart_epoch" '
match($0, /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9:.+Z-]+/) {
ts_str = substr($0, RSTART, RLENGTH)
cmd = "date -d \"" ts_str "\" +%s 2>/dev/null"
cmd | getline ts
close(cmd)
if (ts + 0 > since + 0) print
}
' "$LOG_FILE" 2>/dev/null | grep -q "worker stopped\|worker shutting down"
}
if [ -f "$PID_FILE" ]; then
PID=$(head -n1 "$PID_FILE")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
# Process alive — check whether the worker logged an internal shutdown
# AFTER the last start. If yes, worker is dead-inside; restart.
if shutdown_since_restart; then
kill "$PID" 2>/dev/null
# 10s grace: covers shell handler's 5s child SIGTERM→SIGKILL window
# and leaves room for in-flight jobs to flush. Bump to 30 if your
# jobs run > 10s.
sleep 10
kill -9 "$PID" 2>/dev/null
start_worker
fi
else
# PID file exists but process is gone (crash / kill -9 / reboot).
start_worker
fi
else
start_worker
fi
@@ -1,44 +0,0 @@
[Unit]
Description=gbrain minion worker
Documentation=https://github.com/garrytan/gbrain/blob/master/docs/guides/minions-deployment.md
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# Runs as an unprivileged user that owns the brain repo and any shell-job cwds.
# Create with: sudo useradd --system --home /srv/gbrain --shell /usr/sbin/nologin gbrain
User=gbrain
Group=gbrain
WorkingDirectory=/srv/gbrain
# Env file is mode 600, owned by User=. Do not put secrets in this unit.
EnvironmentFile=/etc/gbrain.env
ExecStart=/usr/local/bin/gbrain jobs work --concurrency 2
# Replaces the cron watchdog. systemd restarts on any non-zero exit.
Restart=always
RestartSec=10s
# Graceful shutdown: SIGTERM → wait → SIGKILL. 30s matches worker grace
# for in-flight jobs and the shell handler's 5s child SIGTERM window.
KillSignal=SIGTERM
TimeoutStopSec=30s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=gbrain-worker
# Default 1024 is tight for Bun + Postgres pool + concurrent subagent LLM calls.
LimitNOFILE=65535
# Hardening (optional — remove if they break your deployment).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/srv/gbrain
[Install]
WantedBy=multi-user.target
-323
View File
@@ -1,323 +0,0 @@
# Minions Worker Deployment Guide
Deploy `gbrain jobs work` so it stays running across crashes, reboots, and
Postgres connection blips. Written for agents to execute line-by-line.
## The problem
The persistent worker can die silently from:
- Database connection drops (Supabase/Postgres maintenance or network blips).
- Lock-renewal failures → the stall detector eventually dead-letters jobs.
- Bun process crashes with no automatic restart.
- Internal event-loop death (PID alive, worker loop stopped).
When the worker dies, submitted jobs sit in `waiting` forever. Nothing in
gbrain core auto-restarts the worker — that's what this guide wires up.
## Variables used in this guide
Substitute these once before copy-pasting any snippet.
| Variable | Meaning | Typical value |
|---|---|---|
| `$GBRAIN_BIN` | Absolute path to the `gbrain` binary | `$(command -v gbrain)` — often `/usr/local/bin/gbrain` or `~/.bun/bin/gbrain` |
| `$GBRAIN_WORKER_USER` | OS user that owns the worker process | the same user that ran `gbrain init`; never `root` |
| `$GBRAIN_WORKER_PID_FILE` | Worker PID + restart-epoch file | `/tmp/gbrain-worker.pid` (or `/var/run/gbrain/worker.pid` for systemd) |
| `$GBRAIN_WORKER_LOG_FILE` | Worker log sink (stdout + stderr merged) | `/tmp/gbrain-worker.log` (or `/var/log/gbrain/worker.log`) |
| `$GBRAIN_WORKSPACE` | `cwd` for shell jobs submitted by this deployment | absolute path, e.g. `/srv/my-brain` |
| `$GBRAIN_ENV_FILE` | Secrets file sourced by crontab / systemd | `/etc/gbrain.env` (mode 600) |
## Preconditions
Run these before Step 1 of any option. Fail fast if something is wrong.
```bash
# 1. gbrain is on PATH and resolves to an absolute location.
command -v gbrain || { echo "gbrain not on PATH. Install, then retry."; exit 1; }
# 2. DATABASE_URL points at reachable Postgres (or PGLite path exists).
gbrain doctor --fast --json | jq '.checks[] | select(.name=="db_connectivity")'
# 3. Schema is up to date. If version=0 or status=="fail", fix it first:
# gbrain apply-migrations --yes
gbrain doctor --fast --json | jq '.checks[] | select(.name=="schema_version")'
# 4. You have write access to at least one crontab mechanism.
crontab -l >/dev/null 2>&1 && echo "user crontab OK"
[ -w /etc/crontab ] && echo "/etc/crontab OK"
# 5. If you plan to submit `shell` jobs, the WORKER process needs
# GBRAIN_ALLOW_SHELL_JOBS=1 (submitters do not). The handler is gated
# in registerBuiltinHandlers(); without the flag the worker startup
# line reads "shell handler disabled (...)".
```
## Which option?
- Your workload runs LLM subagents (`gbrain agent run`) or jobs that take
> 30 s → **Option 1** (watchdog cron + persistent worker).
- Your workload is short deterministic scripts on a fixed schedule (every
3 h, daily, weekly) → **Option 2** (inline `--follow`).
- You don't have shell access to a long-running box (Fly/Render/Railway,
or any systemd host) → **Option 3** (service manager — replaces cron).
## Option 1: watchdog cron + persistent worker
A 5-minute cron checks whether the worker process is alive **and** whether
it has logged an internal shutdown since its last start. Restarts if either
condition fails.
### 1a. Install the env file (secrets stay out of crontab)
Never paste `DATABASE_URL` or API keys into crontab. `/etc/crontab` is
mode 644 (world-readable); user crontabs under `/var/spool/cron/` are
readable by `root`. Use the shipped env-file template:
```bash
sudo install -m 600 -o $GBRAIN_WORKER_USER -g $GBRAIN_WORKER_USER \
docs/guides/minions-deployment-snippets/gbrain.env.example /etc/gbrain.env
sudoedit /etc/gbrain.env
```
Fill in the connection string and `GBRAIN_ALLOW_SHELL_JOBS=1` (if
applicable). See
[`gbrain.env.example`](./minions-deployment-snippets/gbrain.env.example)
for the full list.
### 1b. Install the watchdog script
The [`minion-watchdog.sh`](./minions-deployment-snippets/minion-watchdog.sh)
ships in-repo and writes a two-line PID file (PID on line 1, restart epoch
on line 2). The restart-epoch marker is how the watchdog distinguishes
stale shutdown lines in the log from current ones — without it, every tick
after the first restart would match an old `worker shutting down` line and
loop forever.
Requires GNU coreutils (Linux default). On macOS/BSD install via
`brew install coreutils` and alias `date` to `gdate` in the cron env if you
want to test the watchdog locally; production Linux boxes work as-is.
```bash
sudo install -m 755 -o $GBRAIN_WORKER_USER -g $GBRAIN_WORKER_USER \
docs/guides/minions-deployment-snippets/minion-watchdog.sh \
/usr/local/bin/minion-watchdog.sh
```
### 1c. Wire into cron
Pick the form that matches the crontab you're editing.
**If you ran `crontab -e`** (user crontab — 5-field, no user column):
```
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
BASH_ENV=/etc/gbrain.env
*/5 * * * * /usr/local/bin/minion-watchdog.sh
```
**If you edited `/etc/crontab` directly** (system crontab — 6-field, with
user column):
```
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
BASH_ENV=/etc/gbrain.env
*/5 * * * * gbrain /usr/local/bin/minion-watchdog.sh
```
In both forms, `BASH_ENV=/etc/gbrain.env` tells non-interactive bash to
source the env file before running the watchdog — that's how the
connection string and `GBRAIN_ALLOW_SHELL_JOBS` reach the worker without
landing in the world-readable crontab itself.
### 1d. Log rotation
The watchdog appends to the worker log across restarts. If you expect the
file to grow unbounded, rotate it externally with `logrotate`:
```
# /etc/logrotate.d/gbrain-worker
/tmp/gbrain-worker.log {
daily
rotate 7
missingok
notifempty
copytruncate
}
```
`copytruncate` is important — the watchdog's restart-epoch check survives
it (the epoch is compared against in-log timestamps, not file inode).
## Option 2: inline `--follow` (no persistent worker)
Each cron run brings its own temporary worker. `--follow` starts one on
the queue and blocks until the just-submitted job reaches a terminal state
(`completed` / `failed` / `dead` / `cancelled`). 2-3 s startup overhead
per job; negligible vs job duration for scheduled work.
Example: nightly brain enrichment as a shell job.
```bash
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--queue nightly-enrich \
--params "{\"cmd\":\"$GBRAIN_BIN embed --stale\",\"cwd\":\"$GBRAIN_WORKSPACE\"}" \
--follow \
--timeout-ms 600000
```
Replace `gbrain embed --stale` with whichever gbrain subcommand you're
scheduling (`sync`, `extract`, `orphans`, `doctor`, `check-backlinks`,
`lint`, `autopilot`). If you're shelling out to a non-gbrain binary,
keep its absolute path in the `cmd`.
**Shared-queue gotcha.** If other jobs are already waiting on the same
queue with higher priority or earlier `created_at`, the temporary worker
processes those first before reaching yours. `--follow` still exits only
when YOUR job finishes. For strict single-job semantics on shared queues,
use a dedicated queue name like `nightly-enrich` above.
## Option 3: service manager (systemd / Fly / Render / Railway)
Replaces the watchdog entirely. No cron, no PID file, no restart-loop.
The service manager owns liveness.
### systemd (Linux hosts with shell access)
```bash
# Create the worker user if it doesn't exist.
sudo useradd --system --home "$GBRAIN_WORKSPACE" --shell /usr/sbin/nologin gbrain \
2>/dev/null || true
sudo mkdir -p "$GBRAIN_WORKSPACE" && sudo chown gbrain:gbrain "$GBRAIN_WORKSPACE"
# Install the unit file, substituting /srv/gbrain → your workspace path.
sudo install -m 644 docs/guides/minions-deployment-snippets/systemd.service \
/etc/systemd/system/gbrain-worker.service
sudo sed -i "s|/srv/gbrain|$GBRAIN_WORKSPACE|g" \
/etc/systemd/system/gbrain-worker.service
# See 1a above for /etc/gbrain.env install.
sudo systemctl daemon-reload
sudo systemctl enable --now gbrain-worker
sudo systemctl status gbrain-worker
journalctl -u gbrain-worker -n 50
```
`Restart=always` + `RestartSec=10s` give you crash-loop recovery. The unit
runs as an unprivileged `gbrain` user with `PrivateTmp`, `ProtectSystem=strict`,
and `ReadWritePaths=$GBRAIN_WORKSPACE`. `LimitNOFILE=65535` in the shipped
unit covers Bun + Postgres pool + concurrent LLM subagent calls without
hitting the default 1024 cap.
### Fly.io
Merge the `[processes]` block from
[`fly.toml.partial`](./minions-deployment-snippets/fly.toml.partial) into
your existing `fly.toml`. Set secrets with `fly secrets set`
Fly auto-restarts the process on crash.
### Render / Railway / Heroku
Drop [`Procfile`](./minions-deployment-snippets/Procfile) at the repo root.
Set the connection string and `GBRAIN_ALLOW_SHELL_JOBS=1` via the
platform's env UI or CLI.
## Upgrading an existing deployment
If you deployed on v0.13.x or earlier, walk this checklist:
1. **Stop the worker before upgrading.**
`kill $(head -n1 /tmp/gbrain-worker.pid)` and wait for the process to
exit. Skipping this risks an in-flight job landing partial schema.
2. **Run `gbrain upgrade`**. Then `gbrain apply-migrations --yes` if
`gbrain doctor` reports any migration as `partial` or `pending`.
3. **If you run shell jobs:** from v0.14 onward, the worker requires
`GBRAIN_ALLOW_SHELL_JOBS=1` to register the `shell` handler. Add it to
`/etc/gbrain.env`. Submitters don't need the flag; only the worker does.
4. **If you tuned your watchdog for `max_stalled=1`:** v0.14.3 migration
v15 raised the schema default to 5 and backfilled existing non-terminal
rows. A watchdog tuned around 1-strike dead-lettering will now
over-restart because it takes 5 misses to dead-letter. Switch to the
shipped watchdog (which keys on log markers, not job state).
5. **If your v0.16.1 watchdog is still running:** it has a restart-loop
bug (old shutdown lines in the unrotated log re-match every 5 min
forever). Install the current `minion-watchdog.sh` from this guide's
snippets — it writes a restart epoch into the PID file and only
considers log lines newer than that epoch.
6. **Verify.** `gbrain doctor` should report zero `pending` or `partial`
migrations. `gbrain jobs stats` should show no unexplained growth in
`dead` between pre- and post-upgrade.
## Known issues
### Supabase connection drops
The worker uses a single Postgres connection. If Supabase drops it
(maintenance, connection limits, network blip), lock renewal fails
silently. The stall detector then dead-letters the job after
`max_stalled` misses.
**Current defaults that make this worse:**
- `lockDuration: 30000` (30 s) — too short for long jobs during connection blips.
- `max_stalled: 5` (schema column default on master — see `src/schema.sql`
and `src/core/pglite-schema.ts`). Five missed heartbeats before dead-letter.
- `stalledInterval: 30000` (30 s) — checks too aggressively.
**Tune per-job today.** `gbrain jobs submit` accepts `--max-stalled N`,
`--backoff-type fixed|exponential`, `--backoff-delay <ms>`,
`--backoff-jitter 0..1`, and `--timeout-ms N` as first-class flags
(since v0.13.1). These write onto the job row at submit time — which is
what `handleStalled()` reads — so per-job tuning is the real knob today.
Worker-level `--lock-duration` / `--stall-interval` are on the roadmap;
until they land, rely on per-job `--max-stalled` plus the watchdog (or
systemd) for worker health.
### DO NOT pass `maxStalledCount` to `MinionWorker`
It's a no-op. The stall detector reads the row's `max_stalled` column
(set at submit time), not the worker opt in `src/core/minions/worker.ts:74`.
Use `gbrain jobs submit --max-stalled N` per-job instead.
### Zombie shell children
When the Bun worker crashes hard, child processes from shell jobs can
become zombies. The watchdog's 10 s `SIGTERM → SIGKILL` window covers the
shell handler's 5 s child-kill grace (`KILL_GRACE_MS`). For long-running
shell jobs, bump the watchdog's `sleep 10` to `sleep 30` so the worker
has time to flush in-flight jobs before the kill.
## Smoke test
```bash
# Worker alive?
kill -0 $(head -n1 /tmp/gbrain-worker.pid) 2>/dev/null && echo ALIVE || echo DEAD
# Aggregate queue health.
gbrain jobs stats
# Jobs currently stalled (still `active` with expired lock_until, pre-requeue).
gbrain jobs list --status active --limit 10
# Dead-lettered jobs.
gbrain jobs list --status dead --limit 10
# Shell handler registered? (stderr banner merged into log via 2>&1.)
grep "shell handler enabled" /tmp/gbrain-worker.log
```
## Uninstall
- **Option 1 (watchdog cron):** `crontab -e`, delete the watchdog line.
`kill $(head -n1 /tmp/gbrain-worker.pid) && rm /tmp/gbrain-worker.pid`.
Optionally `sudo rm /etc/gbrain.env /usr/local/bin/minion-watchdog.sh`.
- **Option 2 (inline `--follow`):** remove the cron entry. Nothing else to
clean up — temporary workers exit with their jobs.
- **Option 3 (systemd):** `sudo systemctl disable --now gbrain-worker`,
then `sudo rm /etc/systemd/system/gbrain-worker.service /etc/gbrain.env`,
then `sudo systemctl daemon-reload`.
- **Option 3 (Fly/Render/Railway):** delete the `worker` process from
`fly.toml` / `Procfile` and redeploy. Secrets set via `fly secrets`
persist until `fly secrets unset`.
+1 -1
View File
@@ -73,7 +73,7 @@ F. Install gbrain autopilot --install (env-aware)
G. Record append completed.jsonl status:"complete"
```
If Phase E emits TODOs for host-specific handlers (e.g. your OpenClaw's
If Phase E emits TODOs for host-specific handlers (e.g. Wintermute's
~29 non-gbrain crons), the migration finishes with `status: "partial"`.
Your host agent walks the TODOs using `skills/migrations/v0.11.0.md` +
`docs/guides/plugin-handlers.md`, ships handler registrations in the
-167
View File
@@ -1,167 +0,0 @@
# Minions shell jobs — move deterministic crons off the gateway
## 30 seconds
```bash
# Run your first shell job:
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"echo hello","cwd":"/tmp"}' --follow
# → exit_code: 0, stdout_tail: "hello\n", duration_ms: 43
```
That's it. Your cron scripts now have a home with retry, backoff, DLQ, and
`gbrain jobs list` visibility, without each one booting a full LLM session.
**PGLite users:** `gbrain jobs work` does not run on PGLite (exclusive file
lock). Every crontab invocation must use `--follow` for inline execution.
Postgres users can run a persistent worker; see recipes below.
---
## Why it exists
If your agent runs deterministic scripts from cron (token refresh, API fetch,
scrape + write), each one pays the cost of a full LLM session on the gateway.
Fourteen simultaneous fires on a Series A deployment pin CPU at 100% and block
live messages. None of those scripts need reasoning. They need a shell.
Shell jobs move them to the Minions worker: one deterministic-script execution
per cron, zero LLM tokens, unified visibility and retry.
---
## Security model (read this)
Shell exec is a large blast radius. We ship two independent gates, both must
pass:
1. **MCP boundary.** `submit_job` with `name: 'shell'` is rejected when
`ctx.remote === true` (MCP callers). Independent of the env flag. Remote
agents can never submit shell jobs. `MinionQueue.add('shell', ...)` has its
own guard too, so an in-process handler can't programmatically bypass this.
2. **Env flag.** The worker only registers the shell handler when
`GBRAIN_ALLOW_SHELL_JOBS=1` is set on the worker process. Default: off. Your
agent opts in per-host.
**What the env allowlist does AND does not do.** Shell jobs run with a minimal
env: `PATH, HOME, USER, LANG, TZ, NODE_ENV`. Your secrets like `OPENAI_API_KEY`
and `DATABASE_URL` are NOT passed to the child. You opt-in additional keys per
job via `env: { ... }`. This stops accidental `$OPENAI_API_KEY` interpolation in
a user-authored script. It does **not** sandbox filesystem reads: a shell
script can `cat ~/.env` or any file the worker process can read. The operator
picks a safe `cwd`. That is the trust boundary.
**Audit trail, not forensic insurance.** Every submission writes a JSONL line
to `~/.gbrain/audit/shell-jobs-YYYY-Www.jsonl` (ISO-week rotation; override
with `GBRAIN_AUDIT_DIR`). Failures log to stderr and don't block submission, so
a disk-full adversary could silently disable the trail. Good for "what did
this cron submit last Tuesday", not for security-critical forensics.
**The command text is logged as-is.** If you embed a secret in `cmd`
(`curl -H 'Authorization: Bearer ...'`), it shows up in the audit file. Put
secrets in `env:` instead.
---
## Migrate a cron
### Postgres worker (recommended)
On one terminal, start a persistent worker:
```bash
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work
```
Rewrite crontab to submit shell jobs (no `--follow`):
```cron
# Before (LLM gateway):
# OpenClaw cron: x-garrytan-unified
# After (Minions worker):
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
Worker claims the job on next poll, runs it, records `exit_code` +
`stdout_tail` + `stderr_tail` in the result. Failures retry per
`--max-attempts` with exponential backoff.
### PGLite (inline execution)
PGLite doesn't support the persistent worker daemon. Every crontab invocation
uses `--follow` to run inline:
```cron
# Each cron tick spawns a short-lived worker that runs the job inline.
3 13,16,19,22,1,4,7,10 * * * \
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"node scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--follow --timeout-ms 300000
```
Note: `--follow` blocks the crontab slot until the job finishes. If 14 shell
crons land at the same minute and each takes 30s, they serialize through
crontab's spawning limits. Postgres + persistent worker scales better.
### Submitting with `argv` (no shell interpolation)
For programmatic callers assembling commands from JSON, use `argv` instead of
`cmd`. No shell, no injection surface:
```bash
gbrain jobs submit shell \
--params '{"argv":["node","scripts/fetch.mjs","--date","2026-04-19"],"cwd":"/data"}' \
--follow
```
---
## Debug a failed job
```bash
# List dead shell jobs
gbrain jobs list --status dead
# Inspect one
gbrain jobs get 42
# → error_text, stacktrace, result.stdout_tail, result.stderr_tail
# Submission audit log (operator trail, not forensic)
cat ~/.gbrain/audit/shell-jobs-*.jsonl | jq '.'
# First-time failure mode: submitted without env flag on the worker
gbrain jobs list --status waiting --name shell
# If rows pile up here, no worker with GBRAIN_ALLOW_SHELL_JOBS=1 is running.
```
---
## Limitations
- **Filesystem reads are not sandboxed.** See "Security model" above. Don't
point `cwd` at a directory full of secrets.
- **Audit log is advisory.** Disk-full or EACCES silently disables it.
- **Cancel latency is lock-renewal-bounded** (~7-15 s by default). A cancelled
child keeps running until the next lock-renewal tick fails.
- **`--follow` claim order** is by priority/created_at. If another job is
waiting in the same queue at the time of `--follow`, that one runs first.
- **`cwd` symlink TOCTOU.** The absolute-path check doesn't guard against
symlinks pointing elsewhere at execution time. Operator-scope concern.
---
## Errors {#errors}
| Error | What it means | Fix |
|---|---|---|
| `shell: specify exactly one of cmd or argv` | `cmd` and `argv` are mutually exclusive. Both absent is also invalid. | Choose one. `cmd` for shell-interpolated strings; `argv` for structured args. |
| `shell: cwd is required and must be an absolute path` | `cwd` must be a string starting with `/`. | Set `cwd` in `--params` to an absolute path. |
| `shell: argv must be an array of strings` | `argv` has a non-string entry or isn't an array. | Pass `argv: ["bin","arg1","arg2"]`. |
| `shell: env values must all be strings` | `env` has a number/bool/object value. | Stringify: `"env":{"COUNT":"3"}` not `"env":{"COUNT":3}`. |
| `permission_denied: shell jobs cannot be submitted over MCP` | An MCP client tried to submit a shell job. By design CLI-only. | Submit from CLI or via a trusted operation handler (`ctx.remote === false`). |
| `protected job name 'shell' requires CLI or operation-local submitter` | A caller invoked `MinionQueue.add('shell', ...)` without the `trusted` opt-in. | Pass `{ allowProtectedSubmit: true }` as the 4th arg. CLI and `submit_job` do this automatically. |
| `aborted: timeout` / `aborted: cancel` / `aborted: shutdown` / `aborted: lock-lost` | The worker's abort signal fired mid-execution. Child got SIGTERM, 5s grace, then SIGKILL. | Expected: timeout / user cancel / deploy restart / stall. Inspect `gbrain jobs get` to see which. |
| `exit N: <stderr_tail_500>` | Script exited non-zero. | Read `stderr_tail` in `gbrain jobs get`. |
-182
View File
@@ -1,182 +0,0 @@
# Multi-source brains
**A single gbrain database can hold multiple knowledge repos.** Each one
is a `source`: a logical brain-within-the-brain with its own slug
namespace, its own sync state, and its own federation policy. The rest
of this guide walks the three canonical scenarios.
## The three scenarios
### 1. Unified knowledge recall (wiki + gstack)
You have a personal wiki and a `gstack` checkout. Both belong to you,
both are knowledge you want your agent to recall across. When you ask
"what did I learn about X?" you want the best hit whether it lives in
the wiki or in a gstack plan.
```bash
# Register the gstack source, federate so it joins cross-source search
gbrain sources add gstack --path ~/.gstack --federated
# Pin the directory so `gbrain sync` knows which source it's walking
cd ~/.gstack && gbrain sources attach gstack
# Initial sync
gbrain sync --source gstack
# Now `gbrain search "retry budgets"` returns hits from BOTH wiki and
# gstack. Each result includes source_id so the agent can cite properly.
```
Result: wiki pages and gstack plans are separate (different source_ids,
different slug namespaces) but share the search surface.
### 2. Purpose-separated brains (yc-media + garrys-list)
You run two completely different content pipelines on the same backend.
YC Media covers portfolio news and founder profiles. Garry's List is
personal writing. You explicitly DON'T want them mixed in search — YC
portfolio content leaking into essay searches is a bug, not a feature.
```bash
# Two sources, both isolated (federated=false)
gbrain sources add yc-media --path ~/yc-media --no-federated
gbrain sources add garrys-list --path ~/writing --no-federated
# Pin each checkout directory
(cd ~/yc-media && gbrain sources attach yc-media)
(cd ~/writing && gbrain sources attach garrys-list)
# Sync each independently
gbrain sync --source yc-media
gbrain sync --source garrys-list
```
Result: searching from neither directory returns the `default` source
(your main brain). Searching from inside `~/yc-media` returns only yc-
media hits. Searching from inside `~/writing` returns only garrys-list.
Federation is opt-in, not leaked.
To search across them explicitly on demand:
```bash
gbrain search "tech layoffs" --source yc-media,garrys-list
```
### 3. Mixed (wiki federated + sessions isolated)
Your main wiki is federated with a few trusted sources. Your session
transcripts (coming in v0.18) land in a separate isolated source so
they don't dominate every search result.
```bash
# Federated sources
gbrain sources add gstack --path ~/.gstack --federated
# Isolated source (future v0.18 — sessions use this shape today for ingest)
gbrain sources add sessions --path ~/.claude/sessions --no-federated
```
## Resolution priority
When any command needs to pick a source, gbrain walks this list (highest
first):
1. Explicit `--source <id>` flag.
2. `GBRAIN_SOURCE` environment variable.
3. `.gbrain-source` dotfile in CWD or any ancestor directory.
4. A registered source whose `local_path` contains the CWD (longest
prefix wins for nested checkouts).
5. The brain-level default set via `gbrain sources default <id>`.
6. The seeded `default` source.
So inside `~/.gstack/plans/` on a brain that pinned `gstack` to
`~/.gstack` via `.gbrain-source`, `gbrain put-page` implicitly writes to
the `gstack` source. Outside any registered directory with no env/dotfile
set, it writes to the default.
## Federation flag
Every source row stores `config.federated: boolean` in its JSONB config.
| Value | Meaning |
|-------|---------|
| `true` | Source participates in unqualified `gbrain search "X"` results. |
| `false` (default for new sources) | Source only searched when explicitly named via `--source <id>` or qualified citation. |
The seeded `default` source is `federated=true` so pre-v0.17 brains
behave exactly as before — every page appears in search.
Flip later with `gbrain sources federate <id>` / `unfederate <id>`.
## Commands
Full subcommand reference:
```
gbrain sources add <id> --path <p> [--name <n>] [--federated|--no-federated]
Register a source. id: [a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?
gbrain sources list [--json] List all sources with page counts + federation state.
gbrain sources remove <id> [--yes] [--dry-run] [--keep-storage]
Cascade-delete a source (pages, chunks, timeline).
gbrain sources rename <id> <new-name>
Change display name only; id is immutable.
gbrain sources default <id> Set the brain-level default.
gbrain sources attach <id> Write .gbrain-source in CWD (like kubectl context).
gbrain sources detach Remove .gbrain-source from CWD.
gbrain sources federate <id>
gbrain sources unfederate <id>
```
## Citation format for agents
When agents receive multi-source results they MUST cite pages in
`[source-id:slug]` form. Example:
> You told me about the distillation protocol — see [wiki:topics/ai]
> and [gstack:plans/multi-repo] for where this came from.
The citation key is `sources.id` (immutable). Renaming a source via
`gbrain sources rename` changes the display name only; existing
citations keep working.
## Writing to a specific source
```bash
# Pass --source explicitly
gbrain put-page topics/ai ... --source wiki
# Or rely on the dotfile / env / CWD match
cd ~/.gstack && gbrain put-page plans/multi-repo ...
# → source auto-resolves to gstack
```
Reads span federated sources by default. Writes require a resolved
source (explicit, inferred, or default). The resolver never picks a
source silently when ambiguous — it errors with a clear fix.
## Upgrading an existing brain
`gbrain upgrade` runs the v16 + v17 migrations automatically. Your
existing pages all move under `source_id='default'`. Behavior is
unchanged until you add a second source.
To add one:
```bash
gbrain sources add gstack --path ~/.gstack --federated
cd ~/.gstack && gbrain sources attach gstack && gbrain sync
```
Two commands. The existing default source is untouched.
## Not in v0.18.0
- Session transcript ingest (`.jsonl`, raised size cap, session
PageType) — v0.18.
- Per-source retention/TTL (`gbrain sources prune`) — v0.18.
- ACL enforcement via caller-identity — v0.17.1.
- `gbrain sources import-from-github <url>` one-shot bootstrap — patch
release after the core plumbing stabilizes.
All of these build on the `sources` primitive shipped here.
-163
View File
@@ -1,163 +0,0 @@
# Plugin authors guide (v0.15)
`gbrain` discovers subagent definitions from outside this repo via
`GBRAIN_PLUGIN_PATH`. If you maintain a downstream agent (your OpenClaw
deployment, a workflow host, a private tool) and want to ship custom
subagents alongside it, drop a plugin directory on that env path.
This guide is for plugin authors. The CLI user doesn't need to read it.
## Minimum viable plugin
```
/path/to/my-plugin/
├── gbrain.plugin.json
└── subagents/
└── my-summarizer.md
```
`gbrain.plugin.json`:
```json
{
"name": "my-plugin",
"version": "1.0.0",
"plugin_version": "gbrain-plugin-v1"
}
```
`subagents/my-summarizer.md`:
```markdown
---
name: my-summarizer
model: claude-sonnet-4-6
allowed_tools:
- brain_search
- brain_get_page
---
You are a brain page summarizer. Given a slug, fetch the page and produce
a 3-sentence summary.
```
## Turning it on
```bash
export GBRAIN_PLUGIN_PATH="/path/to/my-plugin"
gbrain jobs work # worker startup prints the plugin load line
gbrain agent run "summarize meetings/2026-04-20" --subagent-def my-summarizer
```
Multiple plugins: colon-separated, just like `$PATH`.
```bash
export GBRAIN_PLUGIN_PATH="/path/to/plugin-a:/path/to/plugin-b"
```
## Rules (strict by design)
**Path policy.** Absolute paths only. Relative paths, `~`-prefixed paths,
and URL-style paths (`https://`, `file://`) are rejected with a warning.
You control where your plugin lives on disk; `gbrain` doesn't guess.
**Collision policy.** If two plugins ship a subagent with the same `name`,
the one listed FIRST in `GBRAIN_PLUGIN_PATH` wins. The other is dropped
with a warning naming both sources.
**Trust policy.** Plugins ship subagent definitions ONLY in v0.15:
- You **cannot** declare new tools.
- You **cannot** extend the brain tool allow-list.
- You **cannot** override any `agentSafe` or similar flag.
- Your `allowed_tools:` frontmatter field MUST subset the derived brain
tool registry. Names not in the registry are rejected at plugin load
time (worker startup), NOT at subagent dispatch time — so a typo in
your plugin gives you a loud startup error, not a silent "tool never
fires" at 3am.
v0.16+ may open up plugin-declared tools with a separate contract. Don't
expect it.
## `gbrain.plugin.json`
| field | type | required | notes |
|------------------|--------|----------|--------------------------------------------------------------------|
| `name` | string | yes | Human-readable plugin id. Shows up in warnings and collision logs. |
| `version` | string | yes | Your plugin's semver. Informational. |
| `plugin_version` | string | yes | Contract lock. Must equal `"gbrain-plugin-v1"` for v0.15. |
| `subagents` | string | no | Subdir name (default `subagents`). Escape-attempts are rejected. |
| `description` | string | no | Shown in future `gbrain plugin list`. |
## Subagent definition files
Plain markdown with YAML frontmatter. The body is the system prompt. The
frontmatter controls runtime behavior.
Recognized frontmatter fields:
| field | type | required | notes |
|-----------------|----------|----------|-----------------------------------------------------------------------------------------|
| `name` | string | no | Subagent identifier used as `--subagent-def`. Defaults to the file basename. |
| `model` | string | no | Anthropic model id. Defaults to the handler default (sonnet). |
| `max_turns` | number | no | Cap on assistant turns. Defaults to 20. |
| `allowed_tools` | string[] | no | Whitelist of tool names. Must subset the derived brain registry. Rejected on mismatch. |
Unknown frontmatter fields are preserved but ignored by the handler. v0.16
may consume more of them.
## Caveats that will bite you
1. **Plugin definitions can't change during a run.** The loader reads the
disk once at worker startup. Editing a subagent def doesn't re-take
effect until you restart the worker. This is deliberate — live
reloads would break crash-resumable replay.
2. **`~/.gbrain/audit/subagent-jobs-*.jsonl` is local only.** If your
worker runs on a different host than the `gbrain agent logs` caller,
the CLI won't see heartbeats from that worker. v0.16 will unify this;
for now assume worker + CLI share a filesystem.
3. **Tool calls always run with `ctx.remote = true`.** Even on local CLI
invocation. Tools that gate on `remote=true` (file_upload's strict
confinement, put_page's namespace check) will apply. Good default; a
subagent definition that wants local-filesystem reach beyond the brain
can't have it.
4. **`put_page` writes are namespace-scoped.** A subagent with id 42 can
only write under `wiki/agents/42/...`. This is enforced both in the
tool schema (the slug pattern shown to the model) AND server-side in
the `put_page` operation (fail-closed if `viaSubagent=true`). Don't
try to route around it; you'll get `permission_denied`.
## Example: a downstream-OpenClaw plugin
```
~/your-openclaw/
└── gbrain-plugin/
├── gbrain.plugin.json
└── subagents/
├── meeting-ingestion.md
├── signal-detector.md
└── daily-task-prep.md
```
`~/your-openclaw/gbrain-plugin/gbrain.plugin.json`:
```json
{
"name": "your-openclaw",
"version": "2026.4.20",
"plugin_version": "gbrain-plugin-v1",
"description": "Your OpenClaw's personal-brain subagents"
}
```
Environment:
```bash
export GBRAIN_PLUGIN_PATH="$HOME/your-openclaw/gbrain-plugin"
```
Then your OpenClaw calls `gbrain agent run --subagent-def meeting-ingestion
--fanout-by transcript ...` and its definitions load automatically.
+3 -3
View File
@@ -4,8 +4,8 @@ GBrain's Minion worker ships with seven built-in handlers: `sync`,
`embed`, `lint`, `import`, `extract`, `backlinks`, `autopilot-cycle`.
These cover every background operation the gbrain CLI itself performs.
Host platforms (OpenClaw deployments, future hosts) register their own
handlers via a plugin bootstrap that imports
Host platforms (Wintermute, other OpenClaw deployments, future hosts)
register their own handlers via a plugin bootstrap that imports
`gbrain/minions`. No `handlers.json`-style data file — handlers are
code, loaded by the worker, with the same trust model as any other
code in the host's repo.
@@ -58,7 +58,7 @@ async function main() {
main().catch(err => { console.error(err); process.exit(1); });
```
Ship this as a separate binary in the host repo (e.g. `your-openclaw-worker`)
Ship this as a separate binary in the host repo (e.g. `wintermute-worker`)
or as a side-effect module that the stock `gbrain jobs work` command
auto-loads on startup (configurable via a host-provided entry point).
-191
View File
@@ -1,191 +0,0 @@
# Progress events
Canonical reference for the JSONL progress stream that `gbrain` writes to
`stderr` when a bulk command runs with `--progress-json`. Stable from
v0.15.2. Additive changes only; no renames or removals without a major
version bump.
Most humans won't read this page. Agents parsing progress will.
## When do I get these events?
Any of these commands stream events when `--progress-json` is set:
- `gbrain doctor` (DB checks, JSONB integrity, markdown body completeness,
integrity sample)
- `gbrain orphans`
- `gbrain embed`
- `gbrain files sync`
- `gbrain export`
- `gbrain extract [links|timeline|all]` (fs or db source)
- `gbrain import`
- `gbrain sync`
- `gbrain migrate --to …`
- `gbrain repair-jsonb`
- `gbrain check-backlinks`
- `gbrain lint`
- `gbrain integrity auto`
- `gbrain eval`
- `gbrain apply-migrations` (the orchestrator + every child command)
Non-bulk commands (`stats`, `graph-query`, `get`, `put`, etc.) don't emit
events — they return in under a second.
## Channel
- Progress events: **`stderr`**, one JSON object per line, `\n`-terminated.
- Data results (`--json` payloads from each command): **`stdout`**.
- Final human summaries: **`stdout`**.
Agents can safely capture stdout for their result parsing and read stderr
separately for progress.
## Flags
| Flag | Behavior |
|---|---|
| *(none)* | Auto. TTY: `\r`-rewriting single line. Non-TTY: plain line-per-event on stderr. |
| `--progress-json` | Force JSON-lines mode on stderr (this doc). |
| `--quiet` | Suppress progress entirely. Warnings and final output still print. |
| `--progress-interval=<ms>` | Override the minimum interval between tick emits (default 1000). |
Global flags: parsed by `src/core/cli-options.ts` before command dispatch,
so `gbrain --progress-json doctor` works the same as
`gbrain doctor --progress-json` (the latter also works — per-command
parsers see the flag via the shared `CliOptions` singleton).
## Event types
Every event is a single-line JSON object with these common fields:
| Field | Type | Notes |
|---|---|---|
| `event` | string | One of: `start`, `tick`, `heartbeat`, `finish`, `abort`. |
| `phase` | string | Machine-stable snake_case, dot-separated. See "Phase names" below. |
| `ts` | ISO 8601 UTC string | Event emission time. |
| `elapsed_ms` | number | Ms since the phase started. Present on `tick`/`heartbeat`/`finish`/`abort`. |
### `start`
Emitted when a phase begins.
```json
{"event":"start","phase":"doctor.db_checks","ts":"2026-04-20T12:34:56.789Z"}
{"event":"start","phase":"import.files","total":52000,"ts":"2026-04-20T12:34:56.789Z"}
```
Optional fields:
- `total` — the total item count if known at start.
### `tick`
Emitted periodically during iteration. Time- and item-gated: the reporter
won't emit more often than `minIntervalMs` (default 1000) and
`minItems` (default `max(10, ceil(total/100))`).
```json
{"event":"tick","phase":"orphans.scan","done":15000,"total":52000,"pct":28.8,"elapsed_ms":4200,"eta_ms":10300,"ts":"..."}
```
Fields:
- `done` — items completed in this phase.
- `total` — total items, if known. Omitted when the scan doesn't have a
total up front (e.g. a streaming iterator).
- `pct``done/total * 100`, one decimal. Omitted when `total` is unknown.
- `eta_ms` — projected ms until `done === total`, from the observed rate.
Omitted when `total` is unknown.
- `note` — optional string with the current item (e.g. a slug or filename).
### `heartbeat`
Emitted for long-running single operations that don't iterate
(e.g. `SELECT` against a 50K-row table). No `done`, no `total` — just a
signal that work is still happening.
```json
{"event":"heartbeat","phase":"doctor.markdown_body_completeness","note":"scanning pages for truncation…","elapsed_ms":1000,"ts":"..."}
```
### `finish`
Emitted when a phase completes normally.
```json
{"event":"finish","phase":"import.files","done":52000,"total":52000,"elapsed_ms":187000,"ts":"..."}
```
### `abort`
Emitted by a single process-level SIGINT/SIGTERM handler that tracks every
live phase. After `abort`, no further events emit for that phase.
```json
{"event":"abort","phase":"doctor.markdown_body_completeness","reason":"SIGINT","elapsed_ms":5300,"ts":"..."}
```
## Phase names
Phases use `snake_case.dot.path` naming. A fresh reporter starts at the
root; `child()` composition appends to the parent's current phase, so a
sync that calls import emits `sync.import.<file>`, not `import.<file>`.
Stable phase names shipped in v0.15.2:
- `doctor.db_checks` (umbrella for all DB-side doctor checks)
- `orphans.scan`
- `embed.pages`
- `extract.links_fs`, `extract.timeline_fs`, `extract.links_db`, `extract.timeline_db`
- `import.files`
- `sync.deletes`, `sync.renames`, `sync.imports`
- `migrate.copy_pages`, `migrate.copy_links`
- `repair_jsonb.run`, `repair_jsonb.<table>.<column>`
- `backlinks.scan`
- `lint.pages`
- `integrity.auto`
- `eval.single`, `eval.ab`
- `export.pages`
- `files.sync`
Sub-phases exposed via `child()`:
- `sync.import.files` — nested inside a sync
- `apply_migrations.v0_12_2.jsonb_repair` — nested inside the orchestrator
## Subprocess inheritance
When a parent CLI spawns `gbrain …` child processes (mostly in
`src/commands/migrations/*`), global flags (`--quiet`, `--progress-json`,
`--progress-interval`) are propagated to the child's argv via the
`childGlobalFlags()` helper in `src/core/cli-options.ts`. Child stderr
passes straight through `stdio: 'inherit'` so the event stream is one
merged JSONL feed on the parent's stderr.
One exception: the orchestrator phase in `migrations/v0_12_2.ts` that
captures child stdout (`repair-jsonb --dry-run --json` for verification)
does not pass `--progress-json` to avoid any risk of stdout pollution
breaking the orchestrator's `JSON.parse`. Its stdio is explicit:
`['ignore', 'pipe', 'inherit']` so stderr still flows through.
## Minion jobs
`gbrain jobs work` (the Minion worker daemon) keeps progress in the DB,
not on stderr. Each Minion handler that runs a bulk core (embed, sync,
extract, import, backlinks) calls `job.updateProgress({done, total,
…})` per iteration. Agents read per-job progress via the
`get_job_progress` MCP operation or `gbrain jobs get <id>`.
The `jobs work` daemon itself emits coarse one-line-per-job stderr output
for liveness only. Per-page detail lives in the DB.
## Compatibility
- **Added**: only. A new event type, a new field, a new phase name — all
safe. Agents must ignore unknown fields and unknown event types.
- **Removed/renamed**: never without a major version bump.
- **Schema changes**: announced in `CHANGELOG.md` and in
`skills/migrations/v<next>.md`.
If your agent depends on this schema and something surprises you, open
an issue with the event you received and what you expected.
-4938
View File
File diff suppressed because it is too large Load Diff
-53
View File
@@ -1,53 +0,0 @@
# GBrain
> GBrain is a personal knowledge brain and GStack mod for agent platforms. Pluggable engines (PGLite default, Postgres+pgvector for scale), contract-first operations, 26 fat-markdown skills. Teaches agents brain ops, ingestion, enrichment, scheduling, identity, and access control.
Repo: https://github.com/garrytan/gbrain
## Core entry points
- [AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/AGENTS.md): Start here if you are not Claude Code. Install order, trust boundary, skill resolver, config/debug/migration pointers.
- [CLAUDE.md](https://raw.githubusercontent.com/garrytan/gbrain/master/CLAUDE.md): Architecture reference. Key files, trust boundaries, engine factory, test layout.
- [INSTALL_FOR_AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/INSTALL_FOR_AGENTS.md): 9-step agent installation.
- [skills/RESOLVER.md](https://raw.githubusercontent.com/garrytan/gbrain/master/skills/RESOLVER.md): Skill dispatcher. Read first for any task.
- [README.md](https://raw.githubusercontent.com/garrytan/gbrain/master/README.md): Project overview, benchmarks, 30-minute setup.
## Configuration
- [docs/ENGINES.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ENGINES.md): PGLite vs Postgres trade-off and when to migrate.
- [docs/GBRAIN_RECOMMENDED_SCHEMA.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_RECOMMENDED_SCHEMA.md): MECE directory structure (people/, companies/, concepts/).
- [docs/guides/live-sync.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/live-sync.md): Incremental markdown sync setup.
- [docs/guides/cron-schedule.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/cron-schedule.md): Recurring job scheduling.
- [docs/guides/minions-deployment.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/minions-deployment.md): Deploying the gbrain jobs worker: crontab + watchdog, inline --follow, systemd/Procfile/fly.toml, upgrade checklist.
- [docs/guides/quiet-hours.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/quiet-hours.md): Notification hold + timezone-aware delivery.
- [docs/mcp/DEPLOY.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/mcp/DEPLOY.md): MCP server deployment.
## Debugging
- [docs/GBRAIN_VERIFY.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/GBRAIN_VERIFY.md): 7-check post-setup verification. Start here when something feels off.
- [docs/guides/minions-fix.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/guides/minions-fix.md): Troubleshooting the Minions job queue.
- [docs/integrations/reliability-repair.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/integrations/reliability-repair.md): Data integrity recovery.
## Migrations
- [docs/UPGRADING_DOWNSTREAM_AGENTS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/UPGRADING_DOWNSTREAM_AGENTS.md): Patches for downstream agent skill forks. One section per release.
- [skills/migrations/](https://raw.githubusercontent.com/garrytan/gbrain/master/skills/migrations/): Per-version (v0.5.0 - v0.14.1) agent-executable migration instructions.
- [CHANGELOG.md](https://raw.githubusercontent.com/garrytan/gbrain/master/CHANGELOG.md): Release-summary voice + itemized changes + self-repair block per version.
## Philosophy
- [docs/ethos/THIN_HARNESS_FAT_SKILLS.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ethos/THIN_HARNESS_FAT_SKILLS.md): Why skills live in markdown.
- [docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md): Homebrew for Personal AI.
## Optional
- [docs/benchmarks/](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/benchmarks/): Retrieval quality benchmarks.
- [docs/designs/](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/designs/): Forward-looking designs.
- [docs/architecture/infra-layer.md](https://raw.githubusercontent.com/garrytan/gbrain/master/docs/architecture/infra-layer.md): Shared infra patterns.
## Operational tips
- `gbrain doctor [--json] [--fast] [--fix]` - built-in health checks.
- `gbrain orphans [--json]` - pages with zero inbound wikilinks.
- `gbrain repair-jsonb [--dry-run]` - repair v0.12.0 double-encoded JSONB rows.
- `gbrain upgrade` runs post-upgrade + apply-migrations.
-2
View File
@@ -1,2 +0,0 @@
dist/
node_modules/
File diff suppressed because it is too large Load Diff
-37
View File
@@ -1,37 +0,0 @@
{
"id": "gbrain",
"name": "GBrain",
"version": "0.1.0",
"description": "Personal knowledge brain for OpenClaw — semantic search, entity resolution, relationship graph, and enrichment for markdown repos",
"main": "dist/index.js",
"configSchema": {
"type": "object",
"properties": {
"databaseUrl": {
"type": "string",
"description": "PostgreSQL connection URL (with pgvector extension)",
"uiHints": { "sensitive": true }
},
"brainPath": {
"type": "string",
"description": "Path to the markdown knowledge repository",
"default": "./brain"
},
"openaiApiKey": {
"type": "string",
"description": "OpenAI API key for embeddings (falls back to OPENAI_API_KEY env)",
"uiHints": { "sensitive": true }
},
"autoSync": {
"type": "boolean",
"description": "Watch brainPath for git changes and auto-reindex",
"default": true
},
"syncIntervalSeconds": {
"type": "number",
"description": "Poll interval for git HEAD changes",
"default": 30
}
}
}
}
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@garrytan/openclaw-gbrain",
"version": "0.1.0",
"description": "GBrain knowledge brain plugin for OpenClaw — semantic search, entity resolution, relationship graph for markdown repos",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --target=node22 --format=esm --outfile=dist/index.js --external:openclaw --external:postgres --external:openai --external:@electric-sql/pglite --external:pgvector --external:gray-matter --external:marked --external:@anthropic-ai/sdk --external:@aws-sdk/client-s3 --external:@modelcontextprotocol/sdk --minify-whitespace",
"typecheck": "tsc --noEmit"
},
"files": [
"dist/",
"openclaw.plugin.json",
"README.md"
],
"dependencies": {
"@sinclair/typebox": "^0.34.0"
},
"devDependencies": {
"esbuild": "^0.28.0",
"typescript": "^5.7.0"
},
"peerDependencies": {
"openclaw": "*"
},
"publishConfig": {
"access": "public"
},
"openclaw": {
"extensions": ["./dist/index.js"]
},
"license": "MIT",
"author": "Garry Tan"
}
-59
View File
@@ -1,59 +0,0 @@
/**
* CLI commands registered under /gbrain.
* Uses OpenClaw's CLI context (ctx.program is a Commander instance).
*/
import { getEngine } from './engine-host.js';
export function registerGBrainCli() {
return (ctx: { program: any; config: any; logger: any }) => {
const { program } = ctx;
program
.command('gbrain')
.description('GBrain status — page count, health score, last sync')
.action(async () => {
try {
const engine = getEngine();
const stats = await engine.getStats();
const health = await engine.getHealth();
console.log(
`GBrain: ${stats.page_count} pages | Score: ${health.brain_score}/100 | Stale: ${health.stale_pages}`,
);
} catch (e) {
console.error('GBrain not connected:', (e as Error).message);
}
});
program
.command('gbrain-sync')
.description('Trigger manual brain sync')
.action(async () => {
console.log('Manual sync triggered (use gbrain CLI for full sync)');
});
program
.command('gbrain-doctor')
.description('Brain health check')
.action(async () => {
try {
const engine = getEngine();
const health = await engine.getHealth();
console.log(`Brain Score: ${health.brain_score}/100`);
console.log(` Embed coverage: ${health.embed_coverage_score}/35`);
console.log(` Link density: ${health.link_density_score}/25`);
console.log(` Timeline: ${health.timeline_coverage_score}/15`);
console.log(` No orphans: ${health.no_orphans_score}/15`);
console.log(` No dead links: ${health.no_dead_links_score}/10`);
if (health.stale_pages > 0) {
console.log(`\n⚠ ${health.stale_pages} stale pages need re-embedding`);
}
if (health.dead_links > 0) {
console.log(`${health.dead_links} dead links found`);
}
} catch (e) {
console.error('GBrain not connected:', (e as Error).message);
}
});
};
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Plugin config resolution. Merges OpenClaw plugin config with env vars.
*/
export interface GBrainPluginConfig {
databaseUrl: string;
brainPath: string;
openaiApiKey: string;
autoSync: boolean;
syncIntervalSeconds: number;
}
export function resolveConfig(raw: Record<string, unknown>): GBrainPluginConfig {
const databaseUrl =
(raw['databaseUrl'] as string) ||
process.env['DATABASE_URL'] ||
process.env['GBRAIN_DATABASE_URL'] ||
'';
const brainPath =
(raw['brainPath'] as string) ||
process.env['GBRAIN_BRAIN_PATH'] ||
'./brain';
const openaiApiKey =
(raw['openaiApiKey'] as string) ||
process.env['OPENAI_API_KEY'] ||
'';
const autoSync = raw['autoSync'] !== false;
const syncIntervalSeconds =
typeof raw['syncIntervalSeconds'] === 'number'
? raw['syncIntervalSeconds']
: 30;
return { databaseUrl, brainPath, openaiApiKey, autoSync, syncIntervalSeconds };
}
-44
View File
@@ -1,44 +0,0 @@
/**
* Engine host manages the singleton BrainEngine lifecycle.
*/
import type { BrainEngine } from '../../src/core/engine.ts';
import { createEngine } from '../../src/core/engine-factory.ts';
import type { GBrainPluginConfig } from './config.js';
let engine: BrainEngine | null = null;
let config: GBrainPluginConfig | null = null;
export async function initEngine(cfg: GBrainPluginConfig): Promise<BrainEngine> {
config = cfg;
engine = await createEngine({
database_url: cfg.databaseUrl,
engine: 'postgres',
});
await engine.connect({ database_url: cfg.databaseUrl, engine: 'postgres' });
return engine;
}
export function getEngine(): BrainEngine {
if (!engine) {
throw new Error(
'GBrain engine not initialized. Ensure the gbrain plugin service is running ' +
'and databaseUrl is configured.',
);
}
return engine;
}
export function getConfig(): GBrainPluginConfig {
if (!config) {
throw new Error('GBrain plugin config not initialized.');
}
return config;
}
export async function shutdownEngine(): Promise<void> {
if (engine) {
await engine.disconnect();
engine = null;
}
}
-135
View File
@@ -1,135 +0,0 @@
/**
* GBrain OpenClaw Plugin native tool registration for personal knowledge brains.
*
* Registers 7 tools that agents discover automatically:
* gbrain_search Hybrid search (keyword + semantic via RRF)
* gbrain_get Direct page read by slug
* gbrain_resolve Entity resolution (name page)
* gbrain_graph Relationship traversal
* gbrain_timeline Temporal queries
* gbrain_ingest Create/update brain pages
* gbrain_stats Brain health and statistics
*
* Plus a background service for engine lifecycle and /gbrain CLI commands.
*/
import { definePluginEntry } from 'openclaw/plugin-sdk/plugin-entry';
import { resolveConfig } from './config.js';
import { createSyncService } from './service.js';
import { registerGBrainCli } from './cli.js';
// Tool schemas and executors
import { gbrainSearchSchema, executeSearch } from './tools/search.js';
import { gbrainGetSchema, executeGet } from './tools/get.js';
import { gbrainResolveSchema, executeResolve } from './tools/resolve.js';
import { gbrainGraphSchema, executeGraph } from './tools/graph.js';
import { gbrainTimelineSchema, executeTimeline } from './tools/timeline.js';
import { gbrainIngestSchema, executeIngest } from './tools/ingest.js';
import { gbrainStatsSchema, executeStats } from './tools/stats.js';
export default definePluginEntry({
id: 'gbrain',
name: 'GBrain',
description:
'Personal knowledge brain — semantic search, entity resolution, ' +
'relationship graph, and enrichment for markdown repos',
register(api) {
const config = resolveConfig(api.config as Record<string, unknown>);
// ── Tools ────────────────────────────────────────────────────────────
api.registerTool({
name: 'gbrain_search',
label: 'GBrain Search',
description:
'Search the knowledge brain using hybrid semantic + keyword search. ' +
'Returns ranked page excerpts with source paths. Use for any question about ' +
'people, companies, deals, meetings, projects, or concepts in the brain.',
parameters: gbrainSearchSchema,
async execute(_toolCallId: string, params: any) {
return executeSearch(params as Record<string, unknown>);
},
} as any);
api.registerTool({
name: 'gbrain_get',
label: 'GBrain Get',
description:
'Read a brain page by its slug. Returns the full compiled truth section, ' +
'optionally with timeline entries and link/backlink graph.',
parameters: gbrainGetSchema,
async execute(_toolCallId: string, params: any) {
return executeGet(params as Record<string, unknown>);
},
} as any);
api.registerTool({
name: 'gbrain_resolve',
label: 'GBrain Resolve',
description:
'Resolve a name, company, or reference to its brain page. ' +
'Uses exact slug match → keyword search cascade.',
parameters: gbrainResolveSchema,
async execute(_toolCallId: string, params: any) {
return executeResolve(params as Record<string, unknown>);
},
} as any);
api.registerTool({
name: 'gbrain_graph',
label: 'GBrain Graph',
description:
'Traverse entity relationships in the knowledge brain. Returns connected ' +
'pages with relationship types and context.',
parameters: gbrainGraphSchema,
async execute(_toolCallId: string, params: any) {
return executeGraph(params as Record<string, unknown>);
},
} as any);
api.registerTool({
name: 'gbrain_timeline',
label: 'GBrain Timeline',
description:
'Query temporal changes for a brain entity. Returns dated timeline entries. ' +
'Supports relative dates like "7d", "30d".',
parameters: gbrainTimelineSchema,
async execute(_toolCallId: string, params: any) {
return executeTimeline(params as Record<string, unknown>);
},
} as any);
api.registerTool({
name: 'gbrain_ingest',
label: 'GBrain Ingest',
description:
'Create or update a brain page with automatic re-indexing. Can create new ' +
'pages, prepend timeline entries, or replace compiled truth sections.',
parameters: gbrainIngestSchema,
async execute(_toolCallId: string, params: any) {
return executeIngest(params as Record<string, unknown>);
},
} as any);
api.registerTool({
name: 'gbrain_stats',
label: 'GBrain Stats',
description:
'Get brain health statistics — page count, embed coverage, link density, ' +
'brain score, most connected entities, and orphan/stale page counts.',
parameters: gbrainStatsSchema,
async execute(_toolCallId: string, _params: any) {
return executeStats();
},
} as any);
// ── Background Service ──────────────────────────────────────────────
api.registerService(createSyncService(config) as any);
// ── CLI ─────────────────────────────────────────────────────────────
api.registerCli(registerGBrainCli() as any, { commands: ['gbrain', 'gbrain-sync', 'gbrain-doctor'] });
},
});
-73
View File
@@ -1,73 +0,0 @@
/**
* Background service manages engine lifecycle and periodic sync.
*/
import { initEngine, shutdownEngine } from './engine-host.js';
import type { GBrainPluginConfig } from './config.js';
import { readFileSync, existsSync } from 'fs';
import { join } from 'path';
let syncTimer: ReturnType<typeof setInterval> | null = null;
let lastGitHead: string | null = null;
function readGitHead(brainPath: string): string | null {
try {
const headPath = join(brainPath, '.git', 'HEAD');
if (!existsSync(headPath)) return null;
const head = readFileSync(headPath, 'utf-8').trim();
if (head.startsWith('ref: ')) {
const refPath = join(brainPath, '.git', head.slice(5));
if (existsSync(refPath)) {
return readFileSync(refPath, 'utf-8').trim();
}
}
return head;
} catch {
return null;
}
}
export function createSyncService(config: GBrainPluginConfig) {
return {
id: 'gbrain-sync',
async start() {
if (!config.databaseUrl) {
console.error('[gbrain] No databaseUrl configured — skipping engine init');
return;
}
await initEngine(config);
console.error(`[gbrain] Engine connected. Brain path: ${config.brainPath}`);
lastGitHead = readGitHead(config.brainPath);
if (config.autoSync) {
syncTimer = setInterval(async () => {
try {
const currentHead = readGitHead(config.brainPath);
if (currentHead && currentHead !== lastGitHead) {
console.error(
`[gbrain] Git HEAD changed (${lastGitHead?.slice(0, 8)}${currentHead.slice(0, 8)}), sync needed`,
);
lastGitHead = currentHead;
// Full sync integration uses the existing gbrain sync pipeline
// For now we detect changes; sync orchestration is a follow-up
}
} catch (e) {
console.error('[gbrain] Sync check error:', e);
}
}, config.syncIntervalSeconds * 1000);
}
},
async stop() {
if (syncTimer) {
clearInterval(syncTimer);
syncTimer = null;
}
await shutdownEngine();
console.error('[gbrain] Engine disconnected.');
},
};
}
-16
View File
@@ -1,16 +0,0 @@
/**
* Shared tool result helpers. OpenClaw's AgentToolResult requires
* both `content` (text/image blocks) and `details` (structured data).
*/
export function textResult(text: string, details: Record<string, unknown> = {}) {
return {
content: [{ type: 'text' as const, text }],
details,
};
}
export function truncate(s: string, max: number): string {
if (s.length <= max) return s;
return s.slice(0, max).replace(/\s\S*$/, '') + '…';
}
-74
View File
@@ -1,74 +0,0 @@
/**
* gbrain_get Direct page read by slug.
*/
import { Type } from '@sinclair/typebox';
import { getEngine } from '../engine-host.js';
import { textResult } from '../tool-result.js';
export const gbrainGetSchema = Type.Object({
slug: Type.String({ description: 'Page slug (e.g. "people/garry-tan", "companies/brex")' }),
includeTimeline: Type.Optional(
Type.Boolean({ default: false, description: 'Include timeline entries' }),
),
includeLinks: Type.Optional(
Type.Boolean({ default: false, description: 'Include links and backlinks' }),
),
});
export async function executeGet(params: Record<string, unknown>) {
const engine = getEngine();
const slug = params['slug'] as string;
const includeTimeline = params['includeTimeline'] as boolean ?? false;
const includeLinks = params['includeLinks'] as boolean ?? false;
const page = await engine.getPage(slug);
if (!page) {
const candidates = await engine.resolveSlugs(slug);
if (candidates.length > 0) {
return textResult(
`Page "${slug}" not found. Did you mean:\n` +
candidates.slice(0, 5).map(c => ` - ${c}`).join('\n'),
);
}
return textResult(`Page "${slug}" not found.`);
}
const lines: string[] = [];
lines.push(`# ${page.title}`);
lines.push(`slug: ${page.slug} | type: ${page.type} | updated: ${page.updated_at.toISOString()}`);
lines.push('');
lines.push(page.compiled_truth);
if (includeTimeline) {
const timeline = await engine.getTimeline(slug);
if (timeline.length > 0) {
lines.push('\n---\n## Timeline\n');
for (const t of timeline) {
lines.push(`**${t.date}** (${t.source}): ${t.summary}`);
if (t.detail) lines.push(` ${t.detail}`);
}
}
}
if (includeLinks) {
const [links, backlinks] = await Promise.all([
engine.getLinks(slug),
engine.getBacklinks(slug),
]);
if (links.length > 0) {
lines.push('\n## Links (outgoing)\n');
for (const l of links) {
lines.push(`- → ${l.to_slug} [${l.link_type}]${l.context ? `${l.context}` : ''}`);
}
}
if (backlinks.length > 0) {
lines.push('\n## Backlinks (incoming)\n');
for (const l of backlinks) {
lines.push(`- ← ${l.from_slug} [${l.link_type}]${l.context ? `${l.context}` : ''}`);
}
}
}
return textResult(lines.join('\n'), { slug: page.slug, type: page.type });
}
-110
View File
@@ -1,110 +0,0 @@
/**
* gbrain_graph Relationship traversal.
*/
import { Type } from '@sinclair/typebox';
import { getEngine } from '../engine-host.js';
import { textResult } from '../tool-result.js';
export const gbrainGraphSchema = Type.Object({
entity: Type.String({
description: 'Entity slug or name to start from (e.g. "people/garry-tan", "Brex")',
}),
direction: Type.Optional(
Type.Union(
[Type.Literal('outgoing'), Type.Literal('incoming'), Type.Literal('both')],
{ default: 'both', description: 'Edge direction to traverse' },
),
),
depth: Type.Optional(
Type.Number({ default: 1, minimum: 1, maximum: 3, description: 'Traversal depth (1-3)' }),
),
});
export async function executeGraph(params: Record<string, unknown>) {
const engine = getEngine();
const entity = params['entity'] as string;
const direction = (params['direction'] as string) ?? 'both';
const depth = (params['depth'] as number) ?? 1;
// Resolve entity to slug
let slug = entity;
let rootPage = await engine.getPage(slug);
if (!rootPage) {
const candidates = await engine.resolveSlugs(slug.toLowerCase().replace(/\s+/g, '-'));
if (candidates.length > 0) {
slug = candidates[0];
rootPage = await engine.getPage(slug);
}
if (!rootPage) {
const search = await engine.searchKeyword(entity, { limit: 1 });
if (search.length > 0) {
slug = search[0].slug;
rootPage = await engine.getPage(slug);
}
}
}
if (!rootPage) return textResult(`No entity found matching "${entity}".`);
const lines: string[] = [];
lines.push(`## ${rootPage.title} (${rootPage.type})`);
lines.push(`slug: ${rootPage.slug}\n`);
const visited = new Set<string>([slug]);
const edges: Array<{ from: string; to: string; type: string; context: string; depth: number }> = [];
await traverse(engine, slug, direction, depth, 1, visited, edges);
if (edges.length === 0) {
lines.push('No connected entities found.');
} else {
lines.push(`Found ${edges.length} connection(s):\n`);
for (const e of edges) {
const arrow = e.from === slug ? '→' : '←';
const other = e.from === slug ? e.to : e.from;
lines.push(`- ${arrow} **${other}** [${e.type}]${e.context ? `${e.context}` : ''} (depth ${e.depth})`);
}
}
return textResult(lines.join('\n'), { edgeCount: edges.length });
}
async function traverse(
engine: ReturnType<typeof getEngine>,
slug: string,
direction: string,
maxDepth: number,
currentDepth: number,
visited: Set<string>,
edges: Array<{ from: string; to: string; type: string; context: string; depth: number }>,
) {
if (currentDepth > maxDepth) return;
const [outgoing, incoming] = await Promise.all([
(direction === 'outgoing' || direction === 'both') ? engine.getLinks(slug) : Promise.resolve([]),
(direction === 'incoming' || direction === 'both') ? engine.getBacklinks(slug) : Promise.resolve([]),
]);
const nextSlugs: string[] = [];
for (const link of outgoing) {
edges.push({ from: link.from_slug, to: link.to_slug, type: link.link_type, context: link.context, depth: currentDepth });
if (!visited.has(link.to_slug)) {
visited.add(link.to_slug);
nextSlugs.push(link.to_slug);
}
}
for (const link of incoming) {
edges.push({ from: link.from_slug, to: link.to_slug, type: link.link_type, context: link.context, depth: currentDepth });
if (!visited.has(link.from_slug)) {
visited.add(link.from_slug);
nextSlugs.push(link.from_slug);
}
}
for (const next of nextSlugs) {
await traverse(engine, next, direction, maxDepth, currentDepth + 1, visited, edges);
}
}
-98
View File
@@ -1,98 +0,0 @@
/**
* gbrain_ingest Create or update brain pages with automatic re-indexing.
*/
import { Type } from '@sinclair/typebox';
import { getEngine, getConfig } from '../engine-host.js';
import { parseMarkdown, serializeMarkdown } from '../../../src/core/markdown.ts';
import type { PageType } from '../../../src/core/types.ts';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { textResult } from '../tool-result.js';
export const gbrainIngestSchema = Type.Object({
slug: Type.String({
description: 'Brain-relative slug (e.g. "people/new-person", "companies/acme")',
}),
content: Type.Optional(
Type.String({ description: 'Full page markdown content (for new pages)' }),
),
timelineEntry: Type.Optional(
Type.String({ description: 'Text to prepend as a new timeline entry (date auto-added)' }),
),
compiledTruthUpdate: Type.Optional(
Type.String({ description: 'New compiled truth body (replaces compiled truth section)' }),
),
});
export async function executeIngest(params: Record<string, unknown>) {
const engine = getEngine();
const config = getConfig();
const slug = params['slug'] as string;
const content = params['content'] as string | undefined;
const timelineEntry = params['timelineEntry'] as string | undefined;
const compiledTruthUpdate = params['compiledTruthUpdate'] as string | undefined;
const filePath = join(config.brainPath, `${slug}.md`);
const actions: string[] = [];
if (content) {
// New page — write full content
const dir = dirname(filePath);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(filePath, content, 'utf-8');
actions.push('created');
// Index the new page
const parsed = parseMarkdown(content, filePath);
await engine.putPage(slug, {
type: parsed.type,
title: parsed.title,
compiled_truth: parsed.compiled_truth,
timeline: parsed.timeline,
frontmatter: parsed.frontmatter,
});
actions.push('indexed');
} else if (existsSync(filePath)) {
const existing = readFileSync(filePath, 'utf-8');
const parsed = parseMarkdown(existing, filePath);
let newCompiledTruth = parsed.compiled_truth;
let newTimeline = parsed.timeline;
if (compiledTruthUpdate) {
newCompiledTruth = compiledTruthUpdate;
actions.push('compiled_truth_updated');
}
if (timelineEntry) {
const today = new Date().toISOString().split('T')[0];
const entry = `- **${today}**: ${timelineEntry}`;
newTimeline = entry + '\n' + newTimeline;
actions.push('timeline_prepended');
}
if (actions.length > 0) {
const serialized = serializeMarkdown(
parsed.frontmatter,
newCompiledTruth,
newTimeline,
{ type: parsed.type, title: parsed.title, tags: parsed.tags },
);
writeFileSync(filePath, serialized, 'utf-8');
// Re-index
await engine.putPage(slug, {
type: parsed.type,
title: parsed.title,
compiled_truth: newCompiledTruth,
timeline: newTimeline,
frontmatter: parsed.frontmatter,
});
actions.push('re-indexed');
}
} else {
return textResult(`Page "${slug}" not found and no content provided for creation.`);
}
return textResult(`Done: ${actions.join(', ')} for ${slug}`, { actions, slug });
}
-88
View File
@@ -1,88 +0,0 @@
/**
* gbrain_resolve Entity resolution (name page).
*/
import { Type } from '@sinclair/typebox';
import { getEngine } from '../engine-host.js';
import type { PageType } from '../../../src/core/types.ts';
import { textResult, truncate } from '../tool-result.js';
export const gbrainResolveSchema = Type.Object({
name: Type.String({
description: 'Entity name to resolve (e.g. "Pedro", "Brex", "the Variant deal")',
}),
type: Type.Optional(
Type.Union(
[
Type.Literal('person'),
Type.Literal('company'),
Type.Literal('deal'),
Type.Literal('meeting'),
Type.Literal('any'),
],
{ default: 'any', description: 'Expected entity type' },
),
),
});
export async function executeResolve(params: Record<string, unknown>) {
const engine = getEngine();
const name = params['name'] as string;
const type = (params['type'] as string) ?? 'any';
// 1. Try direct slug resolution
const slugCandidates = await engine.resolveSlugs(name.toLowerCase().replace(/\s+/g, '-'));
const filtered = type !== 'any'
? await filterByType(engine, slugCandidates, type as PageType)
: slugCandidates;
if (filtered.length > 0) {
const bestSlug = filtered[0];
const page = await engine.getPage(bestSlug);
if (page) {
return textResult(
`Resolved: **${page.title}**\n` +
`slug: ${page.slug} | type: ${page.type}\n\n` +
truncate(page.compiled_truth, 500),
{ slug: page.slug, type: page.type, confidence: 1.0 },
);
}
}
// 2. Fall back to keyword search
const searchOpts = type !== 'any' ? { type: type as PageType, limit: 5 } : { limit: 5 };
const searchResults = await engine.searchKeyword(name, searchOpts);
if (searchResults.length > 0) {
const best = searchResults[0];
const page = await engine.getPage(best.slug);
if (page && best.score > 0.3) {
const others = searchResults.slice(1).map(r =>
` - ${r.title} (${r.slug}, score: ${r.score.toFixed(2)})`,
);
return textResult(
`Best match: **${page.title}** (score: ${best.score.toFixed(2)})\n` +
`slug: ${page.slug} | type: ${page.type}\n\n` +
truncate(page.compiled_truth, 400) +
(others.length > 0 ? `\n\nOther candidates:\n${others.join('\n')}` : ''),
{ slug: page.slug, type: page.type, confidence: best.score },
);
}
}
return textResult(`No confident match found for "${name}".`);
}
async function filterByType(
engine: ReturnType<typeof getEngine>,
slugs: string[],
type: PageType,
): Promise<string[]> {
const result: string[] = [];
for (const slug of slugs) {
const page = await engine.getPage(slug);
if (page && page.type === type) result.push(slug);
}
return result;
}
-67
View File
@@ -1,67 +0,0 @@
/**
* gbrain_search Hybrid search (keyword + semantic via RRF fusion).
*/
import { Type } from '@sinclair/typebox';
import { getEngine } from '../engine-host.js';
import { hybridSearch } from '../../../src/core/search/hybrid.ts';
import type { SearchOpts, PageType } from '../../../src/core/types.ts';
import { textResult, truncate } from '../tool-result.js';
const PAGE_TYPES = [
'person', 'company', 'deal', 'meeting', 'project',
'yc', 'civic', 'concept', 'source', 'media',
] as const;
export const gbrainSearchSchema = Type.Object({
query: Type.String({ description: 'Natural language search query' }),
scope: Type.Optional(
Type.Union(
PAGE_TYPES.map(t => Type.Literal(t)),
{ description: 'Limit search to a specific page type' },
),
),
limit: Type.Optional(
Type.Number({ default: 10, minimum: 1, maximum: 50, description: 'Max results to return' }),
),
mode: Type.Optional(
Type.Union(
[Type.Literal('hybrid'), Type.Literal('keyword'), Type.Literal('semantic')],
{ default: 'hybrid', description: 'Search mode' },
),
),
});
export async function executeSearch(params: Record<string, unknown>) {
const engine = getEngine();
const query = params['query'] as string;
const scope = params['scope'] as PageType | undefined;
const limit = (params['limit'] as number) ?? 10;
const mode = (params['mode'] as string) ?? 'hybrid';
const opts: SearchOpts & { limit: number; type?: PageType } = { limit };
if (scope) opts.type = scope;
let results;
if (mode === 'keyword') {
results = await engine.searchKeyword(query, opts);
} else {
results = await hybridSearch(engine, query, opts);
}
if (results.length === 0) {
return textResult(`No results found for "${query}".`);
}
const lines: string[] = [];
lines.push(`Found ${results.length} result(s) for "${query}":\n`);
for (const r of results) {
lines.push(`## ${r.title}`);
lines.push(`slug: ${r.slug} | type: ${r.type} | score: ${r.score.toFixed(3)}`);
lines.push(truncate(r.chunk_text, 400));
lines.push('');
}
return textResult(lines.join('\n'), { resultCount: results.length });
}
-52
View File
@@ -1,52 +0,0 @@
/**
* gbrain_stats Brain health and statistics.
*/
import { Type } from '@sinclair/typebox';
import { getEngine } from '../engine-host.js';
import { textResult } from '../tool-result.js';
export const gbrainStatsSchema = Type.Object({});
export async function executeStats() {
const engine = getEngine();
const [stats, health] = await Promise.all([
engine.getStats(),
engine.getHealth(),
]);
const lines: string[] = [];
lines.push('## GBrain Stats\n');
lines.push(`**Pages:** ${stats.page_count}`);
lines.push(`**Chunks:** ${stats.chunk_count} (${stats.embedded_count} embedded)`);
lines.push(`**Links:** ${stats.link_count}`);
lines.push(`**Timeline entries:** ${stats.timeline_entry_count}`);
lines.push(`**Tags:** ${stats.tag_count}`);
lines.push('\n### Pages by Type\n');
for (const [type, count] of Object.entries(stats.pages_by_type).sort((a, b) => b[1] - a[1])) {
lines.push(`- ${type}: ${count}`);
}
lines.push('\n### Health\n');
lines.push(`**Brain Score:** ${health.brain_score}/100`);
lines.push(`**Embed Coverage:** ${(health.embed_coverage * 100).toFixed(1)}%`);
lines.push(`**Link Coverage:** ${(health.link_coverage * 100).toFixed(1)}%`);
lines.push(`**Timeline Coverage:** ${(health.timeline_coverage * 100).toFixed(1)}%`);
lines.push(`**Stale Pages:** ${health.stale_pages}`);
lines.push(`**Orphan Pages:** ${health.orphan_pages}`);
lines.push(`**Dead Links:** ${health.dead_links}`);
if (health.most_connected.length > 0) {
lines.push('\n### Most Connected\n');
for (const mc of health.most_connected) {
lines.push(`- ${mc.slug}: ${mc.link_count} links`);
}
}
return textResult(lines.join('\n'), {
pageCount: stats.page_count,
brainScore: health.brain_score,
});
}
-64
View File
@@ -1,64 +0,0 @@
/**
* gbrain_timeline Temporal queries for a specific entity.
*/
import { Type } from '@sinclair/typebox';
import { getEngine } from '../engine-host.js';
import { textResult } from '../tool-result.js';
export const gbrainTimelineSchema = Type.Object({
slug: Type.String({
description: 'Page slug to get timeline for (e.g. "people/garry-tan", "companies/brex")',
}),
since: Type.Optional(
Type.String({ description: 'Only entries after this date (ISO or "7d", "30d")' }),
),
until: Type.Optional(
Type.String({ description: 'Only entries before this date (ISO)' }),
),
limit: Type.Optional(
Type.Number({ default: 20, minimum: 1, maximum: 100, description: 'Max entries to return' }),
),
});
export async function executeTimeline(params: Record<string, unknown>) {
const engine = getEngine();
const slug = params['slug'] as string;
const since = params['since'] as string | undefined;
const until = params['until'] as string | undefined;
const limit = (params['limit'] as number) ?? 20;
const page = await engine.getPage(slug);
if (!page) return textResult(`Page "${slug}" not found.`);
const after = since ? resolveDate(since) : undefined;
const before = until ? resolveDate(until) : undefined;
const timeline = await engine.getTimeline(slug, { limit, after, before });
if (timeline.length === 0) {
return textResult(`No timeline entries found for "${page.title}".`);
}
const lines: string[] = [];
lines.push(`## Timeline: ${page.title}`);
lines.push(`${timeline.length} entries${after ? ` since ${after}` : ''}${before ? ` until ${before}` : ''}:\n`);
for (const t of timeline) {
lines.push(`**${t.date}** (${t.source}): ${t.summary}`);
if (t.detail) lines.push(` ${t.detail}`);
}
return textResult(lines.join('\n'), { entryCount: timeline.length });
}
function resolveDate(input: string): string {
const relMatch = input.match(/^(\d+)d$/);
if (relMatch) {
const days = parseInt(relMatch[1], 10);
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().split('T')[0];
}
return input;
}
-18
View File
@@ -1,18 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"declaration": false,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts"],
"exclude": ["dist", "node_modules"]
}
+6 -13
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.16.4",
"version": "0.13.1",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
@@ -20,13 +20,10 @@
"build": "bun build --compile --outfile bin/gbrain src/cli.ts",
"build:all": "bun build --compile --target=bun-darwin-arm64 --outfile bin/gbrain-darwin-arm64 src/cli.ts && bun build --compile --target=bun-linux-x64 --outfile bin/gbrain-linux-x64 src/cli.ts",
"build:schema": "bash scripts/build-schema.sh",
"build:llms": "bun run scripts/build-llms.ts",
"test": "scripts/check-jsonb-pattern.sh && scripts/check-progress-to-stdout.sh && bun run typecheck && bun test",
"test:e2e": "bash scripts/run-e2e.sh",
"typecheck": "tsc --noEmit",
"test": "scripts/check-jsonb-pattern.sh && bun test",
"test:e2e": "bun test test/e2e/",
"check:jsonb": "scripts/check-jsonb-pattern.sh",
"check:progress": "scripts/check-progress-to-stdout.sh",
"postinstall": "command -v gbrain >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive || echo '[gbrain] postinstall skipped. If installed via bun install -g github:...: run `gbrain doctor` and `gbrain apply-migrations --yes` manually. See https://github.com/garrytan/gbrain/issues/218' 1>&2",
"postinstall": "gbrain --version >/dev/null 2>&1 && gbrain apply-migrations --yes --non-interactive 2>/dev/null || true",
"prepublish:clawhub": "bun run build:all",
"publish:clawhub": "clawhub package publish . --family bundle-plugin"
},
@@ -38,7 +35,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.30.0",
"@aws-sdk/client-s3": "^3.1028.0",
"@electric-sql/pglite": "0.4.3",
"@electric-sql/pglite": "^0.4.4",
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"marked": "^18.0.0",
@@ -47,11 +44,7 @@
"postgres": "^3.4.0"
},
"devDependencies": {
"@types/bun": "latest",
"typescript": "^5.6.0"
"@types/bun": "latest"
},
"trustedDependencies": [
"@electric-sql/pglite"
],
"license": "MIT"
}
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env bun
/**
* build-llms generate llms.txt + llms-full.txt from scripts/llms-config.ts.
*
* Run: `bun run build:llms` (or `bun run scripts/build-llms.ts`).
*
* Outputs:
* - llms.txt llmstxt.org-spec index (H1 / blockquote / H2 sections).
* - llms-full.txt concatenated full content of non-optional entries.
*
* Deterministic: no timestamps, sorted within categories by config order.
* Warns (does not fail) if llms-full.txt exceeds FULL_SIZE_BUDGET. CI catches
* drift via test/build-llms.test.ts.
*
* Fork override: set LLMS_REPO_BASE to regenerate with a different URL base.
*/
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
FULL_SIZE_BUDGET,
INLINE_TIPS,
PROJECT,
SECTIONS,
type DocEntry,
type DocSection,
} from "./llms-config";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function urlFor(entry: DocEntry): string {
return `${PROJECT.rawBaseUrl}/${entry.path}`;
}
function isDirectoryPath(path: string): boolean {
return path.endsWith("/");
}
function renderLlmsTxt(): string {
const lines: string[] = [];
lines.push(`# ${PROJECT.name}`);
lines.push("");
lines.push(`> ${PROJECT.summary}`);
lines.push("");
lines.push(`Repo: ${PROJECT.repoUrl}`);
lines.push("");
for (const section of SECTIONS) {
lines.push(`## ${section.heading}`);
lines.push("");
for (const entry of section.entries) {
lines.push(
`- [${entry.title}](${urlFor(entry)}): ${entry.description}`,
);
}
lines.push("");
}
lines.push("## Operational tips");
lines.push("");
for (const tip of INLINE_TIPS) {
lines.push(`- ${tip}`);
}
lines.push("");
return lines.join("\n");
}
function renderLlmsFullTxt(): { content: string; sizes: Array<{ path: string; bytes: number }> } {
const lines: string[] = [];
const sizes: Array<{ path: string; bytes: number }> = [];
lines.push(`# ${PROJECT.name} — Full Context`);
lines.push("");
lines.push(`> ${PROJECT.summary}`);
lines.push("");
lines.push(
`This file concatenates core GBrain documentation for single-fetch ingestion.`,
);
lines.push(
`For the link-only index, see \`llms.txt\`. Source of truth: ${PROJECT.repoUrl}.`,
);
lines.push("");
for (const section of SECTIONS) {
if (section.optional) continue;
lines.push(`# ${section.heading}`);
lines.push("");
for (const entry of section.entries) {
if (entry.includeInFull === false) continue;
if (isDirectoryPath(entry.path)) continue;
const absPath = join(repoRoot, entry.path);
if (!existsSync(absPath)) {
// build-llms won't silently skip — surface the problem. Test case 1
// catches this too, but fail fast for manual runs.
throw new Error(
`llms-config references missing file: ${entry.path}`,
);
}
const body = readFileSync(absPath, "utf8");
const bytes = Buffer.byteLength(body, "utf8");
sizes.push({ path: entry.path, bytes });
lines.push(`## ${entry.path}`);
lines.push("");
lines.push(`Source: ${urlFor(entry)}`);
lines.push("");
lines.push(body.trimEnd());
lines.push("");
lines.push("---");
lines.push("");
}
}
return { content: lines.join("\n"), sizes };
}
function validateConfig(): void {
for (const section of SECTIONS) {
for (const entry of section.entries) {
const absPath = join(repoRoot, entry.path);
if (!existsSync(absPath)) {
throw new Error(
`llms-config references missing path: ${entry.path}`,
);
}
const st = statSync(absPath);
if (isDirectoryPath(entry.path) && !st.isDirectory()) {
throw new Error(
`llms-config path ends with '/' but is a file: ${entry.path}`,
);
}
if (!isDirectoryPath(entry.path) && !st.isFile()) {
throw new Error(
`llms-config path is a directory but missing trailing '/': ${entry.path}`,
);
}
}
}
}
export function buildLlmsFiles(): {
llmsTxt: string;
llmsFullTxt: string;
sizes: Array<{ path: string; bytes: number }>;
} {
validateConfig();
const llmsTxt = renderLlmsTxt();
const { content: llmsFullTxt, sizes } = renderLlmsFullTxt();
return { llmsTxt, llmsFullTxt, sizes };
}
function main(): void {
const { llmsTxt, llmsFullTxt, sizes } = buildLlmsFiles();
const llmsPath = join(repoRoot, "llms.txt");
const llmsFullPath = join(repoRoot, "llms-full.txt");
writeFileSync(llmsPath, llmsTxt);
writeFileSync(llmsFullPath, llmsFullTxt);
const fullBytes = Buffer.byteLength(llmsFullTxt, "utf8");
console.log(`wrote ${llmsPath} (${Buffer.byteLength(llmsTxt, "utf8")} bytes)`);
console.log(`wrote ${llmsFullPath} (${fullBytes} bytes)`);
if (fullBytes > FULL_SIZE_BUDGET) {
console.warn("");
console.warn(
`WARN: llms-full.txt (${fullBytes} bytes) exceeds FULL_SIZE_BUDGET (${FULL_SIZE_BUDGET} bytes).`,
);
console.warn(
"Add `includeInFull: false` to the biggest entries in scripts/llms-config.ts:",
);
const sorted = [...sizes].sort((a, b) => b.bytes - a.bytes);
for (const entry of sorted.slice(0, 5)) {
console.warn(` ${entry.bytes} bytes ${entry.path}`);
}
}
}
const isMainModule = fileURLToPath(import.meta.url) === process.argv[1];
if (isMainModule) {
try {
main();
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}
-14
View File
@@ -30,17 +30,3 @@ if grep -rEn "$PATTERN" src/ 2>/dev/null; then
fi
echo "OK: no JSON.stringify(x)::jsonb interpolation pattern in src/"
# v0.13.1 #219: guard against max_stalled DEFAULT 1 regressing in any schema
# source file. DEFAULT 1 dead-lettered any SIGKILL'd job on first stall, making
# the "10/10 rescued" claim false for out-of-the-box users. Default is 5 now.
MAX_STALLED_PATTERN='max_stalled\s+INTEGER\s+NOT\s+NULL\s+DEFAULT\s+1\b'
if grep -rEn "$MAX_STALLED_PATTERN" src/schema.sql src/core/migrate.ts src/core/pglite-schema.ts src/core/schema-embedded.ts 2>/dev/null; then
echo
echo "ERROR: max_stalled DEFAULT 1 reintroduced in schema."
echo " Must be DEFAULT 5 to preserve SIGKILL-rescue guarantee. See #219."
exit 1
fi
echo "OK: max_stalled defaults are 5 in all schema sources"
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
# CI guard: fail if any new code emits \r-progress to stdout.
#
# Since v0.14.2, bulk-action progress lives on stderr via the shared
# src/core/progress.ts reporter. \r-rewriting on stdout breaks every
# piped-output scenario: agents that capture stdout for structured
# results see progress garbage mixed with the data, and CI logs show
# a single line per command because everything after the last \r
# is truncated by the terminal emulator when played back.
#
# This script greps for the anti-pattern. Legitimate uses of \r inside
# string literals (e.g. Windows line-ending normalization, regex
# patterns) are expected to contain \r without being preceded by
# `process.stdout.write`. We match the full write-call form only.
#
# Usage: scripts/check-progress-to-stdout.sh
# Exit: 0 when clean, 1 when a banned pattern is found.
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cd "$ROOT"
# The banned pattern: process.stdout.write('\r... or process.stdout.write("\r...
# Greedy quote character class so both quote styles match.
PATTERN="process\.stdout\.write\([\`'\"]\\\\r"
# Files allowed to use this pattern historically. Empty allowlist — the point
# of v0.14.2 was to remove every one of them. Add entries only if you really
# need a \r on stdout (if so, add the rationale as a comment at the call site
# and list the file here).
ALLOWLIST=()
matches=""
if command -v rg >/dev/null 2>&1; then
matches="$(rg -n --no-heading "$PATTERN" src/ 2>/dev/null || true)"
else
matches="$(grep -rEn "$PATTERN" src/ 2>/dev/null || true)"
fi
if [ -n "$matches" ]; then
# Filter out allowlisted files.
filtered="$matches"
for f in "${ALLOWLIST[@]:-}"; do
[ -z "$f" ] && continue
filtered="$(echo "$filtered" | grep -v "^${f}:" || true)"
done
if [ -n "$filtered" ]; then
echo "ERROR: found process.stdout.write('\\r…') pattern(s) in src/:"
echo
echo "$filtered"
echo
echo "Bulk-action progress must go through src/core/progress.ts"
echo "(writes to stderr, handles TTY vs non-TTY, honors --quiet /"
echo " --progress-json / --progress-interval). If you genuinely"
echo "need a \\r on stdout, add the file to the ALLOWLIST at the"
echo "top of this script and explain why at the call site."
exit 1
fi
fi
echo "check-progress-to-stdout: OK (no banned stdout \\r patterns)"
-211
View File
@@ -1,211 +0,0 @@
/**
* llms-config single source of truth for llms.txt + llms-full.txt.
*
* Consumed by scripts/build-llms.ts (emits llms.txt, llms-full.txt) and
* test/build-llms.test.ts (asserts paths resolve, content contract holds).
*
* Adding a doc? Add it here and run `bun run build:llms`. The drift-detection
* test fails CI if you forget.
*
* Fork-friendliness: `rawBaseUrl` reads from `LLMS_REPO_BASE` so forks can
* regenerate without manual URL rewrites:
* LLMS_REPO_BASE=https://raw.githubusercontent.com/fork-org/gbrain/main bun run build:llms
*/
export type DocEntry = {
title: string;
description: string;
path: string;
includeInFull?: boolean;
};
export type DocSection = {
heading: string;
optional?: boolean;
entries: DocEntry[];
};
export const PROJECT = {
name: "GBrain",
summary:
"GBrain is a personal knowledge brain and GStack mod for agent platforms. Pluggable engines (PGLite default, Postgres+pgvector for scale), contract-first operations, 26 fat-markdown skills. Teaches agents brain ops, ingestion, enrichment, scheduling, identity, and access control.",
repoUrl: "https://github.com/garrytan/gbrain",
rawBaseUrl:
process.env.LLMS_REPO_BASE ??
"https://raw.githubusercontent.com/garrytan/gbrain/master",
};
export const SECTIONS: DocSection[] = [
{
heading: "Core entry points",
entries: [
{
title: "AGENTS.md",
description:
"Start here if you are not Claude Code. Install order, trust boundary, skill resolver, config/debug/migration pointers.",
path: "AGENTS.md",
},
{
title: "CLAUDE.md",
description:
"Architecture reference. Key files, trust boundaries, engine factory, test layout.",
path: "CLAUDE.md",
},
{
title: "INSTALL_FOR_AGENTS.md",
description: "9-step agent installation.",
path: "INSTALL_FOR_AGENTS.md",
},
{
title: "skills/RESOLVER.md",
description: "Skill dispatcher. Read first for any task.",
path: "skills/RESOLVER.md",
},
{
title: "README.md",
description: "Project overview, benchmarks, 30-minute setup.",
path: "README.md",
},
],
},
{
heading: "Configuration",
entries: [
{
title: "docs/ENGINES.md",
description: "PGLite vs Postgres trade-off and when to migrate.",
path: "docs/ENGINES.md",
},
{
title: "docs/GBRAIN_RECOMMENDED_SCHEMA.md",
description:
"MECE directory structure (people/, companies/, concepts/).",
path: "docs/GBRAIN_RECOMMENDED_SCHEMA.md",
},
{
title: "docs/guides/live-sync.md",
description: "Incremental markdown sync setup.",
path: "docs/guides/live-sync.md",
},
{
title: "docs/guides/cron-schedule.md",
description: "Recurring job scheduling.",
path: "docs/guides/cron-schedule.md",
},
{
title: "docs/guides/minions-deployment.md",
description:
"Deploying the gbrain jobs worker: crontab + watchdog, inline --follow, systemd/Procfile/fly.toml, upgrade checklist.",
path: "docs/guides/minions-deployment.md",
},
{
title: "docs/guides/quiet-hours.md",
description: "Notification hold + timezone-aware delivery.",
path: "docs/guides/quiet-hours.md",
},
{
title: "docs/mcp/DEPLOY.md",
description: "MCP server deployment.",
path: "docs/mcp/DEPLOY.md",
},
],
},
{
heading: "Debugging",
entries: [
{
title: "docs/GBRAIN_VERIFY.md",
description:
"7-check post-setup verification. Start here when something feels off.",
path: "docs/GBRAIN_VERIFY.md",
},
{
title: "docs/guides/minions-fix.md",
description: "Troubleshooting the Minions job queue.",
path: "docs/guides/minions-fix.md",
},
{
title: "docs/integrations/reliability-repair.md",
description: "Data integrity recovery.",
path: "docs/integrations/reliability-repair.md",
},
],
},
{
heading: "Migrations",
entries: [
{
title: "docs/UPGRADING_DOWNSTREAM_AGENTS.md",
description:
"Patches for downstream agent skill forks. One section per release.",
path: "docs/UPGRADING_DOWNSTREAM_AGENTS.md",
},
{
title: "skills/migrations/",
description:
"Per-version (v0.5.0 - v0.14.1) agent-executable migration instructions.",
path: "skills/migrations/",
},
{
title: "CHANGELOG.md",
description:
"Release-summary voice + itemized changes + self-repair block per version.",
path: "CHANGELOG.md",
includeInFull: false,
},
],
},
{
heading: "Philosophy",
optional: true,
entries: [
{
title: "docs/ethos/THIN_HARNESS_FAT_SKILLS.md",
description: "Why skills live in markdown.",
path: "docs/ethos/THIN_HARNESS_FAT_SKILLS.md",
includeInFull: false,
},
{
title: "docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md",
description: "Homebrew for Personal AI.",
path: "docs/ethos/MARKDOWN_SKILLS_AS_RECIPES.md",
includeInFull: false,
},
],
},
{
heading: "Optional",
optional: true,
entries: [
{
title: "docs/benchmarks/",
description: "Retrieval quality benchmarks.",
path: "docs/benchmarks/",
includeInFull: false,
},
{
title: "docs/designs/",
description: "Forward-looking designs.",
path: "docs/designs/",
includeInFull: false,
},
{
title: "docs/architecture/infra-layer.md",
description: "Shared infra patterns.",
path: "docs/architecture/infra-layer.md",
includeInFull: false,
},
],
},
];
export const INLINE_TIPS = [
"`gbrain doctor [--json] [--fast] [--fix]` - built-in health checks.",
"`gbrain orphans [--json]` - pages with zero inbound wikilinks.",
"`gbrain repair-jsonb [--dry-run]` - repair v0.12.0 double-encoded JSONB rows.",
"`gbrain upgrade` runs post-upgrade + apply-migrations.",
];
// Target ~600KB so llms-full.txt fits in ~150k-token contexts with room to spare.
// Generator prints a WARN if exceeded; ship with includeInFull=false exclusions.
export const FULL_SIZE_BUDGET = 600_000;
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env bash
# Run E2E tests ONE FILE AT A TIME.
#
# Bun's default is to run test files in parallel (each in its own worker).
# Our E2E suite shares one Postgres database across all 13 files, and
# `setupDB()` does TRUNCATE CASCADE + fixture import. When files run in
# parallel, file A's TRUNCATE can race with file B's fixture import,
# producing observed fails like "expected 16 pages, got 8", missing
# links, orphaned timeline entries, etc. The flakiness was visible on
# ~3 of every 5 runs pre-fix.
#
# Running files sequentially eliminates the race entirely. It also costs
# some startup overhead (each file spins up a fresh bun process) but for
# a suite this size that is measured in ~1-2s per file, amortized under
# the natural per-file test time of 5-10s.
#
# Exits non-zero on the first failing file so CI fails fast.
set -euo pipefail
cd "$(dirname "$0")/.."
pass_files=0
fail_files=0
fail_list=()
total_pass=0
total_fail=0
for f in test/e2e/*.test.ts; do
name=$(basename "$f")
echo ""
echo "=== $name ==="
if output=$(bun test "$f" 2>&1); then
pass_files=$((pass_files + 1))
# Extract pass/fail counts from bun's summary (e.g., "123 pass")
p=$(echo "$output" | grep -oE '[0-9]+ pass' | tail -1 | grep -oE '[0-9]+' || echo 0)
total_pass=$((total_pass + p))
echo "$output" | tail -8
else
fail_files=$((fail_files + 1))
fail_list+=("$name")
p=$(echo "$output" | grep -oE '[0-9]+ pass' | tail -1 | grep -oE '[0-9]+' || echo 0)
fl=$(echo "$output" | grep -oE '[0-9]+ fail' | tail -1 | grep -oE '[0-9]+' || echo 0)
total_pass=$((total_pass + p))
total_fail=$((total_fail + fl))
echo "$output"
echo ""
echo "FAILED: $name"
# Continue so we see all failures; exit nonzero at the end.
fi
done
echo ""
echo "========================================"
echo "E2E SUMMARY (sequential execution)"
echo "========================================"
echo "Files: $((pass_files + fail_files)) total, $pass_files passed, $fail_files failed"
echo "Tests: $total_pass passed, $total_fail failed"
if [ ${#fail_list[@]} -gt 0 ]; then
echo ""
echo "Failing files:"
for f in "${fail_list[@]}"; do
echo " - $f"
done
exit 1
fi
+7 -48
View File
@@ -15,7 +15,7 @@
* Returns JSON when --json is passed: { path, score, total, items,
* recommendation }. Exit code is 0 when score == total, 1 otherwise.
*
* Ported from ~/git/your-openclaw/workspace/scripts/skillify-check.mjs
* Ported from ~/git/wintermute/workspace/scripts/skillify-check.mjs
* (genericized: paths computed from $PROJECT_ROOT + runtime test-dir
* detection; replaces the manual `grep AGENTS.md` check with a reference
* to `gbrain check-resolvable` which validates the resolver better).
@@ -23,7 +23,6 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join, basename, dirname, resolve } from 'path';
import { spawnSync } from 'child_process';
function projectRoot(): string {
// Walk up from cwd until we find a package.json — that's the repo root.
@@ -65,45 +64,6 @@ function checkOptional(name: string, passed: boolean, detail?: string): CheckIte
return { name, passed, required: false, detail };
}
/**
* Invoke `gbrain check-resolvable --json` once and cache the result for the
* process lifetime. Binary-missing surfaces a loud error instead of silently
* passing this is the critical guard the failure-mode audit flagged.
*/
interface ResolverResult {
ok: boolean;
detail: string;
}
let _resolverCache: ResolverResult | null = null;
function runCheckResolvableCached(): ResolverResult {
if (_resolverCache) return _resolverCache;
try {
const res = spawnSync('gbrain', ['check-resolvable', '--json'], {
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024,
});
if (res.error || res.status === null) {
const reason = res.error?.message ?? 'spawn returned null status';
console.error(`[skillify] gbrain check-resolvable not runnable: ${reason}`);
_resolverCache = { ok: false, detail: `check-resolvable unavailable: ${reason}` };
return _resolverCache;
}
const payload = JSON.parse(res.stdout);
if (payload.ok === true) {
_resolverCache = { ok: true, detail: 'all skill-tree checks pass' };
} else {
const count = payload.report?.issues?.length ?? 0;
const err = payload.error ? ` (${payload.error})` : '';
_resolverCache = { ok: false, detail: `${count} issue(s)${err} — run: gbrain check-resolvable` };
}
return _resolverCache;
} catch (err) {
console.error(`[skillify] check-resolvable parse failed: ${err}`);
_resolverCache = { ok: false, detail: `check-resolvable parse error: ${err}` };
return _resolverCache;
}
}
/**
* Guess the skill-directory name from a script path.
* scripts/frameio-scraper.ts frameio-scraper
@@ -224,13 +184,12 @@ function runCheck(target: string): {
}
items.push(checkOptional('Resolver trigger eval', hasTriggerEval));
// 8. check-resolvable — invoke the real gate. Cached per process so
// iterating many skills only runs the subprocess once. Binary-missing
// is surfaced loudly so a silent false-pass can't happen.
const resolverResult = runCheckResolvableCached();
items.push(checkOptional('check-resolvable gate',
resolverResult.ok,
resolverResult.detail));
// 8. check-resolvable — we don't run it here (side effects + cost); we
// report whether the SKILL.md exists at all, which is the ground-truth
// input check-resolvable would consume.
items.push(checkOptional('check-resolvable input present',
existsSync(skillMd) && existsSync(RESOLVER_MD),
'run: gbrain check-resolvable'));
// 9. E2E — same as item 4 but required.
items.push(check('E2E test (either under e2e/ or integration test)', hasE2E, 'try /qa or test/e2e/'));
-19
View File
@@ -116,25 +116,6 @@ ingest event.
No separate output. Brain-ops is an always-on behavior layer, not a report generator.
The output is updated brain pages and enriched responses.
## Cross-source citation format (v0.18.0+)
When a brain has multiple sources (wiki, gstack, yc-media, etc.), every
citation MUST include the source id: `[source-id:slug]`. Example:
> You told me about the retry budget approach — see
> [wiki:topics/resilience] and [gstack:plans/retry-policy] for where
> this came from.
Rules:
- The key is `sources.id` (immutable), never `sources.name` (mutable display).
- Single-source brains still write `[default:slug]` OR may omit the prefix
for backward compat.
- Every page payload returned by `search`, `query`, `get_page`, `list_pages`
carries `source_id` — always use it when citing, never guess.
If a search result has `source_id: "gstack"` and `slug: "plans/foo"`,
the citation is `[gstack:plans/foo]`. That's the whole rule.
## Anti-Patterns
- Answering questions about people/companies without checking the brain first
+1 -1
View File
@@ -37,7 +37,7 @@ This skill guarantees:
> **Filing rule:** Read `skills/_brain-filing-rules.md` before creating any new page.
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every mention of a person or company with a brain page MUST create a back-link
FROM that entity's page TO the page mentioning them. An unlinked mention is a
+1 -1
View File
@@ -36,7 +36,7 @@ This skill guarantees:
- Every fact has an inline `[Source: ...]` citation
- Filing follows primary subject rules (not format-based)
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every mention of a person or company with a brain page MUST create a back-link.
Format: `- **YYYY-MM-DD** | Referenced in [page title](path) — brief context`
+1 -1
View File
@@ -29,7 +29,7 @@ Ingest meetings, articles, media, documents, and conversations into the brain.
- State sections are rewritten with current best understanding, never appended to.
- Entity detection fires on every inbound message; notable entities get pages or updates.
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every mention of a person or company with a brain page MUST create a back-link
FROM that entity's page TO the page mentioning them. An unlinked mention is a
+1 -1
View File
@@ -39,7 +39,7 @@ This skill guarantees:
- Raw source files preserved via `gbrain files upload-raw`
- Filing by primary subject, not by media format
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every mention of a person or company with a brain page MUST create a back-link.
+1 -1
View File
@@ -34,7 +34,7 @@ This skill guarantees:
- Meeting is NOT fully ingested until enrich runs for every entity
- Back-links created bidirectionally
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every attendee and company mentioned MUST get a back-link from their page to
the meeting page. An unlinked mention is a broken brain.
+3 -3
View File
@@ -9,7 +9,7 @@ feature_pitch:
# v0.11.0 Migration: Minions — host-agent instruction manual
**Audience: host agents (OpenClaw deployments, future
**Audience: host agents (Wintermute, other OpenClaw deployments, future
hosts) reading this AFTER `gbrain apply-migrations` has run its
mechanical phases.** The orchestrator in
`src/commands/migrations/v0_11_0.ts` is the runtime source of truth for
@@ -32,7 +32,7 @@ Non-empty? Each line is a TODO. Each `type` routes to a section below.
Gbrain rewrites cron entries whose handler name matches a gbrain
builtin (`sync`, `embed`, `lint`, `import`, `extract`, `backlinks`,
`autopilot-cycle`). For host-specific handlers (e.g. `ea-inbox-sweep`,
`frameio-scan`, `x-dm-triage`, `calendar-sync` on your OpenClaw), gbrain
`frameio-scan`, `x-dm-triage`, `calendar-sync` on Wintermute), gbrain
leaves the manifest alone and emits a TODO with shape:
```json
@@ -69,7 +69,7 @@ await worker.start();
### (b) Ship the bootstrap in your host repo
Autopilot already spawns `gbrain jobs work` as a child. Configure it to
spawn your custom worker binary (e.g. `your-openclaw-worker`) instead, or
spawn your custom worker binary (e.g. `wintermute-worker`) instead, or
register handlers as a side-effect module that the stock worker loads on
startup. Either path is documented in `plugin-handlers.md`.
-185
View File
@@ -1,185 +0,0 @@
---
version: 0.14.0
feature_pitch:
headline: "Move deterministic crons off the LLM gateway. Zero tokens per fire."
description: |
OpenClaw operators: your gateway pins at 100% CPU because most cron jobs
boot a full Opus session to do deterministic work (API fetch, token
refresh, scrape + markdown write) that doesn't need reasoning. v0.14.0
adds a `shell` job type to Minions so those jobs run as child processes
under the existing Minions worker. ~60% gateway CPU reduction at typical
scale. Retry, backoff, DLQ, unified `gbrain jobs list` visibility, all
free. The LLM-reasoning crons stay on the gateway where they belong.
recipe: docs/guides/minions-shell-jobs.md
tiers: null
---
# v0.14.0 Migration: Adopt the `shell` job type
**Audience: host agents (OpenClaw deployments, Hermes operators, future
hosts) reading this AFTER `gbrain apply-migrations` has run. There is no
mechanical orchestrator for this release — every change described here
is host-specific and requires operator judgment per cron.** Shell jobs
are off by default on upgrade (nothing breaks). This file walks the
host agent through enabling and adopting them.
## Iron rules
1. **Never auto-rewrite the operator's crontab.** Crontab entries are
host-specific code per the CLAUDE.md "host-specific code" exception.
Every rewrite is an explicit human approval, per cron, with a diff.
2. **LLM-requiring crons stay on the gateway.** If the cron's work
includes reasoning (sentiment classification, triage, synthesis,
prose generation), it belongs on the gateway. Moving it to a shell
job throws away the LLM session the cron needs.
3. **Deterministic crons are the only candidates.** API fetch, token
refresh, database read + markdown write, HTTP refresh call, scrape.
If the script is `node scripts/x.mjs` or `curl ... | jq` or similar
and does zero LLM work, it's a candidate.
## Step 1: Enable the worker
Pick the engine the operator is on:
**Postgres** (most OpenClaw/Hermes deployments):
```bash
# In the worker bootstrap, export the env flag and run the daemon:
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work
```
The worker claims shell jobs from the queue and executes them. Retries,
backoff, and dead-letter all work the same as sync/embed jobs.
**PGLite**: no persistent worker, per-tick inline execution only:
```bash
# Every crontab invocation must use --follow; PGLite's worker daemon
# exits immediately due to exclusive file lock.
GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \
--params '{"cmd":"...","cwd":"..."}' --follow
```
## Step 2: Audit the operator's cron manifest
Read the operator's cron manifest. Typical locations:
- `~/.claude/cron/jobs.json` (OpenClaw)
- `scripts/service-manager.sh` in the host repo
- System crontab (`crontab -l`)
For each entry, classify:
| Pattern | Class | Action |
|---------|-------|--------|
| `agentTurn <skill>` or any OpenClaw-dispatched LLM skill | LLM-requiring | **Leave as-is.** Needs gateway. |
| `node scripts/*.mjs` that hits an API and writes markdown | Deterministic | Propose shell-job rewrite. |
| Token refresh (`ycli token-refresh`, `x-oauth2-refresh`) | Deterministic | Propose shell-job rewrite. |
| Scrape + write (`frameio-scan`, `flight-tracker`) | Deterministic | Propose shell-job rewrite. |
| Audio transcription or any LLM-dependent extract | LLM-requiring | Leave as-is. |
| `bash` wrapper scripts that may call LLM tools internally | Ambiguous | Ask the operator. Don't assume. |
## Step 3: Propose rewrites per cron
For each deterministic cron, propose the exact rewrite with a diff. Show
the operator both sides. Let them approve per-cron, not in bulk.
**Before** (LLM gateway):
```
OpenClaw cron: x-garrytan-unified, 3 13,16,19,22,1,4,7,10 * * *
→ runs agentTurn x-garrytan-unified
→ boots Opus context, invokes script, returns
```
**After** (Minions worker):
```cron
3 13,16,19,22,1,4,7,10 * * * \
gbrain jobs submit shell \
--params '{"cmd":"node /data/.openclaw/workspace/scripts/x-garrytan-daily.mjs","cwd":"/data/.openclaw/workspace"}' \
--max-attempts 3 --timeout-ms 300000
```
Rewrite rules:
- `cwd` is required and must be an absolute path. Operator picks it. It
should be the directory the script expects to run in (the host repo
root, typically).
- `--max-attempts 3` matches the default Minions retry policy. Override
if the script is non-idempotent and should only run once per fire.
- `--timeout-ms N` caps the child's wall-clock runtime. Set to the 95th
percentile of the script's observed runtime, plus slack. Examples:
token refresh → 30s; API fetch → 300s; scrape → 600s.
- **PGLite operators:** add `--follow` to every line. Skip Step 1.
## Step 4: Secrets that the script needs
Shell jobs receive a minimal env allowlist by default: `PATH, HOME,
USER, LANG, TZ, NODE_ENV`. They do NOT inherit `OPENAI_API_KEY`,
`ANTHROPIC_API_KEY`, `DATABASE_URL`, or any other worker env vars.
If a cron's script needs an API key, name it explicitly:
```bash
gbrain jobs submit shell \
--params '{"cmd":"node scripts/yc-sync.mjs","cwd":"/data/.openclaw/workspace","env":{"YC_API_TOKEN":"'"$YC_API_TOKEN"'"}}'
```
The shell expands `$YC_API_TOKEN` at submit time. The worker receives
the JSON with the literal token value. Audit log does not log env
values (keys don't carry sensitive data; values never appear).
## Step 5: Verify the first migrated cron
After rewriting ONE cron with the operator's approval:
1. Wait for the next scheduled fire (or trigger manually: `gbrain jobs
submit shell --params '...' --follow`).
2. Check `gbrain jobs list --status completed --name shell --limit 5`
for the result.
3. `gbrain jobs get <id>` shows `exit_code`, `stdout_tail`, `stderr_tail`,
`duration_ms`.
4. Compare against the pre-migration behavior: did it do the same work?
Same output files changed? Same side effects?
Only after one cron is verified working end-to-end should the operator
approve the next batch.
## Step 6: Starvation sanity check
If the operator submits shell jobs but forgot to set
`GBRAIN_ALLOW_SHELL_JOBS=1` on the worker, jobs sit in `waiting`
indefinitely. The CLI warns on submission, but for daemon-style
deployments the warning scrolls past. Add this to the operator's
ops-check runbook:
```bash
gbrain jobs list --status waiting --name shell
```
If rows pile up here, either (a) no worker has the env flag set, or
(b) the worker crashed. Fix by restarting with the flag.
## Non-goals (explicitly deferred to later releases)
- **Automatic crontab rewrites.** Deferred to a future `gbrain
crontab-to-minions <file>` helper. P1 in TODOS.md.
- **DB-backed scheduler.** `minion_schedules` table replaces host
crontab entirely. P1 in TODOS.md.
- **Orphaned-shell-job stats.** `gbrain jobs stats --orphaned` would
surface the "no worker with env flag" case. P2 in TODOS.md.
- **Configurable buffer sizes.** Output tails are fixed at 64KB stdout
/ 16KB stderr. P2 in TODOS.md.
## When to stop
The migration is done when:
1. The worker runs with `GBRAIN_ALLOW_SHELL_JOBS=1` (Postgres) or every
cron uses `--follow` (PGLite).
2. Every deterministic cron the operator approved has been rewritten.
3. The operator has verified at least one full cron fire cycle
end-to-end and confirmed the output matches pre-migration.
4. `gbrain jobs stats` shows shell jobs completing at expected rates
with few or zero retries.
Gateway CPU should visibly drop after the first few rewrites. That's
the signal the adoption is working.
-164
View File
@@ -1,164 +0,0 @@
---
version: 0.15.2
feature_pitch:
headline: "Silent binaries are dead. Every bulk action now heartbeats."
description: |
`gbrain doctor` on a 52K-page brain used to sit silent for 10+
minutes before an agent timeout killed it. Same pattern on embed,
sync, import, extract, migrate, and every orchestrator. v0.15.2
routes 14 bulk commands through one shared reporter that writes
to stderr. Non-TTY default is plain human lines; agents that
want structured events add `--progress-json` and get one JSON
object per line. Stdout stays clean for data output. Event
schema is locked in docs/progress-events.md.
recipe: docs/progress-events.md
tiers: null
---
# v0.15.2 Migration: Bulk-action progress streaming
**Audience: host agents reading this after `gbrain apply-migrations`
has run. v0.15.2 is purely additive to the CLI surface, there is no
schema change, no data rewrite, and no orchestrator for this release.**
Your binaries just got observable. This file tells you how to use it.
## Mechanical migration: nothing
There is no mechanical step. If `gbrain upgrade` completed, progress
events are already flowing the next time you invoke a bulk command.
Read on to know what's there and how to consume it.
## What's new at the CLI
### Three new global flags
These work on any `gbrain` subcommand:
- `--progress-json` — emit one JSON event per line on stderr.
- `--quiet` — suppress progress output entirely.
- `--progress-interval=<ms>` — minimum ms between progress emits
(default 1000).
Parsed before command dispatch, so both work:
```
gbrain --progress-json doctor --json
gbrain doctor --json --progress-json
```
### Per-TTY behavior
Without `--progress-json`:
- **TTY:** `\r`-rewriting single-line progress on stderr (fancy).
- **Non-TTY (pipe, CI, agent):** one plain-text line per event on
stderr. No JSON, no noise. Human-readable.
The default was deliberately NOT JSON-on-non-TTY. Shell pipelines
that just pipe `gbrain ... | less` should get readable logs, not a
JSON blob. Agents opt in to JSON explicitly.
## What's new per command
Fourteen commands now stream progress through the shared reporter:
| Command | What you'll see |
|---------|-----------------|
| `doctor` | `doctor.db_checks` phase + per-check heartbeats, including a 1s heartbeat while `markdown_body_completeness` scans |
| `orphans` | `orphans.scan` heartbeat while the anti-join runs |
| `embed` | `embed.pages` with per-page ticks |
| `files sync` | `files.sync` with per-file ticks |
| `export` | `export.pages` with per-page ticks |
| `import` | `import.files` with per-file ticks (replaces per-100 stdout logs) |
| `extract [links|timeline|all]` (fs + db) | `extract.links_fs` / `extract.timeline_db` etc. |
| `sync` | `sync.deletes`, `sync.renames`, `sync.imports` phases |
| `migrate --to ...` | `migrate.copy_pages`, `migrate.copy_links` |
| `repair-jsonb` | `repair_jsonb.run` + per-column heartbeats |
| `check-backlinks` | `backlinks.scan` heartbeat |
| `lint` | `lint.pages` per-page ticks |
| `integrity auto` | `integrity.auto` per-page ticks |
| `eval` | `eval.single` / `eval.ab` per-query ticks |
| `apply-migrations` (v0_11/v0_12_0/v0_12_2) | Child processes inherit the parent's progress mode |
## JSON event schema
Documented in `docs/progress-events.md` (canonical reference). Stable
from v0.15.2, additive changes only.
Quick agent cheat sheet:
```json
{"event":"start","phase":"doctor.db_checks","ts":"..."}
{"event":"tick","phase":"orphans.scan","done":15000,"total":52000,"pct":28.8,"elapsed_ms":4200,"eta_ms":10300,"ts":"..."}
{"event":"heartbeat","phase":"doctor.markdown_body_completeness","note":"scanning pages for truncation...","elapsed_ms":1000,"ts":"..."}
{"event":"finish","phase":"doctor.db_checks","elapsed_ms":187000,"ts":"..."}
{"event":"abort","phase":"orphans.scan","reason":"SIGINT","elapsed_ms":5300,"ts":"..."}
```
Parser rules:
1. One JSON object per line on stderr.
2. Ignore unknown event types and unknown fields. Schema is additive.
3. Group by `phase` prefix to track one run: all `doctor.*` events
belong to the same `doctor` invocation.
4. `total` / `pct` / `eta_ms` are absent when the scan doesn't have a
total up front (e.g. heartbeat-only paths). Don't assume they exist.
## Minion jobs
`gbrain jobs work` (the Minion worker daemon) writes progress to the
DB via `job.updateProgress`, not to stderr. Read per-job progress via
the `get_job_progress` MCP op or:
```bash
gbrain jobs submit embed
# while it runs:
gbrain jobs get <id> # .progress updates live as the handler ticks
```
The `embed` Minion handler is wired as of v0.15.2. Other bulk cores
(`sync`, `extract`, `backlinks`, `import`, `autopilot-cycle`) have the
callback plumbing ready and will follow.
## Backward-compatibility warnings
Five commands moved per-page progress from stdout to stderr:
- `embed` (was `\r`-on-stdout)
- `files sync` (was `\r`-on-stdout)
- `export` (was `\r`-on-stdout, newly in scope)
- `migrate-engine` (was per-50 `console.log` to stdout)
- `import` (was per-100 `console.log` to stdout)
If you have scripts that grep `stdout` for progress strings like
`Progress: 1234/52000` or `\r 1234/52000 pages...` — those strings
now live on stderr. The final data summaries (`Embedded N chunks
across M pages`, `Import complete`, etc.) remain on stdout so the
"did it finish" signal is unchanged.
`integrity auto` still writes `~/.gbrain/integrity-progress.jsonl`,
but its role is now "resume marker only" — live progress goes through
the reporter. If you depended on tailing that file for real-time
progress, switch to the stderr stream.
## Verification
```bash
# Your agent sees structured events; stdout stays JSON-parseable:
gbrain --progress-json doctor --json > doctor.json 2> doctor.progress.log
wc -l doctor.progress.log # should be non-zero
jq . doctor.json # should parse cleanly
# For a very large brain, watch the heartbeat:
gbrain --progress-json doctor 2>&1 >/dev/null | grep '"event"'
```
If you see silence for more than a second or two on a non-trivial
command, file an issue with the exact command and the first 100 lines
of stderr.
## That's the whole migration
No mechanical step. No config change. Agents that parse `stdout` keep
working; agents that want progress now have it on a clean stderr
channel with a documented schema.
-167
View File
@@ -1,167 +0,0 @@
---
version: 0.17.0
feature_pitch:
headline: "One brain maintenance cycle, two CLIs. `gbrain dream` delivers the README promise."
description: |
The README has said "the agent runs while I sleep, the dream cycle
scans every conversation, enriches missing entities, fixes broken
citations, consolidates memory" for a year. v0.17 makes that real
as a first-class command (`gbrain dream`) backed by one shared
primitive (`runCycle`). Autopilot users get lint + orphan sweep
added to their nightly cycle automatically — no config change.
Cron users get a single legible verb: `0 2 * * * gbrain dream`.
Both converge on the same phase order (lint → backlinks → sync →
extract → embed → orphans) so file fixes land in the DB the same
night, not the next.
recipe: null
tiers: null
---
# v0.17.0 Migration: `gbrain dream` + unified maintenance cycle
**Audience: agents + humans upgrading from v0.16.x. There is no
mechanical migration step required — the schema migration (v16
cycle-lock table) and behavior changes all apply automatically on
upgrade. This file documents what changed, how to verify it, and
the one opt-out users may care about.**
## What changed
### New command: `gbrain dream`
The brand-promise one-liner. Runs one brain maintenance cycle and
exits. Designed for cron.
```
gbrain dream # full 6-phase cycle
gbrain dream --dry-run --json # preview, agent-readable
gbrain dream --phase lint # single-phase (fast, targeted)
gbrain dream --pull # git pull before syncing
0 2 * * * gbrain dream --json # nightly cron
```
See `gbrain dream --help` for the full flag reference.
### Autopilot now runs lint + orphan sweep
`gbrain autopilot --install` users: on upgrade, your daemon's cycle
gains two phases it didn't run before:
- **lint --fix** — auto-fixes LLM artifacts, placeholder dates, bad
citations across the brain. Modifies files on disk.
- **orphan sweep** — reports (read-only) pages with no inbound
wikilinks. Visible in `gbrain jobs list` output for each
`autopilot-cycle` job.
No action required. The new phases run on the daemon's existing
interval.
### Shared primitive: `src/core/cycle.ts`
Three callers (dream CLI, autopilot inline path, autopilot-cycle
Minions handler) now all delegate to `runCycle(engine, opts)`. One
source of truth for what happens overnight.
### Cycle coordination via a DB lock table
`gbrain_cycle_locks` (new table, migration v16) replaces
session-scoped `pg_try_advisory_lock` which the v0.15.4
PgBouncer-transaction-pooler fix silently broke. The table has a
TTL (30 min), refreshed between phases, so crashed holders
auto-release.
## Verify after upgrade
```bash
# 1. Dream command exists:
gbrain dream --help
# 2. Run a dry cycle (safe, no writes):
gbrain dream --dry-run --json
# 3. If you run autopilot --install:
gbrain jobs list --status complete | head -5
# Each `autopilot-cycle` entry now has 6 phases in its report,
# not 4. Check a recent one with `gbrain jobs get <id>`.
# 4. Schema migration landed:
gbrain doctor # should show no pending migrations
```
Expected `gbrain dream --dry-run` output on a healthy brain:
```
Brain is healthy. 6 phase(s) checked in 1.3s.
```
Or with `--json`:
```json
{
"schema_version": "1",
"status": "clean",
"phases": [...],
"totals": { "lint_fixes": 0, "backlinks_added": 0, ... }
}
```
## Opt-outs for autopilot-installed users
If you explicitly do NOT want autopilot's daemon modifying files
(lint + backlinks phases write to disk):
**Option 1: disable those phases in cron-dream but keep autopilot
running.** Since dream is separate, you can run just the phases
you want from cron without touching autopilot:
```bash
# e.g. only re-embed and orphan-sweep nightly, skip file mutations:
0 2 * * * gbrain dream --phase orphans
```
**Option 2: uninstall autopilot and use cron-dream only.**
```bash
gbrain autopilot --uninstall
# Then add to your crontab:
0 2 * * * gbrain dream --pull
```
**Option 3: accept the default.** The new phases are conservative:
lint only fixes known-safe artifacts (em dashes, placeholder dates),
never destructive. Back-link fills are additive. If something does
go wrong, `gbrain dream --dry-run` always tells you what WOULD
change before you run it for real.
## Troubleshooting
**"cycle_already_running" in dream output:**
Another cycle (probably autopilot's daemon) is holding the lock.
Expected behavior — dream skipped to avoid racing the daemon. The
daemon's next interval will pick up the work.
**`gbrain dream --dry-run` reports changes when you expected none:**
Check `gbrain doctor` for drift: lint issues, stale embeddings,
missing back-links. Dream's dry-run is the honest preview of what
autopilot's daemon will do on its next cycle.
**Minion `autopilot-cycle` jobs failing after upgrade:**
Open a GitHub issue with the output of `gbrain jobs get <id>` for a
failing job. The new runCycle-backed handler preserves the
partial-failure semantic (one phase failing doesn't block future
cycles), but specific phases may surface new error classes.
## What did NOT change
- `gbrain autopilot --install` machinery (launchd / systemd /
crontab generators). Existing installs keep working.
- `~/.gbrain/autopilot.lock` daemon-singleton lockfile. Separate
concern from the new per-cycle lock.
- `gbrain jobs` interface. `gbrain jobs get <id>` now shows a
richer report structure (schema_version:"1"), but the surface
API is stable.
---
*This migration file is informational only. No mechanical step is
required — all changes apply automatically on `gbrain upgrade`.*
-161
View File
@@ -1,161 +0,0 @@
---
version: 0.18.0
feature_pitch:
headline: "Multi-source brains: one DB, many repos. Federated and isolated sources coexist."
description: |
v0.17.0 introduces sources as a first-class primitive. A single
gbrain backend can now hold multiple knowledge repos (wiki, gstack,
yc-media, garrys-list, etc.) with clean scoping. Every page, file,
and ingest_log row is scoped to a `sources(id)` row. Slugs are
unique PER source, not globally — so two sources can both have
`topics/ai` and they're different pages.
Per-source federation controls whether a source participates in
unqualified default search. `federated=true` (the default source
post-upgrade) joins the cross-source recall pool. `federated=false`
is isolation — only searched when explicitly named via `--source`.
This supports both "unified knowledge brain" (wiki + gstack, both
federated) and "purpose-separated brains" (yc-media + garrys-list,
both isolated) at the same time.
Per-directory default via `.gbrain-source` dotfile walk-up +
`GBRAIN_SOURCE` env var. Matches how kubectl / terraform / git
scope context. `cd ~/yc-media && gbrain query "X"` just works.
recipe: docs/guides/multi-source-brains.md
tiers: null
---
# v0.17.0 Migration: Multi-source brains
**Audience: host agents reading this after `gbrain apply-migrations`
has run. v0.17.0 installs a schema primitive for multi-source and
exposes a `sources` CLI subcommand. Existing single-source brains
keep working unchanged — they live under a seeded `default` source
that preserves all prior behavior.**
## Mechanical migration: automatic, no action required
`gbrain upgrade` chains to `gbrain apply-migrations --yes`, which
runs:
- **migration v16** — creates the `sources` table, seeds `default`
with `{"federated": true}` config, inherits your pre-v0.17
`sync.repo_path` and `sync.last_commit` into the default row.
- **migration v17** — adds `pages.source_id TEXT NOT NULL DEFAULT
'default' REFERENCES sources(id)`. Swaps the global `UNIQUE(slug)`
constraint for composite `UNIQUE(source_id, slug)`. Engine
upserts simultaneously re-target `ON CONFLICT (source_id, slug)`
so the constraint swap and the write path land atomically.
Both migrations are idempotent. Safe to re-run.
Later point releases (v0.17.1 and v0.18.0) will layer:
- v0.17.1: ACL enforcement via a caller-identity primitive (the
JSONB slot for `access_policy` ships now; enforcement waits for
identity to be designed).
- v0.18.0: Session ingest (`.jsonl` transcripts, raised size cap,
session PageType) AND per-source retention/TTL at the same time.
## What's new for agents
### `sources` CLI subcommand
```
gbrain sources add <id> --path <p> [--name <n>] [--federated|--no-federated]
gbrain sources list [--json]
gbrain sources remove <id> [--yes] [--dry-run] [--keep-storage]
gbrain sources rename <id> <new-display-name>
gbrain sources default <id>
gbrain sources attach <id> # write .gbrain-source in CWD
gbrain sources detach # remove .gbrain-source
gbrain sources federate <id>
gbrain sources unfederate <id>
```
Source id rules: `[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?` — start + end
with alnum, optional interior hyphens, max 32 chars. Immutable after
creation (rename only changes the display name). Used as the stable
citation key in `[source:slug]` references.
### Per-directory default
Running `gbrain sources attach gstack` inside `~/.gstack/` writes a
`.gbrain-source` file containing the single word `gstack`. Any
gbrain command run from that directory (or any subdirectory) auto-
selects `gstack` as the default source. `gbrain sources detach`
removes the dotfile.
Resolution priority for the source a command targets:
1. Explicit `--source <id>` flag.
2. `GBRAIN_SOURCE` env var.
3. `.gbrain-source` dotfile in CWD or any ancestor.
4. Registered source whose `local_path` contains CWD (longest
prefix wins — nested `~/gstack` + `~/gstack/plans` resolves to
`plans` when deeper).
5. Brain-level default set via `gbrain sources default <id>`.
6. Literal `default` (backward-compat fallback).
### Federation semantics
- `federated=true` (only the `default` source has this out of the
box, by migration): appears in unqualified `gbrain search "X"`
results.
- `federated=false` (new sources default to this): only appears
when `--source <id>` is passed.
Interactive `gbrain sources add` prompts for federation; non-
interactive uses `--federated` / `--no-federated`. Flip later with
`gbrain sources federate <id>` / `unfederate <id>`.
### Citation contract (for agents)
When agents get multi-source search results they MUST cite pages
in `[source-id:slug]` form. Example:
> You told me about the distillation protocol — see
> [wiki:topics/ai] and [gstack:plans/multi-repo] for where this
> came from.
Citations are keyed on `sources.id` (immutable), never
`sources.name` (mutable display). If a user renames a source via
`gbrain sources rename`, existing citations stay valid.
## What's NOT in v0.17.0 yet
The following land in later Steps of this release cycle (already
on the branch but gated until the matching code ships):
- `ingest_log.source_id` — lands with Step 5 sync rewrite.
- `links.resolution_type` + qualified `[[source:slug]]` wikilink
parsing — lands with Step 4 link-extraction rewrite.
- `files.page_slug → page_id` FK rewrite + `file_migration_ledger`
+ storage object prefixing — lands with Step 7 storage backfill.
- Source-aware search dedup — lands with Step 3.
- `gbrain sources import-from-github <url>` — deferred to a patch
release after the plumbing stabilizes.
Existing callers continue to work against the `default` source. No
agent behavioral change is required; the new capabilities are
opt-in via the new `sources` CLI surface.
## Host-repo actions
None required. If your host agent manages the brain via the
standard `gbrain sync` flow, it continues to target the default
source and sees no behavioral change. To start using multi-source:
```
# Register a new source
gbrain sources add gstack --path ~/.gstack --no-federated
# Pin that directory to it so no --source flag is needed
cd ~/.gstack
gbrain sources attach gstack
# Ingest
gbrain sync --source gstack
```
Or see `docs/guides/multi-source-brains.md` for the full three
canonical scenarios (unified, purpose-separated, mixed).
+1 -1
View File
@@ -275,7 +275,7 @@ Inject the key patterns into the agent's system context or AGENTS.md:
1. **Brain-agent loop** (Section 2): read before responding, write after learning
2. **Entity detection** (Section 3): spawn on every message, capture people/companies/ideas
3. **Source attribution** (Section 7): every fact needs `[Source: ...]`
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
4. **Iron law back-linking** (Section 15.4): every mention links back to the entity page
Tell the user: "The production agent guide is at docs/GBRAIN_SKILLPACK.md. It covers
the brain-agent loop, entity detection, enrichment, meeting ingestion, and cron
+1 -1
View File
@@ -39,7 +39,7 @@ This skill guarantees:
- Back-links all entity mentions (Iron Law)
- Citations on every fact written
> **Convention:** See `skills/conventions/quality.md` for Iron Law back-linking.
## Iron Law: Back-Linking (MANDATORY)
Every time this skill creates or updates a brain page that mentions a person or company:
1. Check if that person/company has a brain page
+2 -2
View File
@@ -4,7 +4,7 @@ version: 1.0.0
description: |
Run `gbrain skillpack-check` to produce an agent-readable JSON health report
for the gbrain install. Wraps `gbrain doctor` + `gbrain apply-migrations
--list` so a host agent (your OpenClaw's morning-briefing, any OpenClaw cron)
--list` so a host agent (Wintermute's morning-briefing, any OpenClaw cron)
can see at a glance whether the skillpack needs attention.
Use when the user asks "is gbrain healthy?", when a cron fires a morning
@@ -40,7 +40,7 @@ Exit code:
## When to run
- **Daily cron** (e.g. your OpenClaw's `morning-briefing`): `gbrain skillpack-check --quiet`.
- **Daily cron** (e.g. Wintermute's `morning-briefing`): `gbrain skillpack-check --quiet`.
Exit code alone tells you if anything is wrong; surface a one-liner in the
briefing only when exit != 0. No JSON noise in happy-path briefings.
- **On demand**: `gbrain skillpack-check` for the full JSON when debugging.
+4 -52
View File
@@ -6,7 +6,6 @@ import type { BrainEngine } from './core/engine.ts';
import { operations, OperationError } from './core/operations.ts';
import type { Operation, OperationContext } from './core/operations.ts';
import { serializeMarkdown } from './core/markdown.ts';
import { parseGlobalFlags, setCliOptions, getCliOptions } from './core/cli-options.ts';
import { VERSION } from './version.ts';
// Build CLI name -> operation lookup
@@ -19,16 +18,10 @@ for (const op of operations) {
}
// CLI-only commands that bypass the operation layer
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'dream', 'check-resolvable']);
const CLI_ONLY = new Set(['init', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'features', 'autopilot', 'graph-query', 'jobs', 'apply-migrations', 'skillpack-check', 'resolvers', 'integrity', 'repair-jsonb', 'orphans']);
async function main() {
// Parse global flags (--quiet / --progress-json / --progress-interval)
// BEFORE command dispatch, so `gbrain --progress-json doctor` works.
// The stripped argv is what the command sees.
const rawArgs = process.argv.slice(2);
const { cliOpts, rest: args } = parseGlobalFlags(rawArgs);
setCliOptions(cliOpts);
const args = process.argv.slice(2);
let command = args[0];
if (!command || command === '--help' || command === '-h') {
@@ -155,7 +148,6 @@ function makeContext(engine: BrainEngine, params: Record<string, unknown>): Oper
// Local CLI invocation — the user owns the machine; do not apply remote-caller
// confinement (e.g., cwd-locked file_upload).
remote: false,
cliOpts: getCliOptions(),
};
}
@@ -310,11 +302,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runLint(args);
return;
}
if (command === 'check-resolvable') {
const { runCheckResolvable } = await import('./commands/check-resolvable.ts');
await runCheckResolvable(args);
return;
}
if (command === 'report') {
const { runReport } = await import('./commands/report.ts');
await runReport(args);
@@ -345,11 +332,8 @@ async function handleCliOnly(command: string, args: string[]) {
// Doctor runs filesystem checks first (no DB needed), then DB checks.
// --fast skips DB checks entirely.
const { runDoctor } = await import('./commands/doctor.ts');
const { getDbUrlSource } = await import('./core/config.ts');
if (args.includes('--fast')) {
// Pass the DB URL source so doctor can tell "no config at all" from
// "user chose --fast while config is present".
await runDoctor(null, args, getDbUrlSource());
await runDoctor(null, args);
} else {
try {
const eng = await connectEngine();
@@ -357,31 +341,12 @@ async function handleCliOnly(command: string, args: string[]) {
await eng.disconnect();
} catch {
// DB unavailable — still run filesystem checks
await runDoctor(null, args, getDbUrlSource());
await runDoctor(null, args);
}
}
return;
}
if (command === 'dream') {
// Dream mirrors doctor's pattern: filesystem phases run without a DB,
// so an engine connection failure is non-fatal. runCycle honestly
// reports DB phases as skipped when engine is null.
const { runDream } = await import('./commands/dream.ts');
let eng: BrainEngine | null = null;
try {
eng = await connectEngine();
} catch {
// DB unavailable — lint + backlinks still run against the brain dir.
}
try {
await runDream(eng, args);
} finally {
if (eng) await eng.disconnect();
}
return;
}
// All remaining CLI-only commands need a DB connection
const engine = await connectEngine();
try {
@@ -437,11 +402,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runJobs(engine, args);
break;
}
case 'agent': {
const { runAgent } = await import('./commands/agent.ts');
await runAgent(engine, args);
break;
}
case 'sync': {
const { runSync } = await import('./commands/sync.ts');
await runSync(engine, args);
@@ -472,11 +432,6 @@ async function handleCliOnly(command: string, args: string[]) {
await runOrphans(engine, args);
break;
}
case 'sources': {
const { runSources } = await import('./commands/sources.ts');
await runSources(engine, args);
break;
}
}
} finally {
if (command !== 'serve') await engine.disconnect();
@@ -586,9 +541,6 @@ TOOLS
check-backlinks <check|fix> [dir] Find/fix missing back-links across brain
lint <dir|file> [--fix] Catch LLM artifacts, placeholder dates, bad frontmatter
orphans [--json] [--count] Find pages with no inbound wikilinks
dream [--dry-run] [--json] Run the overnight maintenance cycle once (cron-friendly).
See also: autopilot --install (continuous daemon).
check-resolvable [--json] [--fix] Validate skill tree (reachability/MECE/DRY)
report --type <name> --content ... Save timestamped report to brain/reports/
JOBS (Minions)
-185
View File
@@ -1,185 +0,0 @@
/**
* `gbrain agent logs <job_id> [--follow] [--since <spec>]`
*
* Reads two sources and merges them chronologically:
* - ~/.gbrain/audit/subagent-jobs-*.jsonl (heartbeat + submission events
* lives on the WORKER's filesystem, so this CLI's effectiveness is
* host-local today; see docs/guides/plugin-authors.md caveat #2)
* - subagent_messages (DB rows, authoritative for persisted conversation)
*
* No new DB tables; all the infrastructure landed in prior Lane commits.
*/
import type { BrainEngine } from '../core/engine.ts';
import { readSubagentAuditForJob } from '../core/minions/handlers/subagent-audit.ts';
import type { SubagentAuditEvent } from '../core/minions/handlers/subagent-audit.ts';
import { loadTranscriptRows, renderTranscript } from '../core/minions/transcript.ts';
import type { SubagentMessageRow } from '../core/minions/transcript.ts';
export interface AgentLogsOpts {
follow?: boolean;
/** ISO-8601 timestamp OR relative like "5m" / "1h" / "2d". */
since?: string;
/** Override poll interval for --follow. Default 1000ms. */
pollMs?: number;
/** Injectable writer for testing; default process.stdout.write. */
write?: (s: string) => void;
/** Abort to cut off a --follow loop cleanly (tests + Ctrl-C). */
signal?: AbortSignal;
}
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'dead', 'cancelled']);
export async function runAgentLogs(
engine: BrainEngine,
jobId: number,
opts: AgentLogsOpts = {},
): Promise<void> {
const write = opts.write ?? ((s: string) => { process.stdout.write(s); });
const sinceIso = parseSince(opts.since);
// Seeded render: dump everything we have right now.
let lastTs: string | undefined = sinceIso;
lastTs = await dumpSince(engine, jobId, lastTs, write);
if (!opts.follow) return;
const pollMs = opts.pollMs ?? 1000;
while (!opts.signal?.aborted) {
await sleep(pollMs, opts.signal);
lastTs = await dumpSince(engine, jobId, lastTs, write);
// Break on terminal job status so --follow exits once the run is done.
const status = await readJobStatus(engine, jobId);
if (status && TERMINAL_STATUSES.has(status)) {
write(`\n[gbrain agent] job ${jobId} reached terminal state: ${status}\n`);
return;
}
}
}
/**
* Dump events with ts >= sinceIso. Returns the max ts seen so the next
* poll round filters cleanly. When `sinceIso` is undefined on first call,
* everything is dumped.
*/
async function dumpSince(
engine: BrainEngine,
jobId: number,
sinceIso: string | undefined,
write: (s: string) => void,
): Promise<string | undefined> {
const audit = readSubagentAuditForJob(jobId, sinceIso ? { sinceIso } : {});
const { messages, tools } = await loadTranscriptRows(engine, jobId);
// Merge audit events + message rows into one timeline ordered by ts.
const merged: Array<{ ts: string; line: string }> = [];
for (const e of audit) {
if (sinceIso && e.ts <= sinceIso) continue;
merged.push({ ts: e.ts, line: formatAudit(e) });
}
for (const m of messages) {
const ts = m.ended_at.toISOString();
if (sinceIso && ts <= sinceIso) continue;
merged.push({ ts, line: formatMessage(m) });
}
merged.sort((a, b) => a.ts.localeCompare(b.ts));
let maxTs = sinceIso;
for (const item of merged) {
write(`${item.ts} ${item.line}\n`);
if (!maxTs || item.ts > maxTs) maxTs = item.ts;
}
// Transcript tail (renders the full message/tool tree) only if we
// actually have messages and the job is in a terminal state. This
// avoids spamming a half-rendered transcript mid-run.
if (messages.length > 0 && !sinceIso) {
const status = await readJobStatus(engine, jobId);
if (status && TERMINAL_STATUSES.has(status)) {
write('\n');
write(renderTranscript(messages, tools));
write('\n');
}
}
return maxTs;
}
function formatAudit(e: SubagentAuditEvent): string {
if (e.type === 'submission') {
return `[submission] ${e.caller} model=${e.model ?? '?'} tools=${e.tools_count ?? 0}`;
}
// heartbeat
const parts = [`[${e.event}]`, `turn=${e.turn_idx}`];
if (e.tool_name) parts.push(`tool=${e.tool_name}`);
if (e.ms_elapsed != null) parts.push(`${e.ms_elapsed}ms`);
if (e.tokens) {
const t = e.tokens;
const tokStr = [
t.in ? `in=${t.in}` : null,
t.out ? `out=${t.out}` : null,
t.cache_read ? `cache_read=${t.cache_read}` : null,
t.cache_create ? `cache_create=${t.cache_create}` : null,
].filter(Boolean).join(' ');
if (tokStr) parts.push(`tokens(${tokStr})`);
}
if (e.error) parts.push(`error="${e.error.slice(0, 100)}"`);
return parts.join(' ');
}
function formatMessage(m: SubagentMessageRow): string {
const blockTypes = m.content_blocks.map(b => b.type).join(',');
return `[message #${m.message_idx} ${m.role}] blocks=${blockTypes || '(empty)'}`;
}
async function readJobStatus(engine: BrainEngine, jobId: number): Promise<string | null> {
const rows = await engine.executeRaw<{ status: string }>(
`SELECT status FROM minion_jobs WHERE id = $1`,
[jobId],
);
return rows[0]?.status ?? null;
}
const RELATIVE_RE = /^(\d+)\s*(s|m|h|d)$/i;
/** Parse `--since`. Accepts ISO-8601 or relative ("5m", "1h", "2d"). */
export function parseSince(input: string | undefined): string | undefined {
if (!input) return undefined;
const trimmed = input.trim();
if (!trimmed) return undefined;
const rel = RELATIVE_RE.exec(trimmed);
if (rel) {
const [, nStr, unitRaw] = rel;
const unit = unitRaw!.toLowerCase();
const n = parseInt(nStr!, 10);
const mult = unit === 's' ? 1000
: unit === 'm' ? 60_000
: unit === 'h' ? 3_600_000
: 86_400_000; // 'd'
return new Date(Date.now() - n * mult).toISOString();
}
// Assume ISO. `new Date(input).toISOString()` both validates and
// normalizes; invalid ISO throws.
const d = new Date(trimmed);
if (isNaN(d.getTime())) {
throw new Error(`--since: could not parse "${input}" as ISO-8601 or relative (e.g. "5m", "1h")`);
}
return d.toISOString();
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const t = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms);
const onAbort = () => { clearTimeout(t); resolve(); };
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export const __testing = {
parseSince,
formatAudit,
formatMessage,
dumpSince,
};
-333
View File
@@ -1,333 +0,0 @@
/**
* `gbrain agent` CLI: the user-facing entry point for the v0.15 subagent
* runtime.
*
* gbrain agent run <prompt> [flags]
* gbrain agent logs <job_id> [--follow] [--since <spec>]
*
* `run` submits a subagent job (or fan-out of N subagents + aggregator)
* under the trusted-submit flag so the PROTECTED_JOB_NAMES guard doesn't
* reject. It does NOT execute the loop here the handler runs in a
* `gbrain jobs work` process. `--follow` tails status until terminal;
* without `--follow` (or with `--detach`) the CLI prints the job id and
* exits, leaving the user to check back with `gbrain agent logs`.
*/
import * as fs from 'node:fs';
import type { BrainEngine } from '../core/engine.ts';
import { MinionQueue } from '../core/minions/queue.ts';
import { waitForCompletion, TimeoutError } from '../core/minions/wait-for-completion.ts';
import type { MinionJobInput, SubagentHandlerData, AggregatorHandlerData } from '../core/minions/types.ts';
import { runAgentLogs } from './agent-logs.ts';
// ── arg parsing helpers ────────────────────────────────────
function parseFlag(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : undefined;
}
function hasFlag(args: string[], flag: string): boolean { return args.includes(flag); }
/** Keep CLI args that look like flags from being eaten as the prompt. */
function isKnownFlag(s: string): boolean {
return s.startsWith('--');
}
// ── command dispatcher ────────────────────────────────────
export async function runAgent(engine: BrainEngine, args: string[]): Promise<void> {
const sub = args[0];
if (!sub || sub === '--help' || sub === '-h') {
printHelp();
return;
}
switch (sub) {
case 'run':
await runAgentRun(engine, args.slice(1));
return;
case 'logs':
await runAgentLogsCmd(engine, args.slice(1));
return;
default:
console.error(`gbrain agent: unknown subcommand "${sub}"`);
printHelp();
process.exit(2);
}
}
function printHelp(): void {
console.log(`gbrain agent — durable LLM agent runs (v0.15)
USAGE
gbrain agent run <prompt> [flags]
gbrain agent logs <job_id> [--follow] [--since <spec>]
SUBMITTING
gbrain agent run <prompt>
--subagent-def <name> Named plugin subagent (from GBRAIN_PLUGIN_PATH)
--model <id> Anthropic model id (defaults to sonnet)
--max-turns <n> Max assistant turns (default 20)
--tools a,b,c Subset of registered tool names (comma list)
--timeout-ms <n> Per-job wall-clock timeout
--fanout-manifest <path> JSON array of {prompt, input_vars?} one child each
--follow Tail status until terminal (default on TTY)
--detach Submit + print job id, exit immediately
Flags after \`run\` up to the first unrecognized token are parsed; the
remainder is the prompt. Use \`--\` to explicitly terminate flag parsing.
VIEWING
gbrain agent logs <job_id>
--follow Keep polling until the job reaches terminal
--since <spec> ISO-8601 timestamp OR relative ("5m","1h","2d")
NOTES
Submitting subagent jobs is trusted-only; MCP submitters receive
permission_denied. The worker needs ANTHROPIC_API_KEY set, or the
first LLM turn of a claimed job fails.
`);
}
// ── `gbrain agent run` ────────────────────────────────────
interface RunFlags {
subagentDef?: string;
model?: string;
maxTurns?: number;
tools?: string[];
timeoutMs?: number;
fanoutManifest?: string;
follow: boolean;
detach: boolean;
}
function parseRunFlags(args: string[]): { flags: RunFlags; rest: string[] } {
const flags: RunFlags = {
follow: process.stdout.isTTY === true,
detach: false,
};
let i = 0;
while (i < args.length) {
const a = args[i];
if (a === '--') { i++; break; }
if (!isKnownFlag(a!)) break;
switch (a) {
case '--subagent-def': flags.subagentDef = args[++i]; i++; break;
case '--model': flags.model = args[++i]; i++; break;
case '--max-turns': flags.maxTurns = parseInt(args[++i] ?? '', 10); i++; break;
case '--tools': flags.tools = (args[++i] ?? '').split(',').map(s => s.trim()).filter(Boolean); i++; break;
case '--timeout-ms': flags.timeoutMs = parseInt(args[++i] ?? '', 10); i++; break;
case '--fanout-manifest': flags.fanoutManifest = args[++i]; i++; break;
case '--follow': flags.follow = true; i++; break;
case '--no-follow': flags.follow = false; i++; break;
case '--detach': flags.detach = true; flags.follow = false; i++; break;
default:
throw new Error(`unknown flag: ${a}. Run \`gbrain agent run --help\` for usage.`);
}
}
return { flags, rest: args.slice(i) };
}
export async function runAgentRun(engine: BrainEngine, args: string[]): Promise<void> {
const { flags, rest } = parseRunFlags(args);
const queue = new MinionQueue(engine);
// Fan-out path: --fanout-manifest supplies explicit child inputs. The
// aggregator submits first (so its id is available as parent for each
// child); children submit with on_child_fail='continue' so mixed
// outcomes don't cascade; aggregator waits in waiting-children until
// Lane 1B's terminal-set check unblocks it.
if (flags.fanoutManifest) {
await runFanout(engine, queue, flags, rest.join(' '));
return;
}
const prompt = rest.join(' ').trim();
if (!prompt) {
console.error('gbrain agent run: prompt is required');
process.exit(2);
}
const data: SubagentHandlerData = { prompt };
if (flags.subagentDef) data.subagent_def = flags.subagentDef;
if (flags.model) data.model = flags.model;
if (flags.maxTurns) data.max_turns = flags.maxTurns;
if (flags.tools && flags.tools.length > 0) data.allowed_tools = flags.tools;
const submitOpts: Partial<MinionJobInput> = { max_stalled: 3 };
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
});
process.stderr.write(`submitted: job ${job.id} (subagent)\n`);
if (flags.detach || !flags.follow) {
process.stdout.write(String(job.id) + '\n');
return;
}
await followJob(engine, queue, job.id, flags.timeoutMs);
}
// ── fan-out ───────────────────────────────────────────────
async function runFanout(engine: BrainEngine, queue: MinionQueue, flags: RunFlags, promptTemplate: string): Promise<void> {
const manifestPath = flags.fanoutManifest!;
let manifest: Array<{ prompt?: string; input_vars?: Record<string, unknown> }>;
try {
const raw = fs.readFileSync(manifestPath, 'utf8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) throw new Error('manifest must be a JSON array');
manifest = parsed as typeof manifest;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`gbrain agent run: invalid --fanout-manifest ${manifestPath}: ${msg}`);
process.exit(2);
}
if (manifest.length === 0) {
console.error('gbrain agent run: --fanout-manifest is empty; nothing to run');
process.exit(2);
}
// Short-circuit: 1 entry → single subagent, no aggregator.
if (manifest.length === 1) {
const entry = manifest[0]!;
const data: SubagentHandlerData = {
prompt: entry.prompt ?? promptTemplate,
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
...(flags.model ? { model: flags.model } : {}),
...(flags.maxTurns ? { max_turns: flags.maxTurns } : {}),
...(flags.tools && flags.tools.length > 0 ? { allowed_tools: flags.tools } : {}),
};
const submitOpts: Partial<MinionJobInput> = { max_stalled: 3 };
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
});
process.stderr.write(`submitted: job ${job.id} (single-entry manifest short-circuit)\n`);
if (flags.detach || !flags.follow) { process.stdout.write(`${job.id}\n`); return; }
await followJob(engine, queue, job.id, flags.timeoutMs);
return;
}
// N-entry fan-out: aggregator first (so we have its id as parent), then
// N children, then flip the aggregator's children_ids to include them.
const aggregatorSeed: AggregatorHandlerData = { children_ids: [] };
const aggregator = await queue.add(
'subagent_aggregator',
aggregatorSeed as unknown as Record<string, unknown>,
{ max_stalled: 3 },
{ allowProtectedSubmit: true },
);
const childIds: number[] = [];
for (const entry of manifest) {
const data: SubagentHandlerData = {
prompt: entry.prompt ?? promptTemplate,
...(entry.input_vars ? { input_vars: entry.input_vars } : {}),
...(flags.subagentDef ? { subagent_def: flags.subagentDef } : {}),
...(flags.model ? { model: flags.model } : {}),
...(flags.maxTurns ? { max_turns: flags.maxTurns } : {}),
...(flags.tools && flags.tools.length > 0 ? { allowed_tools: flags.tools } : {}),
};
const submitOpts: Partial<MinionJobInput> = {
parent_job_id: aggregator.id,
on_child_fail: 'continue', // mixed-outcome aggregation
max_stalled: 3,
};
if (flags.timeoutMs) submitOpts.timeout_ms = flags.timeoutMs;
const child = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
allowProtectedSubmit: true,
});
childIds.push(child.id);
}
// Update the aggregator's data with the final children_ids. We have to
// do this after submission because each add() returns the committed
// row's id; the aggregator's seed started with an empty array.
await engine.executeRaw(
`UPDATE minion_jobs SET data = jsonb_set(data, '{children_ids}', $1::jsonb) WHERE id = $2`,
[JSON.stringify(childIds), aggregator.id],
);
process.stderr.write(
`submitted: aggregator job ${aggregator.id} + ${childIds.length} subagent children ` +
`(${childIds[0]}..${childIds[childIds.length - 1]})\n`,
);
if (flags.detach || !flags.follow) {
process.stdout.write(`${aggregator.id}\n`);
return;
}
await followJob(engine, queue, aggregator.id, flags.timeoutMs);
}
// ── follow ────────────────────────────────────────────────
async function followJob(engine: BrainEngine, queue: MinionQueue, jobId: number, timeoutMs?: number): Promise<void> {
process.stderr.write(`[gbrain agent] following job ${jobId} (Ctrl-C to detach)...\n`);
const ac = new AbortController();
const onSigint = () => ac.abort();
process.once('SIGINT', onSigint);
try {
// Streaming logs happen in the background; we poll the terminal state
// in parallel so the function returns as soon as the job completes.
const logsP = runAgentLogs(engine, jobId, { follow: true, signal: ac.signal, pollMs: 1000 });
try {
const job = await waitForCompletion(queue, jobId, {
timeoutMs: timeoutMs ?? 24 * 60 * 60 * 1000,
pollMs: 1000,
signal: ac.signal,
});
ac.abort();
await logsP.catch(() => {});
process.stderr.write(`[gbrain agent] job ${jobId} terminal: ${job.status}\n`);
if (job.result != null) process.stdout.write(JSON.stringify(job.result, null, 2) + '\n');
if (job.status !== 'completed') process.exit(1);
} catch (e) {
if (e instanceof TimeoutError) {
process.stderr.write(`[gbrain agent] timeout after ${e.elapsedMs}ms — job is still running. Check with: gbrain jobs get ${jobId}\n`);
process.exit(3);
}
throw e;
}
} finally {
process.removeListener('SIGINT', onSigint);
}
}
// ── `gbrain agent logs` ────────────────────────────────────
async function runAgentLogsCmd(engine: BrainEngine, args: string[]): Promise<void> {
const jobIdStr = args.find(a => !isKnownFlag(a));
if (!jobIdStr) {
console.error('gbrain agent logs: <job_id> is required');
process.exit(2);
}
const jobId = parseInt(jobIdStr, 10);
if (!Number.isFinite(jobId) || jobId <= 0) {
console.error(`gbrain agent logs: "${jobIdStr}" is not a valid job id`);
process.exit(2);
}
const follow = hasFlag(args, '--follow');
const since = parseFlag(args, '--since');
const ac = new AbortController();
const onSigint = () => ac.abort();
process.once('SIGINT', onSigint);
try {
await runAgentLogs(engine, jobId, { follow, since, signal: ac.signal });
} finally {
process.removeListener('SIGINT', onSigint);
}
}
// Expose for tests.
export const __testing = {
parseRunFlags,
};
+4 -113
View File
@@ -14,12 +14,9 @@
import { VERSION } from '../version.ts';
import { loadConfig } from '../core/config.ts';
import { loadCompletedMigrations, appendCompletedMigration, type CompletedMigrationEntry } from '../core/preferences.ts';
import { loadCompletedMigrations, type CompletedMigrationEntry } from '../core/preferences.ts';
import { migrations, compareVersions, type Migration, type OrchestratorOpts } from './migrations/index.ts';
/** Bug 3 — max consecutive partials before we wedge a migration. */
const MAX_CONSECUTIVE_PARTIALS = 3;
interface ApplyMigrationsArgs {
list: boolean;
dryRun: boolean;
@@ -29,8 +26,6 @@ interface ApplyMigrationsArgs {
specificMigration?: string;
hostDir?: string;
noAutopilotInstall: boolean;
/** Bug 3 — explicit reset for a wedged migration. Writes a 'retry' marker. */
forceRetry?: string;
help: boolean;
}
@@ -54,7 +49,6 @@ function parseArgs(args: string[]): ApplyMigrationsArgs {
specificMigration: val('--migration'),
hostDir: val('--host-dir'),
noAutopilotInstall: has('--no-autopilot-install'),
forceRetry: val('--force-retry'),
help: has('--help') || has('-h'),
};
}
@@ -69,10 +63,6 @@ Usage:
gbrain apply-migrations --list Show applied + pending migrations.
gbrain apply-migrations --migration vX.Y.Z
Force-run a specific migration by version.
gbrain apply-migrations --force-retry vX.Y.Z
Clear a wedged migration (3+ consecutive
partials). Writes a 'retry' marker so the
next run treats it as fresh.
Flags:
--mode <always|pain_triggered|off> Set minion_mode without prompting.
@@ -104,38 +94,14 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex {
: { byVersion: new Map() };
}
/**
* Returns the resolved status for a migration based on its entries.
*
* Semantics (Bug 3 keep "complete wins" safety):
* - If any entry is `complete`, the version is complete. Terminal state.
* - Otherwise, if the latest entry is `retry`, the version is pending
* (user requested a fresh attempt).
* - Otherwise, if any entry is `partial`, the version is partial.
* - Otherwise, pending.
*
* `complete` never regresses. A later accidental `partial` append cannot
* undo a completed migration.
*/
/** Returns the resolved status for a migration based on its entries. */
function statusForVersion(
version: string,
idx: CompletedIndex,
): 'complete' | 'partial' | 'pending' | 'wedged' {
): 'complete' | 'partial' | 'pending' {
const entries = idx.byVersion.get(version) ?? [];
if (entries.length === 0) return 'pending';
if (entries.some(e => e.status === 'complete')) return 'complete';
const latest = entries[entries.length - 1];
if (latest.status === 'retry') return 'pending';
// Bug 3 attempt cap — count consecutive partials from the end (stopping
// at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS,
// the migration is wedged and needs explicit --force-retry to try again.
let consecutive = 0;
for (let i = entries.length - 1; i >= 0; i--) {
const e = entries[i];
if (e.status === 'partial') consecutive++;
else break;
}
if (consecutive >= MAX_CONSECUTIVE_PARTIALS) return 'wedged';
if (entries.some(e => e.status === 'partial')) return 'partial';
return 'pending';
}
@@ -145,7 +111,6 @@ interface Plan {
partial: Migration[];
pending: Migration[];
skippedFuture: Migration[];
wedged: Migration[];
}
/**
@@ -162,7 +127,7 @@ interface Plan {
* skip v0.11.0 when running v0.11.1. Compare against completed.jsonl.
*/
function buildPlan(idx: CompletedIndex, installed: string, filterVersion?: string): Plan {
const plan: Plan = { applied: [], partial: [], pending: [], skippedFuture: [], wedged: [] };
const plan: Plan = { applied: [], partial: [], pending: [], skippedFuture: [] };
for (const m of migrations) {
if (filterVersion && m.version !== filterVersion) continue;
if (compareVersions(m.version, installed) > 0) {
@@ -172,7 +137,6 @@ function buildPlan(idx: CompletedIndex, installed: string, filterVersion?: strin
const status = statusForVersion(m.version, idx);
if (status === 'complete') plan.applied.push(m);
else if (status === 'partial') plan.partial.push(m);
else if (status === 'wedged') plan.wedged.push(m);
else plan.pending.push(m);
}
return plan;
@@ -185,7 +149,6 @@ function printList(plan: Plan, installed: string): void {
const rows: Array<{ status: string; m: Migration }> = [
...plan.applied.map(m => ({ status: 'applied', m })),
...plan.partial.map(m => ({ status: 'partial', m })),
...plan.wedged.map(m => ({ status: 'wedged', m })),
...plan.pending.map(m => ({ status: 'pending', m })),
...plan.skippedFuture.map(m => ({ status: 'future', m })),
];
@@ -264,37 +227,10 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
return;
}
// Bug 3 — --force-retry: write an explicit reset marker for a wedged
// migration, then return. User re-runs `gbrain apply-migrations --yes`
// to actually re-attempt.
if (cli.forceRetry) {
const target = migrations.find(m => m.version === cli.forceRetry);
if (!target) {
console.error(`No migration registered with version "${cli.forceRetry}". Run \`gbrain apply-migrations --list\`.`);
process.exit(2);
}
appendCompletedMigration({ version: cli.forceRetry, status: 'retry' });
console.log(`Wrote 'retry' marker for v${cli.forceRetry}. Run \`gbrain apply-migrations --yes\` to re-attempt.`);
return;
}
const completed = loadCompletedMigrations();
const idx = indexCompleted(completed);
const plan = buildPlan(idx, installed, cli.specificMigration);
// Bug 3 — surface wedged migrations as a loud, actionable error.
if (plan.wedged.length > 0) {
for (const m of plan.wedged) {
console.error(
`\nMigration v${m.version} is WEDGED (${MAX_CONSECUTIVE_PARTIALS}+ consecutive partials with no completion). ` +
`Check ~/.gbrain/upgrade-errors.jsonl for the last failure reasons, fix the underlying issue, then run:\n` +
` gbrain apply-migrations --force-retry ${m.version}\n` +
`Then re-run \`gbrain apply-migrations --yes\`.`,
);
}
// Don't exit — applied/partial/pending are still worth reporting and running.
}
if (cli.specificMigration && plan.applied.length + plan.partial.length + plan.pending.length + plan.skippedFuture.length === 0) {
console.error(`No migration registered with version "${cli.specificMigration}". Run \`gbrain apply-migrations --list\` to see registered versions.`);
process.exit(2);
@@ -312,11 +248,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
// Run each orchestrator in registry order. An orchestrator failure aborts
// the rest of the chain; fixing the failure and re-running picks up where
// we left off (per-phase idempotency markers + resume from "partial").
//
// Bug 3 — the RUNNER owns the ledger write now. Orchestrators return their
// result; we persist it here with a canonical shape. If the write fails,
// surface the error and DO NOT proceed to the next migration (a silent
// ledger drop was the root cause of the original infinite-retry symptom).
let failed = false;
for (const m of toRun) {
console.log(`\n=== Applying migration v${m.version}: ${m.featurePitch.headline} ===`);
@@ -324,45 +255,9 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
const result = await m.orchestrator(orchestratorOptsFrom(cli));
if (result.status === 'failed') {
console.error(`Migration v${m.version} reported status=failed.`);
// Record the attempt as 'partial' (not 'complete') so the cap counts
// it. Don't let a failed orchestrator look like it never ran.
try {
appendCompletedMigration({
version: m.version,
status: 'partial',
phases: result.phases,
files_rewritten: result.files_rewritten,
autopilot_installed: result.autopilot_installed,
install_target: result.install_target,
apply_migrations_pending: result.pending_host_work ? result.pending_host_work > 0 : undefined,
});
} catch (e) {
console.error(`Also: could not persist failure record: ${e instanceof Error ? e.message : String(e)}`);
}
failed = true;
break;
}
// Persist the terminal outcome. appendCompletedMigration no-ops when
// the last entry for this version is already 'complete' (idempotency
// guard), so repeated clean runs don't spam the ledger.
try {
appendCompletedMigration({
version: m.version,
status: result.status, // 'complete' | 'partial'
phases: result.phases,
files_rewritten: result.files_rewritten,
autopilot_installed: result.autopilot_installed,
install_target: result.install_target,
apply_migrations_pending: result.pending_host_work ? result.pending_host_work > 0 : undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`Failed to persist ledger entry for v${m.version}: ${msg}. Stopping to prevent silent drift.`);
failed = true;
break;
}
if (result.status === 'partial') {
console.log(`Migration v${m.version} finished as PARTIAL. Re-run \`gbrain apply-migrations --yes\` after resolving any pending host-work items.`);
} else {
@@ -371,10 +266,6 @@ export async function runApplyMigrations(args: string[]): Promise<void> {
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`Migration v${m.version} threw: ${msg}`);
// Same partial-on-throw treatment so the cap counts runaway failures.
try {
appendCompletedMigration({ version: m.version, status: 'partial' });
} catch { /* swallow ledger-write failure on throw path */ }
failed = true;
break;
}
+36 -54
View File
@@ -44,48 +44,35 @@ function logError(phase: string, e: unknown) {
/**
* Resolve the gbrain CLI entrypoint for spawning the worker child.
*
* A .ts source path is never a valid spawn target spawning it fails with
* EACCES because TypeScript source isn't executable. The canonical install
* puts a shim at `/usr/local/bin/gbrain` (or wherever `which gbrain`
* resolves to) that already wraps the right runtime+entrypoint; prefer it.
* Codex caught the bug in earlier plan drafts: `process.execPath` is the
* Bun (or Node) runtime binary on source installs, not `gbrain`. Blindly
* using it would spawn `bun jobs work`, which does not work.
*
* Order of resolution:
* 1. `which gbrain` the shim on PATH, canonical for installed builds.
* 2. process.execPath if it ends with /gbrain (compiled binary, no shim).
* 3. argv[1] if it ends with /gbrain (e.g., direct invocation of compiled
* binary without PATH). Never .ts source paths.
* 4. Throw with a clear install hint.
* 1. argv[1] if it clearly points at a gbrain entry (cli.ts or /gbrain).
* 2. process.execPath when running as the compiled binary.
* 3. `which gbrain` for installs where the binary is on $PATH.
* 4. Throw nothing on $PATH, no way to supervise the worker.
*/
export function resolveGbrainCliPath(): string {
try {
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (which) return which;
} catch { /* not on $PATH — fall through */ }
const arg1 = process.argv[1] ?? '';
if (arg1.endsWith('/gbrain') || arg1.endsWith('/cli.ts') || arg1.endsWith('\\gbrain.exe')) {
return arg1;
}
const exec = process.execPath ?? '';
if (exec.endsWith('/gbrain') || exec.endsWith('\\gbrain.exe')) {
return exec;
}
const arg1 = process.argv[1] ?? '';
if (arg1.endsWith('/gbrain') || arg1.endsWith('\\gbrain.exe')) {
return arg1;
}
throw new Error('Could not resolve the gbrain CLI path. Install gbrain so it is on $PATH (e.g. /usr/local/bin/gbrain), or run autopilot from the compiled binary directly.');
try {
const which = execSync('which gbrain', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (which) return which;
} catch { /* not on $PATH */ }
throw new Error('Could not resolve the gbrain CLI path. Install gbrain so it is on $PATH, or run autopilot from the compiled binary directly.');
}
export async function runAutopilot(engine: BrainEngine, args: string[]) {
if (args.includes('--help') || args.includes('-h')) {
console.log(
'Usage: gbrain autopilot [--repo <path>] [--interval N] [--json]\n' +
' gbrain autopilot --install [--repo <path>]\n' +
' gbrain autopilot --uninstall\n' +
' gbrain autopilot --status [--json]\n\n' +
'Self-maintaining brain daemon. Runs the full maintenance cycle\n' +
'(lint + backlinks + sync + extract + embed + orphans) on an interval.\n\n' +
'For a one-shot cron-triggered cycle, see `gbrain dream`.',
);
console.log('Usage: gbrain autopilot [--repo <path>] [--interval N] [--json]\n gbrain autopilot --install [--repo <path>]\n gbrain autopilot --uninstall\n gbrain autopilot --status [--json]\n\nSelf-maintaining brain daemon. Runs sync + extract + embed + backlinks in a loop.');
return;
}
@@ -241,32 +228,27 @@ export async function runAutopilot(engine: BrainEngine, args: string[]) {
}
} catch (e) { logError('dispatch', e); cycleOk = false; }
} else {
// Inline fallback — delegate to runCycle so lint + backlinks +
// orphan sweep run too (previously this path only did sync +
// extract + embed, which didn't match the Minions-dispatch
// path's phase set). Now both converge on the same primitive.
// Inline fallback — same as pre-v0.11.1 behavior.
// 1. Sync
try {
const { runCycle } = await import('../core/cycle.ts');
const report = await runCycle(engine, {
brainDir: repoPath,
// Autopilot daemon path: pulls by default (matches
// pre-v0.17 autopilot behavior). CLI dream defaults false
// for cron safety; that choice is scoped to dream only.
pull: true,
yieldBetweenPhases: async () => {
await new Promise(r => setImmediate(r));
},
});
if (report.status === 'failed' || report.status === 'partial') {
cycleOk = false;
const { performSync } = await import('./sync.ts');
const result = await performSync(engine, { repoPath, noEmbed: true });
if (result.status === 'synced') {
console.log(`[sync] +${result.added} ~${result.modified} -${result.deleted}`);
}
if (jsonMode) {
process.stderr.write(JSON.stringify({ event: 'cycle-inline', status: report.status, duration_ms: report.duration_ms, totals: report.totals }) + '\n');
} else {
const t = report.totals;
console.log(`[cycle-inline ${report.status}] lint=${t.lint_fixes} backlinks=${t.backlinks_added} synced=${t.pages_synced} extracted=${t.pages_extracted} embedded=${t.pages_embedded} orphans=${t.orphans_found}`);
}
} catch (e) { logError('cycle-inline', e); cycleOk = false; }
} catch (e) { logError('sync', e); cycleOk = false; }
// 2. Extract (full brain, incremental dedup handles repeats)
try {
const { runExtractCore } = await import('./extract.ts');
await runExtractCore(engine, { mode: 'all', dir: repoPath });
} catch (e) { logError('extract', e); cycleOk = false; }
// 3. Embed stale
try {
const { runEmbedCore } = await import('./embed.ts');
await runEmbedCore(engine, { stale: true });
} catch (e) { logError('embed', e); cycleOk = false; }
}
// 4. Health check + adaptive interval (same for both paths)
+1 -14
View File
@@ -13,8 +13,6 @@
import { readFileSync, writeFileSync, readdirSync, statSync, lstatSync, existsSync } from 'fs';
import { join, relative, basename } from 'path';
import { extractEntityRefs as canonicalExtractEntityRefs } from '../core/link-extraction.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface BacklinkGap {
/** The page that mentions the entity */
@@ -203,18 +201,7 @@ export async function runBacklinksCore(opts: BacklinksOpts): Promise<BacklinksRe
throw new Error(`Directory not found: ${opts.dir}`);
}
// findBacklinkGaps is a sync double-walk of the brain dir. On 50K-page
// brains that can take seconds — heartbeat so agents see we're working.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('backlinks.scan');
const stopHb = startHeartbeat(progress, 'walking pages for missing back-links…');
let gaps: BacklinkGap[];
try {
gaps = findBacklinkGaps(opts.dir);
} finally {
stopHb();
progress.finish();
}
const gaps = findBacklinkGaps(opts.dir);
const pagesAffected = new Set(gaps.map(g => g.targetPage)).size;
if (opts.action === 'fix' && gaps.length > 0) {
-260
View File
@@ -1,260 +0,0 @@
/**
* gbrain check-resolvable Standalone CLI gate for skill-tree integrity.
*
* Thin wrapper over `src/core/check-resolvable.ts`. Exit-code rule is stricter
* than `gbrain doctor`'s resolver_health: this command exits 1 on ANY issue
* (errors OR warnings) so CI can gate on a single command. Honors the README
* contract: "Exits non-zero if anything is off."
*
* Currently covers 4 of 6 checks from the original design: reachability,
* MECE overlap, MECE gap, DRY violations. Checks 5 (trigger routing eval)
* and 6 (brain filing) are tracked as separate GitHub issues and surfaced
* via the `deferred` field in --json output.
*/
import { resolve as resolvePath, isAbsolute } from 'path';
import {
checkResolvable,
autoFixDryViolations,
type ResolvableReport,
type ResolvableIssue,
type AutoFixReport,
} from '../core/check-resolvable.ts';
import { findRepoRoot } from '../core/repo-root.ts';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface DeferredCheck {
check: number;
name: string;
issue: string;
}
export interface Envelope {
ok: boolean;
skillsDir: string | null;
report: ResolvableReport | null;
autoFix: AutoFixReport | null;
deferred: DeferredCheck[];
error: 'no_skills_dir' | null;
message: string | null;
}
export interface Flags {
help: boolean;
json: boolean;
fix: boolean;
dryRun: boolean;
verbose: boolean;
skillsDir: string | null;
}
// TBD: fill these issue URLs after filing the GitHub issues pre-PR.
// grep for 'TBD-check-5' / 'TBD-check-6' before shipping.
export const DEFERRED: DeferredCheck[] = [
{
check: 5,
name: 'trigger_routing_eval',
issue: 'https://github.com/garrytan/gbrain/issues?q=TBD-check-5',
},
{
check: 6,
name: 'brain_filing',
issue: 'https://github.com/garrytan/gbrain/issues?q=TBD-check-6',
},
];
const HELP_TEXT = `gbrain check-resolvable [options]
Validate the skill tree: reachability, MECE overlap, DRY violations, and
gap detection. Exits non-zero if any issues are found (errors OR warnings).
Options:
--json Machine-readable JSON (stable envelope)
--fix Apply DRY auto-fixes before checking
--dry-run With --fix, preview only; no writes
--verbose Show passing checks and the deferred-check note
--skills-dir PATH Override the auto-detected skills/ directory
--help Show this message
Deferred to separate issues (see --json .deferred[]):
- Check 5: trigger routing eval
- Check 6: brain filing
`;
// ---------------------------------------------------------------------------
// Flag parsing — permissive on unknown flags, matching lint/orphans/publish.
// ---------------------------------------------------------------------------
export function parseFlags(argv: string[]): Flags {
const flags: Flags = {
help: false,
json: false,
fix: false,
dryRun: false,
verbose: false,
skillsDir: null,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--help' || a === '-h') flags.help = true;
else if (a === '--json') flags.json = true;
else if (a === '--fix') flags.fix = true;
else if (a === '--dry-run') flags.dryRun = true;
else if (a === '--verbose') flags.verbose = true;
else if (a === '--skills-dir') {
flags.skillsDir = argv[i + 1] ?? null;
i++;
} else if (a?.startsWith('--skills-dir=')) {
flags.skillsDir = a.slice('--skills-dir='.length) || null;
}
// unknown flags silently ignored
}
return flags;
}
// ---------------------------------------------------------------------------
// Skills-dir resolution
// ---------------------------------------------------------------------------
export function resolveSkillsDir(flags: Flags): { dir: string | null; error: Envelope['error']; message: string | null } {
if (flags.skillsDir) {
const dir = isAbsolute(flags.skillsDir)
? flags.skillsDir
: resolvePath(process.cwd(), flags.skillsDir);
return { dir, error: null, message: null };
}
const repoRoot = findRepoRoot();
if (!repoRoot) {
return {
dir: null,
error: 'no_skills_dir',
message:
'Could not locate skills/RESOLVER.md from cwd. Pass --skills-dir <path> or run from inside a gbrain repo.',
};
}
return { dir: resolvePath(repoRoot, 'skills'), error: null, message: null };
}
// ---------------------------------------------------------------------------
// Human output (mirrors doctor's resolver_health formatting)
// ---------------------------------------------------------------------------
function renderHuman(env: Envelope, flags: Flags): void {
if (env.error === 'no_skills_dir') {
console.error(env.message);
return;
}
const report = env.report!;
if (flags.fix && env.autoFix) {
printAutoFixHuman(env.autoFix, flags.dryRun);
}
if (report.ok && report.issues.length === 0) {
console.log(`resolver_health: OK — ${report.summary.total_skills} skills, all reachable`);
} else {
const errors = report.issues.filter(i => i.severity === 'error');
const warnings = report.issues.filter(i => i.severity === 'warning');
const status = errors.length > 0 ? 'FAIL' : 'WARN';
console.log(
`resolver_health: ${status}${report.issues.length} issue(s): ${errors.length} error(s), ${warnings.length} warning(s)`,
);
for (const iss of report.issues) {
console.log(formatIssueLine(iss));
}
}
if (flags.verbose) {
const urls = DEFERRED.map(d => `${d.name} (${d.issue})`).join(', ');
console.log(`Deferred: ${urls}`);
}
}
function formatIssueLine(iss: ResolvableIssue): string {
const type = iss.type.padEnd(18);
const skill = iss.skill.padEnd(24);
return `${type} ${skill} ${iss.action}`;
}
function printAutoFixHuman(autoFix: AutoFixReport, dryRun: boolean): void {
const verb = dryRun ? 'PROPOSED' : 'APPLIED';
for (const outcome of autoFix.fixed) {
console.log(`[${verb}] ${outcome.skillPath} (${outcome.patternLabel})`);
}
const n = autoFix.fixed.length;
const s = autoFix.skipped.length;
if (n === 0 && s === 0) {
console.log('check-resolvable --fix: no DRY violations to repair.');
return;
}
const label = dryRun ? 'fixes proposed' : 'fixes applied';
console.log(`${n} ${label}${s > 0 ? `, ${s} skipped:` : '.'}`);
for (const sk of autoFix.skipped) {
const hint = sk.reason === 'working_tree_dirty' ? ' (run `git stash` first)' : '';
console.log(` - ${sk.skillPath}: ${sk.reason}${hint}`);
}
if (dryRun && n > 0) console.log('Run without --dry-run to apply.\n');
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
export async function runCheckResolvable(args: string[]): Promise<void> {
const flags = parseFlags(args);
if (flags.help) {
console.log(HELP_TEXT);
process.exit(0);
}
const { dir, error, message } = resolveSkillsDir(flags);
if (error === 'no_skills_dir') {
const env: Envelope = {
ok: false,
skillsDir: null,
report: null,
autoFix: null,
deferred: DEFERRED,
error,
message,
};
if (flags.json) {
console.log(JSON.stringify(env, null, 2));
} else {
renderHuman(env, flags);
}
process.exit(1);
}
const skillsDir = dir!;
let autoFix: AutoFixReport | null = null;
if (flags.fix) {
autoFix = autoFixDryViolations(skillsDir, { dryRun: flags.dryRun });
}
const report = checkResolvable(skillsDir);
const env: Envelope = {
ok: report.issues.length === 0,
skillsDir,
report,
autoFix,
deferred: DEFERRED,
error: null,
message: null,
};
if (flags.json) {
console.log(JSON.stringify(env, null, 2));
} else {
renderHuman(env, flags);
}
process.exit(env.ok ? 0 : 1);
}
+20 -261
View File
@@ -2,12 +2,7 @@ import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { LATEST_VERSION } from '../core/migrate.ts';
import { checkResolvable } from '../core/check-resolvable.ts';
import { autoFixDryViolations, type AutoFixReport, type FixOutcome } from '../core/dry-fix.ts';
import { findRepoRoot } from '../core/repo-root.ts';
import { loadCompletedMigrations } from '../core/preferences.ts';
import { createProgress, startHeartbeat, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import type { DbUrlSource } from '../core/config.ts';
import { join } from 'path';
import { existsSync, readFileSync, readdirSync } from 'fs';
@@ -22,25 +17,11 @@ export interface Check {
* Run doctor with filesystem-first, DB-second architecture.
* Filesystem checks (resolver, conformance) run without engine.
* DB checks run only if engine is provided.
*
* `dbSource` is passed only from the `--fast` and DB-unavailable paths in
* cli.ts so we can emit a precise "why no DB check" message. When null, the
* user has no DB configured anywhere; otherwise the caller chose --fast or
* we failed to connect despite a configured URL.
*/
export async function runDoctor(engine: BrainEngine | null, args: string[], dbSource?: DbUrlSource) {
export async function runDoctor(engine: BrainEngine | null, args: string[]) {
const jsonOutput = args.includes('--json');
const fastMode = args.includes('--fast');
const doFix = args.includes('--fix');
const dryRun = args.includes('--dry-run');
const checks: Check[] = [];
let autoFixReport: AutoFixReport | null = null;
// Progress reporter. `--json` is doctor's own JSON output (list of checks);
// progress events stay on stderr regardless, gated by the global --quiet /
// --progress-json flags. On a 52K-page brain the DB checks can take minutes,
// and without a heartbeat agents can't tell doctor from a hang.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
// --- Filesystem checks (always run, no DB needed) ---
@@ -48,15 +29,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
const repoRoot = findRepoRoot();
if (repoRoot) {
const skillsDir = join(repoRoot, 'skills');
// --fix: run auto-repair BEFORE checkResolvable so the post-fix scan
// reflects the new state. Auto-fix only targets DRY violations today;
// other resolver issues are left to human repair.
if (doFix) {
autoFixReport = autoFixDryViolations(skillsDir, { dryRun });
printAutoFixReport(autoFixReport, dryRun, jsonOutput);
}
const report = checkResolvable(skillsDir);
if (report.ok && report.issues.length === 0) {
checks.push({
@@ -97,7 +69,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// status:"complete" for the same version, the install is mid-migration.
// Typical cause: v0.11.0 stopgap wrote a partial record but nobody ran
// `gbrain apply-migrations --yes` afterward. This check fires on every
// `gbrain doctor` invocation so your OpenClaw's health skill catches it.
// `gbrain doctor` invocation so Wintermute's health skill catches it.
try {
const completed = loadCompletedMigrations();
const byVersion = new Map<string, { complete: boolean; partial: boolean }>();
@@ -151,81 +123,30 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Read/parse failure is itself best-effort; skip silently.
}
// 3c. Sync failure trail (Bug 9). sync.ts gates the `sync.last_commit`
// bookmark when per-file parse errors happen, and appends each failure
// to ~/.gbrain/sync-failures.jsonl with the commit hash + exact error.
// Without this doctor check, users see "sync blocked" and have no
// surface showing which files to fix.
try {
const { unacknowledgedSyncFailures, loadSyncFailures } = await import('../core/sync.ts');
const unacked = unacknowledgedSyncFailures();
const all = loadSyncFailures();
if (unacked.length > 0) {
const preview = unacked.slice(0, 3).map(f => `${f.path} (${f.error.slice(0, 60)})`).join('; ');
checks.push({
name: 'sync_failures',
status: 'warn',
message:
`${unacked.length} unacknowledged sync failure(s). ${preview}` +
`${unacked.length > 3 ? `, and ${unacked.length - 3} more` : ''}. ` +
`Fix the file(s) and re-run 'gbrain sync', or use 'gbrain sync --skip-failed' to acknowledge.`,
});
} else if (all.length > 0) {
// Acknowledged-only: informational, not a warning.
checks.push({
name: 'sync_failures',
status: 'ok',
message: `${all.length} historical sync failure(s), all acknowledged.`,
});
}
} catch {
// Best-effort. A broken JSONL should not stop doctor.
}
// --- DB checks (skip if --fast or no engine) ---
if (fastMode || !engine) {
if (!engine) {
// Pick the precise message. When dbSource is provided, we know
// whether a URL exists (env or config-file) — the caller simply
// skipped the connection. When null, there really is no config
// anywhere.
let msg: string;
if (fastMode && dbSource) {
msg = `Skipping DB checks (--fast mode, URL present from ${dbSource})`;
} else if (!fastMode && dbSource) {
msg = `Could not connect to configured DB (URL from ${dbSource}); filesystem checks only`;
} else {
msg = 'No database configured (filesystem checks only). Set GBRAIN_DATABASE_URL or run `gbrain init`.';
}
checks.push({ name: 'connection', status: 'warn', message: msg });
checks.push({ name: 'connection', status: 'warn', message: 'No database configured (filesystem checks only)' });
}
const earlyFail1 = outputResults(checks, jsonOutput);
process.exit(earlyFail1 ? 1 : 0);
return;
}
// DB checks phase — start a single reporter phase so agents see which
// check is running (several take seconds on 50K-page brains; without a
// heartbeat the binary looks hung when stdout is piped).
progress.start('doctor.db_checks');
// 3. Connection
progress.heartbeat('connection');
try {
const stats = await engine.getStats();
checks.push({ name: 'connection', status: 'ok', message: `Connected, ${stats.page_count} pages` });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
checks.push({ name: 'connection', status: 'fail', message: msg });
progress.finish();
const earlyFail2 = outputResults(checks, jsonOutput);
process.exit(earlyFail2 ? 1 : 0);
return;
}
// 4. pgvector extension
progress.heartbeat('pgvector');
try {
const sql = db.getConnection();
const ext = await sql`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
@@ -238,46 +159,7 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
checks.push({ name: 'pgvector', status: 'warn', message: 'Could not check pgvector extension' });
}
// 4b. PgBouncer / prepared-statement compatibility.
// URL-only inspection — no DB roundtrip — so this is cheap and works
// regardless of whether the caller is the module singleton or a
// worker-instance engine.
progress.heartbeat('pgbouncer_prepare');
try {
const { resolvePrepare } = await import('../core/db.ts');
const { loadConfig } = await import('../core/config.ts');
const config = loadConfig();
const url = config?.database_url || '';
const prepare = resolvePrepare(url);
if (prepare === false) {
checks.push({
name: 'pgbouncer_prepare',
status: 'ok',
message: 'Prepared statements disabled (PgBouncer-safe)',
});
} else {
try {
const parsed = new URL(url.replace(/^postgres(ql)?:\/\//, 'http://'));
if (parsed.port === '6543') {
checks.push({
name: 'pgbouncer_prepare',
status: 'warn',
message:
'Port 6543 (PgBouncer transaction mode) detected but prepared statements are enabled. ' +
'This causes "prepared statement does not exist" errors under concurrent load. ' +
'Fix: unset GBRAIN_PREPARE (or set =false), or add ?prepare=false to the connection URL.',
});
}
} catch {
// URL parse failure — skip, nothing actionable
}
}
} catch {
// best-effort; never fail doctor on this check
}
// 5. RLS
progress.heartbeat('rls');
try {
const sql = db.getConnection();
const tables = await sql`
@@ -297,31 +179,15 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
checks.push({ name: 'rls', status: 'warn', message: 'Could not check RLS status' });
}
// 6. Schema version — also surfaces the #218 "postinstall silently failed"
// state: if schema_version is 0/missing but the DB connected, migrations
// never ran. That's the same class as a half-migrated install, just from a
// different root cause (Bun blocked our top-level postinstall on global
// install). Message is actionable either way.
progress.heartbeat('schema_version');
// 6. Schema version
let schemaVersion = 0;
try {
const version = await engine.getConfig('version');
schemaVersion = parseInt(version || '0', 10);
if (schemaVersion >= LATEST_VERSION) {
checks.push({ name: 'schema_version', status: 'ok', message: `Version ${schemaVersion} (latest: ${LATEST_VERSION})` });
} else if (schemaVersion === 0) {
checks.push({
name: 'schema_version',
status: 'fail',
message: `No schema version recorded. Migrations never ran. Fix: gbrain apply-migrations --yes. ` +
`If you installed via 'bun install -g github:...', see https://github.com/garrytan/gbrain/issues/218.`,
});
} else {
checks.push({
name: 'schema_version',
status: 'warn',
message: `Version ${schemaVersion}, latest is ${LATEST_VERSION}. Fix: gbrain apply-migrations --yes`,
});
checks.push({ name: 'schema_version', status: 'warn', message: `Version ${schemaVersion}, latest is ${LATEST_VERSION}. Run gbrain init to migrate.` });
}
} catch {
checks.push({ name: 'schema_version', status: 'warn', message: 'Could not check schema version' });
@@ -335,7 +201,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// but `apply-migrations` didn't follow up.
// 7. Embedding health
progress.heartbeat('embeddings');
try {
const health = await engine.getHealth();
const pct = (health.embed_coverage * 100).toFixed(0);
@@ -352,7 +217,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// 8. Graph health (link + timeline coverage on entity pages).
// dead_links removed in v0.10.1: ON DELETE CASCADE on link FKs makes it always 0.
progress.heartbeat('graph_coverage');
try {
const health = await engine.getHealth();
const linkPct = ((health.link_coverage ?? 0) * 100).toFixed(0);
@@ -366,27 +230,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
message: `Entity link coverage ${linkPct}%, timeline ${timelinePct}%. Run: gbrain link-extract && gbrain timeline-extract`,
});
}
// Bug 11 — brain_score breakdown. When the total is < 100, show which
// components contributed the deficit so users know what to fix.
// Uses distinct *_score field names (not overloading link_coverage /
// timeline_coverage, which are entity-scoped).
if (health.brain_score < 100) {
const parts = [
`embed ${health.embed_coverage_score}/35`,
`links ${health.link_density_score}/25`,
`timeline ${health.timeline_coverage_score}/15`,
`orphans ${health.no_orphans_score}/15`,
`dead-links ${health.no_dead_links_score}/10`,
];
checks.push({
name: 'brain_score',
status: health.brain_score >= 70 ? 'ok' : 'warn',
message: `Brain score ${health.brain_score}/100 (${parts.join(', ')})`,
});
} else {
checks.push({ name: 'brain_score', status: 'ok', message: `Brain score 100/100` });
}
} catch {
checks.push({ name: 'graph_coverage', status: 'warn', message: 'Could not check graph coverage' });
}
@@ -395,8 +238,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Read-only — no network, no writes, no resolver calls. Samples the first
// 500 pages by slug order and surfaces bare-tweet + dead-link counts as a
// warning. Full-brain scan: `gbrain integrity check`.
progress.heartbeat('integrity_sample');
const integrityHb = startHeartbeat(progress, 'scanning 500-page integrity sample…');
try {
const { scanIntegrity } = await import('./integrity.ts');
const res = await scanIntegrity(engine, { limit: 500 });
@@ -422,31 +263,24 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
}
} catch (e) {
checks.push({ name: 'integrity', status: 'warn', message: `integrity scan skipped: ${e instanceof Error ? e.message : String(e)}` });
} finally {
integrityHb();
}
// 10. JSONB integrity (v0.12.3 reliability wave).
// v0.12.0's JSON.stringify()::jsonb pattern stored JSONB string literals
// instead of objects on real Postgres. PGLite masked this; Supabase did not.
// Scan 5 known write sites for rows whose top-level jsonb_typeof is
// 'string'. `page_versions.frontmatter` added in v0.15.2 so doctor's
// surface matches `repair-jsonb` (the previous 4-target scan missed a
// repair target, per #254/Codex review).
progress.heartbeat('jsonb_integrity');
// Scan the 4 known sites (pages.frontmatter, raw_data.data, ingest_log.pages_updated,
// files.metadata) for rows whose top-level jsonb_typeof is 'string'.
try {
const sql = db.getConnection();
const targets: Array<{ table: string; col: string; expected: 'object' | 'array' }> = [
{ table: 'pages', col: 'frontmatter', expected: 'object' },
{ table: 'raw_data', col: 'data', expected: 'object' },
{ table: 'ingest_log', col: 'pages_updated', expected: 'array' },
{ table: 'files', col: 'metadata', expected: 'object' },
{ table: 'page_versions', col: 'frontmatter', expected: 'object' },
{ table: 'pages', col: 'frontmatter', expected: 'object' },
{ table: 'raw_data', col: 'data', expected: 'object' },
{ table: 'ingest_log', col: 'pages_updated', expected: 'array' },
{ table: 'files', col: 'metadata', expected: 'object' },
];
let totalBad = 0;
const breakdown: string[] = [];
for (const { table, col } of targets) {
progress.heartbeat(`jsonb_integrity.${table}.${col}`);
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${table} WHERE jsonb_typeof(${col}) = 'string'`,
);
@@ -470,12 +304,6 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// v0.12.0's splitBody ate everything after the first `---` horizontal rule,
// truncating wiki-style pages. Heuristic: pages whose body is <30% of the
// raw source content length when raw has multiple H2/H3 boundaries.
//
// No total on this check: the regex scan over rd.data -> 'content' is a
// sequential scan that LIMIT 100 bounds only the output, not the scan
// work. We heartbeat every second so agents see life, no fake totals.
progress.heartbeat('markdown_body_completeness');
const mbcHb = startHeartbeat(progress, 'scanning pages for truncation…');
try {
const sql = db.getConnection();
const rows = await sql`
@@ -503,58 +331,8 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
} catch {
// pages_raw.raw_data may not exist on older schemas; best-effort.
checks.push({ name: 'markdown_body_completeness', status: 'ok', message: 'Skipped (raw_data unavailable)' });
} finally {
mbcHb();
}
// 12. Index audit (opt-in via --index-audit). v0.13.1 follow-up to #170.
// Reports indexes with zero recorded scans on Postgres. Informational only;
// we DO NOT auto-drop. On #170's brain, idx_pages_frontmatter and
// idx_pages_trgm showed 0 scans — the suggestion there is "consider
// investigating on YOUR brain," not "drop these globally." Zero scans on a
// fresh install is also normal (nothing has queried yet); the real signal
// is zero scans on a long-running active brain.
if (args.includes('--index-audit')) {
progress.heartbeat('index_audit');
if (engine.kind === 'pglite') {
checks.push({
name: 'index_audit',
status: 'ok',
message: 'Skipped (PGLite — pg_stat_user_indexes is a Postgres extension)',
});
} else {
try {
const sql = db.getConnection();
const rows = await sql`
SELECT schemaname, relname AS table, indexrelname AS index,
idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
AND idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20
`;
if (rows.length === 0) {
checks.push({ name: 'index_audit', status: 'ok', message: 'All public indexes have recorded scans' });
} else {
const list = rows.map((r: any) => `${r.index}(${r.size})`).join(', ');
checks.push({
name: 'index_audit',
status: 'warn',
message: `${rows.length} zero-scan index(es): ${list}. ` +
`Consider investigating whether they're used on YOUR workload (fresh brains naturally show zero scans until queries accumulate). ` +
`Do not drop without confirming.`,
});
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
checks.push({ name: 'index_audit', status: 'warn', message: `Index audit failed: ${msg}` });
}
}
}
progress.finish();
const hasFail = outputResults(checks, jsonOutput);
// Features teaser (non-JSON, non-failing only)
@@ -573,37 +351,18 @@ export async function runDoctor(engine: BrainEngine | null, args: string[], dbSo
// Helpers
// ---------------------------------------------------------------------------
/** Print the auto-fix report in human-readable form. JSON output goes through
* outputResults alongside the check list; this is the pretty-print path. */
function printAutoFixReport(report: AutoFixReport, dryRun: boolean, jsonOutput: boolean): void {
if (jsonOutput) return; // JSON consumers read autoFixReport via the check issues / caller
const verb = dryRun ? 'PROPOSED' : 'APPLIED';
for (const outcome of report.fixed) {
console.log(`[${verb}] ${outcome.skillPath} (${outcome.patternLabel})`);
if (outcome.before) {
console.log('--- before');
console.log(outcome.before);
console.log('--- after');
console.log(outcome.after ?? '');
console.log('');
}
/** Find the GBrain repo root by walking up from cwd looking for skills/RESOLVER.md */
function findRepoRoot(): string | null {
let dir = process.cwd();
for (let i = 0; i < 10; i++) {
if (existsSync(join(dir, 'skills', 'RESOLVER.md'))) return dir;
const parent = join(dir, '..');
if (parent === dir) break;
dir = parent;
}
const n = report.fixed.length;
const s = report.skipped.length;
if (n === 0 && s === 0) {
console.log('Doctor --fix: no DRY violations to repair.');
return;
}
const label = dryRun ? 'fixes proposed' : 'fixes applied';
console.log(`${n} ${label}${s > 0 ? `, ${s} skipped:` : '.'}`);
for (const sk of report.skipped) {
const hint = sk.reason === 'working_tree_dirty' ? ' (run `git stash` first)' : '';
console.log(` - ${sk.skillPath}: ${sk.reason}${hint}`);
}
if (dryRun && n > 0) console.log('\nRun without --dry-run to apply.');
return null;
}
/** Quick skill conformance check — frontmatter + required sections */
function checkSkillConformance(skillsDir: string): Check {
const manifestPath = join(skillsDir, 'manifest.json');
-209
View File
@@ -1,209 +0,0 @@
/**
* gbrain dream run one brain maintenance cycle.
*
* The README brand promise: "the agent runs while I sleep, the dream
* cycle ... I wake up and the brain is smarter." Cron-friendly, JSON
* report, phase-selectable.
*
* Thin alias over runCycle (src/core/cycle.ts). Both this command and
* `gbrain autopilot` converge on the same primitive so there's one
* source of truth for what "overnight maintenance" means.
*
* Usage:
* gbrain dream # full 6-phase cycle
* gbrain dream --dry-run # preview, no writes
* gbrain dream --json # CycleReport JSON (for agents)
* gbrain dream --phase lint # run a single phase
* gbrain dream --pull # also git pull the brain repo
* gbrain dream --dir /path/to/brain # explicit brain location
*
* Cron: 0 2 * * * gbrain dream --json >> /var/log/gbrain-dream.log
*
* Related: `gbrain autopilot --install` for continuous daemonized
* maintenance. dream is the one-shot, autopilot is the scheduler.
*/
import type { BrainEngine } from '../core/engine.ts';
import {
runCycle,
ALL_PHASES,
type CyclePhase,
type CycleReport,
} from '../core/cycle.ts';
import { existsSync } from 'fs';
interface DreamArgs {
json: boolean;
dryRun: boolean;
pull: boolean;
phase: CyclePhase | null;
dir: string | null;
help: boolean;
}
function parseArgs(args: string[]): DreamArgs {
const phaseIdx = args.indexOf('--phase');
const rawPhase = phaseIdx !== -1 ? args[phaseIdx + 1] : null;
const phase = rawPhase && (ALL_PHASES as string[]).includes(rawPhase)
? (rawPhase as CyclePhase)
: null;
if (rawPhase && !phase) {
console.error(`Unknown phase "${rawPhase}". Valid: ${ALL_PHASES.join(', ')}`);
process.exit(1);
}
const dirIdx = args.indexOf('--dir');
const dir = dirIdx !== -1 ? args[dirIdx + 1] : null;
return {
json: args.includes('--json'),
dryRun: args.includes('--dry-run'),
pull: args.includes('--pull'),
phase,
dir,
help: args.includes('--help') || args.includes('-h'),
};
}
/**
* Resolve the brain directory without the `findRepoRoot` footgun.
*
* Prior dream.ts walked up 10 levels of cwd looking for `.git` and would
* happily run lint + sync against an unrelated git repo the user happened
* to be cd'd into. This resolver only trusts two sources:
* 1. An explicit --dir argument.
* 2. The `sync.repo_path` config key set by `gbrain init` (engine-backed).
*
* If neither is available, we error out instead of guessing.
*/
async function resolveBrainDir(
engine: BrainEngine | null,
explicit: string | null,
): Promise<string> {
if (explicit) {
if (!existsSync(explicit)) {
console.error(`--dir path does not exist: ${explicit}`);
process.exit(1);
}
return explicit;
}
if (engine) {
const configured = await engine.getConfig('sync.repo_path');
if (configured && existsSync(configured)) {
return configured;
}
}
console.error(
'No brain directory found. Pass --dir <path> or configure one via `gbrain init`.',
);
process.exit(1);
}
function printHelp() {
console.log(`Usage: gbrain dream [options]
Run one brain maintenance cycle: lint, backlinks, orphan sweep, sync,
extract, and embed. Designed for cron (exits when done).
Options:
--dry-run Preview all fixes without writing (fs or DB)
--json Emit the CycleReport as JSON (agent-readable)
--phase <name> Run a single phase: ${ALL_PHASES.join(' | ')}
--pull git pull the brain repo before syncing (default: no pull)
--dir <path> Brain directory (default: configured brain)
--help, -h Show this help
Examples:
gbrain dream
gbrain dream --dry-run --json
gbrain dream --phase lint
0 2 * * * gbrain dream --json # nightly via cron
Related:
gbrain autopilot --install # continuous maintenance as a daemon
gbrain autopilot # same maintenance cycle, scheduled
`);
}
// ─── Human-friendly report printing ────────────────────────────────
function printHuman(report: CycleReport) {
if (report.status === 'skipped') {
if (report.reason === 'cycle_already_running') {
console.log(`Skipped: another cycle is already running. (locked)`);
} else if (report.reason === 'no_database') {
console.log(`Skipped: no database available.`);
} else {
console.log(`Skipped: ${report.reason ?? 'unknown reason'}.`);
}
return;
}
if (report.status === 'clean') {
console.log(
`Brain is healthy. ${report.phases.length} phase(s) checked in ${(report.duration_ms / 1000).toFixed(1)}s.`,
);
return;
}
console.log(`Dream cycle (${report.status}) in ${(report.duration_ms / 1000).toFixed(1)}s:`);
for (const p of report.phases) {
const icon =
p.status === 'ok' ? '✓' :
p.status === 'warn' ? '!' :
p.status === 'skipped' ? '-' : '✗';
const line = ` ${icon} ${p.phase.padEnd(10)} ${p.summary}`;
console.log(line);
if (p.error) {
const hint = p.error.hint ? ` (${p.error.hint})` : '';
console.log(` [${p.error.class}/${p.error.code}] ${p.error.message}${hint}`);
}
}
const t = report.totals;
const hasTotals =
t.lint_fixes > 0 || t.backlinks_added > 0 || t.pages_synced > 0 ||
t.pages_extracted > 0 || t.pages_embedded > 0 || t.orphans_found > 0;
if (hasTotals) {
console.log(
` totals: lint=${t.lint_fixes} backlinks=${t.backlinks_added} synced=${t.pages_synced} extracted=${t.pages_extracted} embedded=${t.pages_embedded} orphans=${t.orphans_found}`,
);
}
}
// ─── CLI entry ─────────────────────────────────────────────────────
export async function runDream(engine: BrainEngine | null, args: string[]): Promise<CycleReport | void> {
const opts = parseArgs(args);
if (opts.help) {
printHelp();
return;
}
const brainDir = await resolveBrainDir(engine, opts.dir);
const phases: CyclePhase[] | undefined = opts.phase ? [opts.phase] : undefined;
const report = await runCycle(engine, {
brainDir,
dryRun: opts.dryRun,
pull: opts.pull,
phases,
});
if (opts.json) {
console.log(JSON.stringify(report, null, 2));
} else {
printHuman(report);
}
// Exit non-zero when the cycle failed overall (helps cron spot real problems).
// 'partial' is not a failure — it means some phase warned but the cycle ran.
if (report.status === 'failed') {
process.exit(1);
}
return report;
}
+24 -141
View File
@@ -2,8 +2,6 @@ import type { BrainEngine } from '../core/engine.ts';
import { embedBatch } from '../core/embedding.ts';
import type { ChunkInput } from '../core/types.ts';
import { chunkText } from '../core/chunkers/recursive.ts';
import { createProgress, type ProgressReporter } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export interface EmbedOpts {
/** Embed ALL pages (every chunk). */
@@ -14,143 +12,70 @@ export interface EmbedOpts {
slugs?: string[];
/** Embed a single page. */
slug?: string;
/**
* Dry run: enumerate what WOULD be embedded (stale chunk counts)
* without calling the embedding model or writing to the engine.
* Safe to call with no API key. Used by runCycle's dryRun propagation.
*/
dryRun?: boolean;
/**
* Optional progress callback. Called after each page. CLI wrappers
* supply a reporter.tick()-backed implementation; Minion handlers
* supply a job.updateProgress()-backed one so per-job progress lives
* in the DB where `gbrain jobs get` can read it.
*/
onProgress?: (done: number, total: number, embedded: number) => void;
}
/**
* Structured result from a library-level embed run.
*
* In dryRun mode, `embedded = 0` and `would_embed` holds the count of
* stale chunks that WOULD have been sent to the embedding model. In
* non-dryRun mode, `embedded` holds the real count and `would_embed = 0`.
* `skipped` counts chunks that already had embeddings (nothing to do).
*/
export interface EmbedResult {
/** Chunks newly embedded in this run (0 in dryRun). */
embedded: number;
/** Chunks with pre-existing embeddings, skipped. */
skipped: number;
/** Chunks that would be embedded if not for dryRun (0 in non-dryRun). */
would_embed: number;
/** Total chunks considered across all processed pages. */
total_chunks: number;
/** Number of pages processed (whether or not they had stale chunks). */
pages_processed: number;
/** True if this run was a dry-run. */
dryRun: boolean;
}
/**
* Library-level embed. Throws on validation errors; per-page embed failures
* are logged to stderr but do not throw (matches the existing CLI semantics
* for batch runs). Safe to call from Minions handlers no process.exit.
*
* Returns EmbedResult with accurate counts so callers (runCycle, sync
* auto-embed step) can report embeddings in their own structured output.
*/
export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promise<EmbedResult> {
const result: EmbedResult = {
embedded: 0,
skipped: 0,
would_embed: 0,
total_chunks: 0,
pages_processed: 0,
dryRun: !!opts.dryRun,
};
export async function runEmbedCore(engine: BrainEngine, opts: EmbedOpts): Promise<void> {
if (opts.slugs && opts.slugs.length > 0) {
for (const s of opts.slugs) {
try {
await embedPage(engine, s, !!opts.dryRun, result);
} catch (e: unknown) {
try { await embedPage(engine, s); } catch (e: unknown) {
console.error(` Error embedding ${s}: ${e instanceof Error ? e.message : e}`);
}
}
return result;
return;
}
if (opts.all || opts.stale) {
await embedAll(engine, !!opts.stale, !!opts.dryRun, result, opts.onProgress);
return result;
await embedAll(engine, !!opts.stale);
return;
}
if (opts.slug) {
await embedPage(engine, opts.slug, !!opts.dryRun, result);
return result;
await embedPage(engine, opts.slug);
return;
}
throw new Error('No embed target specified. Pass { slug }, { slugs }, { all }, or { stale }.');
}
export async function runEmbed(engine: BrainEngine, args: string[]): Promise<EmbedResult | undefined> {
export async function runEmbed(engine: BrainEngine, args: string[]) {
const slugsIdx = args.indexOf('--slugs');
const all = args.includes('--all');
const stale = args.includes('--stale');
const dryRun = args.includes('--dry-run');
let opts: EmbedOpts;
if (slugsIdx >= 0) {
opts = { slugs: args.slice(slugsIdx + 1).filter(a => !a.startsWith('--')), dryRun };
opts = { slugs: args.slice(slugsIdx + 1).filter(a => !a.startsWith('--')) };
} else if (all || stale) {
opts = { all, stale, dryRun };
opts = { all, stale };
} else {
const slug = args.find(a => !a.startsWith('--'));
if (!slug) {
console.error('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...] [--dry-run]');
console.error('Usage: gbrain embed [<slug>|--all|--stale|--slugs s1 s2 ...]');
process.exit(1);
}
opts = { slug, dryRun };
opts = { slug };
}
// CLI path: wire a reporter so --progress-json / --quiet / TTY rendering
// all work. Minion handlers call runEmbedCore directly with their own
// onProgress (see jobs.ts).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
let progressStarted = false;
opts.onProgress = (done, total, _embedded) => {
if (!progressStarted) {
progress.start('embed.pages', total);
progressStarted = true;
}
progress.tick(1);
};
try {
const result = await runEmbedCore(engine, opts);
if (progressStarted) progress.finish();
return result;
await runEmbedCore(engine, opts);
} catch (e) {
if (progressStarted) progress.finish();
console.error(e instanceof Error ? e.message : String(e));
process.exit(1);
}
}
async function embedPage(
engine: BrainEngine,
slug: string,
dryRun: boolean,
result: EmbedResult,
) {
async function embedPage(engine: BrainEngine, slug: string) {
const page = await engine.getPage(slug);
if (!page) {
throw new Error(`Page not found: ${slug}`);
}
// Get existing chunks or create new ones.
// In dryRun, we still chunk the text locally to count what WOULD be
// embedded — but we never write chunks or call the embedding model.
// Get existing chunks or create new ones
let chunks = await engine.getChunks(slug);
if (chunks.length === 0) {
// Create chunks first
const inputs: ChunkInput[] = [];
if (page.compiled_truth.trim()) {
for (const c of chunkText(page.compiled_truth)) {
@@ -162,15 +87,6 @@ async function embedPage(
inputs.push({ chunk_index: inputs.length, chunk_text: c.text, chunk_source: 'timeline' });
}
}
if (dryRun) {
// Count what chunking WOULD produce, without writing.
result.total_chunks += inputs.length;
result.would_embed += inputs.length;
result.pages_processed++;
return;
}
if (inputs.length > 0) {
await engine.upsertChunks(slug, inputs);
chunks = await engine.getChunks(slug);
@@ -179,18 +95,8 @@ async function embedPage(
// Embed chunks without embeddings
const toEmbed = chunks.filter(c => !c.embedded_at);
result.total_chunks += chunks.length;
result.skipped += chunks.length - toEmbed.length;
if (toEmbed.length === 0) {
console.log(`${slug}: all ${chunks.length} chunks already embedded`);
result.pages_processed++;
return;
}
if (dryRun) {
result.would_embed += toEmbed.length;
result.pages_processed++;
return;
}
@@ -208,19 +114,13 @@ async function embedPage(
}));
await engine.upsertChunks(slug, updated);
result.embedded += toEmbed.length;
result.pages_processed++;
console.log(`${slug}: embedded ${toEmbed.length} chunks`);
}
async function embedAll(
engine: BrainEngine,
staleOnly: boolean,
dryRun: boolean,
result: EmbedResult,
onProgress?: (done: number, total: number, embedded: number) => void,
) {
async function embedAll(engine: BrainEngine, staleOnly: boolean) {
const pages = await engine.listPages({ limit: 100000 });
let total = 0;
let embedded = 0;
let processed = 0;
// Concurrency limit for parallel page embedding.
@@ -239,21 +139,9 @@ async function embedAll(
? chunks.filter(c => !c.embedded_at)
: chunks;
result.total_chunks += chunks.length;
result.skipped += chunks.length - toEmbed.length;
if (toEmbed.length === 0) {
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
return;
}
if (dryRun) {
result.would_embed += toEmbed.length;
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
process.stdout.write(`\r ${processed}/${pages.length} pages, ${embedded} chunks embedded`);
return;
}
@@ -273,14 +161,14 @@ async function embedAll(
token_count: c.token_count || Math.ceil(c.chunk_text.length / 4),
}));
await engine.upsertChunks(page.slug, updated);
result.embedded += toEmbed.length;
embedded += toEmbed.length;
} catch (e: unknown) {
console.error(`\n Error embedding ${page.slug}: ${e instanceof Error ? e.message : e}`);
}
total += toEmbed.length;
processed++;
result.pages_processed++;
onProgress?.(processed, pages.length, result.embedded);
process.stdout.write(`\r ${processed}/${pages.length} pages, ${embedded} chunks embedded`);
}
// Sliding worker pool: N workers share a queue and each pulls the
@@ -299,10 +187,5 @@ async function embedAll(
const numWorkers = Math.min(CONCURRENCY, pages.length);
await Promise.all(Array.from({ length: numWorkers }, () => worker()));
// Stdout summary preserved for scripts/tests that grep for counts.
if (dryRun) {
console.log(`[dry-run] Would embed ${result.would_embed} chunks across ${pages.length} pages`);
} else {
console.log(`Embedded ${result.embedded} chunks across ${pages.length} pages`);
}
console.log(`\n\nEmbedded ${embedded} chunks across ${pages.length} pages`);
}
+3 -14
View File
@@ -50,28 +50,17 @@ export async function runEvalCommand(engine: BrainEngine, args: string[]): Promi
const k = opts.k ?? 5;
const configA = buildConfig(opts, 'a');
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
if (opts.configB || opts.configBPath) {
// A/B comparison mode
const configB = buildConfig(opts, 'b');
progress.start('eval.ab', qrels.length * 2);
const onProgress = (_done: number, _total: number, q: string) => progress.tick(1, q);
const [reportA, reportB] = await Promise.all([
runEval(engine, qrels, configA, k, { onProgress }),
runEval(engine, qrels, configB, k, { onProgress }),
runEval(engine, qrels, configA, k),
runEval(engine, qrels, configB, k),
]);
progress.finish();
printABTable(reportA, reportB, k);
} else {
// Single-run mode
progress.start('eval.single', qrels.length);
const report = await runEval(engine, qrels, configA, k, {
onProgress: (_done, _total, q) => progress.tick(1, q),
});
progress.finish();
const report = await runEval(engine, qrels, configA, k);
printSingleTable(report);
}
}
+4 -10
View File
@@ -2,8 +2,6 @@ import { writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import type { BrainEngine } from '../core/engine.ts';
import { serializeMarkdown } from '../core/markdown.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
export async function runExport(engine: BrainEngine, args: string[]) {
const dirIdx = args.indexOf('--dir');
@@ -12,10 +10,6 @@ export async function runExport(engine: BrainEngine, args: string[]) {
const pages = await engine.listPages({ limit: 100000 });
console.log(`Exporting ${pages.length} pages to ${outDir}/`);
// Progress on stderr so stdout stays clean for scripts parsing counts.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('export.pages', pages.length);
let exported = 0;
for (const page of pages) {
@@ -47,10 +41,10 @@ export async function runExport(engine: BrainEngine, args: string[]) {
}
exported++;
progress.tick();
if (exported % 100 === 0) {
process.stdout.write(`\r ${exported}/${pages.length} exported`);
}
}
progress.finish();
// Stdout summary preserved so scripts that grep for "Exported N pages" keep working.
console.log(`Exported ${exported} pages to ${outDir}/`);
console.log(`\nExported ${exported} pages to ${outDir}/`);
}
+12 -25
View File
@@ -26,8 +26,6 @@ import {
extractFrontmatterLinks,
type UnresolvedFrontmatterRef,
} from '../core/link-extraction.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
// Batch size for addLinksBatch / addTimelineEntriesBatch.
// Postgres bind-parameter limit is 65535. Links use 4 cols/row → 16K hard ceiling;
@@ -417,12 +415,6 @@ async function extractLinksFromDir(
const files = walkMarkdownFiles(brainDir);
const allSlugs = new Set(files.map(f => f.relPath.replace('.md', '')));
// Progress stream on stderr (separate from the action-events --json writes
// to stdout, which tests grep for). Rate-gated; respects global --quiet /
// --progress-json flags.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.links_fs', files.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
// Without this, the same link extracted from N files would print N times in --dry-run.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -462,10 +454,11 @@ async function extractLinksFromDir(
}
}
} catch { /* skip unreadable */ }
progress.tick(1);
if (jsonMode && !dryRun && (i % 100 === 0 || i === files.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_links', done: i + 1, total: files.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
@@ -479,9 +472,6 @@ async function extractTimelineFromDir(
): Promise<{ created: number; pages: number }> {
const files = walkMarkdownFiles(brainDir);
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.timeline_fs', files.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -520,10 +510,11 @@ async function extractTimelineFromDir(
}
}
} catch { /* skip unreadable */ }
progress.tick(1);
if (jsonMode && !dryRun && (i % 100 === 0 || i === files.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_timeline', done: i + 1, total: files.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
@@ -595,9 +586,6 @@ async function extractLinksFromDB(
const slugList = Array.from(allSlugs);
let processed = 0, created = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.links_db', slugList.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -673,10 +661,11 @@ async function extractLinksFromDB(
}
}
processed++;
progress.tick(1);
if (jsonMode && !dryRun && (processed % 500 === 0 || i === slugList.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_links_db', done: processed, total: slugList.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
@@ -710,9 +699,6 @@ async function extractTimelineFromDB(
const slugList = Array.from(allSlugs);
let processed = 0, created = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('extract.timeline_db', slugList.length);
// Dedup in dry-run only — DB enforces uniqueness via ON CONFLICT in batch writes.
const dryRunSeen = dryRun ? new Set<string>() : null;
@@ -767,10 +753,11 @@ async function extractTimelineFromDB(
}
}
processed++;
progress.tick(1);
if (jsonMode && !dryRun && (processed % 500 === 0 || i === slugList.length - 1)) {
process.stderr.write(JSON.stringify({ event: 'progress', phase: 'extracting_timeline_db', done: processed, total: slugList.length }) + '\n');
}
}
await flush();
progress.finish();
if (!jsonMode) {
const label = dryRun ? '(dry run) would create' : 'created';
+5 -10
View File
@@ -4,8 +4,6 @@ import { createHash } from 'crypto';
import type { BrainEngine } from '../core/engine.ts';
import * as db from '../core/db.ts';
import { humanSize } from '../core/file-resolver.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
/** Size threshold: files >= 100 MB use TUS resumable upload */
const SIZE_THRESHOLD = 100 * 1024 * 1024;
@@ -308,14 +306,13 @@ async function syncFiles(dir?: string) {
let uploaded = 0;
let skipped = 0;
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('files.sync', files.length);
for (let i = 0; i < files.length; i++) {
const filePath = files[i];
const relativePath = relative(dir, filePath);
progress.tick(1);
if ((i + 1) % 50 === 0 || i === files.length - 1) {
process.stdout.write(`\r ${i + 1}/${files.length} processed, ${uploaded} uploaded, ${skipped} skipped`);
}
const hash = fileHash(filePath);
const filename = basename(filePath);
@@ -346,9 +343,7 @@ async function syncFiles(dir?: string) {
uploaded++;
}
progress.finish();
// Stdout summary preserved for scripts/tests that grep for it.
console.log(`Files sync complete: ${uploaded} uploaded, ${skipped} skipped (unchanged)`);
console.log(`\n\nFiles sync complete: ${uploaded} uploaded, ${skipped} skipped (unchanged)`);
}
async function verifyFiles() {
@@ -421,7 +416,7 @@ async function mirrorFiles(args: string[]) {
// Write .supabase marker
const marker = stringify({
synced_at: new Date().toISOString(),
bucket: (config.storage as { bucket?: string })?.bucket || 'brain-files',
bucket: config.storage.bucket || 'brain-files',
prefix: basename(dir) + '/',
file_count: uploaded,
});
+17 -64
View File
@@ -5,8 +5,6 @@ import { cpus, totalmem, homedir } from 'os';
import type { BrainEngine } from '../core/engine.ts';
import { importFile } from '../core/import-file.ts';
import { loadConfig } from '../core/config.ts';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
function defaultWorkers(): number {
const cpuCount = cpus().length;
@@ -19,16 +17,7 @@ function defaultWorkers(): number {
return Math.min(byPool, byCpu, byMem);
}
/** Bug 9 — surface per-file failures so callers (performFullSync) can gate state advances. */
export interface RunImportResult {
imported: number;
skipped: number;
errors: number;
chunksCreated: number;
failures: Array<{ path: string; error: string }>;
}
export async function runImport(engine: BrainEngine, args: string[], opts: { commit?: string } = {}): Promise<RunImportResult> {
export async function runImport(engine: BrainEngine, args: string[]) {
const noEmbed = args.includes('--no-embed');
const fresh = args.includes('--fresh');
const jsonOutput = args.includes('--json');
@@ -38,13 +27,12 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
// Find dir: first non-flag arg that isn't a value for --workers
const flagValues = new Set<number>();
if (workersIdx !== -1) flagValues.add(workersIdx + 1);
const dirArg = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
const dir = args.find((a, i) => !a.startsWith('--') && !flagValues.has(i));
if (!dirArg) {
if (!dir) {
console.error('Usage: gbrain import <dir> [--no-embed] [--workers N] [--fresh] [--json]');
process.exit(1);
}
const dir: string = dirArg; // narrowed; survives closure capture
// Collect all .md files
const allFiles = collectMarkdownFiles(dir);
@@ -81,15 +69,14 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
let chunksCreated = 0;
const importedSlugs: string[] = [];
const errorCounts: Record<string, number> = {};
const failures: Array<{ path: string; error: string }> = []; // Bug 9
const startTime = Date.now();
// Progress on stderr so stdout stays clean for the final summary / --json payload.
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('import.files', files.length);
function tickProgress() {
progress.tick(1, `imported=${imported} skipped=${skipped} errors=${errors}`);
function logProgress() {
const elapsed = (Date.now() - startTime) / 1000;
const rate = elapsed > 0 ? Math.round(processed / elapsed) : 0;
const remaining = rate > 0 ? Math.round((files.length - processed) / rate) : 0;
const pct = Math.round((processed / files.length) * 100);
console.log(`[gbrain import] ${processed}/${files.length} (${pct}%) | ${rate} files/sec | imported: ${imported} | skipped: ${skipped} | errors: ${errors} | ETA: ${remaining}s`);
}
async function processFile(eng: BrainEngine, filePath: string) {
@@ -104,8 +91,6 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
skipped++;
if (result.error && result.error !== 'unchanged') {
console.error(` Skipped ${relativePath}: ${result.error}`);
// Bug 9 — non-"unchanged" skips carry a real error reason.
failures.push({ path: relativePath, error: result.error });
}
}
} catch (e: unknown) {
@@ -119,11 +104,10 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
errors++;
skipped++;
failures.push({ path: relativePath, error: msg });
}
processed++;
tickProgress();
if (processed % 100 === 0 || processed === files.length) {
logProgress();
// Save checkpoint every 100 files — track completed file set, not just a counter
if (processed % 100 === 0) {
try {
@@ -151,15 +135,10 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
} else {
const { PostgresEngine } = await import('../core/postgres-engine.ts');
const { resolvePoolSize } = await import('../core/db.ts');
// Default per-worker pool is 2 (small, parallel import case). Users on
// constrained poolers (e.g. Supabase port 6543) can cap below this via
// GBRAIN_POOL_SIZE=1.
const workerPoolSize = Math.min(2, resolvePoolSize(2));
const workerEngines = await Promise.all(
Array.from({ length: actualWorkers }, async () => {
const eng = new PostgresEngine();
await eng.connect({ database_url: config!.database_url!, poolSize: workerPoolSize });
await eng.connect({ database_url: config!.database_url!, poolSize: 2 });
return eng;
})
);
@@ -183,8 +162,6 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
}
}
progress.finish();
// Error summary
for (const [err, count] of Object.entries(errorCounts)) {
if (count > 5) {
@@ -221,41 +198,17 @@ export async function runImport(engine: BrainEngine, args: string[], opts: { com
summary: `Imported ${imported} pages, ${skipped} skipped, ${chunksCreated} chunks`,
});
// Import → sync continuity: write sync checkpoint if this is a git repo.
// Bug 9 — gate last_commit on "no failures" so import doesn't silently
// stomp on the sync bookmark when parsing broke. We still write
// last_run + repo_path either way (those are progress indicators).
let gitHead: string | null = null;
// Import → sync continuity: write sync checkpoint if this is a git repo
try {
if (existsSync(join(dir, '.git'))) {
gitHead = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
const head = execFileSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf-8' }).trim();
await engine.setConfig('sync.last_commit', head);
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', dir);
}
} catch {
// Not a git repo or git not available
// Not a git repo or git not available, skip checkpoint
}
if (gitHead) {
// Record failures into the central JSONL so doctor can surface them.
// Use gitHead as the commit so a later sync can tell "same broken
// state as last time" from "new broken state."
if (failures.length > 0) {
const { recordSyncFailures } = await import('../core/sync.ts');
recordSyncFailures(failures, gitHead);
}
if (failures.length === 0) {
await engine.setConfig('sync.last_commit', gitHead);
} else {
console.error(
`\nImport completed with ${failures.length} failure(s). ` +
`sync.last_commit NOT advanced — re-run 'gbrain sync' to retry, or ` +
`'gbrain sync --skip-failed' to acknowledge and move past them.`,
);
}
await engine.setConfig('sync.last_run', new Date().toISOString());
await engine.setConfig('sync.repo_path', dir);
}
return { imported, skipped, errors, chunksCreated, failures };
}
export function collectMarkdownFiles(dir: string): string[] {
+73 -79
View File
@@ -107,39 +107,36 @@ async function initPGLite(opts: { jsonOutput: boolean; apiKey: string | null; cu
console.log(`Setting up local brain with PGLite (no server needed)...`);
const engine = await createEngine({ engine: 'pglite' });
try {
await engine.connect({ database_path: dbPath, engine: 'pglite' });
await engine.initSchema();
await engine.connect({ database_path: dbPath, engine: 'pglite' });
await engine.initSchema();
const config: GBrainConfig = {
engine: 'pglite',
database_path: dbPath,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
const config: GBrainConfig = {
engine: 'pglite',
database_path: dbPath,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
const stats = await engine.getStats();
const stats = await engine.getStats();
await engine.disconnect();
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'pglite', path: dbPath, pages: stats.page_count }));
} else {
console.log(`\nBrain ready at ${dbPath}`);
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'pglite', path: dbPath, pages: stats.page_count }));
} else {
console.log(`\nBrain ready at ${dbPath}`);
console.log(`${stats.page_count} pages. Engine: PGLite (local Postgres).`);
if (stats.page_count > 0) {
console.log('');
console.log('When you outgrow local: gbrain migrate --to supabase');
reportModStatus();
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
} finally {
try { await engine.disconnect(); } catch { /* best-effort */ }
console.log('');
console.log('When you outgrow local: gbrain migrate --to supabase');
reportModStatus();
}
}
@@ -160,67 +157,64 @@ async function initPostgres(opts: { databaseUrl: string; jsonOutput: boolean; ap
console.log('Connecting to database...');
const engine = await createEngine({ engine: 'postgres' });
try {
try {
await engine.connect({ database_url: databaseUrl });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
console.error('Use the Session pooler connection string instead (port 6543).');
}
throw e;
await engine.connect({ database_url: databaseUrl });
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (databaseUrl.includes('supabase.co') && (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT'))) {
console.error('Connection failed. Supabase direct connections (db.*.supabase.co:5432) are IPv6 only.');
console.error('Use the Session pooler connection string instead (port 6543).');
}
throw e;
}
// Check and auto-create pgvector extension
try {
const conn = (engine as any).sql || (await import('../core/db.ts')).getConnection();
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
if (ext.length === 0) {
console.log('pgvector extension not found. Attempting to create...');
try {
await conn`CREATE EXTENSION IF NOT EXISTS vector`;
console.log('pgvector extension created successfully.');
} catch {
console.error('Could not auto-create pgvector extension. Run manually in SQL Editor:');
console.error(' CREATE EXTENSION vector;');
// Throw so the outer finally runs engine.disconnect() before we die.
throw new Error('pgvector extension missing');
}
// Check and auto-create pgvector extension
try {
const conn = (engine as any).sql || (await import('../core/db.ts')).getConnection();
const ext = await conn`SELECT extname FROM pg_extension WHERE extname = 'vector'`;
if (ext.length === 0) {
console.log('pgvector extension not found. Attempting to create...');
try {
await conn`CREATE EXTENSION IF NOT EXISTS vector`;
console.log('pgvector extension created successfully.');
} catch {
console.error('Could not auto-create pgvector extension. Run manually in SQL Editor:');
console.error(' CREATE EXTENSION vector;');
await engine.disconnect();
process.exit(1);
}
} catch {
// Non-fatal
}
} catch {
// Non-fatal
}
console.log('Running schema migration...');
await engine.initSchema();
console.log('Running schema migration...');
await engine.initSchema();
const config: GBrainConfig = {
engine: 'postgres',
database_url: databaseUrl,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
console.log('Config saved to ~/.gbrain/config.json');
const config: GBrainConfig = {
engine: 'postgres',
database_url: databaseUrl,
...(opts.apiKey ? { openai_api_key: opts.apiKey } : {}),
};
saveConfig(config);
console.log('Config saved to ~/.gbrain/config.json');
const stats = await engine.getStats();
const stats = await engine.getStats();
await engine.disconnect();
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'postgres', pages: stats.page_count }));
if (opts.jsonOutput) {
console.log(JSON.stringify({ status: 'success', engine: 'postgres', pages: stats.page_count }));
} else {
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log(`\nBrain ready. ${stats.page_count} pages. Engine: Postgres (Supabase).`);
if (stats.page_count > 0) {
console.log('');
console.log('Existing brain detected. To wire up the v0.10.3 knowledge graph:');
console.log(' gbrain extract links --source db (typed link backfill)');
console.log(' gbrain extract timeline --source db (structured timeline backfill)');
console.log(' gbrain stats (verify links > 0)');
} else {
console.log('Next: gbrain import <dir>');
}
reportModStatus();
console.log('Next: gbrain import <dir>');
}
} finally {
try { await engine.disconnect(); } catch { /* best-effort */ }
reportModStatus();
}
}
-9
View File
@@ -361,14 +361,8 @@ async function cmdAuto(args: string[]): Promise<void> {
let bucketErr = 0;
let pagesProcessed = 0;
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
try {
const allSlugs = [...(await engine.getAllSlugs())].sort();
const toScan = allSlugs.filter(s => !seen.has(s));
progress.start('integrity.auto', toScan.length);
for (const slug of allSlugs) {
if (pagesProcessed >= limit) break;
if (seen.has(slug)) continue;
@@ -377,7 +371,6 @@ async function cmdAuto(args: string[]): Promise<void> {
if (!page) continue;
pagesProcessed++;
progress.tick(1, slug);
// Bare-tweet handling
if (!skipTweet) {
@@ -463,8 +456,6 @@ async function cmdAuto(args: string[]): Promise<void> {
}
}
progress.finish();
// Summary
console.log('');
console.log(`=== integrity auto summary${dryRun ? ' (DRY RUN)' : ''} ===`);
+42 -220
View File
@@ -57,10 +57,8 @@ export async function runJobs(engine: BrainEngine, args: string[]): Promise<void
USAGE
gbrain jobs submit <name> [--params JSON] [--follow] [--priority N]
[--delay Nms] [--max-attempts N] [--max-stalled N]
[--backoff-type fixed|exponential] [--backoff-delay Nms]
[--backoff-jitter 0..1] [--timeout-ms Nms]
[--idempotency-key K] [--queue Q] [--dry-run]
[--delay Nms] [--max-attempts N] [--queue Q]
[--dry-run]
gbrain jobs list [--status S] [--queue Q] [--limit N]
gbrain jobs get <id>
gbrain jobs cancel <id>
@@ -70,18 +68,6 @@ USAGE
gbrain jobs stats
gbrain jobs smoke
gbrain jobs work [--queue Q] [--concurrency N]
HANDLER TYPES (built in)
sync Pull and embed new pages from the repo
embed (Re-)embed pages; --params '{"slug":...}' or '{"all":true}'
lint Run page linter; --params '{"dir":"...","fix":true}'
import Bulk import markdown; --params '{"dir":"..."}'
extract Extract links + timeline entries; '{"mode":"all"}'
backlinks Check or fix back-links; '{"action":"fix"}'
autopilot-cycle One autopilot pass (sync+extract+embed+backlinks)
shell Run a command or argv. Requires GBRAIN_ALLOW_SHELL_JOBS=1
on the worker. Params: {cmd?, argv?, cwd, env?}.
See: docs/guides/minions-shell-jobs.md
`);
return;
}
@@ -106,25 +92,6 @@ HANDLER TYPES (built in)
const priority = parseInt(parseFlag(args, '--priority') ?? '0', 10);
const delay = parseInt(parseFlag(args, '--delay') ?? '0', 10);
const maxAttempts = parseInt(parseFlag(args, '--max-attempts') ?? '3', 10);
const maxStalledRaw = parseFlag(args, '--max-stalled');
const maxStalled = maxStalledRaw !== undefined ? parseInt(maxStalledRaw, 10) : undefined;
// v0.13.1 field audit: expose retry/backoff/timeout/idempotency knobs so
// users can tune Minions behavior without dropping into TypeScript.
const backoffTypeRaw = parseFlag(args, '--backoff-type');
const backoffType = backoffTypeRaw === 'fixed' || backoffTypeRaw === 'exponential'
? backoffTypeRaw
: undefined;
const backoffDelayRaw = parseFlag(args, '--backoff-delay');
const backoffDelay = backoffDelayRaw !== undefined ? parseInt(backoffDelayRaw, 10) : undefined;
const backoffJitterRaw = parseFlag(args, '--backoff-jitter');
const backoffJitter = backoffJitterRaw !== undefined ? parseFloat(backoffJitterRaw) : undefined;
const timeoutMsRaw = parseFlag(args, '--timeout-ms');
const timeoutMs = timeoutMsRaw !== undefined ? parseInt(timeoutMsRaw, 10) : undefined;
if (timeoutMsRaw !== undefined && (isNaN(timeoutMs!) || timeoutMs! <= 0)) {
console.error('Error: --timeout-ms must be a positive integer (milliseconds)');
process.exit(1);
}
const idempotencyKey = parseFlag(args, '--idempotency-key');
const queueName = parseFlag(args, '--queue') ?? 'default';
const dryRun = hasFlag(args, '--dry-run');
const follow = hasFlag(args, '--follow');
@@ -135,12 +102,6 @@ HANDLER TYPES (built in)
console.log(` Queue: ${queueName}`);
console.log(` Priority: ${priority}`);
console.log(` Max attempts: ${maxAttempts}`);
if (maxStalled !== undefined) console.log(` Max stalled: ${maxStalled}`);
if (backoffType) console.log(` Backoff type: ${backoffType}`);
if (backoffDelay !== undefined) console.log(` Backoff delay: ${backoffDelay}ms`);
if (backoffJitter !== undefined) console.log(` Backoff jitter: ${backoffJitter}`);
if (timeoutMs !== undefined) console.log(` Timeout: ${timeoutMs}ms`);
if (idempotencyKey) console.log(` Idempotency key: ${idempotencyKey}`);
if (delay > 0) console.log(` Delay: ${delay}ms`);
console.log(` Data: ${JSON.stringify(data)}`);
return;
@@ -153,56 +114,12 @@ HANDLER TYPES (built in)
process.exit(1);
}
// The CLI path is a trusted submitter. Pass {allowProtectedSubmit: true}
// ONLY for protected names, not blanket-set for every submission, so any
// future protected name forces explicit opt-in at the call site.
const { isProtectedJobName } = await import('../core/minions/protected-names.ts');
const trusted = isProtectedJobName(name) ? { allowProtectedSubmit: true } : undefined;
const job = await queue.add(name, data, {
priority,
delay: delay > 0 ? delay : undefined,
max_attempts: maxAttempts,
max_stalled: maxStalled,
backoff_type: backoffType,
backoff_delay: backoffDelay,
backoff_jitter: backoffJitter,
timeout_ms: timeoutMs,
idempotency_key: idempotencyKey,
queue: queueName,
}, trusted);
// Submission audit log (operational trace, not forensic insurance).
try {
const { logShellSubmission } = await import('../core/minions/handlers/shell-audit.ts');
if (name.trim() === 'shell') {
logShellSubmission({
caller: 'cli',
remote: false,
job_id: job.id,
cwd: typeof data.cwd === 'string' ? data.cwd : '',
cmd_display: typeof data.cmd === 'string' ? data.cmd.slice(0, 80) : undefined,
argv_display: Array.isArray(data.argv)
? (data.argv as unknown[]).filter((a): a is string => typeof a === 'string').map((a) => a.slice(0, 80))
: undefined,
});
}
} catch { /* audit failures never block submission */ }
// Starvation warning (DX polish). Fire for every non-`--follow` shell submit
// regardless of the submitter's own `GBRAIN_ALLOW_SHELL_JOBS` — the submitter
// env is a weak proxy for the worker env (they may run on different machines),
// so the warning remains useful any time the job might sit in 'waiting'.
if (!follow && name.trim() === 'shell') {
process.stderr.write(
`\n⚠ Shell jobs require GBRAIN_ALLOW_SHELL_JOBS=1 on the worker process.\n` +
` Your job was queued (id=${job.id}) but will sit in 'waiting' until a\n` +
` worker with the env flag starts. To run now:\n\n` +
` GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs submit shell \\\n` +
` --params '...' --follow\n\n` +
` Or start a persistent worker (Postgres only — PGLite uses --follow):\n\n` +
` GBRAIN_ALLOW_SHELL_JOBS=1 gbrain jobs work\n\n`,
);
}
});
if (follow) {
console.log(`Job #${job.id} submitted (${name}). Executing inline...`);
@@ -378,8 +295,6 @@ HANDLER TYPES (built in)
process.exit(1);
}
const sigkillRescue = hasFlag(args, '--sigkill-rescue');
const worker = new MinionWorker(engine, { queue: 'smoke', pollInterval: 100 });
worker.register('noop', async () => ({ ok: true, at: new Date().toISOString() }));
@@ -397,64 +312,22 @@ HANDLER TYPES (built in)
await workerPromise;
const elapsedSec = ((Date.now() - startTime) / 1000).toFixed(2);
if (final?.status !== 'completed') {
if (final?.status === 'completed') {
const cfg = (await import('../core/config.ts')).loadConfig();
const engineLabel = cfg?.engine ?? 'unknown';
console.log(`SMOKE PASS — Minions healthy in ${elapsedSec}s (engine: ${engineLabel})`);
if (engineLabel === 'pglite') {
console.log('Note: the `gbrain jobs work` daemon requires Postgres. PGLite');
console.log('supports inline execution only (`submit --follow`).');
}
try { await queue.removeJob(job.id); } catch { /* non-fatal cleanup */ }
process.exit(0);
} else {
console.error(`SMOKE FAIL — job #${job.id} status: ${final?.status ?? 'timeout'} (${elapsedSec}s elapsed)`);
if (final?.error_text) console.error(` Error: ${final.error_text}`);
process.exit(1);
}
// --sigkill-rescue: regression case for #219. Simulates a SIGKILL
// mid-flight by directly manipulating lock_until via handleStalled.
// Verifies that with the v0.13.1 schema default (max_stalled=5), a
// stalled job is REQUEUED rather than dead-lettered on first stall.
// Full subprocess-level SIGKILL lives in test/e2e/minions.test.ts.
if (sigkillRescue) {
const rescueJob = await queue.add('noop', {}, { queue: 'smoke' });
// Transition to active with a past lock_until, mimicking a worker
// that claimed and then got SIGKILL'd mid-run.
await engine.executeRaw(
`UPDATE minion_jobs
SET status='active',
lock_token='smoke-sigkill-rescue',
lock_until=now() - interval '1 minute',
started_at=now() - interval '2 minute',
attempts_started = attempts_started + 1
WHERE id=$1`,
[rescueJob.id]
);
const result = await queue.handleStalled();
const afterStall = await queue.getJob(rescueJob.id);
if (afterStall?.status === 'dead') {
console.error(
`SMOKE FAIL (--sigkill-rescue) — job #${rescueJob.id} was dead-lettered on first stall. ` +
`This is the #219 regression: schema default max_stalled should rescue, not dead-letter. ` +
`handleStalled: ${JSON.stringify(result)}`
);
process.exit(1);
}
if (afterStall?.status !== 'waiting') {
console.error(
`SMOKE FAIL (--sigkill-rescue) — unexpected status after stall: ${afterStall?.status}. ` +
`Expected 'waiting' (rescued). handleStalled: ${JSON.stringify(result)}`
);
process.exit(1);
}
try { await queue.removeJob(rescueJob.id); } catch { /* non-fatal cleanup */ }
}
const cfg = (await import('../core/config.ts')).loadConfig();
const engineLabel = cfg?.engine ?? 'unknown';
const tag = sigkillRescue ? ' + SIGKILL rescue' : '';
console.log(`SMOKE PASS — Minions healthy${tag} in ${elapsedSec}s (engine: ${engineLabel})`);
if (engineLabel === 'pglite') {
console.log('Note: the `gbrain jobs work` daemon requires Postgres. PGLite');
console.log('supports inline execution only (`submit --follow`).');
}
try { await queue.removeJob(job.id); } catch { /* non-fatal cleanup */ }
process.exit(0);
break;
}
case 'work': {
@@ -511,20 +384,11 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
worker.register('embed', async (job) => {
const { runEmbedCore } = await import('./embed.ts');
// Primary Minion progress channel is job.updateProgress (DB-backed,
// readable via `gbrain jobs get <id>`). Stderr from the worker daemon
// only emits coarse job-start / job-done lines; per-page detail lives
// in the DB. Per Codex review #20.
await runEmbedCore(engine, {
slug: typeof job.data.slug === 'string' ? job.data.slug : undefined,
slugs: Array.isArray(job.data.slugs) ? (job.data.slugs as string[]) : undefined,
all: !!job.data.all,
stale: job.data.all ? false : (job.data.stale !== false),
onProgress: (done, total, embedded) => {
// Fire-and-forget: progress updates are best-effort and must not
// block the worker loop.
job.updateProgress({ done, total, embedded, phase: 'embed.pages' }).catch(() => {});
},
});
return { embedded: true };
});
@@ -569,83 +433,41 @@ export async function registerBuiltinHandlers(worker: MinionWorker, engine: Brai
return await runBacklinksCore({ action, dir, dryRun: !!job.data.dryRun });
});
// Autopilot-cycle handler: delegates to runCycle. Shares the exact same
// phase set and ordering as `gbrain dream` and autopilot's inline path —
// one source of truth for what the brain does overnight.
//
// Yields the event loop between phases so the worker's lock-renewal
// timer (src/core/minions/worker.ts) can fire. Without this the v0.14
// stall-death regression returns: long CPU-bound phases starve the
// renewal callback and the stalled-sweeper kills the job.
//
// Phase failures surface as report.status='partial' (via runCycle's
// derivation); the handler returns { partial, status, report } so
// `gbrain jobs get <id>` shows the full structured report. Does NOT
// throw on partial: a flaky phase must not block every future cycle.
// The killer handler. Autopilot submits ONE `autopilot-cycle` per cycle
// (idempotency_key on cycle slot) instead of a 4-job parent-child DAG,
// because Minions' parent/child is NOT a depends_on primitive (Codex
// H3/H4). Each step is wrapped in its own try/catch; the handler returns
// `{ partial: true, failed_steps: [...] }` when any step fails. It does
// NOT throw on partial failure — that would cause the Minion to retry,
// and an intermittent extract bug would block every future cycle.
worker.register('autopilot-cycle', async (job) => {
const { runCycle } = await import('../core/cycle.ts');
const { performSync } = await import('./sync.ts');
const { runExtractCore } = await import('./extract.ts');
const { runEmbedCore } = await import('./embed.ts');
const { runBacklinksCore } = await import('./backlinks.ts');
const repoPath = typeof job.data.repoPath === 'string'
? job.data.repoPath
: (await engine.getConfig('sync.repo_path')) ?? '.';
const report = await runCycle(engine, {
brainDir: repoPath,
pull: true, // autopilot daemon opts into git pull
yieldBetweenPhases: async () => {
// Yield to the event loop so worker lock-renewal can fire.
await new Promise<void>(r => setImmediate(r));
},
});
const steps: Record<string, unknown> = {};
const failed: string[] = [];
return {
partial: report.status === 'partial' || report.status === 'failed',
status: report.status,
report,
};
});
try { steps.sync = await performSync(engine, { repoPath, noEmbed: true }); }
catch (e) { steps.sync = { error: e instanceof Error ? e.message : String(e) }; failed.push('sync'); }
// Shell handler: registered ONLY when GBRAIN_ALLOW_SHELL_JOBS=1 is set on the
// worker process. Default-closed; opt-in per-host. Without the flag, shell
// jobs submitted via CLI insert rows but no worker claims them (they sit in
// 'waiting' — the CLI prints a starvation warning for that case).
if (process.env.GBRAIN_ALLOW_SHELL_JOBS === '1') {
const { shellHandler } = await import('../core/minions/handlers/shell.ts');
worker.register('shell', shellHandler);
process.stderr.write('[minion worker] shell handler enabled (GBRAIN_ALLOW_SHELL_JOBS=1)\n');
} else {
process.stderr.write('[minion worker] shell handler disabled (set GBRAIN_ALLOW_SHELL_JOBS=1 to enable)\n');
}
try { steps.extract = await runExtractCore(engine, { mode: 'all', dir: repoPath }); }
catch (e) { steps.extract = { error: e instanceof Error ? e.message : String(e) }; failed.push('extract'); }
// v0.15 subagent handlers: always-on. Unlike shell (which needs an env
// flag because of RCE surface), subagent only calls the Anthropic API
// with the operator's own ANTHROPIC_API_KEY — no key, the SDK call
// fails immediately. Who-can-submit is already gated by
// PROTECTED_JOB_NAMES + TrustedSubmitOpts (MCP can't submit subagent
// jobs; only the CLI path with allowProtectedSubmit can). No separate
// cost-ceremony env flag needed.
const { makeSubagentHandler } = await import('../core/minions/handlers/subagent.ts');
const { subagentAggregatorHandler } = await import('../core/minions/handlers/subagent-aggregator.ts');
worker.register('subagent', makeSubagentHandler({ engine }));
worker.register('subagent_aggregator', subagentAggregatorHandler);
process.stderr.write('[minion worker] subagent handlers enabled\n');
try { await runEmbedCore(engine, { stale: true }); steps.embed = { embedded: true }; }
catch (e) { steps.embed = { error: e instanceof Error ? e.message : String(e) }; failed.push('embed'); }
// Plugin discovery — one line per discovered plugin (mirrors the
// openclaw-seam startup line convention from v0.11+). Loaded
// unconditionally; empty GBRAIN_PLUGIN_PATH is a no-op.
try {
const { loadPluginsFromEnv } = await import('../core/minions/plugin-loader.ts');
const { BRAIN_TOOL_ALLOWLIST } = await import('../core/minions/tools/brain-allowlist.ts');
const validNames = new Set<string>();
for (const n of BRAIN_TOOL_ALLOWLIST) validNames.add(`brain_${n}`);
const loaded = loadPluginsFromEnv({ validAgentToolNames: validNames });
for (const w of loaded.warnings) process.stderr.write(w + '\n');
for (const p of loaded.plugins) {
process.stderr.write(
`[plugin-loader] loaded '${p.manifest.name}' v${p.manifest.version} (${p.subagents.length} subagents)\n`,
);
try { steps.backlinks = await runBacklinksCore({ action: 'fix', dir: repoPath }); }
catch (e) { steps.backlinks = { error: e instanceof Error ? e.message : String(e) }; failed.push('backlinks'); }
if (failed.length > 0) {
return { partial: true, failed_steps: failed, steps };
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
process.stderr.write(`[plugin-loader] discovery failed: ${msg}\n`);
}
return { partial: false, steps };
});
}
-9
View File
@@ -268,17 +268,10 @@ export async function runLint(args: string[]) {
const isSingleFile = statSync(target).isFile();
const pages = isSingleFile ? [target] : collectPages(target);
// Progress on stderr. Stdout keeps the per-issue human output it always had.
const { createProgress } = await import('../core/progress.ts');
const { getCliOptions, cliOptsToProgressOptions } = await import('../core/cli-options.ts');
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('lint.pages', pages.length);
for (const page of pages) {
const content = readFileSync(page, 'utf-8');
const relPath = isSingleFile ? page : relative(target, page);
const issues = lintContent(content, relPath);
progress.tick(1);
if (issues.length === 0) continue;
console.log(`\n${relPath}:`);
@@ -299,8 +292,6 @@ export async function runLint(args: string[]) {
}
}
progress.finish();
// Re-run core for the aggregate counts (cheap; re-parses contents but
// produces canonical numbers for the summary line).
const result = await runLintCore({ target, fix: doFix, dryRun });
+4 -10
View File
@@ -14,8 +14,6 @@ import type { EngineConfig } from '../core/types.ts';
import { homedir } from 'os';
import { join } from 'path';
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
import { createProgress } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface MigrateOpts {
targetEngine: 'postgres' | 'pglite';
@@ -148,9 +146,6 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
console.log(`Migrating ${pagesToMigrate.length} pages (${allPages.length} total, ${completedSet.size} already done)...`);
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('migrate.copy_pages', pagesToMigrate.length);
let migrated = 0;
for (const page of pagesToMigrate) {
// Copy page
@@ -208,21 +203,20 @@ export async function runMigrateEngine(sourceEngine: BrainEngine, args: string[]
manifest!.completed_slugs.push(page.slug);
saveManifest(manifest!);
migrated++;
progress.tick(1, page.slug);
if (migrated % 50 === 0 || migrated === pagesToMigrate.length) {
console.log(` Progress: ${migrated}/${pagesToMigrate.length} pages`);
}
}
progress.finish();
// Copy links (after all pages exist in target)
console.log('Copying links...');
progress.start('migrate.copy_links', allPages.length);
for (const page of allPages) {
const links = await sourceEngine.getLinks(page.slug);
for (const link of links) {
await targetEngine.addLink(link.from_slug, link.to_slug, link.context, link.link_type);
}
progress.tick(1);
}
progress.finish();
// Copy config (selective)
const configKeys = ['embedding_model', 'embedding_dimensions', 'chunk_strategy'];
-6
View File
@@ -16,9 +16,6 @@ import { v0_12_0 } from './v0_12_0.ts';
import { v0_12_2 } from './v0_12_2.ts';
import { v0_13_0 } from './v0_13_0.ts';
import { v0_13_1 } from './v0_13_1.ts';
import { v0_14_0 } from './v0_14_0.ts';
import { v0_16_0 } from './v0_16_0.ts';
import { v0_18_0 } from './v0_18_0.ts';
export const migrations: Migration[] = [
v0_11_0,
@@ -26,9 +23,6 @@ export const migrations: Migration[] = [
v0_12_2,
v0_13_0,
v0_13_1,
v0_14_0,
v0_16_0,
v0_18_0,
];
/** Look up a migration by exact version string. */
+17 -8
View File
@@ -23,10 +23,8 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, lstatSync, statSync, realpathSync } from 'fs';
import { join, resolve, dirname } from 'path';
import { execSync } from 'child_process';
import { childGlobalFlags } from '../../core/cli-options.ts';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { savePreferences, loadPreferences } from '../../core/preferences.ts';
// Bug 3 — appendCompletedMigration moved to the runner (apply-migrations.ts).
import { savePreferences, loadPreferences, appendCompletedMigration } from '../../core/preferences.ts';
import { promptLine } from '../../core/cli-util.ts';
import { VERSION } from '../../version.ts';
@@ -61,7 +59,7 @@ export interface PendingHostWorkEntry {
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 60_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 60_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -443,11 +441,22 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const f = phaseFInstall(opts);
phases.push(f);
// Bug 3 — Phase G (record in completed.jsonl) moved to the runner. The
// runner in apply-migrations.ts persists the result after orchestrator
// returns, so we just decide the status here.
// Phase G: record in completed.jsonl. Status depends on whether any
// host work remains pending AND whether the install phase succeeded.
const status: 'complete' | 'partial' = (pending_host_work > 0) ? 'partial' : 'complete';
phases.push({ name: 'record', status: opts.dryRun ? 'skipped' : 'complete', detail: `status=${status} (ledger write in runner)` });
if (!opts.dryRun) {
appendCompletedMigration({
version: '0.11.0',
status,
mode,
files_rewritten,
autopilot_installed: f.status === 'complete',
install_target: undefined, // install target is decided inside autopilot --install
...(status === 'partial' ? { apply_migrations_pending: true } : {}),
});
}
phases.push({ name: 'record', status: opts.dryRun ? 'skipped' : 'complete', detail: `status=${status}` });
// Post-run: print pending-host-work summary if anything needs host action.
if (pending_host_work > 0) {
+14 -8
View File
@@ -32,8 +32,7 @@
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { childGlobalFlags } from '../../core/cli-options.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
import { appendCompletedMigration } from '../../core/preferences.ts';
// ── Phase A — Schema ────────────────────────────────────────
@@ -43,7 +42,7 @@ function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
// 10-minute budget. Migrations v8/v9 dedup with helper-index should be sub-second
// even on 80K-duplicate brains, but the outer wall-clock cap shouldn't be the
// failure mode (the prior 60s ceiling tripped Garry's production upgrade).
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -93,7 +92,7 @@ function phaseCBackfillLinks(opts: OrchestratorOpts): OrchestratorPhaseResult {
// --source db is idempotent: the UNIQUE constraint on
// (from_page_id, to_page_id, link_type) and ON CONFLICT DO NOTHING
// make re-runs cheap. Empty brains return 0/0 quickly.
execSync('gbrain extract links --source db' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain extract links --source db', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'backfill_links', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -104,7 +103,7 @@ function phaseCBackfillLinks(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseDBackfillTimeline(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'backfill_timeline', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain extract timeline --source db' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain extract timeline --source db', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'backfill_timeline', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -217,15 +216,22 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
phases.push(e);
// F. Record
// a.status was narrowed to 'skipped' | 'complete' by the early return above.
const overallStatus: 'complete' | 'partial' | 'failed' =
phases.some(p => p.status === 'failed') ? 'partial' : 'complete';
a.status === 'failed' ? 'failed' :
phases.some(p => p.status === 'failed') ? 'partial' :
'complete';
return finalizeResult(phases, overallStatus);
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
if (status !== 'failed') {
try {
appendCompletedMigration({ version: '0.12.0', status: status as 'complete' | 'partial' });
} catch {
// Recording is best-effort.
}
}
return {
version: '0.12.0',
status,
+13 -16
View File
@@ -22,17 +22,14 @@
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { childGlobalFlags } from '../../core/cli-options.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
import { appendCompletedMigration } from '../../core/preferences.ts';
// ── Phase A — Schema ────────────────────────────────────────
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
// Propagate global progress flags so the child shows the same mode the
// parent orchestrator is running in.
execSync('gbrain init --migrate-only' + childGlobalFlags(), { stdio: 'inherit', timeout: 60_000, env: process.env });
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 60_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -45,8 +42,7 @@ function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseBRepair(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'jsonb_repair', status: 'skipped', detail: 'dry-run' };
try {
// stdio: 'inherit' — child's stderr progress streams straight through.
execSync('gbrain repair-jsonb' + childGlobalFlags(), { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync('gbrain repair-jsonb', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'jsonb_repair', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -59,14 +55,8 @@ function phaseBRepair(opts: OrchestratorOpts): OrchestratorPhaseResult {
function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'verify', status: 'skipped', detail: 'dry-run' };
try {
// Explicit stdio discipline: we must parse JSON off child.stdout, so
// pipe stdout but let child.stderr (progress) pass straight through.
// Any accidental stdout progress from the child would break JSON.parse
// (per Codex review #12). NOTE: we deliberately do NOT pass
// --progress-json here — this child is parsed, not watched.
const out = execSync('gbrain repair-jsonb --dry-run --json', {
encoding: 'utf-8', timeout: 60_000, env: process.env,
stdio: ['ignore', 'pipe', 'inherit'],
});
const parsed = JSON.parse(out) as { total_repaired?: number; engine?: string };
const remaining = parsed.total_repaired ?? 0;
@@ -105,15 +95,22 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const c = phaseCVerify(opts);
phases.push(c);
// a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above.
const overallStatus: 'complete' | 'partial' | 'failed' =
c.status === 'failed' ? 'partial' : 'complete';
a.status === 'failed' || b.status === 'failed' ? 'failed' :
c.status === 'failed' ? 'partial' :
'complete';
return finalizeResult(phases, overallStatus);
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
if (status !== 'failed') {
try {
appendCompletedMigration({ version: '0.12.2', status: status as 'complete' | 'partial' });
} catch {
// Recording is best-effort.
}
}
return {
version: '0.12.2',
status,
+20 -15
View File
@@ -27,8 +27,7 @@
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts). The
// orchestrator returns its result and the runner persists it.
import { appendCompletedMigration } from '../../core/preferences.ts';
// ── Phase A — Schema ────────────────────────────────────────
//
@@ -36,18 +35,17 @@ import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhase
// and swaps the unique constraint. Schema build time on 46K pages is
// ~10s (ALTER + index builds). Bumped timeout accounts for slow Supabase
// links (v0.12.1 pattern — migrations can time out on the 60s default).
//
// Shell out to the canonical `gbrain` shim on PATH (`/usr/local/bin/gbrain`
// by default). An earlier revision resolved via the active Node/Bun runtime
// binary, but on bun-installed trees that binary is `bun` — the spawned
// `bun extract ...` gets reinterpreted as `bun run extract` and crashes the
// upgrade mid-migration. The shim is already the canonical wrapper; trust
// it. Regression guarded by test/migrations-v0_13_0.test.ts.
// Use the CURRENTLY-RUNNING binary path (not `gbrain` off $PATH). After
// `gbrain upgrade` rewrites the binary, a bare `gbrain` could resolve to
// an older installed copy via alias shadowing or stale PATH cache. The
// active process.execPath is the one that loaded THIS migration module,
// so recursing into it is always the right binary.
const GBRAIN = process.execPath;
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 600_000, env: process.env });
execSync(`${GBRAIN} init --migrate-only`, { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -64,7 +62,7 @@ function phaseBBackfill(opts: OrchestratorOpts): OrchestratorPhaseResult {
// `--include-frontmatter` is the v0.13 flag that enables the canonical
// frontmatter link extractor. Default-OFF in the CLI for back-compat;
// the migration explicitly opts in because this is the canonical backfill.
execSync('gbrain extract links --source db --include-frontmatter', {
execSync(`${GBRAIN} extract links --source db --include-frontmatter`, {
stdio: 'inherit',
timeout: 1_800_000, // 30 min hard cap; typical 2-5 min on 46K pages
env: process.env,
@@ -89,7 +87,7 @@ function phaseCVerify(opts: OrchestratorOpts): OrchestratorPhaseResult {
// docs-only brains, and brains with no entity pages legitimately
// produce 0. Phase B's own stdout shows `Links: created N` which is
// the authoritative signal — user sees it during upgrade.
const out = execSync('gbrain call get_stats', {
const out = execSync(`${GBRAIN} call get_stats`, {
encoding: 'utf-8', timeout: 60_000, env: process.env,
});
const parsed = JSON.parse(out) as { link_count?: number; page_count?: number };
@@ -129,15 +127,22 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const c = phaseCVerify(opts);
phases.push(c);
// a.status and b.status were narrowed to 'skipped' | 'complete' by early returns above.
const overallStatus: 'complete' | 'partial' | 'failed' =
c.status === 'failed' ? 'partial' : 'complete';
a.status === 'failed' || b.status === 'failed' ? 'failed' :
c.status === 'failed' ? 'partial' :
'complete';
return finalizeResult(phases, overallStatus);
}
function finalizeResult(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
// Ledger write lives in the runner now (Bug 3).
if (status !== 'failed') {
try {
appendCompletedMigration({ version: '0.13.0', status: status as 'complete' | 'partial' });
} catch {
// Recording is best-effort.
}
}
return {
version: '0.13.0',
status,
+19 -2
View File
@@ -42,7 +42,7 @@ import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhase
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
import type { BrainEngine } from '../../core/engine.ts';
// Bug 3 — ledger writes moved to the runner (apply-migrations.ts).
import { appendCompletedMigration } from '../../core/preferences.ts';
const ROLLBACK_DIR = join(homedir(), '.gbrain', 'migrations');
const ROLLBACK_FILE = join(ROLLBACK_DIR, 'v0_13_1-rollback.jsonl');
@@ -233,7 +233,24 @@ async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult>
const anyFailed = phases.some(p => p.status === 'failed');
const status: OrchestratorResult['status'] = anyFailed ? 'partial' : 'complete';
// Bug 3 — ledger write lives in the runner now.
if (!opts.dryRun && status === 'complete') {
try {
appendCompletedMigration({
version: '0.13.1',
completed_at: new Date().toISOString(),
status: 'complete',
phases: phases.map(p => ({ name: p.name, status: p.status })),
files_rewritten: filesRewritten,
});
} catch (e) {
// Recording failure is non-fatal; migration still ran.
phases.push({
name: 'record',
status: 'failed',
detail: e instanceof Error ? e.message : String(e),
});
}
}
return {
version: '0.13.1',
-180
View File
@@ -1,180 +0,0 @@
/**
* v0.14.0 migration shell-jobs adoption + autopilot cooperative fix.
*
* Ships two phases:
*
* A. Schema: `ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 3`.
* New installs already get the bumped default from schema-embedded.ts +
* pglite-schema.ts. This ALTER is for existing brains where the table
* was created under v0.13.x (default 1). Idempotent running twice is
* a no-op because the default is a table-level attribute, not per-row.
* Existing rows keep their stored max_stalled value; only rows created
* after the ALTER pick up the new default.
*
* B. Pending-host-work ping: emit one entry to
* ~/.gbrain/migrations/pending-host-work.jsonl so the host agent knows
* to read skills/migrations/v0.14.0.md (shell-jobs adoption, autopilot
* cooperative handler wiring, GBRAIN_POOL_SIZE doc). Idempotent the
* write checks for an existing entry before appending.
*
* Ledger writes live in the runner (Bug 3). This orchestrator returns its
* result; apply-migrations.ts persists.
*/
import { existsSync, readFileSync, mkdirSync, appendFileSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
import type { BrainEngine } from '../../core/engine.ts';
// Resolve HOME at CALL time, not module-load time — Bun caches os.homedir()
// and ignores later HOME mutations, which breaks test isolation and scripted
// installs. Match the preferences.ts pattern.
function resolveHome(): string { return process.env.HOME || homedir(); }
function pendingHostWorkDir(): string { return join(resolveHome(), '.gbrain', 'migrations'); }
function pendingHostWorkPath(): string { return join(pendingHostWorkDir(), 'pending-host-work.jsonl'); }
// ---------------------------------------------------------------------------
// Phase A — schema: bump minion_jobs.max_stalled default 1 → 3
// ---------------------------------------------------------------------------
async function phaseASchema(opts: OrchestratorOpts): Promise<{ result: OrchestratorPhaseResult; engine: BrainEngine | null }> {
if (opts.dryRun) {
return { result: { name: 'schema', status: 'skipped', detail: 'dry-run' }, engine: null };
}
try {
const config = loadConfig();
if (!config) {
return {
result: { name: 'schema', status: 'skipped', detail: 'no brain configured (run gbrain init first)' },
engine: null,
};
}
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
try {
// Both Postgres and PGLite accept this ALTER. Idempotent at the
// table level — setting the default to 3 twice is fine.
await engine.executeRaw('ALTER TABLE minion_jobs ALTER COLUMN max_stalled SET DEFAULT 3');
} catch (e) {
// If minion_jobs doesn't exist yet (brand new install), the schema
// file already has the new default, so this is moot. Skip instead of
// fail.
const msg = e instanceof Error ? e.message : String(e);
if (/does not exist|no such table|relation .* does not exist/i.test(msg)) {
return {
result: { name: 'schema', status: 'skipped', detail: 'minion_jobs not yet created (fresh install)' },
engine,
};
}
throw e;
}
return { result: { name: 'schema', status: 'complete' }, engine };
} catch (e) {
return {
result: { name: 'schema', status: 'failed', detail: e instanceof Error ? e.message : String(e) },
engine: null,
};
}
}
// ---------------------------------------------------------------------------
// Phase B — emit pending-host-work entry for the v0.14.0 skill
// ---------------------------------------------------------------------------
interface PendingHostWorkEntry {
migration: string;
ts: string;
skill: string;
reason: string;
}
function existingEntryForVersion(version: string): boolean {
const p = pendingHostWorkPath();
if (!existsSync(p)) return false;
try {
const raw = readFileSync(p, 'utf-8');
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as PendingHostWorkEntry;
if (obj.migration === version) return true;
} catch { /* skip malformed */ }
}
} catch { /* read error */ }
return false;
}
function phaseBHostWork(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) {
return { name: 'host-work', status: 'skipped', detail: 'dry-run' };
}
try {
if (existingEntryForVersion('0.14.0')) {
return { name: 'host-work', status: 'skipped', detail: 'already recorded' };
}
mkdirSync(pendingHostWorkDir(), { recursive: true });
const entry: PendingHostWorkEntry = {
migration: '0.14.0',
ts: new Date().toISOString(),
skill: 'skills/migrations/v0.14.0.md',
reason: 'shell-jobs adoption + autopilot cooperative wiring',
};
appendFileSync(pendingHostWorkPath(), JSON.stringify(entry) + '\n');
return { name: 'host-work', status: 'complete', detail: pendingHostWorkPath() };
} catch (e) {
return { name: 'host-work', status: 'failed', detail: e instanceof Error ? e.message : String(e) };
}
}
// ---------------------------------------------------------------------------
// Orchestrator
// ---------------------------------------------------------------------------
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
const phases: OrchestratorPhaseResult[] = [];
const { result: schemaRes, engine } = await phaseASchema(opts);
phases.push(schemaRes);
try {
const hostRes = phaseBHostWork(opts);
phases.push(hostRes);
} finally {
if (engine) {
try { await engine.disconnect(); } catch { /* best-effort */ }
}
}
const anyFailed = phases.some(p => p.status === 'failed');
const status: OrchestratorResult['status'] = anyFailed ? 'partial' : 'complete';
return {
version: '0.14.0',
status,
phases,
pending_host_work: phases.some(p => p.name === 'host-work' && p.status === 'complete') ? 1 : 0,
};
}
// ---------------------------------------------------------------------------
// Export
// ---------------------------------------------------------------------------
export const v0_14_0: Migration = {
version: '0.14.0',
featurePitch: {
headline: 'Shell jobs + autopilot cooperative handler + max_stalled default bump.',
description:
'v0.14.0 unlocks `shell` as a Minion job type (gated by GBRAIN_ALLOW_SHELL_JOBS=1 ' +
'on the worker). The autopilot-cycle handler now yields to the event loop ' +
'between phases so lock renewal fires on huge brains. The minion_jobs.max_stalled ' +
'default is bumped 1→3 so one lock-lost tick no longer dead-letters a job. ' +
'Host-specific skill doc: skills/migrations/v0.14.0.md.',
},
orchestrator,
};
-140
View File
@@ -1,140 +0,0 @@
/**
* v0.16.0 migration orchestrator Subagent runtime schema.
*
* Adds three tables for durable LLM agent loops:
* - subagent_messages Anthropic message-block persistence
* - subagent_tool_executions Two-phase tool ledger (pending/complete/failed)
* - subagent_rate_leases Lease-based concurrency cap
*
* All DDL is `CREATE TABLE IF NOT EXISTS` and ships in src/schema.sql +
* src/core/pglite-schema.ts (both Postgres and PGLite fresh-install paths).
* This orchestrator's job is therefore only to VERIFY the tables exist after
* `gbrain init --migrate-only` has run, so an upgrade that somehow skipped
* the schema step fails loudly instead of silently.
*
* Phases (all idempotent):
* A. Schema gbrain init --migrate-only (creates tables via SCHEMA_SQL).
* B. Verify confirm all three tables exist.
* C. Record append completed.jsonl.
*/
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { appendCompletedMigration } from '../../core/preferences.ts';
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
const REQUIRED_TABLES = ['subagent_messages', 'subagent_tool_executions', 'subagent_rate_leases'] as const;
// ── Phase A — Schema ────────────────────────────────────────
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 60_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name: 'schema', status: 'failed', detail: msg };
}
}
// ── Phase B — Verify tables exist ───────────────────────────
async function phaseBVerify(opts: OrchestratorOpts): Promise<OrchestratorPhaseResult> {
if (opts.dryRun) return { name: 'verify', status: 'skipped', detail: 'dry-run' };
try {
const config = loadConfig();
if (!config) {
return { name: 'verify', status: 'skipped', detail: 'no brain configured' };
}
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
try {
const rows = await engine.executeRaw<{ table_name: string }>(
`SELECT table_name FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name IN ('subagent_messages','subagent_tool_executions','subagent_rate_leases')`,
);
const found = new Set(rows.map(r => r.table_name));
const missing = REQUIRED_TABLES.filter(t => !found.has(t));
if (missing.length > 0) {
return {
name: 'verify',
status: 'failed',
detail: `missing tables: ${missing.join(', ')}`,
};
}
return { name: 'verify', status: 'complete', detail: `${REQUIRED_TABLES.length} tables present` };
} finally {
try { await engine.disconnect(); } catch {}
}
} catch (e) {
return {
name: 'verify',
status: 'failed',
detail: e instanceof Error ? e.message : String(e),
};
}
}
// ── Orchestrator ────────────────────────────────────────────
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
console.log('');
console.log('=== v0.16.0 — Subagent runtime schema ===');
if (opts.dryRun) console.log(' (dry-run; no side effects)');
console.log('');
const phases: OrchestratorPhaseResult[] = [];
const a = phaseASchema(opts);
phases.push(a);
if (a.status === 'failed') return finalize(phases, 'failed');
const b = await phaseBVerify(opts);
phases.push(b);
// a.status was narrowed to 'skipped' | 'complete' by the early return above.
const status: 'complete' | 'partial' | 'failed' =
b.status === 'failed' ? 'partial' : 'complete';
return finalize(phases, status);
}
function finalize(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
if (status !== 'failed') {
try {
appendCompletedMigration({
version: '0.16.0',
completed_at: new Date().toISOString(),
status: status as 'complete' | 'partial',
phases: phases.map(p => ({ name: p.name, status: p.status })),
});
} catch {
// Recording is best-effort.
}
}
return { version: '0.16.0', status, phases };
}
export const v0_16_0: Migration = {
version: '0.16.0',
featurePitch: {
headline: 'Durable LLM agents land in the brain — survive crashes, sleeps, and worker restarts.',
description:
'v0.16.0 adds the subagent runtime: run long-running, fan-out Anthropic LLM loops ' +
'as first-class Minion jobs. Crash-resumable turn persistence, two-phase tool ledger, ' +
'lease-based rate limit, parent-child fan-out with aggregation. Entry points: `gbrain ' +
'agent run` and `gbrain agent logs`. See docs/guides/plugin-authors.md for shipping ' +
'custom subagent defs from a host repo (your OpenClaw etc.).',
},
orchestrator,
};
/** Exported for unit tests. */
export const __testing = {
phaseASchema,
phaseBVerify,
REQUIRED_TABLES,
};
@@ -1,174 +0,0 @@
/**
* v0.18.0 Step 7 phase B storage backfill loader.
*
* Drives the `file_migration_ledger` state machine forward:
*
* pending copy_done db_updated complete
*
* Each per-file transition is a separate transaction so a crash
* between states leaves a recoverable row (resume-on-partial). The
* ledger is the atomicity backstop for non-atomic object-storage
* "renames" (S3/Supabase = copy+delete).
*
* Crash-point recovery:
* - crash AFTER copy, BEFORE DB update re-run detects
* `status='copy_done'`, completes DB update (copy is idempotent
* against S3 overwrite so re-copy on same path is fine).
* - crash AFTER DB update, BEFORE ledger mark re-run detects
* `status='db_updated'`, marks `complete`.
* - crash AFTER ledger mark, BEFORE old-object delete delete runs
* in the explicit "cleanup" sub-phase so old objects are
* preserved until a separate operator decision.
*
* Scope: v0.18.0 Step 7 DOES rewrite storage_path in the files table
* and copies the bytes to the new source-prefixed path. It does NOT
* delete the old objects that's reserved for a later release once
* operators have had time to verify the new paths. Old and new
* objects coexist during the soak period.
*/
import type { BrainEngine } from '../../core/engine.ts';
import type { StorageBackend, StorageConfig } from '../../core/storage.ts';
interface LedgerRow {
file_id: number;
storage_path_old: string;
storage_path_new: string;
status: 'pending' | 'copy_done' | 'db_updated' | 'complete' | 'failed';
}
export interface BackfillReport {
total: number;
alreadyComplete: number;
nowComplete: number;
failed: number;
skipped: number;
errors: Array<{ file_id: number; error: string }>;
}
/**
* Process all non-complete ledger rows. Safe to re-run; each row
* resumes from whichever state it was in. Storage is injected so the
* caller can pass a real S3/Supabase backend OR a dry-run stub that
* short-circuits the copy.
*
* If storage is null/undefined the function runs as a dry-run: it
* reports what WOULD be processed without touching objects. This is
* used by the orchestrator when storage isn't configured.
*/
export async function runStorageBackfill(
engine: BrainEngine,
storage: StorageBackend | null,
opts?: { dryRun?: boolean },
): Promise<BackfillReport> {
const report: BackfillReport = {
total: 0,
alreadyComplete: 0,
nowComplete: 0,
failed: 0,
skipped: 0,
errors: [],
};
// Snapshot all ledger rows. We don't paginate because the ledger
// is bounded by current files count — every gbrain install has
// at most low-thousands of files.
const rows = await engine.executeRaw<LedgerRow>(
`SELECT file_id, storage_path_old, storage_path_new, status
FROM file_migration_ledger
ORDER BY file_id`,
);
report.total = rows.length;
for (const row of rows) {
if (row.status === 'complete') {
report.alreadyComplete++;
continue;
}
if (row.status === 'failed') {
report.failed++;
continue;
}
if (opts?.dryRun || !storage) {
// Dry-run: count pending rows but don't advance state.
report.skipped++;
continue;
}
// Drive the state machine. Each transition is its own
// executeRaw call so mid-row crashes leave a recoverable state.
try {
let status = row.status;
// pending → copy_done: COPY the bytes.
if (status === 'pending') {
// If the new path is already populated (e.g. from a previous
// partial run), the copy is redundant but idempotent on S3/
// Supabase where upload overwrites the key.
const exists = await storage.exists(row.storage_path_new).catch(() => false);
if (!exists) {
const data = await storage.download(row.storage_path_old);
await storage.upload(row.storage_path_new, data);
}
await engine.executeRaw(
`UPDATE file_migration_ledger
SET status = 'copy_done', updated_at = now()
WHERE file_id = $1`,
[row.file_id],
);
status = 'copy_done';
}
// copy_done → db_updated: flip files.storage_path to the new
// path. Once this commits, downloads go through the new path
// and the old object is orphaned (but still present on disk
// for rollback within the soak window).
if (status === 'copy_done') {
await engine.executeRaw(
`UPDATE files SET storage_path = $1 WHERE id = $2`,
[row.storage_path_new, row.file_id],
);
await engine.executeRaw(
`UPDATE file_migration_ledger
SET status = 'db_updated', updated_at = now()
WHERE file_id = $1`,
[row.file_id],
);
status = 'db_updated';
}
// db_updated → complete: mark terminal. The old-object delete
// happens in a separate sub-phase (future release) so operators
// can verify the new paths before we drop the safety net.
if (status === 'db_updated') {
await engine.executeRaw(
`UPDATE file_migration_ledger
SET status = 'complete', updated_at = now()
WHERE file_id = $1`,
[row.file_id],
);
report.nowComplete++;
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
report.failed++;
report.errors.push({ file_id: row.file_id, error: msg });
// Mark failed so the next run doesn't retry blindly. Operator
// can reset to 'pending' via SQL once the root cause is fixed.
try {
await engine.executeRaw(
`UPDATE file_migration_ledger
SET status = 'failed', error = $1, updated_at = now()
WHERE file_id = $2`,
[msg.slice(0, 500), row.file_id],
);
} catch {
// Best-effort: if we can't even write 'failed', report the
// original error and move on.
}
}
}
return report;
}
-237
View File
@@ -1,237 +0,0 @@
/**
* v0.18.0 migration orchestrator Multi-source brains.
*
* Split across sub-versions of the migration registry for safety:
* - v16 (Step 1 / Lane A): additive-only. Installs sources table +
* default row. Does NOT break any existing engine code.
* - v17 (Step 2 / Lane B, future): breaking schema changes. Rides with
* the engine API rewrite so ON CONFLICT (source_id, slug) lands
* atomically with the composite UNIQUE.
*
* Phase structure (per /plan-ceo-review + /plan-eng-review):
* A. Schema gbrain init --migrate-only runs the migration chain up
* to whichever v-prefix has shipped (v16 today, v17 next).
* B. Storage backfill (Step 7, future) ledger-driven object rewrite.
* C. Verify assert sources('default') exists today. Composite UNIQUE,
* page_id backfill, and ledger completeness get added in Step 2.
* D. (future) Delete old storage objects only runs after C green.
*
* Idempotent: safe to re-run on partial state.
*/
import { execSync } from 'child_process';
import type { Migration, OrchestratorOpts, OrchestratorResult, OrchestratorPhaseResult } from './types.ts';
import { appendCompletedMigration } from '../../core/preferences.ts';
import { loadConfig, toEngineConfig } from '../../core/config.ts';
import { createEngine } from '../../core/engine-factory.ts';
// ── Phase A — Schema ────────────────────────────────────────
function phaseASchema(opts: OrchestratorOpts): OrchestratorPhaseResult {
if (opts.dryRun) return { name: 'schema', status: 'skipped', detail: 'dry-run' };
try {
execSync('gbrain init --migrate-only', { stdio: 'inherit', timeout: 600_000, env: process.env });
return { name: 'schema', status: 'complete' };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { name: 'schema', status: 'failed', detail: msg };
}
}
// ── Phase B — Storage backfill (skeleton, filled by Step 7) ──
async function phaseBBackfillStorage(opts: OrchestratorOpts): Promise<OrchestratorPhaseResult> {
if (opts.dryRun) return { name: 'backfill_storage', status: 'skipped', detail: 'dry-run' };
try {
const config = loadConfig();
if (!config) return { name: 'backfill_storage', status: 'skipped', detail: 'no brain configured' };
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
try {
if (engine.kind === 'pglite') {
return { name: 'backfill_storage', status: 'skipped', detail: 'pglite (no files table)' };
}
const hasLedger = await engine.executeRaw<{ exists: boolean }>(
`SELECT EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'file_migration_ledger') AS exists`,
);
if (!hasLedger[0]?.exists) {
return {
name: 'backfill_storage',
status: 'skipped',
detail: 'file_migration_ledger not yet installed (run apply-migrations first)',
};
}
// Ledger exists. If storage isn't configured, run the dry-run
// path — we can still report the ledger state but we can't
// COPY objects. Operator then wires storage and re-runs.
const storage = config.storage ? await loadStorageBackend(config.storage) : null;
const { runStorageBackfill } = await import('./v0_18_0-storage-backfill.ts');
const report = await runStorageBackfill(engine, storage, { dryRun: !storage });
if (report.total === 0) {
return { name: 'backfill_storage', status: 'complete', detail: 'no files to migrate' };
}
if (report.failed > 0) {
return {
name: 'backfill_storage',
status: 'failed',
detail: `${report.failed}/${report.total} files failed: ${report.errors.slice(0, 3).map(e => `#${e.file_id}: ${e.error.slice(0, 60)}`).join('; ')}`,
};
}
if (report.skipped > 0 && !storage) {
return {
name: 'backfill_storage',
status: 'skipped',
detail: `${report.skipped}/${report.total} files pending; storage backend not configured (wire storage + re-run)`,
};
}
const detail = `${report.total} files: ${report.alreadyComplete} already complete, ${report.nowComplete} newly migrated`;
return { name: 'backfill_storage', status: 'complete', detail };
} finally {
try { await engine.disconnect(); } catch {}
}
} catch (e) {
return {
name: 'backfill_storage',
status: 'failed',
detail: e instanceof Error ? e.message : String(e),
};
}
}
async function loadStorageBackend(storageConfig: unknown): Promise<import('../../core/storage.ts').StorageBackend | null> {
try {
const { createStorage } = await import('../../core/storage.ts');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await createStorage(storageConfig as any);
} catch {
return null;
}
}
// ── Phase C — Verify ────────────────────────────────────────
async function phaseCVerify(opts: OrchestratorOpts): Promise<OrchestratorPhaseResult> {
if (opts.dryRun) return { name: 'verify', status: 'skipped', detail: 'dry-run' };
try {
const config = loadConfig();
if (!config) return { name: 'verify', status: 'skipped', detail: 'no brain configured' };
const engine = await createEngine(toEngineConfig(config));
await engine.connect(toEngineConfig(config));
try {
// 1. sources('default') exists (Step 1 / v16).
const defaults = await engine.executeRaw<{ id: string }>(
`SELECT id FROM sources WHERE id = 'default'`,
);
if (defaults.length !== 1) {
return { name: 'verify', status: 'failed', detail: "sources('default') row missing" };
}
// Step 2 checks (composite UNIQUE, links.resolution_type,
// file_migration_ledger completion) are gated on the future v17
// migration. They run conditionally — if the column/constraint
// exists, verify it; if not, that's fine for Step 1.
// Optional: composite UNIQUE if installed (Step 2 future work).
const constraint = await engine.executeRaw<{ conname: string }>(
`SELECT conname FROM pg_constraint WHERE conname = 'pages_source_slug_key'`,
);
// If installed, verify no pages have NULL source_id.
if (constraint.length === 1) {
const nullSources = await engine.executeRaw<{ n: number }>(
`SELECT COUNT(*)::int AS n FROM pages WHERE source_id IS NULL`,
);
if ((nullSources[0]?.n ?? 0) > 0) {
return { name: 'verify', status: 'failed', detail: `${nullSources[0].n} pages with NULL source_id` };
}
}
return { name: 'verify', status: 'complete', detail: 'sources primitive installed' };
} finally {
try { await engine.disconnect(); } catch {}
}
} catch (e) {
return { name: 'verify', status: 'failed', detail: e instanceof Error ? e.message : String(e) };
}
}
// ── Orchestrator ────────────────────────────────────────────
async function orchestrator(opts: OrchestratorOpts): Promise<OrchestratorResult> {
console.log('');
console.log('=== v0.18.0 — Multi-source brains ===');
if (opts.dryRun) console.log(' (dry-run; no side effects)');
console.log('');
const phases: OrchestratorPhaseResult[] = [];
const a = phaseASchema(opts);
phases.push(a);
if (a.status === 'failed') return finalize(phases, 'failed');
const b = await phaseBBackfillStorage(opts);
phases.push(b);
// Phase B 'failed' is currently expected until Step 7 lands the storage
// loader. Continue to verify so users see the exact gap.
const c = await phaseCVerify(opts);
phases.push(c);
// a.status === 'failed' already early-returned on line 179, so only
// c and b determine the final status here. TypeScript narrowing rejects
// a redundant a.status === 'failed' check.
const status: 'complete' | 'partial' | 'failed' =
c.status === 'failed' ? 'failed' :
b.status === 'failed' ? 'partial' :
'complete';
return finalize(phases, status);
}
function finalize(phases: OrchestratorPhaseResult[], status: 'complete' | 'partial' | 'failed'): OrchestratorResult {
if (status !== 'failed') {
try {
appendCompletedMigration({
version: '0.18.0',
completed_at: new Date().toISOString(),
status: status as 'complete' | 'partial',
phases: phases.map(p => ({ name: p.name, status: p.status })),
});
} catch {
// Best-effort.
}
}
return { version: '0.18.0', status, phases };
}
export const v0_18_0: Migration = {
version: '0.18.0',
featurePitch: {
headline: 'Multi-source brains: one database, many knowledge repos. Federation flag keeps them from polluting each other.',
description:
'v0.18.0 introduces sources — a first-class primitive that lets one gbrain backend hold ' +
'multiple repos (wiki, gstack, yc-media, etc.) with clean scoping. Every page, file, and ' +
'ingest_log row is now scoped to a source. Cross-source search is opt-in per source ' +
'(federated=true) so isolated content (yc-media, garrys-list) never bleeds into your main ' +
'brain. New commands: `gbrain sources add/attach/import-from-github`. Per-directory ' +
'default via .gbrain-source dotfile + GBRAIN_SOURCE env var. See docs/guides/' +
'multi-source-brains.md.',
},
orchestrator,
};
/** Exported for unit tests. */
export const __testing = {
phaseASchema,
phaseBBackfillStorage,
phaseCVerify,
};
+27 -41
View File
@@ -13,8 +13,7 @@
*/
import type { BrainEngine } from '../core/engine.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
import * as db from '../core/db.ts';
// --- Types ---
@@ -98,50 +97,37 @@ export function deriveDomain(frontmatterDomain: string | null | undefined, slug:
// --- Core query ---
/**
* Find pages with no inbound links via the engine's built-in helper.
* Returns raw rows (all pages regardless of filter).
*
* As of v0.17: takes an engine argument. Composes with runCycle which
* passes an explicit engine. No more db.getConnection() global fixes
* the PGLite-vs-Postgres + test-fixture coupling codex flagged.
* Find pages with no inbound links.
* Returns raw rows from the DB (all pages regardless of filter).
*/
export async function queryOrphanPages(
engine: BrainEngine,
): Promise<{ slug: string; title: string; domain: string | null }[]> {
return engine.findOrphanPages();
export async function queryOrphanPages(): Promise<{ slug: string; title: string; domain: string | null }[]> {
const sql = db.getConnection();
const rows = await sql`
SELECT
p.slug,
COALESCE(p.title, p.slug) AS title,
p.frontmatter->>'domain' AS domain
FROM pages p
WHERE NOT EXISTS (
SELECT 1 FROM links l WHERE l.to_page_id = p.id
)
ORDER BY p.slug
`;
return rows as { slug: string; title: string; domain: string | null }[];
}
/**
* Find orphan pages, with optional pseudo-page filtering.
* Returns structured OrphanResult with totals.
*
* As of v0.17: `engine` is required. See queryOrphanPages for rationale.
*/
export async function findOrphans(
engine: BrainEngine,
opts: { includePseudo?: boolean } = {},
): Promise<OrphanResult> {
const includePseudo = !!opts.includePseudo;
// The NOT EXISTS anti-join over pages × links can take seconds on 50K-page
// brains. Heartbeat every second so agents see the scan is alive. Keyset
// pagination was considered and rejected: without an index on
// links.to_page_id it does no useful work. Adding that index is a
// follow-up (v0.14.3 schema migration).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('orphans.scan');
const stopHb = startHeartbeat(progress, 'scanning pages for missing inbound links…');
let allOrphans: { slug: string; title: string; domain: string | null }[];
let total: number;
try {
allOrphans = await engine.findOrphanPages();
// Count total pages in DB for the summary line
const stats = await engine.getStats();
total = stats.page_count;
} finally {
stopHb();
progress.finish();
}
const _totalPages = allOrphans.length; // pages with no inbound links (preserved for ref)
export async function findOrphans(includePseudo: boolean = false): Promise<OrphanResult> {
const allOrphans = await queryOrphanPages();
const totalPages = allOrphans.length; // pages with no inbound links
// Count total pages in DB for the summary line
const sql = db.getConnection();
const [{ count: totalPagesCount }] = await sql`SELECT count(*)::int AS count FROM pages`;
const total = Number(totalPagesCount);
const filtered = includePseudo
? allOrphans
@@ -203,7 +189,7 @@ export function formatOrphansText(result: OrphanResult): string {
// --- CLI entry point ---
export async function runOrphans(engine: BrainEngine, args: string[]) {
export async function runOrphans(_engine: BrainEngine, args: string[]) {
const json = args.includes('--json');
const count = args.includes('--count');
const includePseudo = args.includes('--include-pseudo');
@@ -225,7 +211,7 @@ Summary line: N orphans out of M linkable pages (K total; K-M excluded)
return;
}
const result = await findOrphans(engine, { includePseudo });
const result = await findOrphans(includePseudo);
if (count) {
console.log(String(result.total_orphans));
+13 -31
View File
@@ -31,8 +31,6 @@
import { loadConfig, toEngineConfig } from '../core/config.ts';
import type { EngineConfig } from '../core/types.ts';
import * as db from '../core/db.ts';
import { createProgress, startHeartbeat } from '../core/progress.ts';
import { getCliOptions, cliOptsToProgressOptions } from '../core/cli-options.ts';
interface RepairTarget {
table: string;
@@ -99,44 +97,28 @@ export async function repairJsonb(opts: RepairOpts = { dryRun: false }): Promise
await db.connect(engineCfg);
const sql = db.getConnection();
// Progress on stderr only. Stdout is reserved for the JSON summary that
// migrations/v0_12_2.ts parses via JSON.parse — stray progress lines on
// stdout would break the orchestrator (per Codex review #12).
const progress = createProgress(cliOptsToProgressOptions(getCliOptions()));
progress.start('repair_jsonb.run', TARGETS.length);
for (const t of TARGETS) {
const phase = `repair_jsonb.${t.table}.${t.column}`;
progress.heartbeat(phase);
// Heartbeat the caller while each UPDATE runs (minutes on 50K-row tables).
const stopHb = startHeartbeat(progress, `${t.table}.${t.column}`);
let repaired = 0;
try {
if (opts.dryRun) {
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`,
);
repaired = (rows[0] as unknown as { n: number }).n;
} else {
const rows = await sql.unsafe(
`UPDATE ${t.table}
SET ${t.column} = (${t.column} #>> '{}')::jsonb
WHERE jsonb_typeof(${t.column}) = 'string'
RETURNING 1`,
);
repaired = rows.length;
}
} finally {
stopHb();
if (opts.dryRun) {
const rows = await sql.unsafe(
`SELECT count(*)::int AS n FROM ${t.table} WHERE jsonb_typeof(${t.column}) = 'string'`,
);
repaired = (rows[0] as { n: number }).n;
} else {
const rows = await sql.unsafe(
`UPDATE ${t.table}
SET ${t.column} = (${t.column} #>> '{}')::jsonb
WHERE jsonb_typeof(${t.column}) = 'string'
RETURNING 1`,
);
repaired = rows.length;
}
progress.tick(1, `${t.table}.${t.column}=${repaired}`);
result.per_target.push({ table: t.table, column: t.column, rows_repaired: repaired });
result.total_repaired += repaired;
}
progress.finish();
return result;
}

Some files were not shown because too many files have changed in this diff Show More