total-recall-brain 3.18.2 → 3.18.3
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-CGqvdwHT.js +5742 -0
- 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 +12 -1
- 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/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 +206 -85
- package/package.json +1 -1
- package/src/cli/agent-dir.mjs +2 -1
- package/src/cli/secret.mjs +180 -3
- package/src/core/daemon-loop.mjs +31 -5
- 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/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 +278 -14
- package/src/core/secrets-sync.mjs +1 -1
- package/src/core/source-adapters.mjs +247 -22
- package/src/core/task-executors.mjs +161 -14
- package/src/core/webhook-handlers.mjs +123 -14
- package/src/server/auth.mjs +6 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +24 -0
- package/src/server/routes/config.mjs +64 -23
- 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/system.mjs +20 -1
- package/src/server/routes/update.mjs +11 -4
- package/frontend/dist/assets/index-D5xRw5uq.js +0 -5750
|
@@ -311,15 +311,22 @@ export function getProvider(providerId) {
|
|
|
311
311
|
* @param {string} keyName
|
|
312
312
|
*/
|
|
313
313
|
export function providerForKeyName(keyName) {
|
|
314
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
}
|
package/src/core/research.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
222
|
+
if (sources.wikipedia) {
|
|
223
|
+
fetches.push(
|
|
224
|
+
wikipediaFetch(query, researchConfig).then((r) => r && results.push(r)).catch(() => {}),
|
|
225
|
+
);
|
|
226
|
+
}
|
|
157
227
|
|
|
158
|
-
//
|
|
159
|
-
if (
|
|
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)
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
514
|
+
return slug;
|
|
441
515
|
}
|
|
442
516
|
|
|
443
517
|
// ─── Synthesis ───────────────────────────────────────────────────────────────────
|
package/src/core/scheduler.mjs
CHANGED
|
@@ -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
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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}
|
|
552
|
+
message: `Auto-reset ${resetCount} research items after cooldown (empty→acquisition, failed→acquisition, done+node→monitoring).`,
|
|
531
553
|
});
|
|
532
554
|
}
|
|
533
555
|
|
package/src/core/schema.mjs
CHANGED
|
@@ -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
|
+
}
|