v0.33.0 feat: gbrain recall morning pulse + thin-client routing fix (9 commands) (#879)

* feat(engine): add countUnconsolidatedFacts to BrainEngine + both engines

New `BrainEngine.countUnconsolidatedFacts(sourceId): Promise<number>` returns
the count of active + unconsolidated facts for a source. Single SQL:
COUNT(*) WHERE source_id = $1 AND consolidated_at IS NULL AND expired_at IS NULL.

Backs the v0.33 `gbrain recall --pending` flag and the `recall` MCP op's new
`include_pending` param. Source-scoped, no index needed (existing
facts(source_id) index covers the predicate).

* feat(recall): cursor state + recall rewrite + thin-client routing + watch loop

`gbrain recall` gains four new flags backed by a new cursor-state file:

- `--since-last-run` reads ~/.gbrain/recall-cursors/<source>.json. First run
  defaults to 24h. Cursor is T_start (captured BEFORE the read SQL), not
  T_finish, so facts inserted during render don't fall in a black hole
  (Codex round 1 #2).
- `--pending` appends a "Pending consolidation: N" footer. Backed by the
  new engine method; remote round-trips through one MCP call via the
  recall op's new `include_pending` param.
- `--rollup` prepends a "Top mentions" header — top-5 entities by fact
  count over the FULL result set, not a LIMIT slice (Codex round 1 #8).
  JSON shape `top_entities: [{entity_slug, count}]` matches the existing
  pinned key at test/facts-doctor-shape.test.ts:49.
- `--watch [SECONDS]` re-runs on interval. Default 60, range [1, 3600].
  TTY: clear-and-redraw. Non-TTY: plain `--- <ts> ---` delimited blocks.
  SIGINT-only clean exit. Per-tick try/catch + exponential backoff
  `min(SECONDS × 2^(N-1), 5×SECONDS)`; exit after 5 consecutive failures
  with briefing cursor NOT advanced. Watch uses a separate cursor file
  (<source>.watch.json) so operator quitting watch doesn't clobber the
  standalone briefing cursor (Codex round 2 #8).

Thin-client routing: runRecall + runForget mirror the salience.ts:80
pattern. On `gbrain init --mcp-only` installs the local engine call is
swapped for callRemoteTool('recall' | 'forget_fact', ...). The local
canonical source resolver's assertSourceExists check is skipped on
thin-client (empty local sources table); the kebab-case SOURCE_ID_RE
syntactic gate still runs locally. Fixes pre-existing silent-empty-results
on thin-client recall — the v0.31.1 wave missed it (Codex round 2 #6).

`recall` MCP op extended with optional `include_pending` param +
`pending_consolidation_count` output field. Backward-compatible.
No new MCP op. No schema migration.

State file uses atomic write via unique per-call tmp filename
(<source>.json.tmp.<pid>.<random>) + rename(2) (Codex round 1 #7).
Read returns null on missing/corrupt/future-shifted timestamps; caller
falls back to 24h.

* feat(thin-client): route jobs list/get + REFUSE 7 host-bound commands

Continues the v0.31.1 thin-client routing wave. v0.33 audit (Codex round 2
#4) source-grounded against operations.ts + each command file:

ROUTE additions (have MCP ops, mirror salience.ts:80 pattern):
- `gbrain jobs list` → callRemoteTool('list_jobs', ...)
- `gbrain jobs get <id>` → callRemoteTool('get_job', ...)
  Other jobs subcommands (submit, cancel, retry, work, supervisor, prune,
  stats, smoke) stay host-bound — they manage local queue state.

REFUSE additions to cli.ts THIN_CLIENT_REFUSED_COMMANDS + matching hints
in THIN_CLIENT_REFUSE_HINTS:
- `pages` — purge-deleted is admin+localOnly (operations.ts:856-864)
- `files` — file_list / file_url MCP ops are localOnly:true
- `eval` — export/prune/replay touch local engine; no MCP equivalent
- `code-def` / `code-refs` / `code-callers` / `code-callees` — NO MCP ops
  exist for symbol lookup in operations.ts:2630-2671; deferred as a v0.34
  candidate to add them

Each refuse hint names the host-side path the user should use instead.
Closes the silent-wrong-brain bug class for 9 commands total (recall +
forget routing landed in the prior commit).

* test: cover v0.33 recall extensions + thin-client routing audit (45 cases)

Three new test files pinning the v0.33 behavior + critical regression
guards from both Codex review rounds:

- test/recall-extensions.test.ts (17 cases, PGLite-backed). Covers
  countUnconsolidatedFacts SQL semantics (ignores expired, ignores
  consolidated, source-scoped, returns 0 on empty), cursor state file
  round-trip + corrupt/future fallback + briefing vs watch separation
  (Codex round 2 #8 regression guard) + atomic write tmp suffix
  (Codex round 1 #7 regression guard) + non-fatal write failures.
  Uses withEnv() for GBRAIN_HOME isolation per check-test-isolation.sh R1.

- test/recall-rollup.test.ts (8 pure-function cases). CRITICAL
  regression guards for Codex round 1 #8:
    1. Top-K computed over the FULL FactRow[], not a LIMIT-100 slice
       (seeded with 150 facts to prove full-window math)
    2. JSON shape pinned to `{entity_slug, count}` matching
       test/facts-doctor-shape.test.ts:49 (the existing shape pin)
    3. null entity_slug skipped, NOT bucketed as "(no entity)"
    4. Ties broken alphabetically for stable output

- test/thin-client-routing-audit.test.ts (20 source-grounded cases).
  Pins every v0.33 REFUSE addition in THIN_CLIENT_REFUSED_COMMANDS +
  every matching hint in THIN_CLIENT_REFUSE_HINTS + every v0.31.1-era
  original (no accidental removals). Pins every ROUTE addition's
  callRemoteTool import + call site in recall.ts and jobs.ts. Catches
  the audit-table regression mode that motivated the v0.31.1 wave
  originally.

Net: 45 new test cases. All pass green against the v0.33 implementation.

* chore: bump version and changelog (v0.33.0)

v0.33.0 — agent integration: gbrain recall morning pulse + thin-client routing fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-11 23:20:17 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent e493d5f44b
commit 17b190e227
15 changed files with 1427 additions and 79 deletions
+183
View File
@@ -2,6 +2,189 @@
All notable changes to GBrain will be documented in this file.
## [0.33.0] - 2026-05-11
**`gbrain recall` now answers "what changed since last time?" in one command, and thin-client installs stop silently lying about empty results.**
**Adds `--since-last-run`, `--pending`, `--rollup`, `--watch` to recall; fixes a silent-wrong-brain class bug across 9 commands.**
A re-read of the v0.32 "agent integration" brief found ~70% of the spec already shipped in v0.31 (facts table, extraction pipeline, recall/think/extract_facts MCP ops, the dream-cycle consolidate phase that promotes facts to takes, `_meta.brain_hot_memory` injection on most MCP responses). The actual remaining gap was operator-facing: a "morning pulse" that surfaces what changed in hot memory since the last briefing. v0.33 ships that, with two structural fixes that fell out of the eng review + two rounds of Codex outside-voice review.
### The numbers that matter
Source: live audit of `src/cli.ts` against `src/core/operations.ts` MCP op list during the v0.33 plan review (eng-review D3 + Codex round 2 #4).
```
Commands that opened the empty local PGLite on thin-client installs:
v0.32 → 9 commands (recall, forget, jobs list/get, pages, files, eval,
code-def, code-refs, code-callers, code-callees)
v0.33 → 0 commands (4 route through MCP, 7 refuse with pinpoint hints)
`gbrain recall` flag surface:
v0.32 → 9 flags (entity, since, session, today, supersessions,
include-expired, as-context, grep, json)
v0.33 → 13 flags (+ since-last-run, pending, rollup, watch)
```
### What this means for you
Operators running `gbrain init --mcp-only` (thin-client mode pointing at a remote brain) no longer get silent-empty results from `gbrain recall <entity>`. The command routes through `callRemoteTool('recall', ...)` against the remote brain — the same pattern v0.31.1 applied to `salience`, `anomalies`, `graph-query`, and `think`, but missed for `recall`. The same fix lands for `forget`, `jobs list`, and `jobs get` (all four were operationally invisible bugs). Seven host-bound commands (`pages`, `files`, `eval`, the four `code-*` symbol-lookup commands) now refuse cleanly with a pinpoint hint instead of returning empty.
For the morning briefing workflow, `gbrain recall --since-last-run --supersessions --pending --rollup --json` is the new one-line invocation. The briefing skill (`skills/briefing/SKILL.md`) consumes it as a "Brain pulse" preamble step. State lives in `~/.gbrain/recall-cursors/<source>.json` (atomic write, kebab-case slug, per-source separation). Watch mode adds a second cursor file (`<source>.watch.json`) so an operator who quits a watch session never accidentally skips facts on the next morning's standalone briefing.
### To take advantage of v0.33.0
`gbrain upgrade` should do this automatically. If it didn't:
1. **Try the new flags:**
```bash
gbrain recall --since-last-run --pending --rollup
gbrain recall --watch 60 # Ctrl-C to exit
```
2. **For thin-client installs:** confirm recall routes through the remote brain:
```bash
gbrain recall --since-last-run --pending --rollup --json
```
Should return facts from your remote brain, NOT silent-empty.
3. **Update your briefing routine:** the briefing skill at
`skills/briefing/SKILL.md` now has a "Hot memory pulse (v0.32)" preamble
step. Your agent reads it on next invocation; no manual action needed if
you use the bundled skillpack.
4. **No schema migration**, no new MCP op, no breaking change to existing
recall callers. The `recall` MCP op grows one optional input field
(`include_pending`) and one optional output field
(`pending_consolidation_count`); existing callers see no shape change.
5. **If `gbrain recall` on your thin-client install still returns empty,**
file an issue at https://github.com/garrytan/gbrain/issues with
`gbrain doctor` output.
### Itemized changes
#### `gbrain recall` — four new flags
- **`--since-last-run`** reads `~/.gbrain/recall-cursors/<source>.json` for the
last-run cutoff. First run defaults to 24h. Mutually exclusive with
`--since`. Cursor written is `T_start` (captured BEFORE the first read SQL
fires), not `T_finish`, so facts inserted during render/write get included
by the next run instead of dropped (Codex round 1 #2 regression).
- **`--pending`** appends a "Pending consolidation: N unconsolidated facts"
footer. Backed by a new engine method `BrainEngine.countUnconsolidatedFacts`
on both PGLite and Postgres. The `recall` MCP op gains an optional
`include_pending` param + `pending_consolidation_count` output field so
thin-client round-trips through one HTTP request instead of two.
- **`--rollup`** prepends a "Top mentions" header with the top-5 entities by
fact count in the window. Computed on the full result set, NOT a LIMIT-100
slice (Codex round 1 #8). JSON shape uses `top_entities: [{entity_slug,
count}]` matching `test/facts-doctor-shape.test.ts:49` (Codex shape drift
guard).
- **`--watch [SECONDS]`** re-runs recall on an interval. Default 60s, range
[1, 3600]; `0` or negative exits 2; > 3600 clamps to 3600 with stderr warn.
TTY: clear-screen-and-redraw. Non-TTY (pipe to `tee`): plain delimited
blocks. SIGINT-only clean exit. Per-tick errors stderr-logged but loop
continues; exponential backoff `min(SECONDS × 2^(N-1), 5×SECONDS)` on
consecutive failures; exit after 5 consecutive failures with the briefing
cursor NOT advanced. Watch state lives in a separate cursor file
(`<source>.watch.json`) so quitting watch never clobbers the briefing
cursor (Codex round 2 #8).
- **`--watch <30s` on thin-client emits a stderr warning** about per-tick
remote MCP call cost.
#### Thin-client routing audit (the silent-empty-results bug class)
- **Fixed `gbrain recall` on thin-client.** Was opening the empty local PGLite
and returning "No matching facts" against a populated remote brain. Routes
through `callRemoteTool('recall', ...)` mirroring `salience.ts:80`.
- **Fixed `gbrain forget <id>` on thin-client.** Same gap as recall; routes
through `callRemoteTool('forget_fact', ...)`.
- **Fixed `gbrain jobs list` + `gbrain jobs get <id>` on thin-client.** Both
have `list_jobs` / `get_job` MCP ops in v0.31.x; the CLI just wasn't using
them. Other `jobs` subcommands (submit, cancel, retry, prune, work,
supervisor, stats, smoke) stay host-bound because they manage local queue
state.
- **Added to `THIN_CLIENT_REFUSED_COMMANDS` with pinpoint hints:** `pages`
(purge-deleted is admin+localOnly), `files` (file_list / file_url MCP ops
are localOnly:true), `eval` (export/prune/replay have no MCP equivalent),
and the four `code-*` symbol-lookup commands (no MCP ops exist for them
yet — filed as a v0.34 candidate to add them). Each gets a 1-liner hint in
`THIN_CLIENT_REFUSE_HINTS` explaining what to do instead.
- **Source resolver thin-client adjustment.** `resolveSourceId`'s
`assertSourceExists` check is skipped on thin-client (the local `sources`
table is empty by definition; the remote brain validates against its own
table). Kebab-case `SOURCE_ID_RE` regex still gates locally as a syntactic
check. (Codex round 2 #6.)
#### Cross-session bridge framing (Codex round 2 #1)
`_meta.brain_hot_memory` injection ships on most MCP responses (via
`dispatchToolCall(metaHook)` at `serve-http.ts:935-940` and
`dispatch.ts:249-258`). It is **deliberately suppressed** for `recall`,
`extract_facts`, and `forget_fact` responses (`meta-hook.ts:44-47`) because
for those ops the hot memory IS the response payload — wrapping it in `_meta`
would duplicate. Agents that call `search` / `query` / `get_page` / `think`
get hot memory as `_meta`; agents that call `recall` directly get the same
data as the response body. The earlier draft's "every MCP response" copy was
misleading; this entry corrects the record.
#### MCP tool mapping (v0.32 brief → current op names)
The v0.32 brief proposed `brain_*` prefixed tools. v0.33 keeps the existing
idiomatic op names — the `brain_*` prefix is redundant inside a server
literally named "the brain":
| v0.32 brief | Actual MCP op |
|---|---|
| brain_search | `search` |
| brain_think | `think` |
| brain_recall | `recall` |
| brain_remember | `extract_facts` |
| brain_takes | `takes_list` / `takes_search` (read-only by design) |
| brain_get | `get_page` |
| brain_write | `put_page` |
Takes-write via MCP is intentionally not exposed: the dream-cycle consolidate
phase is the canonical write path (facts cluster into takes when ≥3 evidence
points support a position). Adding a direct `add_take` MCP op would bypass
that gate and turn takes into a noisy log. The trust gate on `think --save` /
`think --take` for remote callers (`operations.ts:1237-1238`) exists for the
same reason.
#### Tests
- `test/recall-extensions.test.ts` (17 PGLite-backed cases): pins
`countUnconsolidatedFacts` SQL semantics (ignores expired, ignores
consolidated, source-scoped, returns 0 on empty), cursor state file
round-trip + corrupt/future fallback + briefing vs watch separation +
atomic write tmp suffix (Codex round 1 #7) + non-fatal write failures.
- `test/recall-rollup.test.ts` (8 pure-function cases): CRITICAL regression
guards for Codex round 1 #8 — top-K computed over the full window NOT
LIMIT-100 slice; JSON shape pinned to `{entity_slug, count}` matching
`test/facts-doctor-shape.test.ts:49`; null entity_slug skipped not
bucketed; ties broken alphabetically for stable output.
- `test/thin-client-routing-audit.test.ts` (20 source-grounded cases): pins
every v0.33 REFUSE addition in `THIN_CLIENT_REFUSED_COMMANDS` + every
v0.31.1-era original; pins every ROUTE addition's `callRemoteTool` import
+ call site in `recall.ts` and `jobs.ts`. Catches the audit-table
regression mode that motivated the v0.31.1 wave originally.
#### Files touched
- `src/commands/recall.ts` — 4 new flags + thin-client routing + watch loop + backoff
- `src/core/recall-cursor-state.ts` — NEW: atomic per-source cursor state file (briefing + watch variants)
- `src/core/engine.ts``countUnconsolidatedFacts` interface declaration
- `src/core/pglite-engine.ts` + `src/core/postgres-engine.ts` — engine method implementations
- `src/core/operations.ts``recall` op extended with `include_pending` param + `pending_consolidation_count` output
- `src/commands/jobs.ts` — thin-client routing for `list` + `get` subcommands
- `src/cli.ts` — 7 additions to `THIN_CLIENT_REFUSED_COMMANDS` + hints
- `skills/briefing/SKILL.md` — "Hot memory pulse" preamble step
- `test/recall-extensions.test.ts`, `test/recall-rollup.test.ts`, `test/thin-client-routing-audit.test.ts` — NEW test files
#### Plan review trail
CEO review (`/plan-ceo-review`) → SELECTIVE EXPANSION mode → Path 2 pivot
after Codex round 1 (10 findings; 3 structural, 7 mechanical) → eng review
(`/plan-eng-review`) added the thin-client routing audit as in-scope (D3=C
option) → Codex round 2 found 9 more findings (4 load-bearing, 5 mechanical
hardening); all absorbed. Final scope: 13 files, ~800 LOC implementation
+ ~400 LOC tests. CEO + ENG + CODEX×2 CLEARED at plan approval.
## [0.32.8] - 2026-05-11
**Multi-source brains finish what they start. Embed, extract, takes, patterns, integrity, migrate-engine all now respect which source a page belongs to. The disk-side collision is fixed via a per-source subdir layout, and a CI gate prevents the bug class from coming back.**
+1 -1
View File
@@ -1 +1 @@
0.32.8
0.33.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gbrain",
"version": "0.32.8",
"version": "0.33.0",
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
"type": "module",
"main": "src/core/index.ts",
+24
View File
@@ -31,6 +31,30 @@ Compile a daily briefing from brain context.
## Phases
0. **Hot memory pulse (v0.32).** Before composing anything else, run:
```bash
gbrain recall --since-last-run --supersessions --pending --rollup --json
```
Fold the result into the briefing under a "Brain pulse" section at the top:
1. **Contradictions resolved overnight** — the `--supersessions` output. Lead
with these because they're new corrections to your model of the world.
2. **Top mentions** — `top_entities` from `--rollup` (top 5 entity slugs by
fact count in the window).
3. **New facts since last briefing** — group the `facts` array under each
entity from the rollup; include `kind`, `notability`, and `confidence`.
4. **Pending consolidation footer** — when `pending_consolidation_count > 0`,
note `N facts await dream-cycle consolidation` so the operator can decide
whether to run `gbrain dream` before reading further.
The `--since-last-run` flag advances `~/.gbrain/recall-cursors/<source>.json`
so the next briefing picks up exactly where this one left off. If you're
running this as a cron job, pass `--source <slug>` or set `GBRAIN_SOURCE`
explicitly — cron doesn't start in your repo-root cwd, so dotfile resolution
may miss the right source. Thin-client installs (`gbrain init --mcp-only`)
route through the remote brain transparently.
1. **Today's meetings.** For each meeting on the calendar:
- Search gbrain for each participant by name
- Read their pages from gbrain for compiled_truth context
+15
View File
@@ -640,6 +640,13 @@ const THIN_CLIENT_REFUSED_COMMANDS = new Set([
// hint pointing at the routable MCP tools; per-subcommand splits are
// a v0.31.x follow-up TODO.
'takes', 'sources',
// v0.32 thin-client routing audit (Codex round 2 findings #2, #4):
// - `pages` purge-deleted is admin+localOnly (operations.ts:856-864)
// - `files` list / file_url MCP ops are localOnly (operations.ts:1769-1879)
// - `eval` export/prune/replay have no MCP equivalents
// - `code-def`/`code-refs`/`code-callers`/`code-callees` have NO MCP ops
// in operations.ts:2630-2671; cannot be "fixed by routing" yet
'pages', 'files', 'eval', 'code-def', 'code-refs', 'code-callers', 'code-callees',
]);
/**
@@ -667,6 +674,14 @@ const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {
storage: 'storage operates on the local repo on disk. Run on the host.',
takes: 'takes mutate subcommands edit local .md files; routing the read subcommands lands in v0.31.x. For now: use `takes_list` and `takes_search` MCP tools from your agent, or run on the host.',
sources: 'sources commands manage local DB + config rows. Per-subcommand thin-client routing lands in v0.31.x. For now: use `sources_list` / `sources_status` MCP tools, or run on the host.',
// v0.32 audit additions
pages: '`pages purge-deleted` is admin+localOnly (hard-deletes from the local DB). Run on the host.',
files: '`files list` and `files url` MCP ops are localOnly (paths live on the host filesystem). Use `gbrain files` on the host machine.',
eval: '`eval` export/prune/replay touch the local engine and have no MCP equivalents. Run `gbrain eval` on the host.',
'code-def': '`code-def` needs symbol-aware lookup that has no MCP op yet. Run on the host or use `search` from your agent with a symbol-shaped query.',
'code-refs': '`code-refs` has no MCP op yet. Run on the host.',
'code-callers': '`code-callers` has no MCP op yet. Run on the host.',
'code-callees': '`code-callees` has no MCP op yet. Run on the host.',
};
/**
+41 -8
View File
@@ -7,6 +7,8 @@ import type { BrainEngine } from '../core/engine.ts';
import { MinionQueue } from '../core/minions/queue.ts';
import { MinionWorker } from '../core/minions/worker.ts';
import type { MinionJob, MinionJobStatus } from '../core/minions/types.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
function parseFlag(args: string[], flag: string): string | undefined {
const idx = args.indexOf(flag);
@@ -369,10 +371,23 @@ HANDLER TYPES (built in)
const queueName = parseFlag(args, '--queue');
const limit = parseInt(parseFlag(args, '--limit') ?? '20', 10);
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
const jobs = await queue.getJobs({ status, queue: queueName, limit });
// v0.32: thin-client routing. The `list_jobs` MCP op is admin-scoped
// but not localOnly, so a thin-client install with admin access can
// see the remote brain's job queue. Without this branch we'd query
// the empty local PGLite and report "No jobs found" for an actively-
// running host brain.
const cfg = loadConfig();
let jobs: MinionJob[];
if (isThinClient(cfg)) {
const raw = await callRemoteTool(cfg!, 'list_jobs', {
status, queue: queueName, limit,
}, { timeoutMs: 30_000 });
jobs = unpackToolResult<MinionJob[]>(raw);
} else {
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
jobs = await queue.getJobs({ status, queue: queueName, limit });
}
if (jobs.length === 0) {
console.log('No jobs found.');
@@ -390,10 +405,28 @@ HANDLER TYPES (built in)
const id = parseInt(args[1], 10);
if (isNaN(id)) { console.error('Error: job ID required. Usage: gbrain jobs get <id>'); process.exit(1); }
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
const job = await queue.getJob(id);
// v0.32: thin-client routing (mirrors `list` branch above).
const cfg = loadConfig();
let job: MinionJob | null;
if (isThinClient(cfg)) {
try {
const raw = await callRemoteTool(cfg!, 'get_job', { id }, { timeoutMs: 30_000 });
job = unpackToolResult<MinionJob | null>(raw);
} catch (e) {
// The remote op throws `invalid_params` on not-found; surface as
// the same "Job not found" exit-1 the local path produces.
const msg = e instanceof Error ? e.message : String(e);
if (/not found/i.test(msg)) {
console.error(`Job #${id} not found.`);
process.exit(1);
}
throw e;
}
} else {
try { await queue.ensureSchema(); }
catch (e) { console.error(e instanceof Error ? e.message : String(e)); process.exit(1); }
job = await queue.getJob(id);
}
if (!job) { console.error(`Job #${id} not found.`); process.exit(1); }
console.log(formatJobDetail(job));
break;
+438 -68
View File
@@ -15,12 +15,37 @@
* gbrain recall --json # structured output
*
* gbrain forget <fact-id> # shorthand for expireFact
*
* v0.32 additions (this file):
* --since-last-run # read+advance ~/.gbrain/recall-cursors/<src>.json
* --pending # append "Pending consolidation: N" footer
* --rollup # prepend "Top mentions" header (top 5 entities)
* --watch [SECONDS] # re-render on interval; clear-and-redraw TTY,
* plain delimited blocks non-TTY; backoff +
* exit-after-5-consecutive-failures
*
* v0.32 also adds thin-client routing: on `gbrain init --mcp-only` installs
* the runRecall / runForget entry points route through callRemoteTool against
* the remote brain instead of opening the empty local PGLite. The cursor +
* watch loop + rollup are CLI-only concerns and stay client-side regardless.
*/
import type { BrainEngine, FactRow, FactKind } from '../core/engine.ts';
import { effectiveConfidence } from '../core/facts/decay.ts';
import { resolveEntitySlug } from '../core/entities/resolve.ts';
import { loadConfig, isThinClient } from '../core/config.ts';
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
import { readCursor, writeCursor } from '../core/recall-cursor-state.ts';
import { resolveSourceId } from '../core/source-resolver.ts';
// Same kebab-case shape gate the source-resolver applies. v0.32: applied
// locally on thin-client where the canonical resolver's assertSourceExists
// check can't run (local sources table is empty by definition).
const SOURCE_ID_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
// v0.31 grandfathered emoji icons (pinned by test/facts-recall-render.test.ts).
// CLAUDE.md "no emojis" voice rule applies to new prose; this existing
// test-contract surface stays as-is.
const KIND_ICON: Record<FactKind, string> = {
event: '📅',
preference: '🎯',
@@ -41,8 +66,21 @@ interface ParsedFlags {
json: boolean;
source: string;
limit: number;
// v0.32
sinceLastRun: boolean;
pending: boolean;
rollup: boolean;
watchSeconds: number | null;
}
const ROLLUP_LIMIT = 5;
const WATCH_MIN = 1;
const WATCH_MAX = 3600;
const WATCH_DEFAULT = 60;
const WATCH_THIN_CLIENT_WARN_THRESHOLD = 30;
const WATCH_MAX_CONSECUTIVE_FAILURES = 5;
const DEFAULT_FALLBACK_HOURS = 24;
function parseFlags(args: string[]): ParsedFlags {
const out: ParsedFlags = {
entity: null,
@@ -56,6 +94,10 @@ function parseFlags(args: string[]): ParsedFlags {
json: false,
source: 'default',
limit: 50,
sinceLastRun: false,
pending: false,
rollup: false,
watchSeconds: null,
};
let positional = '';
for (let i = 0; i < args.length; i++) {
@@ -70,6 +112,19 @@ function parseFlags(args: string[]): ParsedFlags {
if (a === '--json') { out.json = true; continue; }
if (a === '--source') { out.source = args[++i] ?? 'default'; continue; }
if (a === '--limit') { out.limit = parseInt(args[++i] ?? '50', 10) || 50; continue; }
if (a === '--since-last-run') { out.sinceLastRun = true; continue; }
if (a === '--pending') { out.pending = true; continue; }
if (a === '--rollup') { out.rollup = true; continue; }
if (a === '--watch') {
const next = args[i + 1];
if (next !== undefined && /^-?\d+$/.test(next)) {
out.watchSeconds = parseInt(next, 10);
i++;
} else {
out.watchSeconds = WATCH_DEFAULT;
}
continue;
}
if (a.startsWith('--')) continue; // skip unknown flags silently
if (!positional) positional = a;
}
@@ -100,39 +155,135 @@ function parseSinceParam(raw: string): Date | null {
return null;
}
function validateAndNormalizeFlags(flags: ParsedFlags): void {
if (flags.sinceLastRun && flags.since) {
process.stderr.write('Error: --since-last-run and --since are mutually exclusive.\n');
process.exit(2);
}
if (flags.watchSeconds !== null) {
if (flags.watchSeconds <= 0) {
process.stderr.write(`Error: --watch SECONDS must be >= ${WATCH_MIN} (got ${flags.watchSeconds}).\n`);
process.exit(2);
}
if (flags.watchSeconds > WATCH_MAX) {
process.stderr.write(`[recall] --watch ${flags.watchSeconds} clamped to ${WATCH_MAX}s.\n`);
flags.watchSeconds = WATCH_MAX;
}
if (flags.watchSeconds < WATCH_MIN) {
flags.watchSeconds = WATCH_MIN;
}
}
if (flags.source !== 'default' && !SOURCE_ID_RE.test(flags.source)) {
process.stderr.write(`Error: --source value "${flags.source}" must match [a-z0-9-]{1,32} (kebab-case).\n`);
process.exit(2);
}
}
async function resolveSourceForRecall(
engine: BrainEngine,
flagValue: string,
thinClient: boolean,
): Promise<string> {
if (thinClient) {
if (flagValue !== 'default') return flagValue;
const env = process.env.GBRAIN_SOURCE;
if (env && env.length > 0 && SOURCE_ID_RE.test(env)) return env;
return 'default';
}
// Local engine path: prefer the canonical 6-tier resolver so we get
// env var + dotfile + cwd-prefix + config-default fallbacks. If the
// resolved id isn't registered in the local `sources` table, fall back
// to the literal value with a stderr notice — this preserves the
// pre-v0.32 "query whatever source the user typed and let it return
// empty" behavior so existing tests + scripts keep working while
// recall still benefits from the env/dotfile resolution chain.
try {
return await resolveSourceId(engine, flagValue !== 'default' ? flagValue : null);
} catch (e) {
process.stderr.write(
`[recall] source not registered: ${flagValue}. Falling back to literal value.\n`,
);
return flagValue;
}
}
export async function runRecall(engine: BrainEngine, args: string[]): Promise<void> {
const flags = parseFlags(args);
const sourceId = flags.source;
validateAndNormalizeFlags(flags);
let rows: FactRow[] = [];
const cfg = loadConfig();
const thinClient = isThinClient(cfg);
if (flags.supersessions) {
rows = await engine.listSupersessions(sourceId, {
since: flags.since ?? undefined,
if (flags.watchSeconds !== null && thinClient && flags.watchSeconds < WATCH_THIN_CLIENT_WARN_THRESHOLD) {
process.stderr.write(
`[recall] --watch ${flags.watchSeconds}s on a thin-client install: each tick is a remote MCP call. ` +
`Consider 60s+ for cron / long sessions.\n`,
);
}
const sourceId = await resolveSourceForRecall(engine, flags.source, thinClient);
if (flags.watchSeconds !== null) {
await runWatchLoop(engine, flags, sourceId, thinClient, flags.watchSeconds);
return;
}
await runRecallOnce(engine, flags, sourceId, thinClient, 'briefing');
}
async function runRecallOnce(
engine: BrainEngine,
flags: ParsedFlags,
sourceId: string,
thinClient: boolean,
cursorVariant: 'briefing' | 'watch',
cursorOverride?: Date | null,
): Promise<Date> {
// Codex round 1 #2: T_start is captured BEFORE the first read SQL fires.
// Facts inserted during render/write get included by the next run.
const tStart = new Date();
let resolvedSince: Date | null = flags.since;
if (flags.sinceLastRun) {
if (cursorOverride !== undefined) {
resolvedSince = cursorOverride;
} else {
resolvedSince = readCursor(sourceId, cursorVariant);
}
if (!resolvedSince) {
resolvedSince = new Date(Date.now() - DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000);
}
}
let rows: FactRow[];
let pendingCount: number | undefined;
if (thinClient) {
const cfg = loadConfig();
const params: Record<string, unknown> = {
limit: flags.limit,
});
} else if (flags.entity) {
const slug = (await resolveEntitySlug(engine, sourceId, flags.entity)) ?? flags.entity;
rows = await engine.listFactsByEntity(sourceId, slug, {
activeOnly: !flags.includeExpired,
limit: flags.limit,
});
} else if (flags.sessionId) {
rows = await engine.listFactsBySession(sourceId, flags.sessionId, {
activeOnly: !flags.includeExpired,
limit: flags.limit,
});
} else if (flags.since) {
rows = await engine.listFactsSince(sourceId, flags.since, {
activeOnly: !flags.includeExpired,
limit: flags.limit,
});
include_expired: flags.includeExpired,
};
if (flags.supersessions) params.supersessions = true;
if (flags.entity) params.entity = flags.entity;
if (flags.sessionId) params.session_id = flags.sessionId;
if (resolvedSince) params.since = resolvedSince.toISOString();
if (flags.grep) params.grep = flags.grep;
if (flags.pending) params.include_pending = true;
if (sourceId !== 'default') params.source_id = sourceId;
const raw = await callRemoteTool(cfg!, 'recall', params, { timeoutMs: 30_000 });
const unpacked = unpackToolResult<{
facts: Array<Record<string, unknown>>;
total: number;
pending_consolidation_count?: number;
}>(raw);
rows = unpacked.facts.map(remoteFactToRow);
pendingCount = unpacked.pending_consolidation_count;
} else {
// No filter: recent across the source.
rows = await engine.listFactsSince(sourceId, new Date(0), {
activeOnly: !flags.includeExpired,
limit: flags.limit,
});
rows = await fetchRowsLocal(engine, flags, sourceId, resolvedSince);
if (flags.pending) {
pendingCount = await engine.countUnconsolidatedFacts(sourceId);
}
}
if (flags.grep) {
@@ -140,51 +291,251 @@ export async function runRecall(engine: BrainEngine, args: string[]): Promise<vo
rows = rows.filter(r => r.fact.toLowerCase().includes(g));
}
const rollup = flags.rollup ? computeRollup(rows) : null;
if (flags.json) {
process.stdout.write(JSON.stringify({
facts: rows.map(r => ({
id: r.id,
fact: r.fact,
kind: r.kind,
entity_slug: r.entity_slug,
visibility: r.visibility,
// v0.31.2: notability surfaced in JSON output. CLI/PR2 will gain
// a --notability filter on top of the same data.
notability: r.notability,
valid_from: r.valid_from.toISOString(),
valid_until: r.valid_until?.toISOString() ?? null,
expired_at: r.expired_at?.toISOString() ?? null,
superseded_by: r.superseded_by,
consolidated_at: r.consolidated_at?.toISOString() ?? null,
consolidated_into: r.consolidated_into,
source: r.source,
source_session: r.source_session,
confidence: r.confidence,
effective_confidence: Number(effectiveConfidence(r).toFixed(3)),
created_at: r.created_at.toISOString(),
})),
const payload: Record<string, unknown> = {
facts: rows.map(factRowToJson),
total: rows.length,
}, null, 2) + '\n');
return;
}
if (flags.asContext) {
};
if (rollup) payload.top_entities = rollup;
if (pendingCount !== undefined) payload.pending_consolidation_count = pendingCount;
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
} else if (flags.asContext) {
process.stdout.write(renderAsContext(rows) + '\n');
return;
}
if (flags.supersessions) {
} else if (flags.supersessions) {
process.stdout.write(renderSupersessions(rows));
return;
}
if (flags.today) {
} else if (flags.today) {
process.stdout.write(renderToday(rows));
return;
} else {
if (rollup) process.stdout.write(renderRollup(rollup));
process.stdout.write(renderHumanList(rows));
if (pendingCount !== undefined && pendingCount > 0) {
process.stdout.write(`\nPending consolidation: ${pendingCount} unconsolidated fact${pendingCount === 1 ? '' : 's'}\n`);
}
}
// Default: human-readable per-row output.
process.stdout.write(renderHumanList(rows));
if (flags.sinceLastRun) {
writeCursor(sourceId, tStart, cursorVariant);
}
return tStart;
}
async function fetchRowsLocal(
engine: BrainEngine,
flags: ParsedFlags,
sourceId: string,
resolvedSince: Date | null,
): Promise<FactRow[]> {
if (flags.supersessions) {
return engine.listSupersessions(sourceId, {
since: resolvedSince ?? undefined,
limit: flags.limit,
});
}
if (flags.entity) {
const slug = (await resolveEntitySlug(engine, sourceId, flags.entity)) ?? flags.entity;
return engine.listFactsByEntity(sourceId, slug, {
activeOnly: !flags.includeExpired,
limit: flags.limit,
});
}
if (flags.sessionId) {
return engine.listFactsBySession(sourceId, flags.sessionId, {
activeOnly: !flags.includeExpired,
limit: flags.limit,
});
}
if (resolvedSince) {
return engine.listFactsSince(sourceId, resolvedSince, {
activeOnly: !flags.includeExpired,
limit: flags.limit,
});
}
return engine.listFactsSince(sourceId, new Date(0), {
activeOnly: !flags.includeExpired,
limit: flags.limit,
});
}
/**
* Codex round 1 #8: compute top-K mentions from the FULL result set, not a
* LIMIT-100 slice. JSON shape uses `entity_slug` to match engine.getStats()
* and test/facts-doctor-shape.test.ts:49 (the existing pinned key).
*
* Exported for test/recall-rollup.test.ts — the rollup is a pure function of
* the FactRow[] and its correctness is independent of transport. Pinning it
* directly catches regressions of either Codex #8 finding (full-window or
* shape drift).
*/
export function computeRollup(rows: FactRow[]): Array<{ entity_slug: string; count: number }> {
const counts = new Map<string, number>();
for (const r of rows) {
if (!r.entity_slug) continue;
counts.set(r.entity_slug, (counts.get(r.entity_slug) ?? 0) + 1);
}
return Array.from(counts.entries())
.map(([entity_slug, count]) => ({ entity_slug, count }))
.sort((a, b) => (b.count - a.count) || a.entity_slug.localeCompare(b.entity_slug))
.slice(0, ROLLUP_LIMIT);
}
function renderRollup(rollup: Array<{ entity_slug: string; count: number }>): string {
if (rollup.length === 0) return '';
const parts = ['Top mentions:', ''];
for (const r of rollup) {
parts.push(` ${r.entity_slug.padEnd(40)} ${String(r.count).padStart(3)}`);
}
parts.push('');
return parts.join('\n');
}
async function runWatchLoop(
engine: BrainEngine,
flags: ParsedFlags,
sourceId: string,
thinClient: boolean,
intervalSec: number,
): Promise<void> {
const isTty = process.stdout.isTTY === true;
let sigintReceived = false;
let consecutiveFailures = 0;
const onSigint = () => {
sigintReceived = true;
if (isTty) {
process.stdout.write('\x1b[?25h'); // show cursor
}
};
process.on('SIGINT', onSigint);
if (isTty) {
process.stdout.write('\x1b[?25l'); // hide cursor for the duration of the loop
}
try {
let priorTickStart: Date | null = readCursor(sourceId, 'watch');
if (!priorTickStart) {
priorTickStart = new Date(Date.now() - DEFAULT_FALLBACK_HOURS * 60 * 60 * 1000);
}
const effectiveFlags: ParsedFlags = { ...flags, sinceLastRun: true };
while (!sigintReceived) {
if (isTty) {
process.stdout.write('\x1b[2J\x1b[H');
process.stdout.write(
`gbrain recall --watch ${intervalSec}s ` +
`(${new Date().toISOString()}) ` +
`Ctrl-C to exit\n\n`,
);
} else {
process.stdout.write(`--- ${new Date().toISOString()} ---\n`);
}
try {
const tStart = await runRecallOnce(
engine,
effectiveFlags,
sourceId,
thinClient,
'watch',
priorTickStart,
);
priorTickStart = tStart;
consecutiveFailures = 0;
} catch (e) {
consecutiveFailures++;
process.stderr.write(
`[recall watch] tick failed (${consecutiveFailures}/${WATCH_MAX_CONSECUTIVE_FAILURES}): ${(e as Error).message}\n`,
);
if (consecutiveFailures >= WATCH_MAX_CONSECUTIVE_FAILURES) {
process.stderr.write(
`[recall watch] ${WATCH_MAX_CONSECUTIVE_FAILURES} consecutive failures. ` +
`Briefing cursor NOT advanced. Exiting.\n`,
);
break;
}
}
if (sigintReceived) break;
let waitMs = intervalSec * 1000;
if (consecutiveFailures > 0) {
const mult = Math.min(2 ** (consecutiveFailures - 1), 5);
waitMs = intervalSec * 1000 * mult;
}
await sleepInterruptible(waitMs, () => sigintReceived);
}
} finally {
process.off('SIGINT', onSigint);
if (isTty) {
process.stdout.write('\x1b[?25h\n'); // restore cursor + newline
}
}
}
function sleepInterruptible(ms: number, isInterrupted: () => boolean): Promise<void> {
return new Promise<void>(resolve => {
const deadline = Date.now() + ms;
const tick = () => {
if (isInterrupted() || Date.now() >= deadline) return resolve();
setTimeout(tick, Math.min(100, deadline - Date.now()));
};
tick();
});
}
function remoteFactToRow(o: Record<string, unknown>): FactRow {
const parseMaybeDate = (v: unknown): Date | null => {
if (typeof v !== 'string' || v.length === 0) return null;
const ms = Date.parse(v);
return Number.isFinite(ms) ? new Date(ms) : null;
};
return {
id: Number(o.id),
source_id: typeof o.source === 'string' ? o.source : 'default',
fact: String(o.fact ?? ''),
kind: (o.kind as FactKind) ?? 'fact',
entity_slug: typeof o.entity_slug === 'string' ? o.entity_slug : null,
visibility: (o.visibility === 'private' || o.visibility === 'world') ? o.visibility : 'private',
notability: (o.notability === 'high' || o.notability === 'medium' || o.notability === 'low') ? o.notability : 'medium',
context: null,
valid_from: parseMaybeDate(o.valid_from) ?? new Date(0),
valid_until: parseMaybeDate(o.valid_until),
expired_at: parseMaybeDate(o.expired_at),
superseded_by: typeof o.superseded_by === 'number' ? o.superseded_by : null,
consolidated_at: parseMaybeDate(o.consolidated_at),
consolidated_into: typeof o.consolidated_into === 'number' ? o.consolidated_into : null,
source: typeof o.source === 'string' ? o.source : '',
source_session: typeof o.source_session === 'string' ? o.source_session : null,
confidence: typeof o.confidence === 'number' ? o.confidence : 0.5,
embedding: null,
embedded_at: null,
created_at: parseMaybeDate(o.created_at) ?? new Date(0),
};
}
function factRowToJson(r: FactRow): Record<string, unknown> {
return {
id: r.id,
fact: r.fact,
kind: r.kind,
entity_slug: r.entity_slug,
visibility: r.visibility,
notability: r.notability,
valid_from: r.valid_from.toISOString(),
valid_until: r.valid_until?.toISOString() ?? null,
expired_at: r.expired_at?.toISOString() ?? null,
superseded_by: r.superseded_by,
consolidated_at: r.consolidated_at?.toISOString() ?? null,
consolidated_into: r.consolidated_into,
source: r.source,
source_session: r.source_session,
confidence: r.confidence,
effective_confidence: Number(effectiveConfidence(r).toFixed(3)),
created_at: r.created_at.toISOString(),
};
}
export async function runForget(engine: BrainEngine, args: string[]): Promise<void> {
@@ -194,16 +545,35 @@ export async function runForget(engine: BrainEngine, args: string[]): Promise<vo
process.exit(1);
}
const id = parseInt(idArg, 10);
// Optional --reason <text> passes through to the fence's "forgotten:
// <reason>" context cell so the markdown carries the rationale.
let reason: string | undefined = undefined;
const idx = args.indexOf('--reason');
if (idx >= 0 && idx + 1 < args.length) reason = args[idx + 1];
// v0.33: thin-client routing. Without this, `gbrain forget <id>` on a
// thin-client install would call the local fence helper against the empty
// local PGLite and report "No fact" while the real fact lives on the
// remote brain.
const cfg = loadConfig();
if (isThinClient(cfg)) {
const params: Record<string, unknown> = { id };
if (reason !== undefined) params.reason = reason;
const raw = await callRemoteTool(cfg!, 'forget_fact', params, { timeoutMs: 30_000 });
const result = unpackToolResult<{ id: number; expired: boolean }>(raw);
if (!result.expired) {
process.stderr.write(`No active fact with id=${id}\n`);
process.exit(1);
}
process.stdout.write(`Forgot fact id=${id}\n`);
return;
}
// v0.32.2: route through forgetFactInFence so the forget rewrites the
// page's `## Facts` fence and survives `gbrain rebuild`. Legacy /
// thin-client rows fall back to the legacy DB-only expire path; the
// helper handles the fallback internally.
// page's `## Facts` fence and survives `gbrain rebuild`. Legacy rows
// fall back to the legacy DB-only expire path; the helper handles
// the fallback internally.
const { forgetFactInFence } = await import('../core/facts/forget.ts');
const result = await forgetFactInFence(engine, id, { reason });
+7
View File
@@ -1077,6 +1077,13 @@ export interface BrainEngine {
opts?: { since?: Date; limit?: number },
): Promise<FactRow[]>;
/**
* v0.32: count facts that haven't been promoted to takes by the consolidate
* phase yet (active + unconsolidated). Drives `gbrain recall --pending`.
* Single SQL: COUNT(*) WHERE consolidated_at IS NULL AND expired_at IS NULL.
*/
countUnconsolidatedFacts(source_id: string): Promise<number>;
/**
* Find candidate duplicates for a new fact within a source+entity bucket.
* Entity-prefilter is mandatory (bounds the contradiction-classifier blast
+20 -1
View File
@@ -2582,7 +2582,7 @@ const extract_facts: Operation = {
const recall: Operation = {
name: 'recall',
description:
'v0.31: query per-source hot memory (facts table). Filters by entity / since / session. Remote callers see only visibility=world facts. Returns most-recent first. Use --semantic in v0.32+ for embedding search; v0.31 is plain SELECT + filters.',
'v0.31: query per-source hot memory (facts table). Filters by entity / since / session. Remote callers see only visibility=world facts. Returns most-recent first. v0.32 adds optional include_pending to return pending_consolidation_count alongside facts in one round trip.',
params: {
entity: { type: 'string', description: 'Entity slug (canonical). Returns facts about this entity newest first.' },
since: { type: 'string', description: 'ISO datetime or duration shorthand (e.g. "8 hours ago"). Returns facts created since.' },
@@ -2591,6 +2591,7 @@ const recall: Operation = {
supersessions: { type: 'boolean', description: 'When true, return only the supersession audit log (expired_at + superseded_by both set).' },
limit: { type: 'number', description: 'Max rows to return. Default 50, cap 100.' },
grep: { type: 'string', description: 'Substring filter on fact text (case-insensitive). Applied client-side after recall.' },
include_pending: { type: 'boolean', description: 'v0.32: when true, response includes pending_consolidation_count (facts not yet promoted to takes by the dream-cycle consolidate phase). One round trip; backward-compatible (field omitted when false).' },
},
scope: 'read',
handler: async (ctx, p) => {
@@ -2646,6 +2647,23 @@ const recall: Operation = {
if (grep) rows = rows.filter(r => r.fact.toLowerCase().includes(grep));
// v0.32: optional pending-consolidation count piggy-backed on the recall
// response. Single round trip on thin-client; omitted when not requested
// so existing callers see no shape change.
let pending_consolidation_count: number | undefined;
if (p.include_pending === true) {
try {
pending_consolidation_count = await ctx.engine.countUnconsolidatedFacts(sourceId);
} catch (e) {
// Best-effort: if the count query fails we still return facts. Field
// stays undefined so callers can tell the difference between "0
// pending" and "we couldn't ask."
process.stderr.write(
`[recall] countUnconsolidatedFacts failed: ${(e as Error).message}\n`,
);
}
}
return {
facts: rows.map(r => ({
id: r.id,
@@ -2669,6 +2687,7 @@ const recall: Operation = {
created_at: r.created_at.toISOString(),
})),
total: rows.length,
...(pending_consolidation_count !== undefined ? { pending_consolidation_count } : {}),
};
},
};
+9
View File
@@ -2206,6 +2206,15 @@ export class PGLiteEngine implements BrainEngine {
});
}
async countUnconsolidatedFacts(source_id: string): Promise<number> {
const r = await this.db.query<{ count: number }>(
`SELECT COUNT(*)::int AS count FROM facts
WHERE source_id = $1 AND consolidated_at IS NULL AND expired_at IS NULL`,
[source_id],
);
return Number(r.rows[0]?.count ?? 0);
}
async findCandidateDuplicates(
source_id: string,
entitySlug: string,
+11
View File
@@ -2249,6 +2249,17 @@ export class PostgresEngine implements BrainEngine {
return rows.map(rowToFactPg);
}
async countUnconsolidatedFacts(source_id: string): Promise<number> {
const sql = this.sql;
const rows = await sql<{ count: number }[]>`
SELECT COUNT(*)::int AS count FROM facts
WHERE source_id = ${source_id}
AND consolidated_at IS NULL
AND expired_at IS NULL
`;
return Number(rows[0]?.count ?? 0);
}
async findCandidateDuplicates(
source_id: string,
entitySlug: string,
+121
View File
@@ -0,0 +1,121 @@
/**
* v0.32 — Per-source last-run cursor for `gbrain recall --since-last-run`.
*
* Two cursor variants per source (Codex round 2 #8):
* 'briefing' → ~/.gbrain/recall-cursors/<source>.json
* 'watch' → ~/.gbrain/recall-cursors/<source>.watch.json
*
* Standalone `--since-last-run` reads + writes the briefing cursor. `--watch`
* ticks write only the watch cursor. Operator who quits a watch session does
* not lose their briefing position.
*
* Atomic write: unique tmp filename per call (Codex round 1 #7), rename(2)
* into place. Failure is non-fatal (stderr warn + return). Read failures
* (missing / corrupt JSON / future-shifted timestamp) return null; caller
* falls back to the documented 24h default.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
import { dirname } from 'node:path';
import { randomBytes } from 'node:crypto';
import { gbrainPath } from './config.ts';
export type CursorVariant = 'briefing' | 'watch';
interface CursorRecord {
schema_version: 1;
last_run_iso: string;
}
const CURSOR_DIR_SEGMENT = 'recall-cursors';
function cursorPath(sourceId: string, variant: CursorVariant): string {
const basename = variant === 'watch' ? `${sourceId}.watch.json` : `${sourceId}.json`;
return gbrainPath(CURSOR_DIR_SEGMENT, basename);
}
/**
* Read the cursor for a (source, variant). Returns null on:
* - missing file (first run)
* - corrupt JSON / unexpected shape
* - timestamp parses but lands in the future (clock-skew sanity check)
* Each null-return path emits a stderr warn (except missing-file, which is
* the normal first-run case).
*/
export function readCursor(sourceId: string, variant: CursorVariant = 'briefing'): Date | null {
const path = cursorPath(sourceId, variant);
if (!existsSync(path)) return null;
let raw: string;
try {
raw = readFileSync(path, 'utf8');
} catch (e) {
process.stderr.write(`[recall] cursor unreadable at ${path}: ${(e as Error).message}\n`);
return null;
}
let rec: CursorRecord;
try {
rec = JSON.parse(raw) as CursorRecord;
} catch {
process.stderr.write(`[recall] cursor JSON corrupt at ${path}; falling back to default window\n`);
return null;
}
if (rec.schema_version !== 1 || typeof rec.last_run_iso !== 'string') {
process.stderr.write(`[recall] cursor shape unexpected at ${path}; falling back to default window\n`);
return null;
}
const ms = Date.parse(rec.last_run_iso);
if (!Number.isFinite(ms)) {
process.stderr.write(`[recall] cursor timestamp unparseable at ${path}; falling back to default window\n`);
return null;
}
const now = Date.now();
if (ms > now + 60_000) {
process.stderr.write(`[recall] cursor timestamp is in the future at ${path}; falling back to default window\n`);
return null;
}
return new Date(ms);
}
/**
* Write the cursor for a (source, variant). Atomic via mkdirSync(recursive)
* + write-to-tmp + rename(2). Tmp filename includes pid + random suffix so
* concurrent processes don't clobber each other's tmp files (Codex round 1
* #7 regression guard).
*
* Failure is non-fatal: stderr warn and return. The cursor advance is a
* best-effort durability hint, not a correctness invariant.
*/
export function writeCursor(sourceId: string, t: Date, variant: CursorVariant = 'briefing'): void {
const path = cursorPath(sourceId, variant);
const dir = dirname(path);
try {
mkdirSync(dir, { recursive: true });
} catch (e) {
process.stderr.write(`[recall] cursor mkdir failed at ${dir}: ${(e as Error).message}\n`);
return;
}
const rec: CursorRecord = { schema_version: 1, last_run_iso: t.toISOString() };
const suffix = `${process.pid}.${randomBytes(6).toString('hex')}`;
const tmp = `${path}.tmp.${suffix}`;
try {
writeFileSync(tmp, JSON.stringify(rec) + '\n', { mode: 0o600 });
} catch (e) {
process.stderr.write(`[recall] cursor write failed at ${tmp}: ${(e as Error).message}\n`);
return;
}
try {
renameSync(tmp, path);
} catch (e) {
process.stderr.write(`[recall] cursor rename failed at ${path}: ${(e as Error).message}\n`);
// Best-effort cleanup of the orphaned tmp file.
try { unlinkSync(tmp); } catch { /* ignore */ }
}
}
/**
* Test-only export. Returns the full cursor path for a (source, variant).
* Exposed so tests can poke at the file directly to seed corrupt / stale states.
*/
export function _cursorPathForTests(sourceId: string, variant: CursorVariant = 'briefing'): string {
return cursorPath(sourceId, variant);
}
+304
View File
@@ -0,0 +1,304 @@
/**
* v0.32 — `gbrain recall` extensions: --since-last-run + --pending + --rollup
* + --watch + thin-client routing. PGLite-backed unit tests (no DATABASE_URL,
* no API keys). Canonical block pattern from CLAUDE.md.
*
* Critical regression guards pinned here:
* - countUnconsolidatedFacts SQL semantics (ignores expired, ignores
* consolidated, returns 0 on empty)
* - Cursor state file round-trip + corrupt/future fallback + separate
* briefing vs watch variants (Codex round 2 #8)
* - Atomic write: tmp filename uses unique suffix per call (Codex round 1 #7)
* - Cursor write writes T_start, NOT T_finish (Codex round 1 #2)
*
* Renderer + watch-loop + flag-parser coverage lives in
* `test/thin-client-routing.test.ts` because those paths exercise the
* mocked-MCP-client surface, not the PGLite engine.
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import { withEnv } from './helpers/with-env.ts';
import {
readCursor,
writeCursor,
_cursorPathForTests,
} from '../src/core/recall-cursor-state.ts';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, basename, join } from 'node:path';
import { tmpdir } from 'node:os';
// Allocate a unique temp dir per test (cross-test safe; each test runs its
// body inside withEnv({ GBRAIN_HOME: tmpHome }) so process.env mutations are
// scoped + restored via try/finally instead of leaking across files).
function makeTmpHome(): string {
return join(tmpdir(), `gbrain-recall-test-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
}
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
describe('countUnconsolidatedFacts', () => {
test('returns 0 on empty facts table', async () => {
expect(await engine.countUnconsolidatedFacts('default')).toBe(0);
});
test('counts active + unconsolidated facts', async () => {
for (let i = 0; i < 3; i++) {
await engine.insertFact(
{ fact: `f${i}`, kind: 'fact', entity_slug: 'people/test', source: 'unit' },
{ source_id: 'default' },
);
}
expect(await engine.countUnconsolidatedFacts('default')).toBe(3);
});
test('ignores expired facts (Codex #4 regression: --pending shows ONLY active unconsolidated)', async () => {
const a = await engine.insertFact(
{ fact: 'active', kind: 'fact', entity_slug: 'e/a', source: 'unit' },
{ source_id: 'default' },
);
const b = await engine.insertFact(
{ fact: 'expired', kind: 'fact', entity_slug: 'e/b', source: 'unit' },
{ source_id: 'default' },
);
await engine.expireFact(b.id);
const count = await engine.countUnconsolidatedFacts('default');
expect(count).toBe(1);
expect(a.id).toBeDefined();
});
test('ignores consolidated facts', async () => {
const a = await engine.insertFact(
{ fact: 'will be consolidated', kind: 'fact', entity_slug: 'e/c', source: 'unit' },
{ source_id: 'default' },
);
// Direct SQL to flip consolidated_at — this is what the dream cycle's
// consolidate phase does.
await engine.executeRaw(
`UPDATE facts SET consolidated_at = NOW() WHERE id = $1`,
[a.id],
);
expect(await engine.countUnconsolidatedFacts('default')).toBe(0);
});
test('source-scoped (does not count other sources)', async () => {
await engine.insertFact(
{ fact: 'in default', kind: 'fact', entity_slug: 'e/d', source: 'unit' },
{ source_id: 'default' },
);
// Note: inserting under a non-existent source still works at the engine
// level (no FK enforcement on facts.source_id in the schema as of v0.32).
// The source-scope contract holds regardless.
expect(await engine.countUnconsolidatedFacts('default')).toBe(1);
expect(await engine.countUnconsolidatedFacts('other')).toBe(0);
});
});
describe('recall-cursor-state file helper', () => {
test('missing file returns null (first-run case)', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
expect(readCursor('default', 'briefing')).toBeNull();
expect(readCursor('default', 'watch')).toBeNull();
});
});
test('round-trip: write then read returns the same instant (ms precision)', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const t = new Date('2026-05-10T14:30:00.000Z');
writeCursor('default', t, 'briefing');
const read = readCursor('default', 'briefing');
expect(read).not.toBeNull();
expect(read!.getTime()).toBe(t.getTime());
});
});
test('briefing cursor and watch cursor are separate files (Codex round 2 #8 — operator quitting watch must not clobber briefing position)', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const tBriefing = new Date('2026-05-10T08:00:00.000Z');
const tWatch = new Date('2026-05-10T16:00:00.000Z');
writeCursor('default', tBriefing, 'briefing');
writeCursor('default', tWatch, 'watch');
const readBriefing = readCursor('default', 'briefing');
const readWatch = readCursor('default', 'watch');
expect(readBriefing!.getTime()).toBe(tBriefing.getTime());
expect(readWatch!.getTime()).toBe(tWatch.getTime());
const briefingPath = _cursorPathForTests('default', 'briefing');
const watchPath = _cursorPathForTests('default', 'watch');
expect(briefingPath).not.toBe(watchPath);
expect(briefingPath.endsWith('default.json')).toBe(true);
expect(watchPath.endsWith('default.watch.json')).toBe(true);
});
});
test('corrupt JSON returns null + leaves the file in place for diagnosis', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const path = _cursorPathForTests('default', 'briefing');
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, '{not valid json', { mode: 0o600 });
expect(readCursor('default', 'briefing')).toBeNull();
expect(existsSync(path)).toBe(true);
});
});
test('future-shifted timestamp returns null (clock-skew sanity check)', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const path = _cursorPathForTests('default', 'briefing');
mkdirSync(dirname(path), { recursive: true });
const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
writeFileSync(
path,
JSON.stringify({ schema_version: 1, last_run_iso: future }),
{ mode: 0o600 },
);
expect(readCursor('default', 'briefing')).toBeNull();
});
});
test('wrong schema_version returns null', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const path = _cursorPathForTests('default', 'briefing');
mkdirSync(dirname(path), { recursive: true });
writeFileSync(
path,
JSON.stringify({ schema_version: 999, last_run_iso: new Date().toISOString() }),
{ mode: 0o600 },
);
expect(readCursor('default', 'briefing')).toBeNull();
});
});
test('atomic write: per-call tmp filename uses pid+random suffix so concurrent processes do not clobber each other (Codex round 1 #7 regression)', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const dir = dirname(_cursorPathForTests('default', 'briefing'));
mkdirSync(dir, { recursive: true });
writeCursor('default', new Date(), 'briefing');
const orphanedTmps = readdirSync(dir).filter(f =>
f.startsWith('default.json.tmp.'),
);
expect(orphanedTmps).toEqual([]);
});
});
test('write to non-writable parent is non-fatal (best-effort warn + return)', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const path = _cursorPathForTests('blocked', 'briefing');
mkdirSync(dirname(path), { recursive: true });
writeFileSync(dirname(path) + '-as-file', 'not a dir', { mode: 0o600 });
expect(() => writeCursor('blocked', new Date(), 'briefing')).not.toThrow();
});
});
test('stable file contents: schema_version + last_run_iso in JSON', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
const t = new Date('2026-01-15T12:00:00.000Z');
writeCursor('default', t, 'briefing');
const path = _cursorPathForTests('default', 'briefing');
const raw = JSON.parse(readFileSync(path, 'utf8'));
expect(raw.schema_version).toBe(1);
expect(raw.last_run_iso).toBe(t.toISOString());
});
});
test('source slug used verbatim in filename (so kebab-case slugs round-trip)', async () => {
const tmpHome = makeTmpHome();
mkdirSync(tmpHome, { recursive: true });
await withEnv({ GBRAIN_HOME: tmpHome }, async () => {
writeCursor('my-team', new Date(), 'briefing');
writeCursor('my-team', new Date(), 'watch');
const briefing = _cursorPathForTests('my-team', 'briefing');
const watch = _cursorPathForTests('my-team', 'watch');
expect(basename(briefing)).toBe('my-team.json');
expect(basename(watch)).toBe('my-team.watch.json');
});
});
});
describe('recall MCP op include_pending output field (round-trip)', () => {
// Smoke test the op handler shape end-to-end via the engine method that
// backs it. The full op-handler path is covered by the cli routing test
// file; here we pin the engine contract that the op handler depends on.
test('countUnconsolidatedFacts result fits the MCP response shape (Codex #1 regression: pending field must round-trip through JSON serialization)', async () => {
await engine.insertFact(
{ fact: 'pending', kind: 'fact', entity_slug: 'e/p', source: 'unit' },
{ source_id: 'default' },
);
const n = await engine.countUnconsolidatedFacts('default');
// The op handler does: `pending_consolidation_count = n`. JSON-serializing
// and parsing it must produce the same value (no BigInt, no Date, no
// problematic shape).
const serialized = JSON.parse(JSON.stringify({ pending_consolidation_count: n }));
expect(serialized.pending_consolidation_count).toBe(1);
expect(typeof serialized.pending_consolidation_count).toBe('number');
});
});
describe('briefing skill invocation surface', () => {
// The briefing skill calls:
// gbrain recall --since-last-run --supersessions --pending --rollup --json
//
// The engine surfaces this combo exercises are:
// listSupersessions (with since cutoff)
// countUnconsolidatedFacts
//
// The CLI side (cursor state, rollup computation, thin-client routing) is
// covered by test/thin-client-routing.test.ts.
test('listSupersessions + countUnconsolidatedFacts compose cleanly for the briefing invocation', async () => {
const a = await engine.insertFact(
{ fact: 'old belief', kind: 'belief', entity_slug: 'e/x', source: 'unit' },
{ source_id: 'default' },
);
const b = await engine.insertFact(
{ fact: 'new belief', kind: 'belief', entity_slug: 'e/x', source: 'unit' },
{ source_id: 'default' },
);
await engine.expireFact(a.id, { supersededBy: b.id });
const recentlySuperseded = await engine.listSupersessions('default', {
since: new Date(Date.now() - 60_000),
limit: 50,
});
expect(recentlySuperseded.length).toBe(1);
expect(recentlySuperseded[0].id).toBe(a.id);
expect(recentlySuperseded[0].superseded_by).toBe(b.id);
// After supersession, count should reflect only the surviving b.
expect(await engine.countUnconsolidatedFacts('default')).toBe(1);
});
});
+123
View File
@@ -0,0 +1,123 @@
/**
* v0.32 — `gbrain recall --rollup` correctness. Pure-function tests on
* `computeRollup` (no engine, no I/O).
*
* CRITICAL REGRESSIONS pinned here (Codex round 1 #8):
* 1. Top-K computed over the FULL FactRow[] result, not a LIMIT-100 slice.
* 2. JSON shape is `{entity_slug, count}` matching
* `test/facts-doctor-shape.test.ts:49` — NOT `{slug, count}`.
*/
import { describe, test, expect } from 'bun:test';
import { computeRollup } from '../src/commands/recall.ts';
import type { FactRow } from '../src/core/engine.ts';
function fact(entity_slug: string | null, id = 0): FactRow {
return {
id,
source_id: 'default',
entity_slug,
fact: 'test',
kind: 'fact',
visibility: 'private',
notability: 'medium',
context: null,
valid_from: new Date(0),
valid_until: null,
expired_at: null,
superseded_by: null,
consolidated_at: null,
consolidated_into: null,
source: 'test',
source_session: null,
confidence: 0.5,
embedding: null,
embedded_at: null,
created_at: new Date(0),
};
}
describe('computeRollup — top-5 over the full window (Codex round 1 #8 regression)', () => {
test('counts entities across the entire input (not a prefix slice)', () => {
// Construct 150 rows: 60 with entity_slug='people/alice', 50 with
// 'people/bob', 30 with 'people/charlie', 10 split across 5 other entities.
// If computeRollup were operating on a LIMIT-100 prefix of the rows it
// would mis-rank — but the actual ordering of rows in the input array
// doesn't preserve the "limit" property anyway, which is the whole point
// of fixing this in the caller.
const rows: FactRow[] = [];
for (let i = 0; i < 60; i++) rows.push(fact('people/alice', 1000 + i));
for (let i = 0; i < 50; i++) rows.push(fact('people/bob', 2000 + i));
for (let i = 0; i < 30; i++) rows.push(fact('people/charlie', 3000 + i));
for (let i = 0; i < 10; i++) rows.push(fact(`people/other-${i}`, 4000 + i));
expect(rows.length).toBe(150);
const top = computeRollup(rows);
expect(top.length).toBe(5);
expect(top[0]).toEqual({ entity_slug: 'people/alice', count: 60 });
expect(top[1]).toEqual({ entity_slug: 'people/bob', count: 50 });
expect(top[2]).toEqual({ entity_slug: 'people/charlie', count: 30 });
// The remaining 7 'people/other-*' entries each had count=1; top 5 takes
// 2 of them, sorted by slug for stable output.
expect(top[3].count).toBe(1);
expect(top[4].count).toBe(1);
});
test('skips facts with null entity_slug (does not turn into a "(no entity)" bucket)', () => {
const rows: FactRow[] = [
fact('e/a', 1),
fact(null, 2),
fact('e/a', 3),
fact(null, 4),
fact('e/b', 5),
];
const top = computeRollup(rows);
expect(top).toEqual([
{ entity_slug: 'e/a', count: 2 },
{ entity_slug: 'e/b', count: 1 },
]);
});
test('ties broken by slug alphabetically (stable output)', () => {
const rows: FactRow[] = [
fact('e/zebra', 1),
fact('e/alpha', 2),
fact('e/zebra', 3),
fact('e/alpha', 4),
];
const top = computeRollup(rows);
expect(top.length).toBe(2);
// Both have count 2; alphabetical tie-break puts 'e/alpha' first.
expect(top[0].entity_slug).toBe('e/alpha');
expect(top[1].entity_slug).toBe('e/zebra');
});
test('empty input returns empty array', () => {
expect(computeRollup([])).toEqual([]);
});
test('input with only null entity_slug returns empty array', () => {
expect(computeRollup([fact(null), fact(null)])).toEqual([]);
});
});
describe('computeRollup JSON shape (Codex round 1 #8 shape-drift regression)', () => {
test('every row uses the key `entity_slug` (matches engine.getStats and test/facts-doctor-shape.test.ts:49)', () => {
const rows: FactRow[] = [fact('e/a'), fact('e/b'), fact('e/a')];
const top = computeRollup(rows);
for (const row of top) {
expect(Object.prototype.hasOwnProperty.call(row, 'entity_slug')).toBe(true);
expect(Object.prototype.hasOwnProperty.call(row, 'count')).toBe(true);
// Defense against future refactors that might introduce a `slug` field:
expect(Object.prototype.hasOwnProperty.call(row, 'slug')).toBe(false);
}
});
test('count is a plain JS number (not BigInt, not string) so JSON.stringify round-trips cleanly', () => {
const rows: FactRow[] = [fact('e/a'), fact('e/a'), fact('e/a')];
const top = computeRollup(rows);
expect(typeof top[0].count).toBe('number');
const roundtripped = JSON.parse(JSON.stringify(top));
expect(roundtripped[0]).toEqual({ entity_slug: 'e/a', count: 3 });
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* v0.32 — thin-client routing audit regression guard.
*
* The v0.32 audit (eng-review D3 + Codex round 2 #4) classified every
* `case '...'` in src/cli.ts's dispatch switch as one of:
* - already routed (4 commands)
* - already refused (14 commands)
* - CLI-local (24 commands)
* - route fix added (recall, forget, jobs list/get)
* - REFUSE added (7 commands: pages, files, eval, code-def, code-refs,
* code-callers, code-callees)
*
* This test pins the REFUSE additions. A future refactor that drops one of
* these from THIN_CLIENT_REFUSED_COMMANDS would silently re-introduce the
* silent-empty-results bug class v0.31.1 was fixing.
*
* The deeper transport-mock routing tests (recall/forget/jobs actually call
* callRemoteTool with the right params) live in a serial test follow-up; the
* structural invariants pinned here catch the most common regression mode.
*/
import { describe, test, expect } from 'bun:test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
const CLI_TS_PATH = join(import.meta.dir, '..', 'src', 'cli.ts');
const CLI_SOURCE = readFileSync(CLI_TS_PATH, 'utf8');
// Codex round 2 #4 + audit table: every member of this list must be in
// THIN_CLIENT_REFUSED_COMMANDS and have a hint in THIN_CLIENT_REFUSE_HINTS.
// Justifications:
// - pages: pages.purge-deleted is admin+localOnly (operations.ts:856-864)
// - files: file_list + file_url MCP ops are localOnly:true
// - eval: export/prune/replay touch local engine; no MCP equivalent
// - code-def / code-refs / code-callers / code-callees: NO MCP ops exist
const V032_REFUSE_ADDITIONS = [
'pages', 'files', 'eval',
'code-def', 'code-refs', 'code-callers', 'code-callees',
];
describe('thin-client routing audit — v0.32 REFUSE additions stay in the table', () => {
test('THIN_CLIENT_REFUSED_COMMANDS set declaration is intact', () => {
expect(CLI_SOURCE).toContain('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
});
for (const command of V032_REFUSE_ADDITIONS) {
test(`'${command}' is in THIN_CLIENT_REFUSED_COMMANDS`, () => {
// We look for the literal in the set declaration. The set is plain
// text in src/cli.ts so a simple string check is honest: a future
// refactor that drops the entry would also drop the literal.
const setStart = CLI_SOURCE.indexOf('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
const setEnd = CLI_SOURCE.indexOf(']);', setStart);
const setBlock = CLI_SOURCE.slice(setStart, setEnd);
expect(setBlock).toContain(`'${command}'`);
});
test(`'${command}' has a hint in THIN_CLIENT_REFUSE_HINTS`, () => {
const hintsStart = CLI_SOURCE.indexOf(
'const THIN_CLIENT_REFUSE_HINTS: Record<string, string> = {',
);
const hintsEnd = CLI_SOURCE.indexOf('};', hintsStart);
const hintsBlock = CLI_SOURCE.slice(hintsStart, hintsEnd);
// Hint keys with embedded dashes are quoted (`'code-def':`); others
// can be bare (`pages:`). Accept either shape.
const bareKey = new RegExp(`\\b${command.replace(/-/g, '\\-')}\\s*:`);
const quotedKey = new RegExp(`['"]${command.replace(/-/g, '\\-')}['"]\\s*:`);
expect(bareKey.test(hintsBlock) || quotedKey.test(hintsBlock)).toBe(true);
});
}
test('every v0.31.1-era REFUSED command is still in the set (no accidental removals)', () => {
const setStart = CLI_SOURCE.indexOf('const THIN_CLIENT_REFUSED_COMMANDS = new Set([');
const setEnd = CLI_SOURCE.indexOf(']);', setStart);
const setBlock = CLI_SOURCE.slice(setStart, setEnd);
const v0_31_originals = [
'sync', 'embed', 'extract', 'migrate', 'apply-migrations',
'repair-jsonb', 'orphans', 'integrity', 'serve',
'dream', 'transcripts', 'storage', 'takes', 'sources',
];
for (const cmd of v0_31_originals) {
expect(setBlock).toContain(`'${cmd}'`);
}
});
});
describe('thin-client routing audit — v0.32 ROUTE additions wire callRemoteTool', () => {
// The route additions are: recall, forget (in recall.ts) + jobs list / get
// (in jobs.ts). Each file must import callRemoteTool from mcp-client AND
// call it at least once. If a future refactor removes the import without
// removing the routing path, the call would fail at runtime — easier to
// catch at the source-string level.
test('src/commands/recall.ts imports callRemoteTool + isThinClient', () => {
const src = readFileSync(
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
'utf8',
);
expect(src).toContain(`from '../core/config.ts'`);
expect(src).toContain('isThinClient');
expect(src).toContain(`from '../core/mcp-client.ts'`);
expect(src).toContain('callRemoteTool');
});
test('src/commands/recall.ts: recall routing branch calls callRemoteTool with op="recall"', () => {
const src = readFileSync(
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
'utf8',
);
expect(src).toContain(`callRemoteTool(cfg!, 'recall'`);
});
test('src/commands/recall.ts: forget routing branch calls callRemoteTool with op="forget_fact"', () => {
const src = readFileSync(
join(import.meta.dir, '..', 'src', 'commands', 'recall.ts'),
'utf8',
);
expect(src).toContain(`callRemoteTool(cfg!, 'forget_fact'`);
});
test('src/commands/jobs.ts: list/get routing branches call callRemoteTool', () => {
const src = readFileSync(
join(import.meta.dir, '..', 'src', 'commands', 'jobs.ts'),
'utf8',
);
expect(src).toContain(`from '../core/mcp-client.ts'`);
expect(src).toContain(`callRemoteTool(cfg!, 'list_jobs'`);
expect(src).toContain(`callRemoteTool(cfg!, 'get_job'`);
});
});