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.
- package/frontend/dist/assets/{index-D5xRw5uq.js → index-B6AfuKv9.js} +126 -134
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/config.ts +16 -2
- package/frontend/src/api/keys.ts +111 -0
- package/frontend/src/api/notifications.ts +54 -6
- package/frontend/src/api/update.ts +41 -5
- package/frontend/src/components/BrainSelector.spec.tsx +71 -47
- package/frontend/src/components/BrainSelector.tsx +112 -35
- package/frontend/src/components/MobilePairing.spec.tsx +44 -14
- package/frontend/src/components/MobilePairing.tsx +229 -22
- package/frontend/src/pages/HealthPage.tsx +12 -16
- package/frontend/src/pages/NotificationsPage.tsx +6 -3
- package/frontend/src/pages/RulesPage.spec.tsx +46 -15
- package/frontend/src/pages/RulesPage.tsx +41 -9
- package/frontend/src/pages/SecretsPage.tsx +316 -3
- package/frontend/src/pages/SettingsPage.spec.tsx +30 -6
- package/frontend/src/pages/SettingsPage.tsx +228 -86
- package/package.json +1 -1
- package/src/cli/agent-dir.mjs +17 -2
- package/src/cli/chat.mjs +1 -1
- package/src/cli/daemon.mjs +2 -1
- package/src/cli/dream.mjs +14 -10
- package/src/cli/forget.mjs +14 -2
- package/src/cli/friction.mjs +1 -1
- package/src/cli/import-rules.mjs +8 -3
- package/src/cli/ingest.mjs +3 -2
- package/src/cli/lint.mjs +9 -6
- package/src/cli/rebuild.mjs +9 -4
- package/src/cli/remember.mjs +21 -15
- package/src/cli/secret.mjs +184 -4
- package/src/cli/share.mjs +5 -3
- package/src/cli/skill-projection.mjs +21 -2
- package/src/cli/snapshot.mjs +10 -8
- package/src/cli/update.mjs +2 -1
- package/src/core/clarity-rewriter.mjs +31 -12
- package/src/core/config.mjs +10 -4
- package/src/core/daemon-loop.mjs +33 -6
- package/src/core/embeddings.mjs +51 -3
- package/src/core/memory-title.mjs +70 -0
- package/src/core/notifications.mjs +204 -34
- package/src/core/package-auto-update.mjs +99 -3
- package/src/core/pairing.mjs +168 -0
- package/src/core/provider-account-sync.mjs +1145 -0
- package/src/core/provider-catalog.mjs +11 -4
- package/src/core/research.mjs +98 -24
- package/src/core/runtime.mjs +11 -1
- package/src/core/scheduler.mjs +31 -9
- package/src/core/schema.mjs +23 -0
- package/src/core/secret-integration-research.mjs +325 -0
- package/src/core/secrets-store.mjs +284 -14
- package/src/core/secrets-sync.mjs +1 -1
- package/src/core/skills-registry.mjs +12 -0
- package/src/core/source-adapters.mjs +247 -22
- package/src/core/surface.mjs +4 -4
- package/src/core/task-executors.mjs +161 -14
- package/src/core/throttled-fetch.mjs +5 -0
- package/src/core/vault.mjs +7 -1
- package/src/core/webhook-handlers.mjs +123 -14
- package/src/server/auth.mjs +6 -0
- package/src/server/index.mjs +56 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +40 -0
- package/src/server/routes/_shared.mjs +33 -1
- package/src/server/routes/config.mjs +64 -23
- package/src/server/routes/context.mjs +14 -9
- package/src/server/routes/dashboard.mjs +39 -18
- package/src/server/routes/docs.mjs +27 -13
- package/src/server/routes/dream.mjs +10 -11
- package/src/server/routes/export.mjs +43 -12
- package/src/server/routes/field.mjs +11 -9
- package/src/server/routes/graph.mjs +31 -13
- package/src/server/routes/import.mjs +35 -16
- package/src/server/routes/instructions.mjs +41 -5
- package/src/server/routes/memory.mjs +61 -21
- package/src/server/routes/mesh.mjs +2 -1
- package/src/server/routes/notifications.mjs +103 -0
- package/src/server/routes/rules.mjs +75 -20
- package/src/server/routes/secrets.mjs +110 -4
- package/src/server/routes/share.mjs +2 -1
- package/src/server/routes/ssss.mjs +11 -3
- package/src/server/routes/system.mjs +20 -1
- package/src/server/routes/update.mjs +33 -7
- 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
|
-
// ──
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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:
|
|
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
|
|
404
|
+
* SearXNG (preferred free) → Brave → Tavily → Exa → Serper → DuckDuckGo
|
|
327
405
|
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
762
|
-
|
|
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) {
|
package/src/core/surface.mjs
CHANGED
|
@@ -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
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
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 {
|
|
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
|
|
464
|
+
nodeSlug: task._node_slug,
|
|
399
465
|
topic: task.title || task.target || 'Unknown Topic',
|
|
400
466
|
runtimeConfig,
|
|
401
467
|
});
|
|
402
|
-
|
|
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
|
|
485
|
+
nodeSlug: task._node_slug,
|
|
410
486
|
topic: task.title || task.target || 'Unknown Topic',
|
|
411
487
|
runtimeConfig,
|
|
412
488
|
});
|
|
413
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
530
|
+
nodeSlug: task._node_slug,
|
|
435
531
|
topic: task.title || task.target || 'Unknown Topic',
|
|
436
532
|
runtimeConfig,
|
|
437
533
|
});
|
|
438
|
-
|
|
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 {
|
|
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}`);
|
package/src/core/vault.mjs
CHANGED
|
@@ -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
|
-
|
|
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 });
|