total-recall-brain 3.19.2 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.19.2",
3
+ "version": "3.20.0",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -1,30 +1,66 @@
1
1
  agents:
2
- - name: antigravity
3
- binary: antigravity
4
- flags: --sandbox=false --yolo -o json
2
+ # Google Antigravity CLI. The binary is `agy` (NOT `antigravity`) and it runs
3
+ # on the AI Ultra plan. Preferred agent for bulk work.
4
+ # agy -p "prompt" --output-format json -> {status:"SUCCESS", response:"..."}
5
+ # Docs: https://antigravity.google/docs/cli/headless
6
+ - name: agy
7
+ binary: agy
8
+ flags: --output-format json -p
5
9
  priority: 1
6
10
  enabled: true
7
11
  exec: flag
8
12
 
9
- - name: grok
10
- binary: grok
11
- # Grok Build TUI: -p single-turn, plain text for TR parser
12
- flags: --output-format plain --always-approve --permission-mode bypassPermissions
13
- priority: 2
14
- enabled: true
15
- exec: flag
16
-
13
+ # `-p` MUST be present (non-interactive requires --print) and MUST be LAST:
14
+ # `--tools` is variadic (`<tools...>`) in the shipped build, so anything after
15
+ # it — including the appended prompt — is swallowed as a tool name. Proven
16
+ # both ways on claude v2.1.201.
17
17
  - name: claude
18
18
  binary: claude
19
- flags: --output-format json --permission-mode bypassPermissions --setting-sources local --tools ""
20
- priority: 3
19
+ flags: --output-format json --permission-mode bypassPermissions --setting-sources local --tools "" -p
20
+ priority: 2
21
21
  enabled: true
22
22
  exec: flag
23
+
24
+ # `-m` is REQUIRED: ~/.codex/config.toml may pin a model the account cannot
25
+ # use (seen: gpt-5.6-sol -> HTTP 400 "not supported when using Codex with a
26
+ # ChatGPT account"). Account-supported slugs: ~/.codex/models_cache.json.
27
+ # `--full-auto` is hidden from --help but still works as a deprecated alias;
28
+ # the explicit `--sandbox` form is OpenAI's current recommendation.
23
29
  - name: codex
24
30
  binary: codex
25
- flags: --full-auto --json --skip-git-repo-check
26
- priority: 4
31
+ flags: -m gpt-5.5 --sandbox workspace-write --json --skip-git-repo-check
32
+ priority: 3
27
33
  enabled: true
28
34
  exec: subcommand
35
+
36
+ # DISABLED — `antigravity` on PATH is total-recall/bin/antigravity.mjs, a thin
37
+ # wrapper that POSTs to generativelanguage.googleapis.com with GOOGLE_API_KEY,
38
+ # i.e. the METERED Gemini API, not the Ultra plan. Verified 2026-08-01: every
39
+ # call returned 429 "You exceeded your current quota". Use `agy` above.
40
+ - name: antigravity
41
+ binary: antigravity
42
+ flags: --sandbox=false --yolo -o json
43
+ priority: 8
44
+ enabled: false
45
+ exec: flag
46
+
47
+ # DISABLED — this user has no Gemini CLI. Do not enable because a `gemini`
48
+ # binary resolves on PATH; their Google surface is `agy`.
49
+ - name: gemini
50
+ binary: gemini
51
+ flags: --sandbox=false --yolo -o json
52
+ priority: 9
53
+ enabled: false
54
+ exec: flag
55
+
56
+ # DISABLED — not installed. A stale binary may still resolve at
57
+ # ~/.grok/bin/grok; PATH presence is not proof an agent works.
58
+ - name: grok
59
+ binary: grok
60
+ flags: --output-format plain --always-approve --permission-mode bypassPermissions
61
+ priority: 10
62
+ enabled: false
63
+ exec: flag
64
+
29
65
  timeout: 300
30
66
  max_retries: 2
@@ -128,6 +128,7 @@ export default async function recall(args) {
128
128
  // Search all target brains and merge results
129
129
  let allResults = [];
130
130
  let isDegraded = false;
131
+ const searchErrors = [];
131
132
 
132
133
  for (const target of searchTargets) {
133
134
  const vaultDir = path.join(target.brainDir, 'memory-vault');
@@ -168,8 +169,16 @@ export default async function recall(args) {
168
169
  r._layer = target.label;
169
170
  }
170
171
  allResults = allResults.concat(results);
171
- } catch {
172
- // Skip brains that can't be searched (no embeddings, etc.)
172
+ } catch (err) {
173
+ // A brain that cannot be searched is skipped — but NEVER silently.
174
+ //
175
+ // This used to be a bare `catch {}`. A missing native binding for
176
+ // better-sqlite3 ("Could not locate the bindings file") made every
177
+ // semantic search throw, and the swallow turned a hard dependency
178
+ // crash into the message "No matching memory nodes found" — so a
179
+ // fully-populated vault looked empty and the real cause was invisible.
180
+ // Degrading to lexical-only results is fine; hiding why is not.
181
+ searchErrors.push({ label: target.label, message: String(err?.message || err) });
173
182
  }
174
183
  }
175
184
 
@@ -182,8 +191,27 @@ export default async function recall(args) {
182
191
  return;
183
192
  }
184
193
 
194
+ if (searchErrors.length > 0) {
195
+ // Surface the real cause. "No matches" and "search crashed" are very
196
+ // different problems and must never look the same.
197
+ console.log('');
198
+ for (const e of searchErrors) {
199
+ console.log(` ⚠️ ${e.label} brain search failed: ${e.message.split('\n')[0]}`);
200
+ }
201
+ if (/bindings file|\.node|NODE_MODULE_VERSION|better-sqlite3/i.test(searchErrors.map(e => e.message).join(' '))) {
202
+ console.log(' A native module is missing or was built for a different Node version.');
203
+ console.log(' Fix: pnpm rebuild better-sqlite3 (or npm rebuild better-sqlite3)');
204
+ console.log(' With pnpm, the package must also be allowed to run build scripts.');
205
+ }
206
+ console.log(' Results below (if any) are lexical-only and may be incomplete.');
207
+ }
208
+
185
209
  if (allResults.length === 0) {
186
- console.log('\n 🔍 No matching memory nodes found.');
210
+ console.log(
211
+ searchErrors.length > 0
212
+ ? '\n 🔍 No results — search failed above, so this is NOT proof the vault is empty.'
213
+ : '\n 🔍 No matching memory nodes found.',
214
+ );
187
215
  return;
188
216
  }
189
217
  if (isDegraded) {
@@ -47,11 +47,21 @@ export function findBinaryInPath(binaryName) {
47
47
 
48
48
  // ─── Default agent registry (used when agents.yml doesn't exist) ─────────────
49
49
 
50
+ // NOTE ON FLAG ORDER AND MODEL PINS (verified by live probe 2026-08-01):
51
+ // agy — the REAL Google Antigravity CLI (AI Ultra). The binary named
52
+ // `antigravity` is a metered Gemini API wrapper, NOT this. Disabled
53
+ // below; enabling it bills an exhausted API budget (429s).
54
+ // claude — `-p` is required for non-interactive AND must be LAST, because
55
+ // `--tools` is variadic and otherwise swallows the prompt.
56
+ // codex — `-m` must be pinned; a config.toml model the account cannot use
57
+ // fails with HTTP 400 at exit code 0.
50
58
  const DEFAULT_AGENTS = [
51
- { name: 'antigravity', binary: 'antigravity', flags: '--sandbox=false --yolo -o json', priority: 1, enabled: true, exec: 'flag' },
52
- { name: 'grok', binary: 'grok', flags: '--output-format plain --always-approve --permission-mode bypassPermissions', priority: 2, enabled: true, exec: 'flag' },
53
- { name: 'claude', binary: 'claude', flags: '--output-format json --permission-mode bypassPermissions --setting-sources local --tools ""', priority: 3, enabled: true, exec: 'flag' },
54
- { name: 'codex', binary: 'codex', flags: '--sandbox workspace-write --json --skip-git-repo-check', priority: 4, enabled: true, exec: 'subcommand' },
59
+ { name: 'agy', binary: 'agy', flags: '--output-format json -p', priority: 1, enabled: true, exec: 'flag' },
60
+ { name: 'claude', binary: 'claude', flags: '--output-format json --permission-mode bypassPermissions --setting-sources local --tools "" -p', priority: 2, enabled: true, exec: 'flag' },
61
+ { name: 'codex', binary: 'codex', flags: '-m gpt-5.5 --sandbox workspace-write --json --skip-git-repo-check', priority: 3, enabled: true, exec: 'subcommand' },
62
+ { name: 'antigravity', binary: 'antigravity', flags: '--sandbox=false --yolo -o json', priority: 8, enabled: false, exec: 'flag' },
63
+ { name: 'gemini', binary: 'gemini', flags: '--sandbox=false --yolo -o json', priority: 9, enabled: false, exec: 'flag' },
64
+ { name: 'grok', binary: 'grok', flags: '--output-format plain --always-approve --permission-mode bypassPermissions', priority: 10, enabled: false, exec: 'flag' },
55
65
  ];
56
66
 
57
67
  /**
@@ -299,29 +309,65 @@ export async function callLocalRuntime(prompt, system, config) {
299
309
  }
300
310
  } catch {}
301
311
 
302
- // Try agents in priority order with retry budget
303
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
304
- const agent = attempt === 0 ? resolveAgent(config) : resolveAgent({
305
- ...config,
306
- agents: agents.filter(a => a.name !== lastError?.agentName),
307
- });
312
+ // Try agents in priority order, never re-trying one that already failed.
313
+ //
314
+ // The old filter excluded only `lastError.agentName` — the MOST RECENT
315
+ // failure — so a chain of three could hand work back to an agent that had
316
+ // already failed while a healthy one further down was never reached. Track
317
+ // every failure instead.
318
+ //
319
+ // The iteration cap is `agents.length + maxRetries` so that each enabled
320
+ // agent gets a turn even when the retry budget is smaller than the chain;
321
+ // the loop exits as soon as the untried pool is empty.
322
+ const failedAgents = new Set();
323
+ const maxAttempts = agents.length + maxRetries;
324
+
325
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
326
+ const remaining = agents.filter(a => !failedAgents.has(a.name));
327
+ const agent = remaining.length > 0
328
+ ? resolveAgent({ ...config, agents: remaining })
329
+ : null;
308
330
 
309
331
  if (!agent) {
310
- throw new Error('No CLI agents available. Install antigravity, grok, claude, or codex.');
332
+ if (failedAgents.size > 0) {
333
+ throw new Error(
334
+ `All CLI agents failed (tried: ${[...failedAgents].join(', ')}). ` +
335
+ `Last error: ${lastError?.error || 'unknown'}`,
336
+ );
337
+ }
338
+ throw new Error(
339
+ 'No CLI agents available. Install agy (Google Antigravity), claude, or codex.',
340
+ );
311
341
  }
312
342
 
343
+ // The prompt goes LAST, after every flag. Pushing it first (the old
344
+ // behaviour) broke the registry's own flag sets: it emitted a duplicate
345
+ // `-p` for agents whose flags already carry one, and it defeats the
346
+ // ordering that keeps a variadic flag (claude's `--tools <tools...>`) from
347
+ // swallowing the prompt as one of its values.
313
348
  const args = [];
314
349
  if (agent.exec === 'subcommand') {
315
- args.push('exec', fullPrompt);
316
- } else {
317
- args.push('-p', fullPrompt);
350
+ args.push('exec');
318
351
  }
319
352
  if (agent.flags) {
320
- args.push(...agent.flags.split(/\s+/).filter(Boolean));
353
+ // spawnSync does not go through a shell, so quotes in the registry are
354
+ // literal characters: `--tools ""` would pass a two-character string
355
+ // rather than an empty one. Strip matched surrounding quotes per token.
356
+ args.push(
357
+ ...agent.flags
358
+ .split(/\s+/)
359
+ .filter(Boolean)
360
+ .map(tok => tok.replace(/^(['"])(.*)\1$/, '$2')),
361
+ );
321
362
  }
322
363
  if (agent.model) {
323
364
  args.push('-m', agent.model);
324
365
  }
366
+ // `exec: flag` agents need an explicit `-p` only if their flags omit it.
367
+ if (agent.exec !== 'subcommand' && !args.includes('-p') && !args.includes('--print')) {
368
+ args.push('-p');
369
+ }
370
+ args.push(fullPrompt);
325
371
 
326
372
  const cmd = `${agent.binaryPath} ${args.map(a => a.includes(' ') || a.includes('\n') ? `"${a.replace(/"/g, '\\"')}"` : a).join(' ')}`;
327
373
 
@@ -373,11 +419,27 @@ export async function callLocalRuntime(prompt, system, config) {
373
419
  cwd: process.cwd(),
374
420
  });
375
421
 
376
- if (result.status === 0) {
422
+ const softFailure = detectAgentFailure(result.stdout || '');
423
+
424
+ if (result.status === 0 && !softFailure) {
377
425
  const output = result.stdout?.trim() || '';
378
426
  return parseAgentOutput(output);
379
427
  }
380
428
 
429
+ if (softFailure) {
430
+ // Exit code 0 but the payload says the run failed. Auth and model-config
431
+ // failures are not transient, so record and move to the next agent
432
+ // instead of burning the remaining retries on the same broken agent.
433
+ logger.error({
434
+ subsystem: 'runtime',
435
+ message: `${agent.name} reported failure despite exit 0`,
436
+ error: softFailure.slice(0, 500),
437
+ });
438
+ lastError = { agentName: agent.name, error: softFailure.slice(0, 500) };
439
+ failedAgents.add(agent.name);
440
+ continue;
441
+ }
442
+
381
443
  const err = result.stderr || result.stdout || 'Unknown error';
382
444
  logger.error({
383
445
  subsystem: 'runtime',
@@ -385,9 +447,13 @@ export async function callLocalRuntime(prompt, system, config) {
385
447
  error: err.slice(0, 500),
386
448
  });
387
449
  lastError = { agentName: agent.name, error: err.slice(0, 500) };
450
+ failedAgents.add(agent.name);
388
451
  }
389
452
 
390
- throw new Error(`All CLI agents failed. Last error: ${lastError?.error || 'unknown'}`);
453
+ throw new Error(
454
+ `All CLI agents failed (tried: ${[...failedAgents].join(', ') || 'none'}). ` +
455
+ `Last error: ${lastError?.error || 'unknown'}`,
456
+ );
391
457
  }
392
458
 
393
459
  /**
@@ -406,6 +472,57 @@ export async function callLocalRuntimeRaw(messages, config, _tools = undefined)
406
472
  /**
407
473
  * Parse JSON or text output from a CLI agent into plain text.
408
474
  */
475
+ /**
476
+ * Control-frame `type` values in codex's `--json` event stream. None of them
477
+ * carry the model's answer.
478
+ */
479
+ const STREAM_CONTROL_TYPES = new Set([
480
+ 'thread.started', 'turn.started', 'turn.completed', 'turn.failed',
481
+ 'item.started', 'item.updated', 'item.completed', 'error',
482
+ ]);
483
+
484
+ /**
485
+ * Detects a failure the exit code did not report.
486
+ *
487
+ * CLI agents exit 0 on failed runs and describe the failure only in the
488
+ * payload (verified 2026-08-01):
489
+ * claude → {"subtype":"success","is_error":true,"result":"Not logged in …"}
490
+ * codex → {"type":"turn.failed","error":{"message":"…"}}
491
+ * agy → {"status":"ERROR"|"CANCELED","error":"…"}
492
+ *
493
+ * Without this, `status === 0` accepts the error text as the model's answer —
494
+ * e.g. "Not logged in · Please run /login" gets stored as a memory node.
495
+ *
496
+ * @param {string} output raw stdout
497
+ * @returns {string|null} failure reason, or null if the run looks clean
498
+ */
499
+ function detectAgentFailure(output) {
500
+ if (!output) return null;
501
+ for (const line of output.split('\n')) {
502
+ const t = line.trim();
503
+ if (!t.startsWith('{')) continue;
504
+ let parsed;
505
+ try {
506
+ parsed = JSON.parse(t);
507
+ } catch {
508
+ continue;
509
+ }
510
+ if (parsed.is_error === true) {
511
+ return typeof parsed.result === 'string' ? parsed.result : 'is_error: true';
512
+ }
513
+ if (parsed.type === 'turn.failed' || parsed.type === 'error') {
514
+ const msg = parsed.error?.message ?? parsed.message;
515
+ return typeof msg === 'string' ? msg : String(parsed.type);
516
+ }
517
+ if (typeof parsed.status === 'string' && parsed.status.toUpperCase() !== 'SUCCESS') {
518
+ return typeof parsed.error === 'string'
519
+ ? `${parsed.status}: ${parsed.error}`
520
+ : `status=${parsed.status}`;
521
+ }
522
+ }
523
+ return null;
524
+ }
525
+
409
526
  function parseAgentOutput(output) {
410
527
  try {
411
528
  const parsed = JSON.parse(output);
@@ -418,7 +535,34 @@ function parseAgentOutput(output) {
418
535
  return last.content || last.text || last.response || JSON.stringify(last);
419
536
  }
420
537
  } catch {
421
- // Not JSON — return raw text
538
+ // Not a single JSON document. It may still be a JSONL event stream —
539
+ // codex `--json` emits one frame per line, so a whole-output JSON.parse
540
+ // always throws and the raw stream used to be returned as the "answer".
541
+ const answers = [];
542
+ for (const line of output.split('\n')) {
543
+ const t = line.trim();
544
+ if (!t.startsWith('{')) continue;
545
+ let frame;
546
+ try {
547
+ frame = JSON.parse(t);
548
+ } catch {
549
+ continue;
550
+ }
551
+ // codex: the reply is item.text of the `agent_message` item.
552
+ if (frame.item?.type === 'agent_message' && typeof frame.item.text === 'string') {
553
+ answers.push(frame.item.text);
554
+ continue;
555
+ }
556
+ if (STREAM_CONTROL_TYPES.has(frame.type)) continue;
557
+ for (const key of ['response', 'result', 'content', 'text']) {
558
+ if (typeof frame[key] === 'string') {
559
+ answers.push(frame[key]);
560
+ break;
561
+ }
562
+ }
563
+ }
564
+ // Last message wins: earlier frames are partial or superseded.
565
+ if (answers.length > 0) return answers[answers.length - 1];
422
566
  }
423
567
  return output;
424
568
  }
@@ -552,6 +552,23 @@ export function resetGateStats() {
552
552
  * Reset internal rate-limit/concurrency state (for testing only).
553
553
  */
554
554
  export function resetGateStateForTests() {
555
+ // Tear down policy watchers FIRST. They are module-level singletons that
556
+ // outlive an individual test, and their callbacks call applyPolicy() +
557
+ // drainQueue() — so a watcher registered by an earlier test can fire in the
558
+ // middle of a later one, silently rewriting blockedDomains/domainMinInterval
559
+ // and draining the queue. That made the firewall-reject and minInterval
560
+ // tests fail intermittently under full-suite load while passing in
561
+ // isolation. Closing them here makes each test start from a quiet state.
562
+ for (const w of [policyWatcher, parentWatcher]) {
563
+ try {
564
+ w?.close();
565
+ } catch {
566
+ // already closed / never opened
567
+ }
568
+ }
569
+ policyWatcher = null;
570
+ parentWatcher = null;
571
+
555
572
  gateGeneration += 1;
556
573
  globalInFlight = 0;
557
574
  domainInFlight.clear();