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
@@ -311,15 +311,22 @@ export function getProvider(providerId) {
311
311
  * @param {string} keyName
312
312
  */
313
313
  export function providerForKeyName(keyName) {
314
- const k = String(keyName || '').toUpperCase();
314
+ // Strip packaging prefixes so DEVELOPER_BRAVE_SEARCH_API_KEY → BRAVE_SEARCH_API_KEY
315
+ let k = String(keyName || '').toUpperCase();
316
+ k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_|PORTFOLIO_|ULTRACHAT_)+/g, '');
315
317
  for (const p of PROVIDER_CATALOG) {
316
- if (p.key_patterns.some((pat) => pat.toUpperCase() === k || k.includes(pat.replace(/_API_KEY$/, '')))) {
318
+ if (p.key_patterns.some((pat) => {
319
+ const P = pat.toUpperCase();
320
+ return P === k || k === P || k.endsWith('_' + P) || k.includes(P.replace(/_API_KEY$|_TOKEN$|_KEY$/i, ''));
321
+ })) {
317
322
  return p;
318
323
  }
319
324
  }
320
- // fuzzy
325
+ // fuzzy product id in the remaining name
326
+ const compact = k.replace(/-/g, '_');
321
327
  for (const p of PROVIDER_CATALOG) {
322
- if (k.includes(p.id.toUpperCase().replace('-', '_'))) return p;
328
+ const id = p.id.toUpperCase().replace(/-/g, '_');
329
+ if (compact.includes(id) || compact.startsWith(id + '_')) return p;
323
330
  }
324
331
  return null;
325
332
  }
@@ -77,9 +77,10 @@ export async function handleProactiveResearch(task, context = {}) {
77
77
  }
78
78
 
79
79
  // Phase 3: Write and integrate findings into a single consolidated main draft node
80
+ let draftSlug = null;
80
81
  for (const [i, results] of allResults.entries()) {
81
82
  if (results.length === 0) continue;
82
- writeOrUpdateConsolidatedDraft(task.target, queries[i], results, inboxDir);
83
+ draftSlug = writeOrUpdateConsolidatedDraft(task.target, queries[i], results, inboxDir);
83
84
  }
84
85
 
85
86
  // Phase 4: Synthesize using local runtime (which consists of high-powered CLI agents)
@@ -88,7 +89,7 @@ export async function handleProactiveResearch(task, context = {}) {
88
89
 
89
90
  // Save the beautiful synthesized executive summary at the top of our main consolidated document
90
91
  if (finalReport) {
91
- saveSynthesizedReportToDraft(task.target, finalReport, inboxDir);
92
+ draftSlug = saveSynthesizedReportToDraft(task.target, finalReport, inboxDir) || draftSlug;
92
93
  }
93
94
 
94
95
  // Phase 5: Also add topic to the Research Agenda for ongoing tracking
@@ -100,7 +101,16 @@ export async function handleProactiveResearch(task, context = {}) {
100
101
  tags: ['deep-research'],
101
102
  });
102
103
 
103
- return finalReport;
104
+ // Always return structured result so executors can advance the research queue with a real node slug
105
+ if (!draftSlug) {
106
+ draftSlug = `research-report-${slugify(task.target)}`;
107
+ }
108
+ return {
109
+ report: finalReport || null,
110
+ factSlug: draftSlug,
111
+ sources: flatResults.length,
112
+ queries,
113
+ };
104
114
  }
105
115
 
106
116
  // ─── Planning ───────────────────────────────────────────────────────────────────
@@ -146,43 +156,105 @@ Generate 3 targeted search queries targeting active, timely information post-${c
146
156
 
147
157
  // ─── Real Source Gathering ───────────────────────────────────────────────────────
148
158
 
159
+ /**
160
+ * Decide source mix from the *meaning* of the query (not only crude keyword lists).
161
+ * Combines semantic hints with optional suggested_sources from the planner.
162
+ */
163
+ function planSourcesForQuery(query, availability, suggested = []) {
164
+ const q = String(query || '').toLowerCase();
165
+ const suggestedSet = new Set((suggested || []).map((s) => String(s).toLowerCase()));
166
+ const avail = new Set(availability?.available || []);
167
+
168
+ const want = {
169
+ web: true, // always try web when possible
170
+ wikipedia: true,
171
+ arxiv: false,
172
+ npm: false,
173
+ github: false,
174
+ };
175
+
176
+ if (
177
+ suggestedSet.has('arxiv') ||
178
+ /\b(paper|arxiv|neural|transformer|llm|benchmark|dataset|training|model card)\b/.test(q)
179
+ ) {
180
+ want.arxiv = true;
181
+ }
182
+ if (
183
+ suggestedSet.has('npm') ||
184
+ /\b(npm|package\.json|node\.js|typescript package|js library)\b/.test(q)
185
+ ) {
186
+ want.npm = true;
187
+ }
188
+ if (
189
+ suggestedSet.has('github') ||
190
+ /\b(github|open.?source|repository|pull request|commit)\b/.test(q)
191
+ ) {
192
+ want.github = true;
193
+ }
194
+ // API / integration research: bias web + github docs, not arxiv
195
+ if (/\b(api|endpoint|oauth|sdk|webhook|authentication|base url)\b/.test(q)) {
196
+ want.github = true;
197
+ want.web = true;
198
+ want.arxiv = false;
199
+ }
200
+
201
+ return {
202
+ web: want.web && (
203
+ avail.has('searx') ||
204
+ avail.has('brave-search') ||
205
+ avail.has('serper') ||
206
+ avail.has('tavily') ||
207
+ avail.has('exa') ||
208
+ avail.has('duckduckgo')
209
+ ),
210
+ wikipedia: want.wikipedia && avail.has('wikipedia'),
211
+ arxiv: want.arxiv && avail.has('arxiv'),
212
+ npm: want.npm && avail.has('npm'),
213
+ github: want.github && avail.has('github'),
214
+ };
215
+ }
216
+
149
217
  async function gatherForQuery(query, researchConfig, availability) {
150
218
  const results = [];
151
219
  const fetches = [];
220
+ const sources = planSourcesForQuery(query, availability);
152
221
 
153
- // Always try: Wikipedia + DuckDuckGo
154
- fetches.push(
155
- wikipediaFetch(query, researchConfig).then(r => r && results.push(r)).catch(() => {}),
156
- );
222
+ if (sources.wikipedia) {
223
+ fetches.push(
224
+ wikipediaFetch(query, researchConfig).then((r) => r && results.push(r)).catch(() => {}),
225
+ );
226
+ }
157
227
 
158
- // 1. Web search — Brave with Serper fallback
159
- if (availability.available.includes('brave-search') || availability.available.includes('serper')) {
228
+ // Paid chain (Brave→Tavily→Exa→Serper) or DuckDuckGo — always prefer web for product/API research
229
+ if (sources.web) {
160
230
  fetches.push(
161
- webSearch(query, researchConfig, 4).then(r => results.push(...r)).catch(err => {
162
- logger.info({ subsystem: 'deep-research', message: `Web search failed for "${query}": ${err.message}` });
163
- }),
231
+ webSearch(query, researchConfig, 4)
232
+ .then((r) => results.push(...r))
233
+ .catch((err) => {
234
+ logger.info({
235
+ subsystem: 'deep-research',
236
+ message: `Web search failed for "${query}": ${err.message}`,
237
+ });
238
+ }),
164
239
  );
165
240
  }
166
241
 
167
- // arXiv for academic topics
168
- if (/\b(model|llm|neural|ml|ai|research|paper|algorithm|training)\b/i.test(query)) {
242
+ if (sources.arxiv) {
169
243
  fetches.push(
170
- arxivSearch(query, researchConfig, 2).then(r => results.push(...r)).catch(() => {}),
244
+ arxivSearch(query, researchConfig, 2).then((r) => results.push(...r)).catch(() => {}),
171
245
  );
172
246
  }
173
247
 
174
- // npm for JS/package topics
175
- if (/\b(npm|node|javascript|typescript|package|library|framework)\b/i.test(query)) {
248
+ if (sources.npm) {
176
249
  fetches.push(
177
- npmSearch(query, researchConfig, 3).then(r => results.push(...r)).catch(() => {}),
250
+ npmSearch(query, researchConfig, 3).then((r) => results.push(...r)).catch(() => {}),
178
251
  );
179
252
  }
180
253
 
181
- // GitHub for code/tool topics
182
- if (/\b(github|repo|open.source|tool|cli|sdk|api|integration)\b/i.test(query)) {
254
+ if (sources.github) {
183
255
  fetches.push(
184
256
  githubSearch(query, researchConfig, 'repositories', 3)
185
- .then(r => results.push(...r))
257
+ .then((r) => results.push(...r))
186
258
  .catch(() => {}),
187
259
  );
188
260
  }
@@ -190,7 +262,9 @@ async function gatherForQuery(query, researchConfig, availability) {
190
262
  await Promise.all(fetches);
191
263
 
192
264
  // Deep-crawl top web result using smartFetch (Playwright if installed, plain fetch otherwise)
193
- const topWebResult = results.find(r => ['brave-search', 'serper'].includes(r.source) && r.url);
265
+ const topWebResult = results.find((r) =>
266
+ ['searx', 'brave-search', 'serper', 'tavily', 'exa'].includes(r.source) && r.url,
267
+ );
194
268
  if (topWebResult) {
195
269
  try {
196
270
  const crawled = await smartFetch(topWebResult.url, researchConfig);
@@ -343,7 +417,7 @@ export function writeOrUpdateConsolidatedDraft(parentTopic, query, results, inbo
343
417
  };
344
418
 
345
419
  atomicWrite(filePath, safeStringify(bodyLines.join('\n'), frontmatter));
346
- return filePath;
420
+ return slug;
347
421
  }
348
422
 
349
423
  export function saveSynthesizedReportToDraft(parentTopic, finalReport, inboxDir) {
@@ -437,7 +511,7 @@ export function saveSynthesizedReportToDraft(parentTopic, finalReport, inboxDir)
437
511
  };
438
512
 
439
513
  atomicWrite(filePath, safeStringify(bodyLines.join('\n'), frontmatter));
440
- return filePath;
514
+ return slug;
441
515
  }
442
516
 
443
517
  // ─── Synthesis ───────────────────────────────────────────────────────────────────
@@ -139,12 +139,22 @@ export function loadRuntimeConfig(_configPath) {
139
139
  }
140
140
 
141
141
  // Tier D: Check SSSS Compiled Memory Preference Surface
142
+ // Surfaces live at agent root / repo root — not under brainDir (…/skills/total-recall/)
142
143
  if (!preferredAgent) {
143
144
  try {
144
145
  const checkPaths = [
146
+ path.join(agentDir, 'INSTRUCTIONS.md'),
147
+ path.join(agentDir, 'AGENTS.md'),
148
+ path.join(agentDir, 'GEMINI.md'),
149
+ path.join(path.dirname(agentDir), 'INSTRUCTIONS.md'),
150
+ path.join(path.dirname(agentDir), 'AGENTS.md'),
151
+ path.join(process.cwd(), 'INSTRUCTIONS.md'),
152
+ path.join(process.cwd(), 'AGENTS.md'),
153
+ path.join(process.cwd(), 'Claude.md'),
154
+ // legacy mistaken brainDir locations (kept as last resort)
145
155
  path.join(brainDir, 'INSTRUCTIONS.md'),
146
156
  path.join(brainDir, 'GEMINI.md'),
147
- path.join(brainDir, 'AGENTS.md')
157
+ path.join(brainDir, 'AGENTS.md'),
148
158
  ];
149
159
  for (const p of checkPaths) {
150
160
  if (fs.existsSync(p)) {
@@ -510,16 +510,38 @@ export function createScheduler({ queueDir, vaultDir, sessionsDir }) {
510
510
  for (const item of allItems) {
511
511
  if (item.status === 'done' || item.status === 'failed') {
512
512
  const completedTime = new Date(item.completed_at || item.updated_at || 0).getTime();
513
- if (Date.now() - completedTime >= COOLDOWN_MS) {
514
- try {
515
- updateQueueItem(item.id, { status: 'pending' });
516
- resetCount++;
517
- } catch (updateErr) {
518
- logger.error({
519
- subsystem: 'scheduler',
520
- message: `Failed to reset research queue item ${item.id} to pending: ${updateErr.message}`,
513
+ if (Date.now() - completedTime < COOLDOWN_MS) continue;
514
+
515
+ // Empty "done" (no node, no conclusions) is NOT real research — re-acquire
516
+ const emptyDone =
517
+ item.status === 'done' &&
518
+ !item.node_slug &&
519
+ !(item.summary?.conclusions?.length > 0);
520
+
521
+ // Successful done with real content: re-queue only for monitoring refresh, keep node
522
+ // Failed or empty: force acquisition from scratch
523
+ try {
524
+ if (emptyDone || item.status === 'failed' || !item.node_slug) {
525
+ updateQueueItem(item.id, {
526
+ status: 'pending',
527
+ research_phase: 'acquisition',
528
+ notes: emptyDone
529
+ ? 'Auto-reset empty done project → re-run acquisition (previous run produced no memory node)'
530
+ : item.notes || null,
531
+ });
532
+ } else {
533
+ // Has a real node — optional re-monitor after cooldown (don't wipe conclusions)
534
+ updateQueueItem(item.id, {
535
+ status: 'pending',
536
+ research_phase: 'monitoring',
521
537
  });
522
538
  }
539
+ resetCount++;
540
+ } catch (updateErr) {
541
+ logger.error({
542
+ subsystem: 'scheduler',
543
+ message: `Failed to reset research queue item ${item.id} to pending: ${updateErr.message}`,
544
+ });
523
545
  }
524
546
  }
525
547
  }
@@ -527,7 +549,7 @@ export function createScheduler({ queueDir, vaultDir, sessionsDir }) {
527
549
  if (resetCount > 0) {
528
550
  logger.info({
529
551
  subsystem: 'scheduler',
530
- message: `Auto-reset ${resetCount} completed/failed research items back to pending after cooldown.`,
552
+ message: `Auto-reset ${resetCount} research items after cooldown (empty→acquisition, failed→acquisition, done+node→monitoring).`,
531
553
  });
532
554
  }
533
555
 
@@ -908,6 +908,27 @@ export const WebhookConfigSchema = z.object({
908
908
  events: z.array(z.string()).default([]),
909
909
  }).passthrough();
910
910
 
911
+ /** Alert rule for mesh/system events → desktop/webhook/email channels. */
912
+ export const NotificationRuleSchema = z.object({
913
+ type: z.literal('notification_rule'),
914
+ title: z.string().min(1),
915
+ description: z.string().min(1),
916
+ timestamp: ssssDatetime(),
917
+ id: z.string().min(1),
918
+ event: z.enum([
919
+ 'node_offline',
920
+ 'leader_change',
921
+ 'webhook_failed',
922
+ 'secret_sync_failed',
923
+ 'daemon_error',
924
+ 'research_complete',
925
+ ]),
926
+ channel: z.enum(['desktop', 'webhook', 'email']),
927
+ priority: z.enum(['critical', 'high', 'low']).default('high'),
928
+ enabled: z.boolean().default(true),
929
+ quietHours: z.boolean().default(false),
930
+ }).passthrough();
931
+
911
932
  const NetworkInterfaceEntrySchema = z.object({
912
933
  name: z.string().min(1),
913
934
  kind: z.enum(['loopback', 'wifi', 'ethernet', 'bridge', 'vpn_overlay', 'other']),
@@ -990,6 +1011,7 @@ export const DaemonLeaderSchema = z.object({
990
1011
  export const SSSS_HOST_EXTENSION_TYPES = [
991
1012
  'network_policy',
992
1013
  'webhook_config',
1014
+ 'notification_rule',
993
1015
  'mesh_node',
994
1016
  'daemon_leader',
995
1017
  ];
@@ -998,6 +1020,7 @@ export const SSSS_HOST_EXTENSION_TYPES = [
998
1020
  export const SSSS_SCHEMAS = {
999
1021
  network_policy: NetworkPolicySchema,
1000
1022
  webhook_config: WebhookConfigSchema,
1023
+ notification_rule: NotificationRuleSchema,
1001
1024
  mesh_node: MeshNodeSchema,
1002
1025
  daemon_leader: DaemonLeaderSchema,
1003
1026
  memory: MemoryNodeSchema,
@@ -0,0 +1,325 @@
1
+ /**
2
+ * AI-only secret → integration research.
3
+ *
4
+ * When a new secret is stored, a CLI agent (callLocalRuntime) decides:
5
+ * - whether this credential is worth product-API research
6
+ * - which product/provider it is
7
+ * - what research topic + notes to enqueue
8
+ *
9
+ * No hardcoded provider lists, no key-name regex classification, no catalog matching.
10
+ * Optional code-usage snippets are gathered as *evidence for the model*, not as rules.
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { spawnSync } from 'node:child_process';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { logger } from './logger.mjs';
18
+
19
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
20
+
21
+ const INFER_SYSTEM = `You are a senior integrations engineer for Total Recall (a personal memory / mesh OS).
22
+ You are given only:
23
+ (1) an environment/secret KEY NAME (never the secret value)
24
+ (2) optional code snippets where that key is referenced in THIS repo
25
+
26
+ Decide whether Total Recall should queue a *research* job to learn an *external third-party* product API that is NOT already part of this product.
27
+
28
+ Output ONLY valid JSON (no markdown fences):
29
+ {
30
+ "researchable": boolean,
31
+ "skip_reason": string|null,
32
+ "product_name": string|null,
33
+ "product_slug": string|null,
34
+ "kind": "api_key"|"password"|"oauth"|"webhook"|"internal"|"self_hosted"|"already_integrated"|"unknown",
35
+ "topic": string|null,
36
+ "notes": string|null,
37
+ "priority": "low"|"medium"|"high",
38
+ "confidence": number
39
+ }
40
+
41
+ Rules (strict):
42
+ - researchable=true ONLY for external SaaS/third-party APIs the product might need to learn about from the public internet.
43
+ - researchable=false when code shows the key is for:
44
+ - infrastructure we already own/run (mesh, Tailscale-compatible control planes, Headscale, local routers, self-hosted mail, etc.)
45
+ - features already implemented in this repo (routes, clients, adapters present)
46
+ - passwords, email logins, SSO/client secrets, webhook signing secrets, private keys, session/DB secrets
47
+ - Total Recall / TR_ / mesh-sync internal credentials
48
+ - If the key is used by existing modules (e.g. headscale routes, mesh auth, mail), set researchable=false with kind "already_integrated" or "self_hosted".
49
+ - Never queue research whose topic is the env var name itself.
50
+ - topic (when researchable) must name the external product and API surface; notes must say use official docs, not the raw key string.
51
+ - Prefer skip when uncertain. Do not invent product paths.`;
52
+
53
+ /**
54
+ * Collect a small amount of code-usage context for the AI (not classification rules).
55
+ * @param {string} key
56
+ * @param {{ roots?: string[], maxHits?: number }} [opts]
57
+ * @returns {string}
58
+ */
59
+ export function gatherCodeUsageContext(key, opts = {}) {
60
+ const roots = opts.roots || [path.join(REPO_ROOT, 'src')];
61
+ const maxHits = opts.maxHits ?? 12;
62
+ const snippets = [];
63
+
64
+ // Prefer ripgrep if available; fall back to a tiny walk.
65
+ const rg = spawnSync(
66
+ 'rg',
67
+ [
68
+ '-n',
69
+ '--no-heading',
70
+ '-m',
71
+ '8',
72
+ '-g',
73
+ '!**/node_modules/**',
74
+ '-g',
75
+ '!**/*.spec.*',
76
+ '-e',
77
+ key,
78
+ ...roots.filter((r) => fs.existsSync(r)),
79
+ ],
80
+ { encoding: 'utf8', timeout: 8000, maxBuffer: 512_000 },
81
+ );
82
+
83
+ if (rg.status === 0 && rg.stdout) {
84
+ for (const line of rg.stdout.split('\n').filter(Boolean).slice(0, maxHits)) {
85
+ snippets.push(line.slice(0, 400));
86
+ }
87
+ } else {
88
+ // Minimal fallback walk
89
+ for (const root of roots) {
90
+ if (!fs.existsSync(root)) continue;
91
+ walkForKey(root, key, snippets, maxHits, 0);
92
+ if (snippets.length >= maxHits) break;
93
+ }
94
+ }
95
+
96
+ if (!snippets.length) {
97
+ return '(no code references found in local src tree)';
98
+ }
99
+ return snippets.join('\n');
100
+ }
101
+
102
+ function walkForKey(dir, key, out, maxHits, depth) {
103
+ if (out.length >= maxHits || depth > 6) return;
104
+ let entries;
105
+ try {
106
+ entries = fs.readdirSync(dir, { withFileTypes: true });
107
+ } catch {
108
+ return;
109
+ }
110
+ for (const ent of entries) {
111
+ if (out.length >= maxHits) return;
112
+ if (ent.name === 'node_modules' || ent.name === '.git' || ent.name.startsWith('.')) continue;
113
+ const full = path.join(dir, ent.name);
114
+ if (ent.isDirectory()) {
115
+ walkForKey(full, key, out, maxHits, depth + 1);
116
+ continue;
117
+ }
118
+ if (!/\.(mjs|js|ts|tsx|yml|yaml|json|md)$/i.test(ent.name)) continue;
119
+ if (/\.spec\./i.test(ent.name)) continue;
120
+ let text;
121
+ try {
122
+ text = fs.readFileSync(full, 'utf8');
123
+ } catch {
124
+ continue;
125
+ }
126
+ if (!text.includes(key)) continue;
127
+ const lines = text.split('\n');
128
+ for (let i = 0; i < lines.length && out.length < maxHits; i++) {
129
+ if (lines[i].includes(key)) {
130
+ const from = Math.max(0, i - 1);
131
+ const to = Math.min(lines.length, i + 2);
132
+ const chunk = lines
133
+ .slice(from, to)
134
+ .map((l, idx) => `${full}:${from + idx + 1}:${l.trim().slice(0, 200)}`)
135
+ .join('\n');
136
+ out.push(chunk);
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Parse first JSON object from model output.
144
+ * @param {string} raw
145
+ */
146
+ export function parseAiJson(raw) {
147
+ const text = String(raw || '');
148
+ const start = text.indexOf('{');
149
+ const end = text.lastIndexOf('}');
150
+ if (start < 0 || end <= start) return null;
151
+ try {
152
+ return JSON.parse(text.slice(start, end + 1));
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Ask a CLI agent to infer provider / research brief from key name + code usage.
160
+ *
161
+ * @param {string} key
162
+ * @param {{
163
+ * meta?: object,
164
+ * codeContext?: string,
165
+ * runtimeConfig?: object,
166
+ * callRuntime?: Function,
167
+ * }} [opts]
168
+ * @returns {Promise<{
169
+ * researchable: boolean,
170
+ * skip_reason: string|null,
171
+ * product_name: string|null,
172
+ * product_slug: string|null,
173
+ * kind: string,
174
+ * topic: string|null,
175
+ * notes: string|null,
176
+ * priority: string,
177
+ * confidence: number,
178
+ * source: 'ai',
179
+ * }>}
180
+ */
181
+ export async function inferSecretIntegrationWithAi(key, opts = {}) {
182
+ const codeContext =
183
+ opts.codeContext !== undefined
184
+ ? opts.codeContext
185
+ : gatherCodeUsageContext(key, { roots: opts.roots });
186
+
187
+ const prompt = [
188
+ `Secret key name: ${key}`,
189
+ opts.meta?.provider ? `Stored provider tag (may be wrong/empty): ${opts.meta.provider}` : null,
190
+ opts.meta?.label ? `Label: ${opts.meta.label}` : null,
191
+ opts.meta?.notes ? `User notes: ${String(opts.meta.notes).slice(0, 500)}` : null,
192
+ '',
193
+ 'Code references (evidence only — reason about them):',
194
+ codeContext,
195
+ '',
196
+ 'Return the JSON decision now.',
197
+ ]
198
+ .filter((l) => l !== null)
199
+ .join('\n');
200
+
201
+ const callRuntime =
202
+ opts.callRuntime ||
203
+ (async (user, system, cfg) => {
204
+ const { callLocalRuntime, loadRuntimeConfig } = await import('./runtime.mjs');
205
+ return callLocalRuntime(user, system, cfg || opts.runtimeConfig || loadRuntimeConfig());
206
+ });
207
+
208
+ let raw;
209
+ try {
210
+ raw = await callRuntime(prompt, INFER_SYSTEM, opts.runtimeConfig);
211
+ } catch (err) {
212
+ logger.warn('secrets', 'AI secret integration inference failed', {
213
+ key,
214
+ error: err.message,
215
+ });
216
+ return {
217
+ researchable: false,
218
+ skip_reason: `AI inference unavailable: ${err.message}`,
219
+ product_name: null,
220
+ product_slug: null,
221
+ kind: 'unknown',
222
+ topic: null,
223
+ notes: null,
224
+ priority: 'low',
225
+ confidence: 0,
226
+ source: 'ai',
227
+ };
228
+ }
229
+
230
+ const parsed = parseAiJson(raw);
231
+ if (!parsed || typeof parsed !== 'object') {
232
+ return {
233
+ researchable: false,
234
+ skip_reason: 'AI returned non-JSON inference',
235
+ product_name: null,
236
+ product_slug: null,
237
+ kind: 'unknown',
238
+ topic: null,
239
+ notes: null,
240
+ priority: 'low',
241
+ confidence: 0,
242
+ source: 'ai',
243
+ };
244
+ }
245
+
246
+ const researchable = parsed.researchable === true;
247
+ return {
248
+ researchable,
249
+ skip_reason: researchable ? null : String(parsed.skip_reason || 'AI declined research'),
250
+ product_name: parsed.product_name ? String(parsed.product_name) : null,
251
+ product_slug: parsed.product_slug ? String(parsed.product_slug) : null,
252
+ kind: String(parsed.kind || 'unknown'),
253
+ topic: researchable && parsed.topic ? String(parsed.topic) : null,
254
+ notes: researchable && parsed.notes ? String(parsed.notes) : null,
255
+ priority: ['low', 'medium', 'high'].includes(parsed.priority) ? parsed.priority : 'low',
256
+ confidence: Number.isFinite(Number(parsed.confidence)) ? Number(parsed.confidence) : 0,
257
+ source: 'ai',
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Build brief via AI only.
263
+ * @returns {Promise<{ topic: string, notes: string, priority: string, inference: object } | null>}
264
+ */
265
+ export async function buildIntegrationResearchBrief(key, meta = {}, opts = {}) {
266
+ const inference = await inferSecretIntegrationWithAi(key, { meta, ...opts });
267
+ if (!inference.researchable || !inference.topic) {
268
+ return null;
269
+ }
270
+ return {
271
+ topic: inference.topic,
272
+ notes: inference.notes || `AI-inferred product research for secret \`${key}\`.`,
273
+ priority: inference.priority || 'low',
274
+ inference,
275
+ };
276
+ }
277
+
278
+ /**
279
+ * Enqueue integration research when the AI says so.
280
+ * @returns {Promise<{ enqueued: boolean, item?: object, skipped?: string, inference?: object }>}
281
+ */
282
+ export async function maybeEnqueueIntegrationResearch(brainDir, key, meta = {}, opts = {}) {
283
+ const inference = await inferSecretIntegrationWithAi(key, { meta, ...opts });
284
+ if (!inference.researchable || !inference.topic) {
285
+ return {
286
+ enqueued: false,
287
+ skipped: inference.skip_reason || 'AI declined research',
288
+ inference,
289
+ };
290
+ }
291
+
292
+ const { addToQueue } = await import('./research-queue.mjs');
293
+ const item = addToQueue({
294
+ topic: inference.topic,
295
+ priority: inference.priority || 'low',
296
+ notes: inference.notes || `AI-inferred product research for secret \`${key}\`.`,
297
+ brainDir,
298
+ });
299
+ return { enqueued: true, item, inference };
300
+ }
301
+
302
+ /**
303
+ * Retire obsolete key-name scrape queue items (old Automated API Integration Build: KEY topics).
304
+ */
305
+ export function cancelBogusApiIntegrationQueueItems(overrideBrainDir) {
306
+ return import('./research-queue.mjs').then(({ loadQueue, updateQueueItem }) => {
307
+ const items = loadQueue(overrideBrainDir);
308
+ let cancelled = 0;
309
+ for (const item of items) {
310
+ if (item.status !== 'pending' && item.status !== 'in_progress') continue;
311
+ if (!/^Automated API Integration Build:/i.test(String(item.topic || ''))) continue;
312
+ updateQueueItem(
313
+ item.id,
314
+ {
315
+ status: 'failed',
316
+ notes: `${item.notes || ''}\n\n[auto-cancelled] Obsolete key-name scrape job; re-add the secret to trigger AI provider inference.`,
317
+ research_phase: 'cancelled',
318
+ },
319
+ overrideBrainDir,
320
+ );
321
+ cancelled += 1;
322
+ }
323
+ return { cancelled };
324
+ });
325
+ }