total-recall-brain 3.21.2 → 3.22.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 (81) hide show
  1. package/frontend/dist/assets/index-C7_Xpk27.css +1 -0
  2. package/frontend/dist/assets/{index-D0XeeXhG.js → index-DJjuZ3t3.js} +106 -106
  3. package/frontend/dist/index.html +2 -2
  4. package/frontend/src/App.spec.tsx +1 -1
  5. package/frontend/src/api/_base.ts +3 -3
  6. package/frontend/src/api/embeddings.ts +46 -0
  7. package/frontend/src/api/mesh.ts +47 -0
  8. package/frontend/src/api/network.spec.ts +9 -9
  9. package/frontend/src/api/network.ts +4 -4
  10. package/frontend/src/api/notifications.ts +5 -4
  11. package/frontend/src/api/webhooks.ts +1 -1
  12. package/frontend/src/components/BrainSelector.spec.tsx +4 -4
  13. package/frontend/src/components/BrainSelector.tsx +1 -1
  14. package/frontend/src/components/ContextualHelp.spec.tsx +14 -14
  15. package/frontend/src/components/ContextualHelp.tsx +3 -1
  16. package/frontend/src/components/DocumentTable.spec.tsx +1 -1
  17. package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
  18. package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
  19. package/frontend/src/components/Graph3D.spec.tsx +4 -4
  20. package/frontend/src/components/Graph3D.tsx +3 -3
  21. package/frontend/src/components/MobilePairing.spec.tsx +1 -1
  22. package/frontend/src/components/MobilePairing.tsx +1 -1
  23. package/frontend/src/components/UsageChart.spec.tsx +4 -4
  24. package/frontend/src/components/UsageChart.tsx +1 -1
  25. package/frontend/src/components/VoiceInput.spec.tsx +6 -6
  26. package/frontend/src/components/VoiceInput.tsx +19 -4
  27. package/frontend/src/components/brand/BrandMark.spec.tsx +3 -4
  28. package/frontend/src/index.css +6 -0
  29. package/frontend/src/pages/AutomationsPage.spec.tsx +4 -4
  30. package/frontend/src/pages/ChatPage.spec.tsx +3 -3
  31. package/frontend/src/pages/CollabPage.spec.tsx +1 -2
  32. package/frontend/src/pages/DesignDocsPage.spec.tsx +1 -1
  33. package/frontend/src/pages/FilesPage.spec.tsx +2 -2
  34. package/frontend/src/pages/GraphPage.spec.tsx +1 -1
  35. package/frontend/src/pages/HealthPage.spec.tsx +2 -2
  36. package/frontend/src/pages/HelpPage.spec.tsx +5 -5
  37. package/frontend/src/pages/InboxPage.spec.tsx +4 -4
  38. package/frontend/src/pages/InstructionsPage.spec.tsx +5 -5
  39. package/frontend/src/pages/IntegrationsPage.spec.tsx +7 -7
  40. package/frontend/src/pages/MemoryPage.spec.tsx +2 -2
  41. package/frontend/src/pages/MeshPage.spec.tsx +111 -2
  42. package/frontend/src/pages/MeshPage.tsx +95 -11
  43. package/frontend/src/pages/NetworkPage.spec.tsx +7 -8
  44. package/frontend/src/pages/NetworkPage.tsx +12 -12
  45. package/frontend/src/pages/NotificationsPage.tsx +9 -9
  46. package/frontend/src/pages/OnboardingPage.spec.tsx +7 -7
  47. package/frontend/src/pages/OpenWikiPage.tsx +3 -3
  48. package/frontend/src/pages/RulesPage.tsx +3 -3
  49. package/frontend/src/pages/SecretsPage.spec.tsx +5 -5
  50. package/frontend/src/pages/SecretsPage.tsx +13 -14
  51. package/frontend/src/pages/SettingsPage.spec.tsx +6 -6
  52. package/frontend/src/pages/SettingsPage.tsx +2 -2
  53. package/frontend/src/pages/SkillsPage.spec.tsx +1 -2
  54. package/frontend/src/pages/SkillsPage.tsx +3 -3
  55. package/frontend/src/pages/TasksPage.spec.tsx +3 -3
  56. package/frontend/src/pages/UsagePage.spec.tsx +2 -2
  57. package/frontend/src/pages/UsagePage.tsx +1 -1
  58. package/frontend/src/pages/WebhooksPage.spec.tsx +1 -1
  59. package/frontend/src/pages/WebhooksPage.tsx +19 -19
  60. package/frontend/src/setupTests.ts +4 -4
  61. package/package.json +1 -1
  62. package/scaffold/.agent/skills/total-recall/SKILL.md +92 -0
  63. package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +15 -13
  64. package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
  65. package/src/cli/secret.mjs +93 -12
  66. package/src/core/browser-session.mjs +185 -0
  67. package/src/core/daemon-loop.mjs +28 -1
  68. package/src/core/embeddings.mjs +52 -10
  69. package/src/core/headscale-client.mjs +227 -0
  70. package/src/core/mesh-enroll.mjs +439 -0
  71. package/src/core/ollama-embeddings.mjs +336 -0
  72. package/src/core/provider-catalog.mjs +175 -6
  73. package/src/core/provider-rotation-recipes.mjs +299 -0
  74. package/src/core/rotation-capability.mjs +189 -0
  75. package/src/core/secrets-rotate.mjs +192 -0
  76. package/src/server/rest.mjs +2 -0
  77. package/src/server/route-manifest.json +20 -0
  78. package/src/server/routes/embeddings.mjs +111 -0
  79. package/src/server/routes/headscale.mjs +21 -81
  80. package/src/server/routes/mesh.mjs +41 -0
  81. package/frontend/dist/assets/index-B4RASWbV.css +0 -1
@@ -0,0 +1,336 @@
1
+ /**
2
+ * src/core/ollama-embeddings.mjs
3
+ *
4
+ * Local / mesh Ollama embedding provider.
5
+ *
6
+ * Two properties here are load-bearing:
7
+ *
8
+ * 1. No hardcoded host. The endpoint comes from explicit config, then the
9
+ * loopback default, then live mesh discovery. A machine name baked into
10
+ * product code would leak one operator's topology into every install.
11
+ *
12
+ * 2. No hardcoded model. The model is selected from what the server actually
13
+ * reports — filtered by its declared capability and matched against the
14
+ * vault's vector width. Pinning a name would break the day it is renamed,
15
+ * unpulled, or superseded by a better one.
16
+ *
17
+ * Calls go through plain fetch rather than throttledFetch: this is local
18
+ * infrastructure on the operator's own mesh, not a rate-limited third-party
19
+ * API, and it must keep working when the outbound network policy is strict.
20
+ */
21
+
22
+ import { logger } from './logger.mjs';
23
+
24
+ const OLLAMA_DEFAULT_PORT = 11434;
25
+ const PROBE_TIMEOUT_MS = 2500;
26
+ const SHOW_TIMEOUT_MS = 5000;
27
+ const EMBED_TIMEOUT_MS = 30000;
28
+ const DISCOVERY_TTL_MS = 5 * 60_000;
29
+
30
+ /** fetch with a hard deadline. Manual controller — AbortSignal.timeout has bitten this file before. */
31
+ async function fetchWithDeadline(url, options = {}, timeoutMs = PROBE_TIMEOUT_MS) {
32
+ const controller = new AbortController();
33
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
34
+ try {
35
+ return await fetch(url, { ...options, signal: controller.signal });
36
+ } finally {
37
+ clearTimeout(timer);
38
+ }
39
+ }
40
+
41
+ /** Accepts `host`, `host:port`, or a full URL — OLLAMA_HOST is used all three ways in the wild. */
42
+ export function normalizeBaseUrl(value) {
43
+ const raw = String(value || '').trim();
44
+ if (!raw) return null;
45
+ const withScheme = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
46
+ try {
47
+ const u = new URL(withScheme);
48
+ if (!u.port && !/:\d+$/.test(u.host)) u.port = String(OLLAMA_DEFAULT_PORT);
49
+ return u.origin;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** Explicitly configured endpoints, highest precedence first. */
56
+ function configuredBaseUrls() {
57
+ return [process.env.TR_OLLAMA_URL, process.env.OLLAMA_HOST]
58
+ .map(normalizeBaseUrl)
59
+ .filter(Boolean);
60
+ }
61
+
62
+ /**
63
+ * Online mesh peers, as endpoint candidates. Discovery, not configuration —
64
+ * this is how a laptop finds the box that actually holds the models without
65
+ * anyone writing an address into the repo.
66
+ */
67
+ async function meshCandidateUrls() {
68
+ try {
69
+ const { readTailscaleStatus } = await import('./mesh-enroll.mjs');
70
+ const status = readTailscaleStatus();
71
+ if (!status) return [];
72
+ const peers = Object.values(status.Peer || {});
73
+ return peers
74
+ .filter((p) => p?.Online)
75
+ .flatMap((p) => (p.TailscaleIPs || []).filter((ip) => !ip.includes(':')))
76
+ .map((ip) => normalizeBaseUrl(`${ip}:${OLLAMA_DEFAULT_PORT}`))
77
+ .filter(Boolean);
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ /** @returns {Promise<Array<object>|null>} the server's model list, or null when it is not an Ollama endpoint. */
84
+ export async function listOllamaModels(baseUrl) {
85
+ try {
86
+ const res = await fetchWithDeadline(`${baseUrl}/api/tags`, {}, PROBE_TIMEOUT_MS);
87
+ if (!res.ok) return null;
88
+ const data = await res.json();
89
+ return Array.isArray(data?.models) ? data.models : null;
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** @type {{url: string, at: number}|null} */
96
+ let endpointCache = null;
97
+
98
+ /**
99
+ * Resolve a reachable Ollama endpoint: configured → loopback → mesh peers.
100
+ * @param {{force?: boolean}} [opts]
101
+ * @returns {Promise<string|null>}
102
+ */
103
+ export async function resolveOllamaEndpoint({ force = false } = {}) {
104
+ if (!force && endpointCache && Date.now() - endpointCache.at < DISCOVERY_TTL_MS) {
105
+ return endpointCache.url;
106
+ }
107
+
108
+ const configured = configuredBaseUrls();
109
+ const candidates = [
110
+ ...configured,
111
+ normalizeBaseUrl(`127.0.0.1:${OLLAMA_DEFAULT_PORT}`),
112
+ // Mesh discovery is last and only consulted when nothing local answers, so
113
+ // the common case costs one loopback probe rather than a network sweep.
114
+ ...(configured.length ? [] : await meshCandidateUrls()),
115
+ ];
116
+
117
+ const seen = new Set();
118
+ for (const url of candidates) {
119
+ if (!url || seen.has(url)) continue;
120
+ seen.add(url);
121
+ const models = await listOllamaModels(url);
122
+ if (models) {
123
+ endpointCache = { url, at: Date.now() };
124
+ logger.debug('embeddings: resolved Ollama endpoint', { url, models: models.length });
125
+ return url;
126
+ }
127
+ }
128
+
129
+ endpointCache = null;
130
+ return null;
131
+ }
132
+
133
+ /** Full metadata for one model, including declared capabilities. */
134
+ async function describeModel(baseUrl, name) {
135
+ try {
136
+ const res = await fetchWithDeadline(
137
+ `${baseUrl}/api/show`,
138
+ {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ model: name }),
142
+ },
143
+ SHOW_TIMEOUT_MS,
144
+ );
145
+ if (!res.ok) return null;
146
+ return await res.json();
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * The embedding width a model actually produces.
154
+ *
155
+ * Read the family-scoped key exactly. Generative models publish several
156
+ * *.embedding_length entries (a vision tower's width, an audio tower's width)
157
+ * and a loose regex will happily match one of those — picking a chat model
158
+ * that cannot embed at all.
159
+ */
160
+ export function embeddingWidth(info) {
161
+ const family = info?.details?.family;
162
+ const modelInfo = info?.model_info || {};
163
+ if (family && Number.isFinite(modelInfo[`${family}.embedding_length`])) {
164
+ return modelInfo[`${family}.embedding_length`];
165
+ }
166
+ return null;
167
+ }
168
+
169
+ /** Ollama declares this explicitly; trust it over any name-based heuristic. */
170
+ export function canEmbed(info) {
171
+ return Array.isArray(info?.capabilities) && info.capabilities.includes('embedding');
172
+ }
173
+
174
+ /** @type {Map<string, string|null>} */
175
+ const modelCache = new Map();
176
+
177
+ /**
178
+ * Choose an embedding model from what the server reports.
179
+ *
180
+ * @param {string} baseUrl
181
+ * @param {{dims: number, preferred?: string|null, force?: boolean}} opts
182
+ * `dims` is the vault's vector width — a model of any other width cannot be
183
+ * stored in the existing index, so width is a hard filter, not a preference.
184
+ * @returns {Promise<string|null>}
185
+ */
186
+ export async function selectEmbeddingModel(baseUrl, { dims, preferred = null, force = false } = {}) {
187
+ const cacheKey = `${baseUrl}|${dims}|${preferred || ''}`;
188
+ if (!force && modelCache.has(cacheKey)) return modelCache.get(cacheKey);
189
+
190
+ const models = await listOllamaModels(baseUrl);
191
+ if (!models?.length) {
192
+ modelCache.set(cacheKey, null);
193
+ return null;
194
+ }
195
+
196
+ const names = models.map((m) => m.name || m.model).filter(Boolean);
197
+ // An operator-pinned model still has to prove it can embed at the right
198
+ // width; a stale pin should fall through to discovery, not hard-fail.
199
+ const ordered = preferred
200
+ ? [...names.filter((n) => n === preferred || n.split(':')[0] === preferred), ...names]
201
+ : names;
202
+
203
+ const bySize = new Map(models.map((m) => [m.name || m.model, m.size || Number.MAX_SAFE_INTEGER]));
204
+ const viable = [];
205
+
206
+ for (const name of [...new Set(ordered)]) {
207
+ const info = await describeModel(baseUrl, name);
208
+ if (!info || !canEmbed(info)) continue;
209
+ const width = embeddingWidth(info);
210
+ if (width !== dims) {
211
+ logger.debug('embeddings: skipping Ollama model on width mismatch', { name, width, want: dims });
212
+ continue;
213
+ }
214
+ if (preferred && (name === preferred || name.split(':')[0] === preferred)) {
215
+ modelCache.set(cacheKey, name);
216
+ return name;
217
+ }
218
+ viable.push(name);
219
+ }
220
+
221
+ // Smallest viable model wins: embedding models at equal width are near-equal
222
+ // in quality, and the small one keeps recall latency down.
223
+ viable.sort((a, b) => (bySize.get(a) || 0) - (bySize.get(b) || 0));
224
+ const chosen = viable[0] || null;
225
+ if (chosen) logger.debug('embeddings: selected Ollama model', { model: chosen, dims });
226
+ modelCache.set(cacheKey, chosen);
227
+ return chosen;
228
+ }
229
+
230
+ /**
231
+ * Embed one string. Returns the vector, or throws with a reason the caller can log.
232
+ *
233
+ * @param {string} text
234
+ * @param {{baseUrl: string, model: string, dims: number}} opts
235
+ * @returns {Promise<number[]>}
236
+ */
237
+ export async function getOllamaEmbedding(text, { baseUrl, model, dims }) {
238
+ const res = await fetchWithDeadline(
239
+ `${baseUrl}/api/embeddings`,
240
+ {
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify({ model, prompt: text }),
244
+ },
245
+ EMBED_TIMEOUT_MS,
246
+ );
247
+
248
+ if (!res.ok) {
249
+ const body = await res.text().catch(() => '');
250
+ throw new Error(`Ollama embedding error ${res.status}: ${body || 'no response body'}`);
251
+ }
252
+
253
+ const data = await res.json();
254
+ const embedding = data?.embedding;
255
+ if (!Array.isArray(embedding) || !embedding.length) {
256
+ throw new Error('Ollama returned no embedding vector');
257
+ }
258
+ // Storing a wrong-width vector corrupts the index silently; fail loudly instead.
259
+ if (Number.isFinite(dims) && embedding.length !== dims) {
260
+ throw new Error(`Ollama model ${model} returned ${embedding.length} dims, vault requires ${dims}`);
261
+ }
262
+ return embedding;
263
+ }
264
+
265
+ /**
266
+ * Every model the server holds, annotated for an operator choosing one:
267
+ * whether it can embed at all, how wide its vectors are, and whether that
268
+ * width fits the vault index. Models that cannot be used are still returned,
269
+ * with the reason visible, so the UI can explain the absence rather than
270
+ * silently showing an empty list.
271
+ *
272
+ * @param {string} baseUrl
273
+ * @param {{dims?: number}} [opts]
274
+ */
275
+ export async function describeEmbeddingCandidates(baseUrl, { dims } = {}) {
276
+ const models = await listOllamaModels(baseUrl);
277
+ if (!models?.length) return [];
278
+
279
+ const out = [];
280
+ for (const m of models) {
281
+ const name = m.name || m.model;
282
+ if (!name) continue;
283
+ const info = await describeModel(baseUrl, name);
284
+ const embeds = info ? canEmbed(info) : false;
285
+ const width = info ? embeddingWidth(info) : null;
286
+ out.push({
287
+ name,
288
+ size: m.size ?? null,
289
+ family: info?.details?.family ?? null,
290
+ capabilities: info?.capabilities ?? [],
291
+ embedding: embeds,
292
+ dims: width,
293
+ compatible: Boolean(embeds && Number.isFinite(dims) && width === dims),
294
+ });
295
+ }
296
+ return out;
297
+ }
298
+
299
+ /**
300
+ * Current state of the local/mesh embedding provider, for display.
301
+ * Never throws — an unreachable endpoint is a reportable state, not an error.
302
+ *
303
+ * @param {{dims: number, preferred?: string|null}} opts
304
+ */
305
+ export async function getOllamaProviderStatus({ dims, preferred = null } = {}) {
306
+ const endpoint = await resolveOllamaEndpoint();
307
+ if (!endpoint) {
308
+ return {
309
+ available: false,
310
+ endpoint: null,
311
+ selected: null,
312
+ preferred,
313
+ dims,
314
+ candidates: [],
315
+ reason: 'No Ollama endpoint answered on this host or any online mesh peer.',
316
+ };
317
+ }
318
+
319
+ const candidates = await describeEmbeddingCandidates(endpoint, { dims });
320
+ const selected = await selectEmbeddingModel(endpoint, { dims, preferred });
321
+ return {
322
+ available: Boolean(selected),
323
+ endpoint,
324
+ selected,
325
+ preferred,
326
+ dims,
327
+ candidates,
328
+ reason: selected ? null : `No model on ${endpoint} embeds at ${dims} dims.`,
329
+ };
330
+ }
331
+
332
+ /** Reset memoised discovery — used by tests and after a topology change. */
333
+ export function resetOllamaDiscovery() {
334
+ endpointCache = null;
335
+ modelCache.clear();
336
+ }
@@ -11,6 +11,11 @@
11
11
  * console_url?: string,
12
12
  * key_patterns: string[],
13
13
  * schema: { auth: string, header?: string, env_keys: string[], notes?: string },
14
+ * rotation?: {
15
+ * class?: 'provider_api' | 'provider_browser' | 'self_generated' | 'manual',
16
+ * high_risk?: boolean,
17
+ * manual_keys?: string[],
18
+ * },
14
19
  * default_monthly_cap_usd?: number | null,
15
20
  * tiers?: { id: string, label: string, monthly_usd?: number | null }[],
16
21
  * }} ProviderDef */
@@ -295,6 +300,124 @@ export const PROVIDER_CATALOG = [
295
300
  default_monthly_cap_usd: 0,
296
301
  tiers: [{ id: 'self-hosted', label: 'Self-hosted', monthly_usd: 0 }],
297
302
  },
303
+ {
304
+ id: 'stripe',
305
+ name: 'Stripe',
306
+ docs_url: 'https://docs.stripe.com/api',
307
+ pricing_url: 'https://stripe.com/pricing',
308
+ console_url: 'https://dashboard.stripe.com/apikeys',
309
+ key_patterns: ['STRIPE_SECRET_KEY', 'STRIPE_API_KEY', 'STRIPE_PUBLISHABLE_KEY', 'STRIPE_WEBHOOK_SECRET'],
310
+ schema: {
311
+ auth: 'bearer',
312
+ header: 'Authorization: Bearer <key>',
313
+ env_keys: ['STRIPE_SECRET_KEY'],
314
+ notes: 'Live keys are payment-critical. Roll with a grace window; never revoke before export confirms.',
315
+ },
316
+ // Stripe exposes no API to mint or roll secret keys — dashboard only, by design.
317
+ rotation: { class: 'provider_browser', high_risk: true },
318
+ default_monthly_cap_usd: null,
319
+ tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
320
+ },
321
+ {
322
+ id: 'vercel',
323
+ name: 'Vercel',
324
+ docs_url: 'https://vercel.com/docs/rest-api',
325
+ console_url: 'https://vercel.com/account/settings/tokens',
326
+ key_patterns: ['VERCEL_API_TOKEN', 'VERCEL_TOKEN'],
327
+ schema: { auth: 'bearer', header: 'Authorization: Bearer <token>', env_keys: ['VERCEL_API_TOKEN'] },
328
+ rotation: { class: 'provider_browser' },
329
+ default_monthly_cap_usd: 20,
330
+ tiers: [{ id: 'hobby', label: 'Hobby', monthly_usd: 0 }, { id: 'pro', label: 'Pro', monthly_usd: 20 }],
331
+ },
332
+ {
333
+ id: 'telnyx',
334
+ name: 'Telnyx',
335
+ docs_url: 'https://developers.telnyx.com/api',
336
+ console_url: 'https://portal.telnyx.com/#/app/api-keys',
337
+ key_patterns: ['TELNYX_API_KEY', 'TELNYX_SIP_PASSWORD'],
338
+ schema: { auth: 'bearer', header: 'Authorization: Bearer <key>', env_keys: ['TELNYX_API_KEY'] },
339
+ rotation: { class: 'provider_browser' },
340
+ default_monthly_cap_usd: null,
341
+ tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
342
+ },
343
+ {
344
+ id: 'fal',
345
+ name: 'fal.ai',
346
+ docs_url: 'https://docs.fal.ai',
347
+ console_url: 'https://fal.ai/dashboard/keys',
348
+ key_patterns: ['FAL_KEY', 'FAL_AI_API_KEY'],
349
+ schema: { auth: 'header', header: 'Authorization: Key <key>', env_keys: ['FAL_KEY'] },
350
+ rotation: { class: 'provider_browser' },
351
+ default_monthly_cap_usd: 20,
352
+ tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
353
+ },
354
+ {
355
+ id: 'tavily',
356
+ name: 'Tavily',
357
+ docs_url: 'https://docs.tavily.com',
358
+ console_url: 'https://app.tavily.com/home',
359
+ key_patterns: ['TAVILY_API_KEY'],
360
+ schema: { auth: 'bearer', header: 'Authorization: Bearer <key>', env_keys: ['TAVILY_API_KEY'] },
361
+ rotation: { class: 'provider_browser' },
362
+ default_monthly_cap_usd: 10,
363
+ tiers: [{ id: 'free', label: 'Free', monthly_usd: 0 }],
364
+ },
365
+ {
366
+ id: 'exa',
367
+ name: 'Exa',
368
+ docs_url: 'https://docs.exa.ai',
369
+ console_url: 'https://dashboard.exa.ai/api-keys',
370
+ key_patterns: ['EXA_API_KEY'],
371
+ schema: { auth: 'header', header: 'x-api-key: <key>', env_keys: ['EXA_API_KEY'] },
372
+ rotation: { class: 'provider_browser' },
373
+ default_monthly_cap_usd: 10,
374
+ tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
375
+ },
376
+ {
377
+ id: 'npm',
378
+ name: 'npm',
379
+ docs_url: 'https://docs.npmjs.com/about-access-tokens',
380
+ console_url: 'https://www.npmjs.com/settings/~/tokens',
381
+ key_patterns: ['NPM_TOKEN', 'NPM_RECOVERY_CODE'],
382
+ schema: { auth: 'bearer', header: 'Authorization: Bearer <token>', env_keys: ['NPM_TOKEN'] },
383
+ // Recovery codes are single-issue artifacts, never machine-rotatable.
384
+ rotation: { class: 'provider_browser', manual_keys: ['NPM_RECOVERY_CODE'] },
385
+ default_monthly_cap_usd: 0,
386
+ tiers: [{ id: 'free', label: 'Free', monthly_usd: 0 }],
387
+ },
388
+ {
389
+ id: 'pexels',
390
+ name: 'Pexels',
391
+ docs_url: 'https://www.pexels.com/api/documentation/',
392
+ console_url: 'https://www.pexels.com/api/new/',
393
+ key_patterns: ['PEXELS_API_KEY'],
394
+ schema: { auth: 'header', header: 'Authorization: <key>', env_keys: ['PEXELS_API_KEY'] },
395
+ rotation: { class: 'provider_browser' },
396
+ default_monthly_cap_usd: 0,
397
+ tiers: [{ id: 'free', label: 'Free', monthly_usd: 0 }],
398
+ },
399
+ {
400
+ id: 'artificial-analysis',
401
+ name: 'Artificial Analysis',
402
+ docs_url: 'https://artificialanalysis.ai/documentation',
403
+ console_url: 'https://artificialanalysis.ai/account/api-keys',
404
+ key_patterns: ['ARTIFICIAL_ANALYSIS_API_KEY'],
405
+ schema: { auth: 'header', header: 'x-api-key: <key>', env_keys: ['ARTIFICIAL_ANALYSIS_API_KEY'] },
406
+ rotation: { class: 'provider_browser' },
407
+ default_monthly_cap_usd: 0,
408
+ tiers: [{ id: 'free', label: 'Free', monthly_usd: 0 }],
409
+ },
410
+ {
411
+ id: 'browser-use',
412
+ name: 'Browser Use',
413
+ docs_url: 'https://docs.browser-use.com',
414
+ console_url: 'https://cloud.browser-use.com/settings/api-keys',
415
+ key_patterns: ['BROWSER_USE_API_KEY'],
416
+ schema: { auth: 'bearer', header: 'Authorization: Bearer <key>', env_keys: ['BROWSER_USE_API_KEY'] },
417
+ rotation: { class: 'provider_browser' },
418
+ default_monthly_cap_usd: 10,
419
+ tiers: [{ id: 'payg', label: 'Pay as you go', monthly_usd: null }],
420
+ },
298
421
  ];
299
422
 
300
423
  const byId = new Map(PROVIDER_CATALOG.map((p) => [p.id, p]));
@@ -310,24 +433,70 @@ export function getProvider(providerId) {
310
433
  /**
311
434
  * @param {string} keyName
312
435
  */
436
+ /**
437
+ * Minimum length for a derived stem before it may be used for a fuzzy match.
438
+ * Guards against short stems swallowing unrelated keys — e.g. the pattern
439
+ * `TR_TOKEN` reduces to the stem `TR`, which as a bare substring matches
440
+ * S-TR-IPE_SECRET_KEY and mis-attributes Stripe credentials to Total Recall.
441
+ */
442
+ const MIN_FUZZY_STEM = 4;
443
+
444
+ /** Does `k` contain `stem` on an underscore token boundary (not mid-word)? */
445
+ function matchesOnTokenBoundary(k, stem) {
446
+ return (
447
+ k === stem ||
448
+ k.startsWith(stem + '_') ||
449
+ k.endsWith('_' + stem) ||
450
+ k.includes('_' + stem + '_')
451
+ );
452
+ }
453
+
454
+ /**
455
+ * Resolve the provider that owns a secret key name.
456
+ *
457
+ * Matching is tiered strongest→weakest so an exact pattern always wins over a
458
+ * fuzzy stem. Fuzzy tiers require a token boundary and a minimum stem length;
459
+ * bare substring matching is never used.
460
+ *
461
+ * @param {string} keyName
462
+ * @returns {ProviderDef|null}
463
+ */
313
464
  export function providerForKeyName(keyName) {
314
465
  // Strip packaging prefixes so DEVELOPER_BRAVE_SEARCH_API_KEY → BRAVE_SEARCH_API_KEY
315
466
  let k = String(keyName || '').toUpperCase();
316
467
  k = k.replace(/^(DEVELOPER_|VITE_|NEXT_PUBLIC_|PUBLIC_|NUXT_PUBLIC_|PORTFOLIO_|ULTRACHAT_)+/g, '');
468
+
469
+ // Tier 1 — exact pattern match.
317
470
  for (const p of PROVIDER_CATALOG) {
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
- })) {
471
+ if (p.key_patterns.some((pat) => pat.toUpperCase() === k)) return p;
472
+ }
473
+
474
+ // Tier 2 — key is a suffixed variant of a full pattern (FOO_OPENAI_API_KEY).
475
+ for (const p of PROVIDER_CATALOG) {
476
+ if (p.key_patterns.some((pat) => k.endsWith('_' + pat.toUpperCase()))) return p;
477
+ }
478
+
479
+ // Tier 3 — pattern stem on a token boundary (GITHUB_TOKEN → GITHUB_APP_CLIENT_SECRET).
480
+ for (const p of PROVIDER_CATALOG) {
481
+ if (
482
+ p.key_patterns.some((pat) => {
483
+ const stem = pat.toUpperCase().replace(/_API_KEY$|_TOKEN$|_KEY$|_SECRET$/, '');
484
+ if (stem.length < MIN_FUZZY_STEM) return false;
485
+ return matchesOnTokenBoundary(k, stem);
486
+ })
487
+ ) {
322
488
  return p;
323
489
  }
324
490
  }
325
- // fuzzy product id in the remaining name
491
+
492
+ // Tier 4 — provider id on a token boundary.
326
493
  const compact = k.replace(/-/g, '_');
327
494
  for (const p of PROVIDER_CATALOG) {
328
495
  const id = p.id.toUpperCase().replace(/-/g, '_');
329
- if (compact.includes(id) || compact.startsWith(id + '_')) return p;
496
+ if (id.length < MIN_FUZZY_STEM) continue;
497
+ if (matchesOnTokenBoundary(compact, id)) return p;
330
498
  }
499
+
331
500
  return null;
332
501
  }
333
502