mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 21:19:18 +00:00
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:
co-authored by
Claude Opus 4.7
parent
e493d5f44b
commit
17b190e227
+183
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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'`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user