mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
124 lines
4.6 KiB
TypeScript
124 lines
4.6 KiB
TypeScript
/**
|
|
* 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 });
|
|
});
|
|
});
|