-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbackground.js
More file actions
1589 lines (1432 loc) · 69.7 KB
/
Copy pathbackground.js
File metadata and controls
1589 lines (1432 loc) · 69.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { detectPlatform } from './url-detector.js';
import { fetchRepoData } from './fetcher.js';
import { buildPrompt } from './prompt.js';
import { parseClaudeResponse } from './parser.js';
import { saveAnalysis, searchLibrary, upsertNode, addEdge, scrollLibrary, scrollPoints, saveRepo } from './store.js';
import { buildAttemptPlan } from './routing.js';
import {
COMPAT_PROVIDERS,
compatProviderById,
compatEndpoint,
compatModelFor,
compatProtocol,
isCompatConnected,
provKeyName,
openaiBody,
anthropicBody,
parseOpenAiText,
parseAnthropicText,
compatStorageKeys,
} from './providers.js';
import { withRetry } from './retry.js';
import { categorizeError, rankErrors } from './errors.js';
import { estimateTokens } from './estimate.js';
import { buildTagPrompt, parseTags } from './tag-prompt.js';
import { nodeIdFor, edgeIdFor, ideaIdFor } from './graph.js';
import { deriveCapabilities } from './taxonomy.js';
import { deriveFit } from './verdict.js';
import { combineCandidates } from './combinator.js';
import { buildCombinatorPrompt, parseCombinator } from './combinator-prompt.js';
import { refreshXaiToken, XAI_CHAT_PROXY } from './oauth-xai.js';
import {
OPENAI_OAUTH_ERROR_KEY,
OPENAI_OAUTH_STATE_KEY,
OPENAI_OAUTH_VERIFIER_KEY,
OPENAI_CREDENTIALS_KEY,
clearOpenAICredentials,
exchangeOpenAICode,
isOpenAIOAuthCallbackUrl,
mintOpenAIApiKey,
refreshOpenAIToken,
} from './oauth-openai.js';
import {
fetchSource,
buildAtomsPrompt, parseAtoms,
buildLineagePrompt, parseLineage,
buildFeynmanPrompt, parseFeynman,
} from './deepdive.js';
import { scanRepo } from './runner.js';
import { buildSystemsPrompt, parseSystems, isFramework } from './systems.js';
import { buildIdeatePrompt, parseIdeate, isIdeateFramework } from './ideate.js';
import { buildHeuristicsPrompt, parseHeuristics, isHeuristicFramework } from './heuristics.js';
import { withTone } from './tone.js';
import { buildSktpgPrompt, parseSktpg } from './sktpg.js';
import { buildDocsQualityPrompt, parseDocsQuality } from './docs-quality.js';
import { buildVersusPrompt, parseVersus } from './versus.js';
import { buildAskPrompt, parseAskAnswer, buildFilterPrompt, parseFilterResult } from './ask-library.js';
import { buildMaintenancePrompt, parseMaintenance } from './maintenance.js';
import { fetchMaintenanceSignals } from './fetcher.js';
import { buildSynergiesPrompt, parseSynergies } from './synergies.js';
import { cacheAnalysis, getCached, listCached } from './cache.js';
import { emptyLens, withRun, setActive } from './lens-runs.js';
import { diffAnalyses } from './diff-analysis.js';
import { buildFitsStackPrompt, parseFitsStack } from './fits-stack.js';
import { buildStackPrompt, parseStack } from './stack-prompt.js';
import { buildAskRepoPrompt, parseAskRepoAnswer } from './ask-repo.js';
import { buildComparePrompt, parseCompareResult } from './compare-repos.js';
import { startScanAnim, stopScanAnim } from './icon-anim.js';
// Notify when a scan completes — clicking the notification focuses the result tab.
chrome.notifications.onClicked.addListener(async (notifId) => {
chrome.notifications.clear(notifId);
if (notifId.startsWith('rl_scan_')) {
const tabUrl = notifId.slice('rl_scan_'.length);
const [existing] = await chrome.tabs.query({ url: tabUrl });
if (existing) {
await chrome.tabs.update(existing.id, { active: true });
await chrome.windows.update(existing.windowId, { focused: true });
} else {
chrome.tabs.create({ url: tabUrl });
}
} else if (notifId.startsWith('rl_batch_')) {
const libUrl = chrome.runtime.getURL('library.html');
const [existing] = await chrome.tabs.query({ url: libUrl });
if (existing) {
await chrome.tabs.update(existing.id, { active: true });
await chrome.windows.update(existing.windowId, { focused: true });
} else {
chrome.tabs.create({ url: libUrl });
}
}
});
// Best-effort semantic-graph write: upsert both endpoint nodes, then a deterministic
// (idempotent) edge. Graph write errors are swallowed — building the graph
// must never block or fail a scan. Mirrors the existing "save error = skip" policy.
async function linkRepos({ source, sourcePayload, targetKey, targetPayload, label, properties }) {
try {
const src = nodeIdFor(source);
const tgt = nodeIdFor(targetKey);
await upsertNode(src, sourcePayload);
await upsertNode(tgt, targetPayload);
await addEdge({ id: edgeIdFor(src, label, tgt), source: src, target: tgt, label, properties: properties || {} });
} catch { /* best-effort: additive graph, write error = skip */ }
}
// Pin a generated combo as a first-class IDEA node + COMBINES edges (best-effort, non-fatal).
async function pinIdea({ title, pitch, sources = [], novelty = 0, feasibility = 0, createdIso = '' }) {
try {
const ideaId = ideaIdFor(sources);
await upsertNode(ideaId, {
kind: 'idea', title: title || '', pitch: pitch || '', sources,
novelty: Number(novelty) || 0, feasibility: Number(feasibility) || 0, analyzed: false, created: createdIso || '',
});
for (const src of sources) {
const srcId = nodeIdFor(src);
await addEdge({ id: edgeIdFor(srcId, 'COMBINES', ideaId), source: srcId, target: ideaId, label: 'COMBINES', properties: { title: title || '' } });
}
} catch { /* best-effort: ontology is additive, write error = skip */ }
}
// Build the analyzed-repo node payload from a parsed scan (shared by every write site).
function repoNodePayload(repoId, data = {}, analyzed = true) {
return {
repoId,
name: repoId.split('/').pop() || repoId,
platform: data.platform || '',
language: data.language || '',
category: data.category || '',
analyzed,
};
}
const SESSION_KEY_PREFIX = 'repolens_';
// First run: open Settings so the user can connect a provider and see Getting Started.
chrome.runtime.onInstalled.addListener(({ reason }) => {
if (reason === 'install') chrome.runtime.openOptionsPage();
// Register "Scan with RepoLens" on link right-clicks — recreated on every install/update.
chrome.contextMenus.removeAll(() => {
chrome.contextMenus.create({
id: 'repolens-scan-link',
title: 'Scan with RepoLens',
contexts: ['link'],
});
});
// Drift check alarm — fires once a day to count stale repos.
chrome.alarms.create('repolens-drift', { delayInMinutes: 1, periodInMinutes: 60 * 24 });
});
// Count repos not scanned in 14+ days and write a summary for the library banner.
chrome.alarms.onAlarm.addListener(async (alarm) => {
if (alarm.name !== 'repolens-drift') return;
try {
const points = await scrollPoints({ limit: 2000 });
const STALE_MS = 14 * 24 * 60 * 60 * 1000;
const staleCount = points.filter(p => p.payload?.saved_at && (Date.now() - Date.parse(p.payload.saved_at)) > STALE_MS).length;
await chrome.storage.local.set({ repolens_drift: { staleCount, checkedAt: new Date().toISOString() } });
} catch { /* offline or IDB unavailable */ }
});
// Scan a link right-clicked anywhere — detect platform from the href, open output tab.
chrome.contextMenus.onClicked.addListener(async (info) => {
if (info.menuItemId !== 'repolens-scan-link') return;
const url = info.linkUrl || '';
const detected = detectPlatform(url);
if (!detected) {
const sessionKey = SESSION_KEY_PREFIX + crypto.randomUUID();
await chrome.storage.session.set({ [sessionKey]: { loading: false, error: `Not a supported repo URL: ${url}` } });
chrome.tabs.create({ url: chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`) });
return;
}
const gateKeys = await chrome.storage.local.get(['anthropicKey', 'googleKey', 'openrouterKey', 'xaiKey', 'nousKey', ...compatStorageKeys()]);
const hasKey = gateKeys.anthropicKey || gateKeys.googleKey || gateKeys.openrouterKey || gateKeys.xaiKey || gateKeys.nousKey || compatStorageKeys().some(k => gateKeys[k]);
const sessionKey = SESSION_KEY_PREFIX + crypto.randomUUID();
if (!hasKey) {
await chrome.storage.session.set({ [sessionKey]: { loading: false, error: 'No AI provider configured — open Settings to add a key.', errorKind: 'none' } });
chrome.tabs.create({ url: chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`) });
return;
}
await chrome.storage.session.set({ [sessionKey]: { loading: true, status: 'fetching', ...detected } });
chrome.tabs.create({ url: chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`) });
runAnalysis(sessionKey, detected);
});
// ─── Listen for content script + output-tab signals ──────────────────────────
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'REPO_PAGE' && sender.tab?.id) {
chrome.action.setIcon({
tabId: sender.tab.id,
path: { 16: 'icons/icon16.png', 48: 'icons/icon48.png', 128: 'icons/icon128.png' }
});
return;
}
// Retry from the output tab — re-run analysis for the same repo into the same session key.
if (msg.type === 'RERUN' && msg.sessionKey && msg.platform && msg.repoId) {
const detected = { platform: msg.platform, repoId: msg.repoId };
chrome.storage.session
.set({ [msg.sessionKey]: { loading: true, status: 'fetching', ...detected } })
.then(() => {
sendResponse({ ok: true });
runAnalysis(msg.sessionKey, detected, sender.tab?.id); // fire and forget; tab polls the session
})
.catch((err) => sendResponse({ ok: false, error: err?.message || 'Could not start the scan' }));
return true; // keep the message channel open for the async sendResponse
}
// Deep Dive from the output tab — multi-stage source analysis into the same key.
if (msg.type === 'DEEP_DIVE' && msg.sessionKey && msg.platform && msg.repoId) {
sendResponse({ ok: true });
runDeepDive(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId });
return true;
}
// Framework lenses from the output tab — accept one or many frameworks; run sequentially.
if (msg.type === 'SYSTEMS' && msg.sessionKey && msg.platform && msg.repoId && Array.isArray(msg.frameworks)) {
const fws = msg.frameworks.filter(isFramework);
if (fws.length) { sendResponse({ ok: true }); runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, SYSTEMS_LENS); return true; }
}
if (msg.type === 'IDEATE' && msg.sessionKey && msg.platform && msg.repoId && Array.isArray(msg.frameworks)) {
const fws = msg.frameworks.filter(isIdeateFramework);
if (fws.length) { sendResponse({ ok: true }); runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, IDEATE_LENS); return true; }
}
if (msg.type === 'PRIORITIZE' && msg.sessionKey && msg.platform && msg.repoId && Array.isArray(msg.frameworks)) {
const fws = msg.frameworks.filter(isHeuristicFramework);
if (fws.length) { sendResponse({ ok: true }); runFrameworkLens(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, fws, PRIORITIZE_LENS); return true; }
}
// SKTPG directional-intelligence skill from the output tab — one-tap, one run.
if (msg.type === 'SKTPG' && msg.sessionKey && msg.platform && msg.repoId) {
sendResponse({ ok: true });
runSktpg(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId });
return true;
}
// Versus from the output tab — head-to-head of the scanned repo vs a competitor.
if (msg.type === 'VERSUS' && msg.sessionKey && msg.platform && msg.repoId && msg.competitor) {
sendResponse({ ok: true });
runVersus(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, msg.competitor);
return true;
}
// Docs Quality from the output tab — on-demand README + file-tree scan.
if (msg.type === 'DOCS_QUALITY' && msg.sessionKey && msg.platform && msg.repoId) {
sendResponse({ ok: true });
runDocsQuality(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId });
return true;
}
// Synergies from the output tab — complementary repos grounded in the library.
if (msg.type === 'SYNERGIES' && msg.sessionKey && msg.platform && msg.repoId) {
sendResponse({ ok: true });
runSynergies(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId });
return true;
}
if (msg.type === 'COMBINATOR' && msg.sessionKey && msg.platform && msg.repoId) {
sendResponse({ ok: true });
runCombinator(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId }, { mode: msg.mode || 'repo', wildness: Number(msg.wildness) || 0 });
return true;
}
if (msg.type === 'PIN_IDEA' && msg.sessionKey && msg.idea && Array.isArray(msg.idea.sources)) {
sendResponse({ ok: true });
(async () => {
const cur = (await chrome.storage.session.get(msg.sessionKey))[msg.sessionKey] || {};
await pinIdea({ ...msg.idea, createdIso: new Date().toISOString() });
})();
return true;
}
if (msg.type === 'TAG_LIBRARY' && msg.sessionKey) {
sendResponse({ ok: true });
runTagLibrary(msg.sessionKey);
return true;
}
// Maintenance & Abandonment lens — commit recency, bus factor, CI, open issues.
if (msg.type === 'MAINTENANCE' && msg.sessionKey && msg.platform && msg.repoId) {
sendResponse({ ok: true });
runMaintenance(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId });
return true;
}
// Fits MY Stack? — library-grounded "does this slot in, conflict, or shift the paradigm?"
if (msg.type === 'FITS_STACK' && msg.sessionKey && msg.platform && msg.repoId) {
sendResponse({ ok: true });
runFitsStack(msg.sessionKey, { platform: msg.platform, repoId: msg.repoId });
return true;
}
// Batch Scan — queue multiple repo URLs for sequential analysis.
if (msg.type === 'BATCH_SCAN' && msg.sessionKey && Array.isArray(msg.urls) && msg.urls.length) {
sendResponse({ ok: true });
runBatchScan(msg.sessionKey, msg.urls);
return true;
}
// Tech-Stack Builder — multi-repo wiring diagram from the library.
if (msg.type === 'STACK_BUILD' && msg.sessionKey && Array.isArray(msg.repoIds) && msg.repoIds.length >= 2) {
sendResponse({ ok: true });
runStackBuild(msg.sessionKey, msg.repoIds);
return true;
}
// Ask This Repo — grounded Q&A over the current analysis in session storage.
// Stores up to 5 Q&A pairs in askRepo.history; current pending is askRepo.pending.
if (msg.type === 'ASK_REPO' && msg.sessionKey && msg.question) {
sendResponse({ ok: true });
(async () => {
const getSession = async () => (await chrome.storage.session.get(msg.sessionKey))[msg.sessionKey] || {};
const setAsk = async (patch) => {
const cur = await getSession();
await chrome.storage.session.set({ [msg.sessionKey]: { ...cur, askRepo: { ...(cur.askRepo || {}), ...patch } } });
};
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const cur = await getSession();
// Seed session history from local storage if this is the first question in the session
let sessionHistory = cur.askRepo?.history || [];
if (!sessionHistory.length && cur.repoId) {
try {
const persisted = await chrome.storage.local.get(`repolens_ask_${cur.repoId}`);
sessionHistory = persisted[`repolens_ask_${cur.repoId}`] || [];
} catch (_) {}
}
const history = sessionHistory.slice(-4); // keep last 4 completed pairs for AI context
await setAsk({ pending: { status: 'thinking', question: msg.question }, history });
const prompt = buildAskRepoPrompt(msg.question, cur, history);
if (!prompt) { await setAsk({ pending: { status: 'error', question: msg.question, error: 'Not enough context — try re-scanning first.' }, history }); return; }
const text = await callAI(keys, prompt, 'ask');
const answer = parseAskRepoAnswer(text);
const updated = [...history, { question: msg.question, answer }].slice(-5);
await setAsk({ pending: null, history: updated });
if (cur.repoId) {
chrome.storage.local.set({ [`repolens_ask_${cur.repoId}`]: updated.slice(-10) });
}
} catch (e) {
const cur = await getSession();
const history = cur.askRepo?.history || [];
await setAsk({ pending: { status: 'error', question: msg.question, error: e?.message || 'Ask failed' }, history });
}
})();
return true;
}
// Quick Ask — grounded Q&A for a single cached repo (no session key needed).
if (msg.type === 'ASK_CACHED' && msg.question && msg.analysis?.repoId) {
(async () => {
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const prompt = buildAskRepoPrompt(msg.question, msg.analysis);
if (!prompt) { sendResponse({ ok: false, error: 'Not enough context.' }); return; }
const text = await callAI(keys, prompt, 'ask');
sendResponse({ ok: true, answer: parseAskRepoAnswer(text) });
} catch (e) {
sendResponse({ ok: false, error: e?.message || 'Ask failed' });
}
})();
return true;
}
// AI-powered head-to-head comparison of two cached repos.
if (msg.type === 'COMPARE_REPOS' && msg.a?.repoId && msg.b?.repoId) {
(async () => {
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const prompt = buildComparePrompt(msg.a, msg.b);
if (!prompt) { sendResponse({ ok: false, error: 'Not enough context to compare.' }); return; }
const text = await callAI(keys, prompt, 'ask');
const result = parseCompareResult(text);
if (!result) { sendResponse({ ok: false, error: 'Could not parse comparison result.' }); return; }
sendResponse({ ok: true, result });
} catch (e) {
sendResponse({ ok: false, error: e?.message || 'Comparison failed' });
}
})();
return true;
}
// Natural-language library filter — ranks matching repo IDs for a query.
if (msg.type === 'FILTER_LIBRARY' && msg.question && Array.isArray(msg.docs)) {
(async () => {
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const prompt = buildFilterPrompt(msg.question, msg.docs);
if (!prompt) { sendResponse({ ok: false, error: 'No question or context provided.' }); return; }
const text = await callAI(keys, prompt, 'ask');
const ids = parseFilterResult(text);
sendResponse({ ok: true, ids });
} catch (e) {
sendResponse({ ok: false, error: e?.message || String(e) });
}
})();
return true;
}
// Ask Across My Library — grounded Q&A over the user's saved analyses.
if (msg.type === 'ASK_LIBRARY' && msg.question && Array.isArray(msg.docs)) {
(async () => {
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const prompt = buildAskPrompt(msg.question, msg.docs);
if (!prompt) { sendResponse({ ok: false, error: 'No question or context provided.' }); return; }
const text = await callAI(keys, prompt, 'ask');
sendResponse({ ok: true, answer: parseAskAnswer(text) });
} catch (e) {
sendResponse({ ok: false, error: e?.message || String(e) });
}
})();
return true;
}
// Provider self-test from Settings — connection + function check for a registry provider.
if (msg.type === 'TEST_PROVIDER' && msg.provider) {
(async () => {
const keys = await chrome.storage.local.get([...compatStorageKeys(), OPENAI_CREDENTIALS_KEY]); // registry settings + ChatGPT-login record
try {
sendResponse(await testProvider(msg.provider, keys));
} catch (e) {
sendResponse({ ok: false, connection: false, function: false, detail: e?.message || String(e) });
}
})();
return true; // async sendResponse
}
});
// Turn a competitor input (URL or "owner/name") into { platform, repoId }.
function resolveCompetitor(input) {
const s = (input || '').trim();
const detected = detectPlatform(s); // handles full GitHub/GitLab/npm/PyPI URLs
if (detected) return detected;
const repoId = s.replace(/^https?:\/\/(www\.)?github\.com\//i, '').replace(/\.git$/, '').replace(/^\/+|\/+$/g, '');
return { platform: 'github', repoId };
}
// One redirect can fire BOTH a navigation event and tabs.onUpdated, and the auth
// code is single-use — this de-dups so only the first handler runs the exchange.
const _handledOAuthCodes = new Set();
// ─── OpenAI OAuth callback handling ("Sign in with ChatGPT", Codex CLI flow) ───
//
// The redirect lands on http://localhost:1455/auth/callback — the loopback server
// the CLI runs doesn't exist in a browser, so the navigation can't complete. We
// intercept it (onBeforeNavigate fires first, with the ?code=), exchange the code,
// then mint an API key so inference uses the standard OpenAI engine.
async function handleOpenAIOAuthCallback(rawUrl, tabId) {
if (!rawUrl || !isOpenAIOAuthCallbackUrl(rawUrl)) return;
let url;
try {
url = new URL(rawUrl);
} catch {
return;
}
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
const errorDesc = url.searchParams.get('error_description');
const cleanupFlowMarkers = async () => {
await chrome.storage.local.remove([
OPENAI_OAUTH_VERIFIER_KEY,
OPENAI_OAUTH_STATE_KEY,
]).catch(() => {});
};
if (error) {
const msg = errorDesc || error;
console.warn('[RepoLens OAuth] OpenAI provider returned error:', msg);
await chrome.storage.local.set({ [OPENAI_OAUTH_ERROR_KEY]: `ChatGPT sign-in error: ${msg}` });
await cleanupFlowMarkers();
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
return;
}
if (!code) return; // an early loopback hit without the auth code — ignore
if (_handledOAuthCodes.has(code)) return; // the other listener got here first
_handledOAuthCodes.add(code);
const stored = await chrome.storage.local.get([OPENAI_OAUTH_VERIFIER_KEY, OPENAI_OAUTH_STATE_KEY]);
const verifier = stored[OPENAI_OAUTH_VERIFIER_KEY];
const storedState = stored[OPENAI_OAUTH_STATE_KEY];
if (!verifier) {
console.warn('[RepoLens OAuth] No stored OpenAI verifier — flow interrupted or for another extension');
await cleanupFlowMarkers(); // clear any stale state marker from an interrupted flow
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
return;
}
try {
const creds = await exchangeOpenAICode({ code, state, verifier, storedState });
// Mint a usable API key so scans run through the ordinary OpenAI engine.
const apiKey = await mintOpenAIApiKey(creds.id_token);
await chrome.storage.local.set({ openaiKey: apiKey });
await cleanupFlowMarkers();
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
} catch (err) {
console.error('[RepoLens OAuth] OpenAI exchange error:', err.message);
// No usable key ⇒ don't leave half-finished OAuth state that reads as "connected".
await clearOpenAICredentials().catch(() => {});
await chrome.storage.local.set({ [OPENAI_OAUTH_ERROR_KEY]: err.message });
await cleanupFlowMarkers();
if (tabId) chrome.tabs.remove(tabId).catch(() => {});
}
}
// The OpenAI loopback redirect can't load (no local server), so onCompleted never
// fires for it — onBeforeNavigate runs first and still carries the ?code=.
chrome.webNavigation.onBeforeNavigate.addListener((details) => {
if (details.frameId !== 0) return;
handleOpenAIOAuthCallback(details.url, details.tabId);
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.url) {
handleOpenAIOAuthCallback(changeInfo.url, tabId);
}
});
// Main click handler
chrome.action.onClicked.addListener(async (tab) => {
const detected = detectPlatform(tab.url);
if (!detected) {
const sessionKey = SESSION_KEY_PREFIX + crypto.randomUUID();
await chrome.storage.session.set({ [sessionKey]: { loading: false, error: 'Not a supported page. Navigate to a GitHub, GitLab, npm, or PyPI repo page and click the icon there.' } });
chrome.tabs.create({ url: `output-tab.html?key=${sessionKey}` });
return;
}
const sessionKey = SESSION_KEY_PREFIX + crypto.randomUUID();
// Cache hit → show the saved analysis instantly (no AI call, works offline).
// The output tab offers a "Re-run fresh" affordance.
const cached = await getCached(detected.platform, detected.repoId);
if (cached) {
await chrome.storage.session.set({ [sessionKey]: { ...cached, cached: true, loading: false } });
await chrome.tabs.create({ url: `output-tab.html?key=${sessionKey}` });
return;
}
// Gate: at least one provider must be configured (runAnalysis reads the rest).
const gateKeys = await chrome.storage.local.get(
['anthropicKey', 'googleKey', 'openrouterKey', 'xaiKey', 'xaiRefresh', 'nousKey', ...compatStorageKeys()]
);
const firstClass = gateKeys.anthropicKey || gateKeys.googleKey || gateKeys.openrouterKey ||
gateKeys.xaiKey || gateKeys.xaiRefresh || gateKeys.nousKey;
const anyCompat = COMPAT_PROVIDERS.some((p) => isCompatConnected(p.id, gateKeys));
if (!firstClass && !anyCompat) {
chrome.runtime.openOptionsPage();
return;
}
// Open the output tab immediately with a loading state, then run the analysis.
await chrome.storage.session.set({ [sessionKey]: { loading: true, status: 'fetching', ...detected } });
await chrome.tabs.create({ url: `output-tab.html?key=${sessionKey}` });
runAnalysis(sessionKey, detected, tab.id);
});
// Every provider credential + model-selector key, read together wherever an AI
// call is made. Single source of truth — add a provider here and every scan path
// picks it up.
const PROVIDER_KEYS = [
'anthropicKey', 'anthropicModel', 'googleKey', 'googleModel',
'openrouterKey', 'openrouterModel', 'xaiKey', 'xaiRefresh', 'xaiModel',
'nousKey', 'nousModel',
...compatStorageKeys(), // registry providers' key / model / endpoint / enabled / proto slots
OPENAI_CREDENTIALS_KEY, // ChatGPT-login OAuth record (drives re-mint on 401)
'partRouting', // per-part model routing map (loaded alongside provider keys)
];
// ─── Batch Scan ──────────────────────────────────────────────────────────────
// Processes a list of URLs sequentially, writing progress to batchKey.
async function runBatchScan(batchKey, urls) {
const items = urls.map((url) => {
const parsed = detectPlatform(url);
return parsed
? { url, platform: parsed.platform, repoId: parsed.repoId, status: 'queued', fit: null, error: null }
: { url, platform: null, repoId: null, status: 'error', fit: null, error: 'Unrecognised URL' };
});
const writeBatch = (done = false) =>
chrome.storage.session.set({ [batchKey]: { type: 'batch', total: items.length, items: items.map((i) => ({ ...i })), done } });
await writeBatch(false);
for (let i = 0; i < items.length; i++) {
if (items[i].status === 'error') continue; // skip unrecognised URLs immediately
items[i].status = 'scanning';
await writeBatch(false);
const subKey = SESSION_KEY_PREFIX + crypto.randomUUID();
try {
await chrome.storage.session.set({ [subKey]: { loading: true, status: 'fetching', platform: items[i].platform, repoId: items[i].repoId } });
runAnalysis(subKey, { platform: items[i].platform, repoId: items[i].repoId });
// Poll until the sub-analysis finishes (max 90 s per repo)
const deadline = Date.now() + 90_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 600));
const stored = await chrome.storage.session.get(subKey).catch(() => ({}));
const result = stored[subKey];
if (result && !result.loading) {
items[i].status = result.error ? 'error' : 'done';
items[i].fit = result.fit?.level ?? null;
items[i].error = result.error ?? null;
items[i].repoId = result.repoId || items[i].repoId;
await chrome.storage.session.remove(subKey).catch(() => {});
break;
}
}
if (items[i].status === 'scanning') {
items[i].status = 'error';
items[i].error = 'Timed out';
}
} catch (e) {
items[i].status = 'error';
items[i].error = e?.message || 'Scan failed';
}
await writeBatch(false);
// Polite pause between scans to respect API rate limits
if (i < items.length - 1) await new Promise((r) => setTimeout(r, 1200));
}
await writeBatch(true);
try {
const done = items.filter((i) => i.status === 'done').length;
const errors = items.filter((i) => i.status === 'error').length;
const msg = errors
? `${done} saved, ${errors} failed`
: `${done} repo${done === 1 ? '' : 's'} saved`;
chrome.notifications.create(`rl_batch_${batchKey}`, {
type: 'basic',
iconUrl: chrome.runtime.getURL('icons/icon128.png'),
title: 'RepoLens — Batch scan done',
message: msg,
silent: true,
});
} catch { /* notifications are best-effort */ }
}
// Fetch → AI → parse → store. Used by the initial click and by RERUN (retry).
async function runAnalysis(sessionKey, detected, tabId) {
// Load every provider credential + model + routing in one read; pass the whole
// object to callAI so registry (compat) providers are reachable too — not just
// the five first-class ones. Extra keys (autoSave/tone) are ignored downstream.
const settings = await chrome.storage.local.get([...PROVIDER_KEYS, 'autoSave', 'tone']);
const { autoSave = true, tone } = settings;
try {
startScanAnim(tabId); // fire-and-forget; no-ops without a tabId / when disabled / reduced motion
// Snapshot the previous cached analysis for diff comparison (before it's overwritten).
const prevCached = await getCached(detected.platform, detected.repoId).catch(() => null);
// Fetch metadata + README
await chrome.storage.session.set({ [sessionKey]: { loading: true, status: 'fetching', statusMsg: 'Fetching repo metadata…', ...detected } });
const repoData = await fetchRepoData(detected.platform, detected.repoId);
// Write quick snapshot so the output tab can render something while AI thinks.
const quickData = {
repoId: repoData.repoId,
description: repoData.description,
language: repoData.language,
license: repoData.license,
stars: repoData.stars,
languages: repoData.languages,
};
// Name the provider we'll try first so the loading copy is accurate.
let primaryProvider = '';
try {
const plan = buildAttemptPlan({ routing: settings.partRouting || {}, part: 'core', keys: settings });
if (plan[0]) primaryProvider = providerLabel(plan[0].provider);
} catch { /* leave blank — the tab falls back to a generic phrase */ }
await chrome.storage.session.set({
[sessionKey]: {
loading: true, status: 'thinking',
statusMsg: primaryProvider ? `Asking ${primaryProvider}…` : 'Analysing with AI…',
quickData, ...detected, provider: primaryProvider,
},
});
// Call AI provider — tried in order: Nous > Gemini > OpenRouter > Grok > Anthropic,
// then any connected compatible provider.
const corePrompt = withTone(tone, buildPrompt(repoData));
const text = await callAI(settings, corePrompt, 'core');
const analysis = parseClaudeResponse(text);
const fullData = {
...repoData,
...analysis,
inputTokensEstimate: estimateTokens(corePrompt),
loading: false,
error: null,
autoSave,
saved: autoSave ? 'pending' : 'skipped',
};
// Attach diff against the previous scan (null on first scan — tab renders "Nothing to compare").
const diff = diffAnalyses(prevCached, fullData);
await chrome.storage.session.set({ [sessionKey]: { ...fullData, diff } });
cacheAnalysis(detected.platform, detected.repoId, fullData).catch(() => {}); // history/cache (no diff stored)
if (autoSave) {
let saveErr = null;
try {
await saveAnalysis(fullData);
} catch (err) {
saveErr = err;
}
await chrome.storage.session.set({
[sessionKey]: saveErr
? { ...fullData, diff, saved: false, saveError: saveErr.message || 'Could not save to your library' }
: { ...fullData, diff, saved: true, saveError: null },
});
// Semantic graph: this repo + its named alternatives (only when the save worked).
// Best-effort — never throws.
if (!saveErr) {
const sourcePayload = repoNodePayload(fullData.repoId, fullData, true);
for (const alt of (fullData.alternatives || [])) {
if (!alt?.name) continue;
await linkRepos({
source: fullData.repoId, sourcePayload,
targetKey: alt.name, targetPayload: { name: alt.name, analyzed: false },
label: 'ALTERNATIVE_TO', properties: { name: alt.name, when: alt.when || '' },
});
}
}
}
// Scan-complete notification — fires after the tab is updated so clicking it
// focuses the already-loaded result rather than triggering a fresh poll.
try {
const repoName = fullData.repoId?.split('/').pop() || fullData.repoId || 'Repo';
const fit = deriveFit(fullData);
const fitMsg = { strong: 'Strong fit', solid: 'Solid fit', care: 'Use with care', risky: 'Risky' }[fit.level] || 'Analysis ready';
const tabUrl = chrome.runtime.getURL(`output-tab.html?key=${sessionKey}`);
chrome.notifications.create(`rl_scan_${tabUrl}`, {
type: 'basic',
iconUrl: chrome.runtime.getURL('icons/icon128.png'),
title: `RepoLens — ${repoName}`,
message: fitMsg,
silent: true,
});
} catch { /* notifications are best-effort */ }
stopScanAnim(tabId); // success: reset to the static icon
} catch (err) {
stopScanAnim(tabId); // error: reset to the static icon
// AI failures already carry a humanized message + kind; other failures (fetch,
// parse) get classified here so the tab can still route the error CTA.
const errorKind = err.kind || categorizeError(err).kind;
await chrome.storage.session.set({
[sessionKey]: { ...detected, loading: false, error: err.message, errorKind }
});
}
}
// ─── Deep Dive: multi-stage source analysis (on-demand from the output tab) ───
async function runDeepDive(sessionKey, detected) {
const keys = await chrome.storage.local.get(
[...PROVIDER_KEYS, 'tone', 'runnerUrl']
);
// Merge a patch into the session entry's deepDive object without clobbering analysis.
const setDeep = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
await chrome.storage.session.set({
[sessionKey]: { ...cur, deepDive: { ...(cur.deepDive || {}), ...patch } },
});
};
try {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const repoData = {
repoId: detected.repoId,
platform: detected.platform,
description: cur.description || '',
language: cur.language || '',
};
await setDeep({ status: 'fetching', error: null });
const source = await fetchSource(detected.platform, detected.repoId);
// Deeper scan: measured facts from the runner when reachable (best-effort → null).
const facts = await scanRepo(keys.runnerUrl, detected.platform, detected.repoId);
await setDeep({ status: 'atoms', degraded: !!source.degraded, facts });
const { atoms } = parseAtoms(await callAI(keys, withTone(keys.tone, buildAtomsPrompt(repoData, source, facts)), 'deepdive'));
await setDeep({ atoms });
await setDeep({ status: 'lineage' });
const lineage = parseLineage(await callAI(keys, withTone(keys.tone, buildLineagePrompt(atoms)), 'deepdive'));
await setDeep({ lineage });
await setDeep({ status: 'feynman' });
const feynman = parseFeynman(await callAI(keys, withTone(keys.tone, buildFeynmanPrompt(repoData, atoms, lineage)), 'deepdive'));
await setDeep({ feynman });
await setDeep({ status: 'done' });
} catch (err) {
await setDeep({ status: 'error', error: err.message || 'Deep Dive failed' });
}
}
// Generic framework-lens runner: run each requested framework sequentially, writing
// per-framework state under `slot` via the lens-runs reducer. Source is fetched once
// and reused across frameworks. Each AI call still flows through the throttled callAI,
// so "Run all" can't burst a provider; one framework's error doesn't sink the batch.
const SYSTEMS_LENS = { slot: 'systems', build: buildSystemsPrompt, parse: parseSystems, label: 'Systems analysis' };
const IDEATE_LENS = { slot: 'ideate', build: buildIdeatePrompt, parse: parseIdeate, label: 'Ideation' };
const PRIORITIZE_LENS = { slot: 'prioritize', build: buildHeuristicsPrompt, parse: parseHeuristics, label: 'Prioritization' };
async function runFrameworkLens(sessionKey, detected, frameworks, cfg) {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const cur0 = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const repoData = {
repoId: detected.repoId, platform: detected.platform,
description: cur0.description || '', language: cur0.language || '',
};
const setRun = async (fw, patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const lens = withRun(cur[cfg.slot] || emptyLens(), fw, patch);
await chrome.storage.session.set({ [sessionKey]: { ...cur, [cfg.slot]: setActive(lens, fw) } });
};
let source = null;
for (const fw of frameworks) {
try {
await setRun(fw, { status: 'fetching', error: null, result: null });
if (!source) source = await fetchSource(detected.platform, detected.repoId);
await setRun(fw, { status: 'running' });
const result = cfg.parse(fw, await callAI(keys, withTone(keys.tone, cfg.build(fw, repoData, source)), 'lens'));
await setRun(fw, { status: 'done', result });
} catch (err) {
await setRun(fw, { status: 'error', error: err.message || `${cfg.label} failed` });
}
}
}
// ─── SKTPG: one-tap directional-intelligence skill (on-demand) ────────────────
async function runSktpg(sessionKey, detected) {
const keys = await chrome.storage.local.get(
[...PROVIDER_KEYS, 'tone']
);
const setSk = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
await chrome.storage.session.set({
[sessionKey]: { ...cur, sktpg: { ...(cur.sktpg || {}), ...patch } },
});
};
try {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const repoData = {
repoId: detected.repoId,
platform: detected.platform,
description: cur.description || '',
language: cur.language || '',
};
await setSk({ status: 'fetching', error: null, result: null });
const source = await fetchSource(detected.platform, detected.repoId);
await setSk({ status: 'running' });
const result = parseSktpg(await callAI(keys, withTone(keys.tone, buildSktpgPrompt(repoData, source)), 'sktpg'));
await setSk({ status: 'done', result });
} catch (err) {
await setSk({ status: 'error', error: err.message || 'SKTPG failed' });
}
}
// ─── Docs Quality: README + file-tree documentation score (on-demand) ────────
async function runDocsQuality(sessionKey, detected) {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const setDq = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
await chrome.storage.session.set({
[sessionKey]: { ...cur, docsQuality: { ...(cur.docsQuality || {}), ...patch } },
});
};
try {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const repoData = {
repoId: detected.repoId,
platform: detected.platform,
description: cur.description || '',
language: cur.language || '',
readme: cur.readme || '',
};
await setDq({ status: 'fetching', error: null, result: null });
const source = await fetchSource(detected.platform, detected.repoId);
await setDq({ status: 'running' });
const result = parseDocsQuality(
await callAI(keys, withTone(keys.tone, buildDocsQualityPrompt(repoData, source)), 'docs')
);
await setDq({ status: 'done', result });
} catch (err) {
await setDq({ status: 'error', error: err.message || 'Docs Quality scan failed' });
}
}
// ─── Maintenance & Abandonment: commit recency + bus factor + CI (on-demand) ──
async function runMaintenance(sessionKey, detected) {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
const setM = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
await chrome.storage.session.set({
[sessionKey]: { ...cur, maintenance: { ...(cur.maintenance || {}), ...patch } },
});
};
try {
await setM({ status: 'fetching', error: null, result: null });
const [signals, source] = await Promise.all([
fetchMaintenanceSignals(detected.platform, detected.repoId).catch(() => null),
fetchSource(detected.platform, detected.repoId).catch(() => ({ tree: [], files: [], degraded: true })),
]);
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const repoData = {
repoId: detected.repoId,
description: cur.description || '',
stars: cur.stars || 0,
language: cur.language || '',
license: cur.license || '',
};
await setM({ status: 'running' });
const result = parseMaintenance(
await callAI(keys, withTone(keys.tone, buildMaintenancePrompt(repoData, signals, source.tree)), 'maintenance'),
signals
);
await setM({ status: 'done', result });
} catch (err) {
await setM({ status: 'error', error: err.message || 'Maintenance scan failed.' });
}
}
// ─── Fits MY Stack?: library-grounded fit analysis ────────────────────────────
async function runFitsStack(sessionKey, detected) {
const setF = async (patch) => {
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
await chrome.storage.session.set({ [sessionKey]: { ...cur, fitsStack: { ...(cur.fitsStack || {}), ...patch } } });
};
try {
const keys = await chrome.storage.local.get([...PROVIDER_KEYS, 'tone']);
await setF({ status: 'fetching', error: null });
const cur = (await chrome.storage.session.get(sessionKey))[sessionKey] || {};
const repoData = {
repoId: detected.repoId,
description: cur.description || '',
language: cur.language || '',
category: cur.category || '',
capabilities: cur.capabilities || [],
};
const nearestRepos = await searchLibrary({
query: [repoData.language, repoData.category, ...(repoData.capabilities || [])].filter(Boolean).join(' '),
topK: 8,
excludeRepoId: detected.repoId,
});
if (!nearestRepos.length) {
await setF({ status: 'done', result: {
verdict: 'new-paradigm',
summary: 'Your library is empty — scan a few repos first to get a personalised stack fit.',
integrations: [], risks: [],
recommendation: 'Scan more repos, then re-run Fits MY Stack?',
}});
return;
}
await setF({ status: 'running' });
const prompt = buildFitsStackPrompt(repoData, nearestRepos);
const text = await callAI(keys, withTone(keys.tone, prompt), 'fits');
const result = parseFitsStack(text);
if (!result) throw new Error('Could not parse stack fit response.');
await setF({ status: 'done', result });
} catch (err) {
await setF({ status: 'error', error: err.message || 'Stack fit analysis failed.' });
}
}
// ─── Tech-Stack Builder: multi-repo wiring diagram ────────────────────────────