total-recall-brain 3.18.2 → 3.19.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.
Files changed (83) hide show
  1. package/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
  2. package/frontend/dist/index.html +1 -1
  3. package/frontend/src/api/config.ts +16 -2
  4. package/frontend/src/api/keys.ts +111 -0
  5. package/frontend/src/api/notifications.ts +54 -6
  6. package/frontend/src/api/update.ts +41 -5
  7. package/frontend/src/components/BrainSelector.spec.tsx +71 -47
  8. package/frontend/src/components/BrainSelector.tsx +112 -35
  9. package/frontend/src/components/MobilePairing.spec.tsx +44 -14
  10. package/frontend/src/components/MobilePairing.tsx +229 -22
  11. package/frontend/src/pages/HealthPage.tsx +12 -16
  12. package/frontend/src/pages/NotificationsPage.tsx +6 -3
  13. package/frontend/src/pages/RulesPage.spec.tsx +46 -15
  14. package/frontend/src/pages/RulesPage.tsx +41 -9
  15. package/frontend/src/pages/SecretsPage.tsx +316 -3
  16. package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
  17. package/frontend/src/pages/SettingsPage.tsx +228 -86
  18. package/package.json +1 -1
  19. package/src/cli/agent-dir.mjs +17 -2
  20. package/src/cli/chat.mjs +1 -1
  21. package/src/cli/daemon.mjs +2 -1
  22. package/src/cli/dream.mjs +14 -10
  23. package/src/cli/forget.mjs +14 -2
  24. package/src/cli/friction.mjs +1 -1
  25. package/src/cli/import-rules.mjs +8 -3
  26. package/src/cli/ingest.mjs +3 -2
  27. package/src/cli/lint.mjs +9 -6
  28. package/src/cli/rebuild.mjs +9 -4
  29. package/src/cli/remember.mjs +21 -15
  30. package/src/cli/secret.mjs +184 -4
  31. package/src/cli/share.mjs +5 -3
  32. package/src/cli/skill-projection.mjs +21 -2
  33. package/src/cli/snapshot.mjs +10 -8
  34. package/src/cli/update.mjs +2 -1
  35. package/src/core/clarity-rewriter.mjs +31 -12
  36. package/src/core/config.mjs +10 -4
  37. package/src/core/daemon-loop.mjs +33 -6
  38. package/src/core/embeddings.mjs +51 -3
  39. package/src/core/memory-title.mjs +70 -0
  40. package/src/core/notifications.mjs +204 -34
  41. package/src/core/package-auto-update.mjs +99 -3
  42. package/src/core/pairing.mjs +168 -0
  43. package/src/core/provider-account-sync.mjs +1145 -0
  44. package/src/core/provider-catalog.mjs +11 -4
  45. package/src/core/research.mjs +98 -24
  46. package/src/core/runtime.mjs +11 -1
  47. package/src/core/scheduler.mjs +31 -9
  48. package/src/core/schema.mjs +23 -0
  49. package/src/core/secret-integration-research.mjs +325 -0
  50. package/src/core/secrets-store.mjs +284 -14
  51. package/src/core/secrets-sync.mjs +1 -1
  52. package/src/core/skills-registry.mjs +12 -0
  53. package/src/core/source-adapters.mjs +247 -22
  54. package/src/core/surface.mjs +4 -4
  55. package/src/core/task-executors.mjs +161 -14
  56. package/src/core/throttled-fetch.mjs +5 -0
  57. package/src/core/vault.mjs +7 -1
  58. package/src/core/webhook-handlers.mjs +123 -14
  59. package/src/server/auth.mjs +6 -0
  60. package/src/server/index.mjs +56 -0
  61. package/src/server/rest.mjs +2 -0
  62. package/src/server/route-manifest.json +40 -0
  63. package/src/server/routes/_shared.mjs +33 -1
  64. package/src/server/routes/config.mjs +64 -23
  65. package/src/server/routes/context.mjs +14 -9
  66. package/src/server/routes/dashboard.mjs +39 -18
  67. package/src/server/routes/docs.mjs +27 -13
  68. package/src/server/routes/dream.mjs +10 -11
  69. package/src/server/routes/export.mjs +43 -12
  70. package/src/server/routes/field.mjs +11 -9
  71. package/src/server/routes/graph.mjs +31 -13
  72. package/src/server/routes/import.mjs +35 -16
  73. package/src/server/routes/instructions.mjs +41 -5
  74. package/src/server/routes/memory.mjs +61 -21
  75. package/src/server/routes/mesh.mjs +2 -1
  76. package/src/server/routes/notifications.mjs +103 -0
  77. package/src/server/routes/rules.mjs +75 -20
  78. package/src/server/routes/secrets.mjs +110 -4
  79. package/src/server/routes/share.mjs +2 -1
  80. package/src/server/routes/ssss.mjs +11 -3
  81. package/src/server/routes/system.mjs +20 -1
  82. package/src/server/routes/update.mjs +33 -7
  83. package/src/server/routes/vault.mjs +53 -28
@@ -34,6 +34,31 @@ const USAGE_FILE = path.join(brainDir, 'config', 'search-usage.json');
34
34
  // (credit rounds up, so we stay safe). Users on paid plans can raise this.
35
35
  const DEFAULT_DAILY_LIMIT = 50;
36
36
 
37
+ /**
38
+ * Pull a secret by any of several env-style names (case-insensitive).
39
+ * Avoids "only brave_api_key works" when the vault has BRAVE_SEARCH_API_KEY.
40
+ */
41
+ function secretPick(secrets, ...names) {
42
+ if (!secrets || typeof secrets !== 'object') return null;
43
+ const entries = Object.entries(secrets).filter(([k]) => k !== '__tr_secrets_meta');
44
+ const upperMap = new Map(entries.map(([k, v]) => [String(k).toUpperCase(), v]));
45
+ for (const name of names) {
46
+ const v = upperMap.get(String(name).toUpperCase());
47
+ if (v != null && String(v).trim() !== '') return String(v);
48
+ }
49
+ // fuzzy: any key that contains the brand + KEY/TOKEN
50
+ for (const name of names) {
51
+ const brand = String(name).toUpperCase().replace(/_API_KEY$|_API_TOKEN$|_TOKEN$|_KEY$/i, '');
52
+ if (brand.length < 3) continue;
53
+ for (const [k, v] of upperMap) {
54
+ if (k.includes(brand) && /(KEY|TOKEN)$/.test(k) && !/PASSWORD|SECRET|PRIVATE|WEBHOOK|CLIENT_SECRET/i.test(k)) {
55
+ if (v != null && String(v).trim() !== '') return String(v);
56
+ }
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
37
62
  export function loadResearchConfig(configPath = DEFAULT_CONFIG_PATH) {
38
63
  let fileConfig = {};
39
64
  if (fs.existsSync(configPath)) {
@@ -47,14 +72,67 @@ export function loadResearchConfig(configPath = DEFAULT_CONFIG_PATH) {
47
72
  secrets = loadSecretsSync(path.dirname(path.dirname(configPath)));
48
73
  } catch {}
49
74
 
75
+ // SearXNG instance base URL (no trailing path). Prefer self-hosted.
76
+ // Examples: http://127.0.0.1:8080 https://searx.example.com
77
+ const searxUrlRaw =
78
+ process.env.SEARX_URL ||
79
+ process.env.SEARXNG_URL ||
80
+ process.env.TR_SEARX_URL ||
81
+ secretPick(secrets, 'SEARX_URL', 'SEARXNG_URL', 'TR_SEARX_URL') ||
82
+ fileConfig.searx_url ||
83
+ fileConfig.searxng_url ||
84
+ null;
85
+ const searxUrl = searxUrlRaw ? String(searxUrlRaw).replace(/\/+$/, '') : null;
86
+ // When true (default if URL set), webSearch tries SearX before paid APIs
87
+ const preferSearx =
88
+ fileConfig.prefer_searx !== undefined
89
+ ? !!fileConfig.prefer_searx
90
+ : process.env.TR_PREFER_SEARX !== '0';
91
+
50
92
  return {
51
- // ── Paid search providers (fallback chain: Brave → Tavily → Exa → Serper) ──
52
- braveApiKey: process.env.BRAVE_SEARCH_API_KEY || process.env.BRAVE_API_KEY || secrets.brave_api_key || fileConfig.brave_api_key || defaultBraveKey || defaultBraveSearchKey || null,
53
- tavilyApiKey: process.env.TAVILY_API_KEY || secrets.tavily_api_key || fileConfig.tavily_api_key || defaultTavilyKey || null,
54
- exaApiKey: process.env.EXA_API_KEY || secrets.exa_api_key || fileConfig.exa_api_key || defaultExaKey || null,
55
- serperApiKey: process.env.SERPER_API_KEY || secrets.serper_api_key || fileConfig.serper_api_key || defaultSerperKey || null,
93
+ // ── Free metasearch (preferred when configured) ──
94
+ searxUrl,
95
+ preferSearx: !!searxUrl && preferSearx,
96
+ // Optional: categories CSV for SearX (default: general)
97
+ searxCategories: fileConfig.searx_categories || process.env.SEARX_CATEGORIES || 'general',
98
+ // Optional: language code (en, en-US, all)
99
+ searxLanguage: fileConfig.searx_language || process.env.SEARX_LANGUAGE || 'en',
100
+ // ── Paid search providers (fallback: Brave → Tavily → Exa → Serper) ──
101
+ // Resolve from env, then any vault alias (BRAVE_SEARCH_API_KEY, DEVELOPER_*, …), then defaults.
102
+ braveApiKey:
103
+ process.env.BRAVE_SEARCH_API_KEY ||
104
+ process.env.BRAVE_API_KEY ||
105
+ secretPick(secrets, 'BRAVE_SEARCH_API_KEY', 'BRAVE_API_KEY', 'brave_api_key', 'DEVELOPER_BRAVE_SEARCH_API_KEY') ||
106
+ fileConfig.brave_api_key ||
107
+ defaultBraveKey ||
108
+ defaultBraveSearchKey ||
109
+ null,
110
+ tavilyApiKey:
111
+ process.env.TAVILY_API_KEY ||
112
+ secretPick(secrets, 'TAVILY_API_KEY', 'tavily_api_key') ||
113
+ fileConfig.tavily_api_key ||
114
+ defaultTavilyKey ||
115
+ null,
116
+ exaApiKey:
117
+ process.env.EXA_API_KEY ||
118
+ secretPick(secrets, 'EXA_API_KEY', 'exa_api_key') ||
119
+ fileConfig.exa_api_key ||
120
+ defaultExaKey ||
121
+ null,
122
+ serperApiKey:
123
+ process.env.SERPER_API_KEY ||
124
+ secretPick(secrets, 'SERPER_API_KEY', 'serper_api_key', 'DEVELOPER_SERPER_API_KEY') ||
125
+ fileConfig.serper_api_key ||
126
+ defaultSerperKey ||
127
+ null,
56
128
  // GitHub: personal access token for higher rate limits (60→5000 req/hr)
57
- githubToken: process.env.GITHUB_TOKEN || secrets.github_token || fileConfig.github_token || defaultGithubToken || null,
129
+ githubToken:
130
+ process.env.GITHUB_TOKEN ||
131
+ process.env.GH_TOKEN ||
132
+ secretPick(secrets, 'GITHUB_TOKEN', 'GH_TOKEN', 'github_token') ||
133
+ fileConfig.github_token ||
134
+ defaultGithubToken ||
135
+ null,
58
136
  fetchTimeoutMs: fileConfig.fetch_timeout_ms || 10000,
59
137
  maxResultsPerSource: fileConfig.max_results_per_source || 5,
60
138
  userAgent:
@@ -63,7 +141,7 @@ export function loadResearchConfig(configPath = DEFAULT_CONFIG_PATH) {
63
141
  'TotalRecall/1.0 (knowledge-acquisition)',
64
142
  // Daily cap: protects free-tier users from unexpected overage charges.
65
143
  // Default 50/day ≈ 1,500/month — within Brave/Tavily/Exa free tiers (~1,000/month each).
66
- // Set to 0 to disable (paid plans with high volume).
144
+ // Set to 0 to disable (paid plans with high volume). SearX does NOT count against this cap.
67
145
  dailyWebSearchLimit: Number(fileConfig.daily_web_search_limit ?? process.env.TR_DAILY_SEARCH_LIMIT ?? DEFAULT_DAILY_LIMIT),
68
146
  };
69
147
  }
@@ -323,24 +401,144 @@ export async function exaSearch(query, config, count = 5) {
323
401
 
324
402
  /**
325
403
  * Web search with automatic fallback chain:
326
- * Brave → Tavily → Exa → Serper → DuckDuckGo (free, always on)
404
+ * SearXNG (preferred free) → Brave → Tavily → Exa → Serper → DuckDuckGo
327
405
  *
328
- * Respects the daily paid-search cap (default 50/day ≈ 1,500/month).
329
- * When the cap is hit, falls back to free DuckDuckGo — research never stops.
406
+ * Paid providers respect the daily paid-search cap (default 50/day).
407
+ * SearXNG is free/self-hosted and does not count against the paid cap.
330
408
  */
331
409
  export async function webSearch(query, config, count = 5) {
410
+ // isDailyCapReached increments the paid counter when still under limit
332
411
  const capReached = isDailyCapReached(config);
412
+ const errors = [];
413
+
414
+ const tryProvider = async (name, fn) => {
415
+ try {
416
+ const results = await fn();
417
+ if (Array.isArray(results) && results.length > 0) return results;
418
+ errors.push(`${name}: empty results`);
419
+ } catch (err) {
420
+ errors.push(`${name}: ${err.message}`);
421
+ }
422
+ return null;
423
+ };
424
+
425
+ // 1) SearXNG first when configured (prefer_searx default true when URL set)
426
+ if (config.searxUrl && config.preferSearx !== false) {
427
+ const hit = await tryProvider('searx', () => searxSearch(query, config, count));
428
+ if (hit) return hit;
429
+ }
333
430
 
431
+ // 2) Paid providers — on 429/timeout/error, continue to next
334
432
  if (!capReached) {
335
- if (config.braveApiKey) return braveSearch(query, config, count);
336
- if (config.tavilyApiKey) return tavilySearch(query, config, count);
337
- if (config.exaApiKey) return exaSearch(query, config, count);
338
- if (config.serperApiKey) return serperSearch(query, config, count);
433
+ if (config.braveApiKey) {
434
+ const hit = await tryProvider('brave-search', () => braveSearch(query, config, count));
435
+ if (hit) return hit;
436
+ }
437
+ if (config.tavilyApiKey) {
438
+ const hit = await tryProvider('tavily', () => tavilySearch(query, config, count));
439
+ if (hit) return hit;
440
+ }
441
+ if (config.exaApiKey) {
442
+ const hit = await tryProvider('exa', () => exaSearch(query, config, count));
443
+ if (hit) return hit;
444
+ }
445
+ if (config.serperApiKey) {
446
+ const hit = await tryProvider('serper', () => serperSearch(query, config, count));
447
+ if (hit) return hit;
448
+ }
339
449
  }
340
450
 
341
- // No paid key or daily cap reached — free fallback, always available
342
- const ddg = await duckduckgoInstant(query, config).catch(() => null);
343
- return ddg ? [ddg] : [];
451
+ // 3) SearX again if de-prioritized (prefer_searx: false) but URL is set
452
+ if (config.searxUrl && config.preferSearx === false) {
453
+ const hit = await tryProvider('searx', () => searxSearch(query, config, count));
454
+ if (hit) return hit;
455
+ }
456
+
457
+ // 4) Free DDG Instant Answer — last resort
458
+ const ddg = await duckduckgoInstant(query, config).catch((err) => {
459
+ errors.push(`duckduckgo: ${err.message}`);
460
+ return null;
461
+ });
462
+ if (ddg) return Array.isArray(ddg) ? ddg : [ddg];
463
+
464
+ if (errors.length) {
465
+ throw new Error(`All web search providers failed: ${errors.join(' | ')}`);
466
+ }
467
+ return [];
468
+ }
469
+
470
+ /**
471
+ * Search via SearXNG (or classic SearX) JSON API.
472
+ *
473
+ * Instance must enable JSON format (settings.yml: search.formats includes json).
474
+ * Self-host recommended — many public instances disable JSON / rate-limit bots.
475
+ *
476
+ * @param {string} query
477
+ * @param {object} config - from loadResearchConfig()
478
+ * @param {number} [count=5]
479
+ * @returns {Promise<SourceResult[]>}
480
+ */
481
+ export async function searxSearch(query, config, count = 5) {
482
+ if (!config.searxUrl) {
483
+ throw new Error('SEARX_URL / searx_url not set (SearXNG instance base URL)');
484
+ }
485
+
486
+ const base = String(config.searxUrl).replace(/\/+$/, '');
487
+ // Accept either bare host or host already ending in /search
488
+ const searchPath = base.endsWith('/search') ? base : `${base}/search`;
489
+ const url = new URL(searchPath);
490
+ url.searchParams.set('q', query);
491
+ url.searchParams.set('format', 'json');
492
+ url.searchParams.set('categories', config.searxCategories || 'general');
493
+ if (config.searxLanguage) {
494
+ url.searchParams.set('language', config.searxLanguage);
495
+ }
496
+
497
+ const response = await safeFetch(
498
+ url.toString(),
499
+ {
500
+ headers: {
501
+ Accept: 'application/json',
502
+ 'User-Agent': config.userAgent || 'TotalRecall/1.0 (knowledge-acquisition)',
503
+ },
504
+ },
505
+ config.fetchTimeoutMs || 15000,
506
+ );
507
+
508
+ if (!response.ok) {
509
+ const body = await response.text().catch(() => '');
510
+ throw new Error(`SearX API error ${response.status}: ${body.slice(0, 200)}`);
511
+ }
512
+
513
+ const contentType = response.headers.get('content-type') || '';
514
+ const text = await response.text();
515
+ let data;
516
+ try {
517
+ data = JSON.parse(text);
518
+ } catch {
519
+ throw new Error(
520
+ `SearX returned non-JSON (content-type=${contentType}). Enable JSON in instance settings.yml: search.formats: [html, json]`,
521
+ );
522
+ }
523
+
524
+ const results = Array.isArray(data.results) ? data.results : [];
525
+ return results
526
+ .slice(0, count)
527
+ .map((r, i) => ({
528
+ source: 'searx',
529
+ type: 'web',
530
+ title: r.title || 'Untitled',
531
+ url: r.url || r.pretty_url || '',
532
+ snippet: (r.content || r.description || r.snippet || '').slice(0, 500),
533
+ published: normalizePublishedDate(r.publishedDate || r.pubdate || null),
534
+ relevance: Math.max(0.3, 1 - i * 0.08),
535
+ extra: {
536
+ engine: r.engine || (Array.isArray(r.engines) ? r.engines.join(',') : null),
537
+ category: r.category || null,
538
+ score: r.score ?? null,
539
+ },
540
+ }))
541
+ .filter((r) => r.url);
344
542
  }
345
543
 
346
544
  // ─── arXiv API ─────────────────────────────────────────────────────────────────
@@ -746,21 +944,48 @@ export function checkSourceAvailability(config) {
746
944
  // Always available (no auth)
747
945
  available.push('arxiv', 'npm', 'wikipedia', 'duckduckgo', 'web-fetch');
748
946
 
749
- // Web search — Brave primary, Serper fallback
947
+ // SearXNG / SearX (self-hosted metasearch — preferred free web search)
948
+ if (config.searxUrl) {
949
+ available.push('searx');
950
+ } else {
951
+ unavailable.push('searx');
952
+ }
953
+
954
+ // Paid web search chain — Brave → Tavily → Exa → Serper
750
955
  if (config.braveApiKey) {
751
956
  available.push('brave-search');
752
957
  } else {
753
958
  unavailable.push('brave-search');
754
- warnings.push('BRAVE_SEARCH_API_KEY not set — set in env (already in UltraChat .env.local)');
959
+ }
960
+
961
+ if (config.tavilyApiKey) {
962
+ available.push('tavily');
963
+ } else {
964
+ unavailable.push('tavily');
965
+ }
966
+
967
+ if (config.exaApiKey) {
968
+ available.push('exa');
969
+ } else {
970
+ unavailable.push('exa');
755
971
  }
756
972
 
757
973
  if (config.serperApiKey) {
758
974
  available.push('serper');
759
975
  } else {
760
976
  unavailable.push('serper');
761
- if (!config.braveApiKey) {
762
- warnings.push('SERPER_API_KEY not set — no web search available (set BRAVE_SEARCH_API_KEY or SERPER_API_KEY)');
763
- }
977
+ }
978
+
979
+ const hasPaidSearch =
980
+ config.braveApiKey || config.tavilyApiKey || config.exaApiKey || config.serperApiKey;
981
+ if (!config.searxUrl && !hasPaidSearch) {
982
+ warnings.push(
983
+ 'No SearX URL and no paid web-search key. Set SEARX_URL (preferred) or Brave/Tavily/Exa/Serper — otherwise only DuckDuckGo Instant Answer.',
984
+ );
985
+ } else if (config.searxUrl) {
986
+ warnings.push(
987
+ `SearXNG preferred at ${config.searxUrl} (ensure search.formats includes json).`,
988
+ );
764
989
  }
765
990
 
766
991
  if (config.githubToken) {
@@ -186,16 +186,16 @@ function modalityMarker(node) {
186
186
  */
187
187
  export function heuristicCompact(node) {
188
188
  const rawTitle = (node.title || '').trim();
189
+ // Legacy prefix only — provenance is source.type / tags, not title text
189
190
  const title = rawTitle.replace(/^Self-captured memory:\s*/i, '').trim();
190
191
  const text = (node.body || node.content || '').trim();
191
192
  const marker = modalityMarker(node);
192
193
  const prefix = marker ? `${marker} ` : '';
193
194
 
194
195
  // Determine the best display text, avoiding title/body duplication.
195
- // When title is auto-generated ("Self-captured memory: ...") it echoes the body —
196
- // use the body directly to avoid doubling the same content.
197
- const titleIsEcho = rawTitle.startsWith('Self-captured memory:') ||
198
- (text && title.length > 20 && text.toLowerCase().startsWith(title.toLowerCase().slice(0, 20)));
196
+ // Echo titles (legacy "Self-captured…" or title that is just a body prefix) use body only.
197
+ const titleIsEcho = /^Self-captured memory:/i.test(rawTitle) ||
198
+ (text && title.length > 20 && text.toLowerCase().startsWith(title.toLowerCase().replace(/\.\.\.$/, '').slice(0, 20)));
199
199
 
200
200
  if (node.category && ['invariants', 'preferences', 'anti-patterns'].includes(node.category)) {
201
201
  if (text) {
@@ -369,6 +369,45 @@ async function runResearchLegacy(task, ctx) {
369
369
  String(task.slug || '').includes('fact-seeker') ||
370
370
  String(task.slug || '').includes('knowledge-acquisition')
371
371
  ) {
372
+ // Prefer deep multi-source research when this came from the research queue
373
+ const deep =
374
+ String(task.slug || '').startsWith('research-acquisition-') ||
375
+ task.created_by === 'research-queue' ||
376
+ task._research_id;
377
+
378
+ if (deep) {
379
+ try {
380
+ const { handleProactiveResearch } = await import('./research.mjs');
381
+ const deepResult = await handleProactiveResearch(
382
+ {
383
+ target: task.target || task.title || 'Unknown',
384
+ body: task.body || task.reason || '',
385
+ },
386
+ { runtimeConfig },
387
+ );
388
+ const draftSlug =
389
+ deepResult?.factSlug ||
390
+ findLatestResearchDraftSlug(inboxDir, task.target);
391
+ if (draftSlug) {
392
+ return {
393
+ success: true,
394
+ output: `Deep researched "${task.target}" → ${draftSlug} (${deepResult?.sources || 0} sources)`,
395
+ factSlug: draftSlug,
396
+ executor: 'research',
397
+ };
398
+ }
399
+ logger.info({
400
+ subsystem: 'task-executors',
401
+ message: 'Deep research returned no draft slug — falling back to knowledge acquisition',
402
+ });
403
+ } catch (err) {
404
+ logger.info({
405
+ subsystem: 'task-executors',
406
+ message: `Deep research failed (${err.message}) — falling back to knowledge acquisition`,
407
+ });
408
+ }
409
+ }
410
+
372
411
  const { runKnowledgeAcquisitionCycle } = await import('./fact-seeker.mjs');
373
412
  const result = await runKnowledgeAcquisitionCycle({
374
413
  vaultDir: VAULT_DIR,
@@ -380,8 +419,20 @@ async function runResearchLegacy(task, ctx) {
380
419
  instructionsFile: INSTRUCTIONS_FILE,
381
420
  runtimeConfig,
382
421
  });
422
+ // CRITICAL: skipped/no-results is NOT success — otherwise phases advance empty forever
383
423
  if (result.skipped) {
384
- return { success: true, output: `Knowledge acquisition: ${result.skipped}`, executor: 'research' };
424
+ return {
425
+ success: false,
426
+ error: `Knowledge acquisition skipped: ${result.skipped}${result.errors?.length ? ` (${result.errors.map((e) => e.error || e.source).join('; ')})` : ''}`,
427
+ executor: 'research',
428
+ };
429
+ }
430
+ if (!result.factSlug) {
431
+ return {
432
+ success: false,
433
+ error: `Knowledge acquisition produced no memory node for "${result.topic || task.target}"`,
434
+ executor: 'research',
435
+ };
385
436
  }
386
437
  return {
387
438
  success: true,
@@ -391,51 +442,104 @@ async function runResearchLegacy(task, ctx) {
391
442
  };
392
443
  }
393
444
 
445
+ // Later phases require a real node from acquisition — never no-op success
446
+ const requireNode = (label) => {
447
+ const slug = task._node_slug;
448
+ if (!slug || slug === 'pending') {
449
+ return {
450
+ success: false,
451
+ error: `${label} requires node_slug from acquisition (got ${slug || 'null'}). Reset phase to acquisition.`,
452
+ executor: 'research',
453
+ };
454
+ }
455
+ return null;
456
+ };
457
+
394
458
  if (String(task.slug || '').startsWith('research-deliberation-')) {
459
+ const missing = requireNode('Deliberation');
460
+ if (missing) return missing;
395
461
  const { runResearchDeliberationCycle } = await import('./fact-seeker.mjs');
396
- await runResearchDeliberationCycle({
462
+ const result = await runResearchDeliberationCycle({
397
463
  vaultDir: VAULT_DIR,
398
- nodeSlug: task._node_slug || 'pending',
464
+ nodeSlug: task._node_slug,
399
465
  topic: task.title || task.target || 'Unknown Topic',
400
466
  runtimeConfig,
401
467
  });
402
- return { success: true, output: 'Deliberation complete', executor: 'research' };
468
+ if (result?.success === false) {
469
+ return { success: false, error: result.error || 'Deliberation failed', executor: 'research' };
470
+ }
471
+ return {
472
+ success: true,
473
+ output: result?.output || 'Deliberation complete',
474
+ factSlug: result?.factSlug || task._node_slug,
475
+ executor: 'research',
476
+ };
403
477
  }
404
478
 
405
479
  if (String(task.slug || '').startsWith('research-improvement-')) {
480
+ const missing = requireNode('Improvement');
481
+ if (missing) return missing;
406
482
  const { runResearchImprovementCycle } = await import('./fact-seeker.mjs');
407
- await runResearchImprovementCycle({
483
+ const result = await runResearchImprovementCycle({
408
484
  vaultDir: VAULT_DIR,
409
- nodeSlug: task._node_slug || 'pending',
485
+ nodeSlug: task._node_slug,
410
486
  topic: task.title || task.target || 'Unknown Topic',
411
487
  runtimeConfig,
412
488
  });
413
- return { success: true, output: 'Improvement complete', executor: 'research' };
489
+ if (result?.success === false) {
490
+ return { success: false, error: result.error || 'Improvement failed', executor: 'research' };
491
+ }
492
+ return {
493
+ success: true,
494
+ output: result?.output || 'Improvement complete',
495
+ factSlug: result?.factSlug || task._node_slug,
496
+ executor: 'research',
497
+ };
414
498
  }
415
499
 
416
500
  if (String(task.slug || '').startsWith('research-monitoring-')) {
501
+ const missing = requireNode('Monitoring');
502
+ if (missing) return missing;
417
503
  const { runResearchMonitoringCycle } = await import('./fact-seeker.mjs');
418
- await runResearchMonitoringCycle({
504
+ const result = await runResearchMonitoringCycle({
419
505
  vaultDir: VAULT_DIR,
420
- nodeSlug: task._node_slug || 'pending',
506
+ nodeSlug: task._node_slug,
421
507
  topic: task.title || task.target || 'Unknown Topic',
422
508
  runtimeConfig,
423
509
  skillsDir: SKILLS_DIR,
424
510
  derivedDir: DERIVED_DIR,
425
511
  instructionsFile: INSTRUCTIONS_FILE,
426
512
  });
427
- return { success: true, output: 'Monitoring complete', executor: 'research' };
513
+ if (result?.success === false) {
514
+ return { success: false, error: result.error || 'Monitoring failed', executor: 'research' };
515
+ }
516
+ return {
517
+ success: true,
518
+ output: result?.output || 'Monitoring complete',
519
+ factSlug: result?.factSlug || task._node_slug,
520
+ executor: 'research',
521
+ };
428
522
  }
429
523
 
430
524
  if (String(task.slug || '').startsWith('research-expansion-')) {
525
+ const missing = requireNode('Expansion');
526
+ if (missing) return missing;
431
527
  const { runResearchExpansionCycle } = await import('./fact-seeker.mjs');
432
- await runResearchExpansionCycle({
528
+ const result = await runResearchExpansionCycle({
433
529
  vaultDir: VAULT_DIR,
434
- nodeSlug: task._node_slug || 'pending',
530
+ nodeSlug: task._node_slug,
435
531
  topic: task.title || task.target || 'Unknown Topic',
436
532
  runtimeConfig,
437
533
  });
438
- return { success: true, output: 'Expansion complete', executor: 'research' };
534
+ if (result?.success === false) {
535
+ return { success: false, error: result.error || 'Expansion failed', executor: 'research' };
536
+ }
537
+ return {
538
+ success: true,
539
+ output: result?.output || 'Expansion complete',
540
+ factSlug: result?.factSlug || task._node_slug,
541
+ executor: 'research',
542
+ };
439
543
  }
440
544
 
441
545
  if (String(task.slug || '').includes('validate')) {
@@ -455,7 +559,50 @@ async function runResearchLegacy(task, ctx) {
455
559
  };
456
560
  }
457
561
 
458
- return { success: true, output: 'No-op research task', executor: 'research' };
562
+ return {
563
+ success: false,
564
+ error: `No research handler matched slug=${task.slug} category=${task.category}`,
565
+ executor: 'research',
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Locate the newest consolidated research draft for a topic in memory-inbox/pending.
571
+ */
572
+ function findLatestResearchDraftSlug(inboxDir, topic) {
573
+ try {
574
+ if (!fs.existsSync(inboxDir)) return null;
575
+ const needle = String(topic || '')
576
+ .toLowerCase()
577
+ .replace(/[^a-z0-9]+/g, '-')
578
+ .replace(/^-|-$/g, '')
579
+ .slice(0, 40);
580
+ const files = fs
581
+ .readdirSync(inboxDir)
582
+ .filter((f) => f.endsWith('.md'))
583
+ .map((f) => {
584
+ const full = path.join(inboxDir, f);
585
+ const st = fs.statSync(full);
586
+ return { slug: f.replace(/\.md$/, ''), mtime: st.mtimeMs, name: f.toLowerCase() };
587
+ })
588
+ .sort((a, b) => b.mtime - a.mtime);
589
+ if (!files.length) return null;
590
+ if (needle) {
591
+ const hit = files.find(
592
+ (f) =>
593
+ f.name.includes(needle) ||
594
+ f.name.includes('research') ||
595
+ f.name.includes('deep-research') ||
596
+ f.name.includes('consolidated'),
597
+ );
598
+ if (hit) return hit.slug;
599
+ }
600
+ // Prefer research-looking drafts, else newest
601
+ const researchish = files.find((f) => /research|fact-|deep-/.test(f.name));
602
+ return (researchish || files[0]).slug;
603
+ } catch {
604
+ return null;
605
+ }
459
606
  }
460
607
 
461
608
  // ─── Registry ───────────────────────────────────────────────────────────────────
@@ -140,6 +140,9 @@ export async function loadFirewallPolicy(brainDir) {
140
140
  policyWatcher = fs.watch(policyPath, (eventType) => {
141
141
  if (eventType === 'change') applyPolicy();
142
142
  });
143
+ // Unref so this watcher does not prevent one-shot CLI commands (e.g.
144
+ // `recall`) from exiting cleanly once their work is done.
145
+ policyWatcher.unref();
143
146
  }
144
147
 
145
148
  // Also watch the parent directory so we pick up the policy file being
@@ -153,8 +156,10 @@ export async function loadFirewallPolicy(brainDir) {
153
156
  policyWatcher = fs.watch(policyPath, (evt) => {
154
157
  if (evt === 'change') applyPolicy();
155
158
  });
159
+ policyWatcher.unref();
156
160
  }
157
161
  });
162
+ parentWatcher.unref();
158
163
  }
159
164
  } catch (err) {
160
165
  logger.error('throttled-fetch', `Failed to load network policy: ${err.message}`);
@@ -76,7 +76,13 @@ export function loadNodes(vaultDir) {
76
76
  const raw = fs.readFileSync(file, 'utf8');
77
77
  const { data, content } = matter(raw);
78
78
  if (data.type === 'memory') {
79
- nodes.push({ ...data, body: content.trim() });
79
+ // Attach absolute path for stampers/rewriters (both spellings used in codebase)
80
+ nodes.push({
81
+ ...data,
82
+ body: content.trim(),
83
+ _filePath: file,
84
+ _filepath: file,
85
+ });
80
86
  }
81
87
  } catch (err) {
82
88
  logger.warn('vault', `Failed to parse ${file}`, { err: err.message });