Repository navigation
Expand file tree
/
Copy pathextension.js
More file actions
4063 lines (3837 loc) · 227 KB
/
Copy pathextension.js
File metadata and controls
4063 lines (3837 loc) · 227 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
/*---------------------------------------------------------------------------------------------
* LevelCode — AI (M2, feature 1: Chat with Claude)
*
* A native chat side panel. Requests go directly from the editor to the provider
* (Anthropic or local Ollama) — there is no LevelCode server in the middle. The Anthropic
* API key is stored in VS Code SecretStorage (encrypted by the OS keychain).
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';
const vscode = require('vscode');
const path = require('path');
const fs = require('fs');
const os = require('os');
const cp = require('child_process');
const crypto = require('crypto');
const host = require('./host'); // workspace I/O + what this machine can do (browser vs desktop)
const providers = require('./providers/index');
const catalog = require('./providers/catalog');
const { resolveGateway } = require('./providers/gateway');
const session = require('./providers/session');
const { createAuthRetry } = require('./providers/authRetry');
const { registerAiEdit } = require('./aiEdit');
const { registerLmProvider } = require('./lmProvider');
const { registerInlineComplete } = require('./inlineComplete');
const { runAgent, resetContextAnnounce } = require('./agent');
const { findCompactionCut, estimateMsgTokens } = require('./agentMemory');
const imageStore = require('./imageStore');
const { supportsVisionForModel } = require('./providers/catalog');
const sessionStore = require('./sessionStore');
const sessionEvents = require('./sessionEvents');
const sessionMemory = require('./sessionMemory');
const { createSessions } = require('./sessions');
const { registerReview } = require('./reviewSession');
const { formatDiagnosticLines, diagKey, createPreviewGate } = require('./verify');
const { loadSkills, skillsMenu, getSkillBody } = require('./skills');
const { openCustomize } = require('./customize');
const { importFromVscode } = require('./importVscode');
const { reapMcp, listActive, getServer } = require('./mcpClient');
const { userScopedSetting, isNamespacedToolName, safeCopy, loadServerConfig, summarizeMcp, parseArgv, UNSAFE_KEYS } = require('./mcpConfig');
const { createDiagrams } = require('./diagram/service');
const diagramBundle = require('./diagram/bundle');
const diagramLinks = require('./diagram/links');
const diagramExport = require('./diagram/exportCheck');
const diagramText = require('./diagram/text');
const diagramStats = require('./diagram/stats');
const diagramTool = require('./diagram/tool');
const diagramAscii = require('./diagram/ascii');
const SECRET_KEY = 'levelcode.ai.anthropicKey'; // legacy Anthropic key location (kept for back-compat)
const FILE_EXCLUDES = '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/.vscode-test/**,**/*.map}';
const STOPWORDS = new Set(['the','and','for','with','that','this','how','does','what','where','when','why','from','into','your','you','are','was','were','will','can','could','should','would','about','have','has','its','it','the','file','code','function','please','show','tell','explain','using','use','used','there','their','then','than','they','them','some','any','all','not','but','get','set']);
const SYSTEM_PROMPT =
'You are LevelCode\'s built-in AI assistant, helping the user write and understand code inside their editor. ' +
'Be concise and practical. Use Markdown and fenced code blocks. When given a code selection as context, focus your answer on it.';
/** @type {vscode.ExtensionContext} */
let ctx;
/**
* THE chat surface — whichever webview is currently hosting the conversation. `post()` writes here.
*
* The chat can live in two places: the sidebar view it is contributed as, or an editor tab
* (openChatInEditor). Only ONE is ever live — a "move", not a mirror. Two live surfaces would mean
* fanning out every post() and making every handler idempotent, for a UI that can then disagree with
* itself; moving keeps one source of truth and is what "open in editor" means to a user anyway.
* @type {vscode.Webview | undefined}
*/
let activeWebview;
/** @type {vscode.WebviewPanel | undefined} */
let chatEditorPanel; // set only while the chat is open as an editor tab
let chatProvider; // the single provider instance; both surfaces wire through it
// The visible transcript lives in the webview's DOM, so swapping surfaces would blank it. Set before
// handing over; the freshly-loaded surface replays on its `ready`, which is the first moment it can
// receive anything at all.
let pendingTranscriptReplay = '';
let sessionsWebview; // the Sessions sidebar webview (for pushing list refreshes after a History action)
// Which tab a pending reveal wants. The container is hidden by default, so the FIRST reveal usually
// resolves the view from scratch and there is no webview to post to yet — the request has to wait for
// the view to announce itself. Flushed on `listSessions`, the earliest thing a loaded view sends.
let pendingSessionsTab = '';
/** @type {{role:string,content:string}[]} */
let conversation = [];
// Bumped by every teardown. A turn captures it when it starts and checks it before writing anything
// back, so work still unwinding after the conversation was torn down cannot repopulate the state the
// teardown just cleared — nor clobber the turn that replaced it.
let conversationEpoch = 0;
/** @type {string | null} */
let pendingContext = null;
/** Files pinned as chat context (whole codebase-wide context). @type {{id:string,uri:vscode.Uri,name:string,rel:string}[]} */
let contextFiles = [];
/** @type {AbortController | null} */
let abort = null;
/** Cached "signed in to LevelCode Cloud" flag — the gateway only routes when signed in, so the footer/
* model chip must gate on this too (kept in sync by currentAccount / storeSession / accountSignOut). */
let cloudSignedIn = false;
/** Agent mode: sending runs the autonomous tool loop instead of plain chat. */
let agentMode = true;
/** Command approvals, one of three modes (agentReview.js):
* 'ask' Default Approvals: every command asks.
* 'review' Ask When Risky: a reviewer model passes routine commands; the rest ask.
* 'autopilot' Autopilot: commands run without asking, and the agent prefers self-verifying over pausing.
* In 'review' and 'autopilot' the danger set (commandSafety.js) still asks. Initialized from config on
* 'ready', switched live from the chat's approvals menu, and persisted so a choice survives a restart. */
let approvalMode = 'ask';
const APPROVAL_MODES = ['ask', 'review', 'autopilot'];
/** What the person typed as goals in THIS conversation, newest last — the only text the Ask When Risky
* reviewer treats as their word (with their ask_user choices, below). Kept by the host because only the
* host knows which user messages a person wrote: the transcript also holds the loop's own nudges. */
let humanGoals = [];
/** The person's choices in ask_user this conversation (agent.js appends), for the same reviewer. */
let humanAnswers = [];
/** Apply-then-review session (Keep/Undo for applied agent edits). Set in activate(). */
let review;
/** Persistent agent transcript for the session (tool calls + results), so follow-up goals
* remember prior runs. Reset by New Chat. */
let agentMessages = [];
/** The agent's task list for this conversation (docs/PLAN-PROGRESS.md). agent.js keeps `steps` current through
* update_plan and wrap_up, and every run of the conversation gets the same object, so an {"advance": true}
* moves the list an earlier run left. A new conversation, and a resumed one, start with none. */
let agentPlan = { steps: null };
/** Messages the user pressed "Send now" on while the agent works. The running loop takes them at its next
* step (ctx.takeSteers); what it never reaches it hands back to the chat's queue when the run ends. */
let steerInbox = [];
/** The agent run going now, as a token only its own agentFlow holds: a late finally from a run that was torn
* down cannot mark a newer run as over. */
let activeAgentRun = null;
function post(msg) { if (activeWebview) { activeWebview.postMessage(msg); } }
function aiConfig() { return vscode.workspace.getConfiguration('levelcode.ai'); }
/** The approvals mode from settings. `agent.approvals` wins when the user has set it; before it existed
* the only setting was the `agent.autopilot` boolean, so an older opt-in still means Autopilot.
* Only the USER's value is read: a folder's .vscode/settings.json is written by whoever wrote the
* repository, and must not be able to loosen approvals (the setting is application-scoped too). */
function readApprovalMode() {
const cfg = aiConfig();
const i = cfg.inspect('agent.approvals');
const set = i && i.globalValue;
if (APPROVAL_MODES.includes(/** @type {any} */ (set))) { return /** @type {string} */ (set); }
return cfg.get('agent.autopilot', false) ? 'autopilot' : 'ask';
}
/** Switch modes from the chat menu: live for the running goal (agent.js reads it per command), saved,
* and echoed so the menu can never disagree with the host. `agent.autopilot` is kept equal to
* "mode is Autopilot", so nothing that still reads the old boolean sees a stale answer. */
function setApprovalMode(mode) {
if (!APPROVAL_MODES.includes(mode)) { return; }
approvalMode = mode;
const cfg = aiConfig();
cfg.update('agent.approvals', mode, vscode.ConfigurationTarget.Global);
if (cfg.get('agent.autopilot', false) !== (mode === 'autopilot')) { cfg.update('agent.autopilot', mode === 'autopilot', vscode.ConfigurationTarget.Global); }
dbg('approvals.set', { mode });
post({ type: 'approvals', mode });
}
/** Inline debug trace — prints a 🐛 DEBUG line in the chat (gated by levelcode.ai.debug). */
function dbg(label, data) { if (aiConfig().get('debug', false)) { post({ type: 'debug', label, data: data != null ? data : null }); } }
/** The currently selected provider id (settings value; `claude` is the default/legacy Anthropic). */
function currentProviderId() { return providers.normId(aiConfig().get('provider', 'claude')); }
/** SecretStorage key for a provider (Anthropic keeps its legacy location; others namespaced; noKey → null). */
function secretKeyFor(providerId) { return providers.secretStorageKey(providerId); }
/** The active model id for a provider — per-provider settings for the two legacy ones, generic `model` otherwise. */
function activeModel(cfg, providerId) {
const id = providers.normId(providerId);
if (id === 'claude') { return cfg.get('claude.model', 'claude-sonnet-4-6'); }
if (id === 'ollama') { return cfg.get('ollama.model', 'llama3.1'); }
const m = cfg.get('model', '');
if (m) { return m; }
const p = providers.getProvider(id);
return (p && p.models && p.models[0]) ? p.models[0].id : '';
}
/** The base URL for a provider — Ollama honors `ollama.url`, `custom` uses `levelcode.ai.baseURL`, else the registry default. */
function baseUrlFor(cfg, providerId) {
const id = providers.normId(providerId);
if (id === 'ollama') { return String(cfg.get('ollama.url', 'http://localhost:11434')).replace(/\/+$/, '') + '/v1'; }
if (id === 'custom') { return String(cfg.get('baseURL', '') || '').replace(/\/+$/, ''); }
const p = providers.getProvider(id);
return p ? p.baseURL : null;
}
/** Max output tokens for chat/edit (shared across providers; keeps the existing Claude setting name). */
function maxOutputTokens(cfg) { return cfg.get('claude.maxTokens', 4096); }
/** An explicitly-set `levelcode.ai.contextWindow` (user override, e.g. the Claude 1M-token beta), or undefined. */
function explicitContextWindow() {
const insp = aiConfig().inspect('contextWindow');
if (!insp) { return undefined; }
return insp.globalValue != null ? insp.globalValue
: insp.workspaceValue != null ? insp.workspaceValue
: insp.workspaceFolderValue != null ? insp.workspaceFolderValue
: undefined;
}
/** Effective context window (tokens): an explicit user override wins; otherwise the model's real window. */
function contextLimitFor(providerId, model) {
const explicit = explicitContextWindow();
if (explicit != null) { return explicit; }
return catalog.contextWindowFor(providerId, model, 200000);
}
/** The active model's context window (tokens) — drives the chat context-usage meter. */
/**
* The model the meter should cost against — the same resolution currentContextLimit uses.
*
* estimateMsgTokens needs it to pick an image's resolution tier: the high-res tier is 4,784 visual
* tokens against the standard tier's 1,568, so calling it without a model silently costs every
* screenshot at a THIRD of what a Claude 4.7+ model is actually charged.
*/
function meterModel() {
const cfg = aiConfig();
if (providerMode() === 'gateway' && cloudSignedIn) { return capsModel(gatewayModel()); }
return activeModel(cfg, currentProviderId());
}
function currentContextLimit() {
const cfg = aiConfig();
if (providerMode() === 'gateway' && cloudSignedIn) {
return contextLimitFor('openai', capsModel(gatewayModel())); // Auto → flagship window (widest it could route to)
}
const pid = currentProviderId();
return contextLimitFor(pid, activeModel(cfg, pid));
}
/** Prompt for — and store — the API key for a provider. Provider-aware copy. `noKey`/unknown → undefined. */
async function promptForKey(providerId) {
const id = (typeof providerId === 'string' && providerId) ? providerId : currentProviderId();
const p = providers.getProvider(id) || providers.getProvider('claude');
if (p.noKey) { vscode.window.showInformationMessage('LevelCode AI: ' + p.label + ' needs no API key.'); return undefined; }
const skey = secretKeyFor(p.id);
const key = await vscode.window.showInputBox({
title: 'LevelCode AI — ' + p.label + ' API Key',
prompt: 'Paste your ' + p.label + ' API key. Stored encrypted in your OS keychain — it never leaves your machine except to ' + p.label + '.',
password: true,
ignoreFocusOut: true,
placeHolder: p.kind === 'anthropic' ? 'sk-ant-…' : 'sk-…'
});
if (key && skey) { await ctx.secrets.store(skey, key.trim()); }
return key ? key.trim() : undefined;
}
/** Look up a provider's key from SecretStorage; optionally prompt if missing.
* Returns '' for a `noKey` provider (Ollama), or `undefined` if the key is missing/declined. */
async function getProviderKey(providerId, opts) {
const skey = secretKeyFor(providerId);
if (!skey) { return ''; } // noKey provider (e.g. Ollama)
let key = await ctx.secrets.get(skey);
if (!key && opts && opts.prompt) { key = await promptForKey(providerId); }
return key || undefined;
}
/** User-facing message for a failed prepProviderRequest (shared by chat + edit). */
function providerErrorMessage(req) {
if (req.reason === 'baseURL') { return 'Set a base URL for the custom OpenAI-compatible provider first (levelcode.ai.baseURL).'; }
if (req.reason === 'insecureBaseURL') { return 'Refusing to send your API key over plain http to a non-local host. Use an https base URL (or a localhost endpoint) for the custom provider.'; }
if (req.reason === 'insecureGateway') { return 'Refusing to send your LevelCode Cloud token over plain http. Set "levelcode.cloud.endpoint" to an https URL to use gateway mode.'; }
if (req.reason === 'signedOut') { return session.SESSION_EXPIRED_MESSAGE; }
return 'No API key set for ' + req.label + '. Use the key button or “LevelCode: AI: Set API Key”.';
}
/** The configured provider routing mode ('byok' default | 'gateway'). NOTE: the key is
* `levelcode.ai.providerMode`, NOT `provider.mode` — the latter collides with the scalar
* `levelcode.ai.provider` (VS Code navigates into the string → always undefined). */
function providerMode() { return aiConfig().get('providerMode', 'gateway'); }
// ── LevelCode Cloud gateway model entitlement (mirrors backend LevelCode.gateway_model) ──────────
// The free plan runs the cheap open-weights engine; a paid plan runs the flagship. The BACKEND
// is the source of truth — it forces the model per the user's wallet — so these are the CLIENT
// reflection: they drive which model the picker/footer show and let the free tier surface an
// "Upgrade" CTA. Selecting a model here never overrides the server's plan-based decision.
const GATEWAY_FREE_MODEL = 'openai/gpt-oss-120b';
const GATEWAY_FREE_LABEL = 'gpt-oss-120b';
const GATEWAY_PRO_MODEL = 'moonshotai/kimi-k2.7-code';
const GATEWAY_PRO_LABEL = 'Kimi K2.7 Code';
// "Auto" pseudo-model: the gateway routes each turn to the cheapest engine that fits (trivial →
// open-weights, standard/agent → flagship). The backend does the routing; the client just requests it.
const GATEWAY_AUTO_MODEL = 'auto';
const GATEWAY_AUTO_LABEL = 'Auto';
/** True for a paid LevelCode Cloud plan — anything managed that isn't the free tier. */
function isPaidCloudPlan(plan) {
const p = String(plan || '').trim().toLowerCase();
return p !== '' && p !== 'free';
}
/** The stored LevelCode Cloud plan name (from the cached profile); '' when signed out/unknown. */
function cloudPlanName() {
const p = (ctx && ctx.globalState.get(ACCOUNT_PROFILE_KEY)) || {};
return p.plan || '';
}
/** The active gateway model. Free plan → the open-weights engine (only option). Paid plan → the
* user's chosen engine (`levelcode.ai.cloudModel`: flagship OR open-weights), defaulting to the
* flagship. The backend re-checks entitlement, so this can never over-reach the plan. */
function gatewayModel() {
if (!isPaidCloudPlan(cloudPlanName())) { return GATEWAY_FREE_MODEL; } // free can't pick the flagship
// Paid: the user's chosen roster model (the picker only offers LIVE ones), else the flagship.
// The backend re-checks entitlement + price-confirmation, so this can never over-reach the plan.
return aiConfig().get('cloudModel', '') || GATEWAY_PRO_MODEL;
}
/** The model id to use for CAPABILITY lookups (context window, tool support). "Auto" is a routing
* pseudo-model resolved per-turn on the server, so for caps we use the flagship — the widest window
* it could route to (avoids under-reporting the context meter). Any real id passes through. */
function capsModel(id) {
return id === GATEWAY_AUTO_MODEL ? GATEWAY_PRO_MODEL : id;
}
/** The plan model roster from the last GET /account/models fetch — powers the picker + footer labels. */
let cloudRoster = [];
/** Friendly label for a gateway model id (footer chip). Prefers the fetched roster; falls back to the
* built-in flagship/free labels, then the raw id. */
function gatewayModelLabel(id) {
if (id === GATEWAY_AUTO_MODEL) { return GATEWAY_AUTO_LABEL; }
const m = cloudRoster.find((x) => x.id === id);
if (m) { return m.label; }
if (id === GATEWAY_PRO_MODEL) { return GATEWAY_PRO_LABEL; }
if (id === GATEWAY_FREE_MODEL) { return GATEWAY_FREE_LABEL; }
return id;
}
/** Fetch the plan's model roster (entitled models + credits + ≈ turns-left). Caches it for the
* footer/picker. Best-effort; returns null when signed out / offline / not gateway. */
async function fetchCloudRoster() {
// The presence of a stored token IS the signed-in truth; don't gate on the cloudSignedIn flag, which
// can still be false mid-activation while a valid token already exists (another way the picker was
// degrading to the 2-model fallback on a fresh open).
if (providerMode() !== 'gateway' || !ctx) { return null; }
let token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
if (!token) { return null; }
const api = cloudApiUrl();
if (!/^https:\/\//i.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(api)) { return null; }
// Last-known-good roster: a transient failure keeps the FULL model list rather than collapsing to the
// 2-model offline fallback. The per-model fields — INCLUDING "≈ turns left" — are whatever the last
// good fetch returned, so they may be slightly stale. The account-level credit BALANCE is NOT carried
// here, so pickCloudModel just omits the "$X credits left" header until the next successful fetch.
const cached = () => (cloudRoster && cloudRoster.length ? { plan: cloudPlanName(), models: cloudRoster } : null);
const get = (bearer) => fetch(api + '/api/levelcode/v1/account/models', { headers: { authorization: 'Bearer ' + bearer } });
try {
let res = await get(token);
// THE FIX: on a fresh open, last session's short-lived access token is usually EXPIRED, so this
// first call 401s. Refresh once and retry. Without it the 401 silently degrades the picker to the
// 2-model offline fallback and hides the plan's real roster (Opus, K3, …) — exactly the reported
// bug. The profile fetch and the agent loop already refresh on 401; the roster fetch didn't.
if (res.status === 401 && await refreshCloudToken()) {
// Guard the refreshed token: if it comes back falsy for any reason, retrying would send
// `Authorization: Bearer null` — noise that masks the real 401. Skip the retry instead and let
// the !res.ok path below fall back to the cached roster.
const fresh = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
if (fresh) { token = fresh; res = await get(token); }
}
if (!res.ok) { dbg('cloud.roster', { ok: false, status: res.status }); return cached(); }
const data = await res.json().catch(() => null);
// Only a payload with a real models array IS a roster. A 200 carrying an error object, a partial
// response, or a schema drift is not — returning it would make pickCloudModel see "no models" and
// collapse to the 2-model fallback despite a valid last-known-good list. Prefer the cache then.
if (data && Array.isArray(data.models)) {
cloudRoster = data.models;
// The roster carries each model's short label — refresh the footer chip so it shows
// "Opus 4.8" instead of the raw id it fell back to before the roster finished loading.
sendConfigToWebview();
return data;
}
return cached();
} catch (e) { dbg('cloud.roster', { error: String((e && e.message) || e) }); return cached(); }
}
/**
* Report a 👍/👎 reaction to LevelCode Cloud as a per-model quality signal — but ONLY on the
* metered gateway when signed in. BYOK stays fully private: the reaction is a local toggle
* and nothing leaves the machine. Best-effort + fire-and-forget (feedback is low-stakes).
*/
async function recordFeedback(rating, turnModel) {
if (rating !== 'up' && rating !== 'down') { return; }
if (providerMode() !== 'gateway' || !cloudSignedIn) { return; } // BYOK / signed out → local only
const token = ctx ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null;
if (!token) { return; }
const api = cloudApiUrl();
if (!/^https:\/\//i.test(api) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(api)) { return; }
// The model the RATED turn actually ran on (stamped by the webview), falling back to the
// current entitlement — so a mid-session plan change can't misattribute the vote.
const model = (typeof turnModel === 'string' && turnModel) ? turnModel : gatewayModel();
try {
await fetch(api + '/api/levelcode/v1/feedback', {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + token },
body: JSON.stringify({ rating, model })
});
dbg('feedback.post', { rating, model });
} catch (e) { dbg('feedback.post', { error: String((e && e.message) || e) }); }
}
/** True when an error thrown by a provider adapter is an auth failure (HTTP 401) — used to trigger
* a single gateway-token refresh + retry. The openai adapter throws `<label> API 401: <body>`. */
function isAuthError(e) { return /\bAPI 401\b|\b401\b.*unauthor/i.test(String((e && e.message) || e)); }
/**
* Refresh the LevelCode Cloud access token in gateway mode: POST the stored refresh token to
* {apiUrl}/api/levelcode/v1/auth/refresh (the Rails backend), store the new access token, return true.
* No-op (returns false) when not applicable (byok, signed out, no refresh token, or non-https apiUrl).
*/
/**
* Renew the access token from the refresh token. Returns true on success.
*
* Two things this now does that it did not before, both for the same incident — a user back from a
* week away was shown "LevelCode Cloud API 401: Signature has expired" in the transcript while the
* account popover still said they were signed in:
*
* 1. It STORES the rotated refresh token the server now returns. Before, the editor kept the
* refresh token from sign-in for its whole life, so the 30 days ran from the last sign-in rather
* than the last use, and an active user was logged out on a schedule they could not see.
* 2. It knows the difference between "this attempt failed" and "the session is over". Only an
* explicit 401 from the refresh endpoint is the latter; that ends the session (sessionExpired).
* Offline, a 5xx, a malformed reply: nothing is known yet, so the tokens stay.
*
* And two things the wait in the middle demands. The request is out for as long as the network
* takes, and the rest of the editor does not stand still for it:
*
* 3. Its answer is about the session it STARTED against, and is applied to that one only.
* Meanwhile the user can sign in again, or sign out. A late 401 must not delete the pair a
* sign-in just stored; a late 200 must not sign someone back in, or file one account's tokens
* under another's name. See sameSession().
* 4. It has a deadline, body included. `ready` waits on this before it restores the chat, so a
* host that accepts the connection and then goes quiet would otherwise hold the transcript, the
* config and the review controls behind it. Running out of time is (2)'s "this attempt failed".
*
* So "true" means: a usable access token is in place now — this refresh's, or a newer session's.
*/
async function refreshCloudToken() {
if (!ctx) { return false; }
const endpoint = cloudApiUrl();
if (!/^https:\/\//i.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(endpoint)) { return false; }
// Never rejects. The stores can throw as well as the network (a locked keychain, say), and the
// callers are a webview's `ready`, a timer on window focus, and the 401 path of a request that is
// already in trouble — none of which can do anything with that except fail worse. It is one more
// way for this attempt to fail.
try { return await renewSession(endpoint); }
catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); return false; }
}
/** refreshCloudToken's work, free to throw. */
async function renewSession(endpoint) {
// Which session this request is about. Read under the lock, so never halfway through a sign-in.
const was = await withSessionLock(async () => ({ generation: sessionGeneration, refresh: await ctx.secrets.get(ACCOUNT_REFRESH_KEY) }));
if (!was.refresh) { return false; }
let outcome = 'retry';
/** @type {any} */
let data = null;
const ac = new AbortController();
const deadline = setTimeout(() => ac.abort(), session.REFRESH_TIMEOUT_MS);
try {
const res = await fetch(endpoint + '/api/levelcode/v1/auth/refresh', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ refresh: was.refresh }),
signal: ac.signal
});
data = await res.json().catch(() => null);
outcome = session.classifyRefresh({ status: res.status, body: data });
dbg('cloud.refresh', { outcome, status: res.status, code: data && data.error && data.error.code });
} catch (e) { dbg('cloud.refresh', { error: String((e && e.message) || e) }); }
finally { clearTimeout(deadline); } // only now: the deadline covers reading the body too
if (outcome === 'retry') { return false; }
if (outcome === 'expired') {
if (await sessionExpired(was)) { return false; }
} else {
const renewed = await withSessionLock(async () => {
if (!await sameSession(was)) { return false; }
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, data.access || data.token);
if (data.refresh) { await ctx.secrets.store(ACCOUNT_REFRESH_KEY, data.refresh); }
return true;
});
if (renewed) { return true; }
}
// Not applied: the stored session is no longer the one this request was about — a sign-in, a
// sign-out, another refresh that finished first, or another window. What the caller needs is
// whether a token is in place to retry with.
const token = await storedToken();
dbg('cloud.refresh', { superseded: true, outcome, token: !!token });
if (!!token !== cloudSignedIn) { await catchUpWithStoredSession(token); }
return !!token;
}
/**
* The stored access token, read between session changes rather than in the middle of one. Every
* change this window makes moves the token and `cloudSignedIn` together inside the lock, so a token
* read this way that disagrees with the flag was changed by ANOTHER window.
*/
function storedToken() {
return withSessionLock(() => ctx.secrets.get(ACCOUNT_TOKEN_KEY));
}
/**
* The stored session belongs to every window, and a window only hears about the changes it makes
* itself. When what is stored stops matching what this window is showing, another window has signed
* out, signed in, or found the expiry — and a chat that is already open sends no second `ready` to
* notice. Bring this window level: the flag, the popover and footer, and the card. An expiry that
* is now waiting gets its card; if none is (the other window signed in), the account message takes
* a card that is still up back down.
* @param {string|undefined} token what SecretStorage holds now
*/
async function catchUpWithStoredSession(token) {
cloudSignedIn = !!token;
if (!token && sessionExpiredPending()) { postSessionExpired(); }
await postAccount(false);
sendConfigToWebview();
}
/**
* Every change to the stored session — a sign-in, a sign-out, a refresh's new tokens, an expiry —
* runs through here, one at a time.
*
* Each of them is several awaits long (two secrets and some globalState), and they are started by
* things that do not know about each other: the auth callback, a button, a timer on window focus.
* Without this, a sign-in could land between an expiry deciding "this is still the dead session" and
* its deletes, and lose the pair it had just stored. Nothing slow may run inside — above all not the
* network — and nothing inside may call this again.
*/
let sessionQueue = Promise.resolve();
/** @template T @param {() => Promise<T>} fn @returns {Promise<T>} */
function withSessionLock(fn) {
const run = sessionQueue.then(fn);
sessionQueue = run.then(() => { }, () => { }); // a failed change must not wedge the ones behind it
return run;
}
/** How many sessions this window has been through: a sign-in, a sign-out and an expiry each bump it. */
let sessionGeneration = 0;
/**
* Is the stored session still the one `was` describes? Call it INSIDE withSessionLock, immediately
* before acting on the answer.
*
* Two tests, because there are two ways for it to have changed. The generation counts what THIS
* window did, and under the lock it is exact. The refresh token itself catches what ANOTHER window
* did, which no counter in this process can see. That half is best-effort — another process can
* still write between this read and what follows — but it turns "any late answer" into
* "a few milliseconds".
*/
async function sameSession(was) {
return was.generation === sessionGeneration && await ctx.secrets.get(ACCOUNT_REFRESH_KEY) === was.refresh;
}
/**
* The cloud session `was` is over and cannot be renewed: forget its dead credentials, tell the
* webview, and resync the account popover so it stops claiming the user is signed in. Returns
* whether it ended anything — false when the stored session is no longer that one (see
* sameSession), in which case it touches nothing.
*
* The cached profile is deliberately KEPT — the sign-in card can say who it is talking to, and the
* next sign-in overwrites it anyway. What must go is anything the editor would otherwise keep
* presenting as a live session: the tokens, and the `cloudSignedIn` flag the footer and model gate
* read. A session ends once: a second caller holding the same dead session finds it already gone.
*
* It also leaves ACCOUNT_EXPIRED_KEY behind, because "the tokens are gone" says nothing afterwards:
* that is just as true of someone who signed out, or who never signed in. The marker is what
* records that a session ENDED here and the user has not answered it yet — sessionExpiredPending()
* has the two things that hang off it.
*/
async function sessionExpired(was) {
if (!ctx) { return false; }
const ended = await withSessionLock(async () => {
if (!await sameSession(was)) { return false; }
sessionGeneration++;
cloudSignedIn = false;
// Marker FIRST: if the editor dies between these writes, tokens-without-marker would read as an
// ordinary sign-out and the card would be lost; marker-with-tokens just finds the expiry again.
await ctx.globalState.update(ACCOUNT_EXPIRED_KEY, true);
await ctx.secrets.delete(ACCOUNT_TOKEN_KEY);
await ctx.secrets.delete(ACCOUNT_REFRESH_KEY);
return true;
});
if (!ended) { return false; }
const p = ctx.globalState.get(ACCOUNT_PROFILE_KEY) || {};
dbg('cloud.sessionExpired', { name: p.name || p.email || '' });
// In BYOK mode nothing the user is doing has stopped working, so there is no card to show — the
// popover going back to "signed out" is the whole of it. (The account message that follows would
// take the card straight down again anyway.) The marker is kept for if they return to gateway mode.
if (sessionExpiredPending()) { postSessionExpired(); }
await postAccount(false);
sendConfigToWebview();
return true;
}
/** Show the sign-in card. The profile outlives the session so the card can say who it is talking to. */
function postSessionExpired() {
const p = (ctx && ctx.globalState.get(ACCOUNT_PROFILE_KEY)) || {};
post({ type: 'sessionExpired', name: p.name || '', message: session.SESSION_EXPIRED_MESSAGE });
}
/**
* True while an ended session is still waiting on the user: sessionExpired() left its marker, and
* nothing has answered it — a sign-in, a sign-out, or "use my own key" (clearSessionExpired).
*
* Everything about the sign-in card hangs off it, and each use needs more than "there is no token":
*
* 1. Showing it — replaySessionExpired. The session can end with no chat open to hear about it
* (the focus check does not need one), and post() has nowhere to send the card.
* 2. Stopping a request to ask for a sign-in — prepProviderRequest. Gateway is the DEFAULT mode,
* so "gateway mode, no token" describes everyone who uses LevelCode on their own key with no
* account. Only a session that ended may stop a request; the rest fall back to BYOK, as the
* setting promises.
* 3. Calling a failed request an expiry — isEndedSessionError. A 401 that lands after a
* deliberate sign-out finds no session either, and is not one.
* 4. Taking it away — currentAccount reports it as `expired`, and the webview drops the card as
* soon as that is false.
*
* Scoped to where the card can be acted on: gateway mode, with a host to sign in to. Leaving either
* (BYOK chosen in Settings, a cleared host) puts the expiry out of play WITHOUT answering it: the
* marker stays, and coming back brings the card back.
*/
function sessionExpiredPending() {
return !!(ctx && ctx.globalState.get(ACCOUNT_EXPIRED_KEY)) && providerMode() === 'gateway' && !!cloudEndpoint();
}
/**
* Is this failure the session having ENDED — the one failure that gets the sign-in card instead of
* its own message? Chat and the agent both ask it about a gateway 401 their refresh could not
* recover, and every clause is there for a case that looks the same without it:
*
* - a gateway request: a BYOK provider's 401 is that provider's business;
* - this window no longer signed in: a refresh that merely failed leaves the session alive;
* - an expiry still waiting on the user: a 401 that lands after they signed OUT, mid-request,
* finds the session gone as well — and "your session has expired" is the wrong thing to tell
* someone who has just pressed Sign out;
* - and the error being that 401 at all.
*/
function isEndedSessionError(req, e) {
return !!(req && req.gateway) && !cloudSignedIn && sessionExpiredPending() && session.isSessionExpiredError(e);
}
/** The user has answered the expiry, so stop asking: the marker goes. Unconditional — another window
* may have set it, and this window's view of globalState can lag behind the write. */
async function clearSessionExpired() {
if (ctx) { await ctx.globalState.update(ACCOUNT_EXPIRED_KEY, undefined); }
}
/**
* Put the card (back) in the transcript while the expiry is unanswered. Two kinds of caller:
*
* - `ready`: a document that was not there to see the session end — the chat was closed, or it
* has just moved to an editor tab. Called LAST there, so the card lands under a replayed
* transcript instead of above it. When the check earlier in the same `ready` is what found the
* expiry, this posts the card a second time; the webview keeps one, at the bottom.
* - anything that rewrites the transcript IN PLACE, with no new `ready` to follow: New Chat and a
* resumed session (both post `reset`, which empties the log) and a checkpoint restore (which
* drops every node after the restored turn). Each takes the card with it, and the next message
* would be stopped by an expiry nothing on screen mentions any more.
*
* Never rejects. It runs at the end of things that have already done their work — a resume, a
* restore — and a store that will not read is no reason to cut those short.
*/
async function replaySessionExpired() {
if (!sessionExpiredPending()) { return; }
try {
const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
// Asked AGAIN, because the answer can change while that read is out: "Use my own key instead"
// clears the marker, a Settings change takes the session out of play. Posting on the earlier
// answer would put back a card the user has just dismissed.
if (token || !sessionExpiredPending()) { return; }
dbg('cloud.sessionExpired.replay', {});
postSessionExpired();
} catch (e) { dbg('cloud.sessionExpired.replay', { error: String((e && e.message) || e) }); }
}
/**
* Check the session BEFORE the user types anything, so an expiry found on launch shows up as a
* sign-in card at the top of the chat rather than as the reply to their first message.
*
* Cheap by design: the access token's own `exp` is read locally and the network is only touched
* when it is expired or about to be. A session with hours left costs nothing here. Called on the
* webview's `ready` and again every time the window regains focus.
*
* Two halves. Catching up with the other windows is one local read, so it happens on every call.
* The half that can cost a request is rationed on focus. Never rejects: `ready` awaits this before
* it restores the chat.
*/
const SESSION_CHECK_EVERY_MS = 10 * 60 * 1000;
let lastSessionCheck = 0;
async function checkCloudSession(reason) {
if (!ctx || providerMode() !== 'gateway') { return; }
try {
const token = await storedToken();
// First, whether this window is still showing the session that is actually stored. On `ready`
// there is nothing to catch up on — the handler has just read the same token — and an expiry
// found while no chat was open is replayed LAST there, by replaySessionExpired(). On focus this
// is what tells an open chat that another window has ended, left or renewed the session.
if (!!token !== cloudSignedIn) { await catchUpWithStoredSession(token); }
if (!token) { return; }
// From here on it can cost a request, so focus gets a ration: a window clicked in and out of
// while offline must not retry the refresh on every click.
if (reason === 'focus' && Date.now() - lastSessionCheck <= SESSION_CHECK_EVERY_MS) { return; }
lastSessionCheck = Date.now();
if (!session.accessNeedsRefresh(token)) { return; }
dbg('cloud.sessionCheck', { reason, expiresAt: session.jwtExpiresAt(token) });
await refreshCloudToken(); // an expired refresh token lands in sessionExpired() from inside
} catch (e) { dbg('cloud.sessionCheck', { reason, error: String((e && e.message) || e) }); }
}
/** Gateway-mode token refresh (the streaming 401 retry path). Delegates to refreshCloudToken. */
async function refreshGatewayToken() {
if (providerMode() !== 'gateway') { return false; }
return refreshCloudToken();
}
/**
* A lapsed access token, for everything that sends a provider request and is neither the chat nor
* the agent: inline edit, Agent Sketch and inline completion (each is handed this in its `deps`),
* and Compact and the session-memory summary further down. They sent a request once and reported
* the gateway's 401 — for a token the chat would have renewed and carried on with, and one nothing
* renews ahead of time in a window that simply stays focused (checkCloudSession runs on the
* webview's `ready` and on regaining focus). Through this, the token is renewed and the request sent
* once more.
*
* The rules are in providers/authRetry.js. The two that matter here: it never ends a session — the
* refresh endpoint answering 401, above, is still the only thing that does — and a BYOK request is
* never refreshed or retried. The chat and the agent keep their own retry (handleSend; the agent's
* refreshAuth hook): they have a transcript and a sign-in card to keep in step with it.
*
* ONE instance for the window, so that requests which fail together wait on a single renewal. And it
* is told which session this window is on (sessionGeneration), for the reason the refresh keeps that
* count: a request belongs to the session it was sent in. A token stored by a LATER sign-in is not a
* renewal of the old one, and is never put on a request the earlier session sent. (A sign-in made in
* ANOTHER window this count cannot see; for that, authRetry goes by who the tokens say they are for.)
*/
const authRetry = createAuthRetry({ prepProviderRequest, refreshGatewayToken, isAuthError, sessionGeneration: () => sessionGeneration, dbg });
/**
* Resolve everything needed to call the active provider: id, key, model, baseURL, maxTokens.
* Returns { ok:false, reason } when a required key/baseURL is missing (after optional prompting) or
* a custom endpoint would leak the key over plaintext — the caller renders providerErrorMessage().
* @returns {Promise<{ok:boolean, providerId?:string, apiKey?:string, model?:string, baseURL?:string, maxTokens?:number, label?:string, reason?:string, gateway?:boolean}>}
*/
async function prepProviderRequest(opts) {
const cfg = aiConfig();
// Gateway mode: when signed in (a cloud token is present) and mode==='gateway', route AI through the
// LevelCode Cloud metered gateway (an openai-kind endpoint) instead of the user's own provider/key. Falls
// back to the BYOK path below when signed out or in byok mode — the default is untouched.
const token = ctx ? await ctx.secrets.get(ACCOUNT_TOKEN_KEY) : null;
// No token in gateway mode is NOT, on its own, a reason to stop. Gateway is the default mode, so
// that describes everyone who uses LevelCode on their own key with no account, and they take the
// BYOK path below — "falls back to BYOK when you're signed out" is the setting's own promise.
// The exception is a session that ENDED and has not been answered: that user was on their plan a
// moment ago, and "No API key set for OpenAI" would send them hunting for a key they never needed.
// They get the sign-in card, until they sign in again or choose their own key.
if (!token && sessionExpiredPending()) {
return { ok: false, providerId: 'openai', label: 'LevelCode Cloud', reason: 'signedOut', gateway: true };
}
const gw = resolveGateway({ mode: providerMode(), endpoint: cloudApiUrl(), token: token || '' });
if (gw.use) {
if (!gw.ok) { return { ok: false, providerId: 'openai', label: 'LevelCode Cloud', reason: gw.reason }; }
return {
ok: true, providerId: gw.providerId, apiKey: gw.apiKey,
// Plan-scoped model (free → gpt-oss-120b, paid → Kimi K2.7 Code). The backend
// re-forces this per the wallet, so we send the entitled model, not the BYOK one.
model: gatewayModel(),
baseURL: gw.baseURL,
maxTokens: maxOutputTokens(cfg),
label: 'LevelCode Cloud',
gateway: true
};
}
const providerId = currentProviderId();
const p = providers.getProvider(providerId) || providers.getProvider('claude');
const baseURL = baseUrlFor(cfg, providerId);
if (providerId === 'custom' && !baseURL) {
return { ok: false, providerId, label: p.label, reason: 'baseURL' };
}
let apiKey = '';
if (!p.noKey) {
apiKey = await getProviderKey(providerId, opts);
if (!apiKey) { return { ok: false, providerId, label: p.label, reason: 'key' }; }
}
if (providerId === 'custom' && apiKey && providers.isInsecureCustomUrl(baseURL)) {
return { ok: false, providerId, label: p.label, reason: 'insecureBaseURL' };
}
return {
ok: true, providerId, apiKey,
model: activeModel(cfg, providerId),
baseURL,
maxTokens: maxOutputTokens(cfg),
label: p.label
};
}
function captureSelection() {
const ed = vscode.window.activeTextEditor;
if (!ed || ed.selection.isEmpty) { return null; }
const text = ed.document.getText(ed.selection);
const lang = ed.document.languageId || '';
const name = ed.document.uri.scheme === 'file' ? path.basename(ed.document.uri.fsPath) : 'selection';
const lines = ed.selection.end.line - ed.selection.start.line + 1;
return {
block: 'Context from `' + name + '`:\n```' + lang + '\n' + text + '\n```',
label: name + ' · ' + lines + ' line' + (lines === 1 ? '' : 's')
};
}
/**
* Reveal the chat view for its side effect only, and never reject.
*
* `executeCommand` returns a Thenable, so a bare call in a void context turns any rejection into an
* unhandled promise rejection in the extension host — noisy, and attributed to nothing in particular,
* which is the part that makes it useless.
*
* Every caller here is a BACKGROUND reveal: the work it accompanies (a selection added, a session
* resumed, a login launched) has already succeeded by the time this runs. Failing that work because
* the panel would not come forward would be worse than the panel not coming forward.
*
* Logged, never swallowed. `.catch(() => {})` would hide the one failure that is genuinely hard to
* diagnose — a chat surface that silently never appears — so `why` names the caller in the log.
*
* NOT for a command handler whose whole job IS the reveal: `levelcode.ai.focus` returns the thenable
* instead, so VS Code reports the failure to the user who asked for it.
*
* "The chat" is now the editor tab and nothing else — this used to reveal the contributed view in the
* right-hand bar, which is why every one of these callers kept pulling a panel out on the right.
*/
function focusChatView(why) {
return Promise.resolve(openChatInEditor())
.then(undefined, (e) => {
const msg = String((e && e.message) || e);
console.warn('[levelcode-ai] chat.focus.failed', { why, msg });
dbg('chat.focus.failed', { why, msg });
});
}
function addSelection() {
const sel = captureSelection();
if (!sel) { vscode.window.showInformationMessage('LevelCode AI: select some code first.'); return; }
pendingContext = sel.block;
focusChatView('addSelection');
post({ type: 'context', label: sel.label });
}
function postContextFiles() {
post({ type: 'contextFiles', files: contextFiles.map((f) => ({ id: f.id, name: f.name, rel: f.rel })) });
}
function removeFileContext(id) {
contextFiles = contextFiles.filter((f) => f.id !== id);
postContextFiles();
}
/** Unified "+ Add context" picker: choose the current selection and/or files from the workspace. */
async function addContext() {
/** @type {any[]} */
const items = [];
const sel = captureSelection();
if (sel) { items.push({ label: '$(selection) Selected code', description: sel.label, _kind: 'sel', _sel: sel }); }
const uris = await vscode.workspace.findFiles('**/*', FILE_EXCLUDES, 5000);
if (sel && uris.length) { items.push({ label: 'Workspace files', kind: vscode.QuickPickItemKind.Separator }); }
for (const u of uris) {
items.push({ label: '$(file) ' + path.basename(u.fsPath), description: vscode.workspace.asRelativePath(u), _kind: 'file', _uri: u });
}
if (!items.length) { vscode.window.showInformationMessage('LevelCode AI: no workspace files to add.'); return; }
const picks = await vscode.window.showQuickPick(items, {
canPickMany: true,
matchOnDescription: true,
placeHolder: 'Add context — pick the selection and/or files (type to filter)'
});
if (!picks || !picks.length) { return; }
for (const p of picks) {
if (p._kind === 'sel') {
pendingContext = p._sel.block;
post({ type: 'context', label: p._sel.label });
} else if (p._kind === 'file') {
const id = p._uri.fsPath;
if (!contextFiles.find((f) => f.id === id)) {
contextFiles.push({ id, uri: p._uri, name: path.basename(id), rel: vscode.workspace.asRelativePath(p._uri) });
}
}
}
focusChatView('addContext');
postContextFiles();
}
/** Read pinned context files as content blocks, capped to protect the context window. */
async function contextFileBlocks() {
const PER_FILE = 80 * 1024;
const TOTAL = 250 * 1024;
const blocks = [];
let used = 0;
for (const f of contextFiles) {
try {
const doc = await vscode.workspace.openTextDocument(f.uri);
let body = doc.getText();
if (body.length > PER_FILE) { body = body.slice(0, PER_FILE) + '\n…(file truncated for context)…'; }
if (used + body.length > TOTAL) { blocks.push('…(some pinned files omitted to fit the context window)…'); break; }
used += body.length;
blocks.push('File `' + f.rel + '`:\n```' + (doc.languageId || '') + '\n' + body + '\n```');
} catch { /* unreadable/binary file — skip */ }
}
return blocks;
}
/** List workspace files once (cached per send), respecting the standard excludes. */
async function listWorkspaceFiles() {
if (!vscode.workspace.workspaceFolders || !vscode.workspace.workspaceFolders.length) { return []; }
return vscode.workspace.findFiles('**/*', FILE_EXCLUDES, 5000);
}
/** Identifiers/keywords from a question, minus common stop-words. */
function extractKeywords(text) {
const raw = text.match(/[A-Za-z_][A-Za-z0-9_]{2,}/g) || [];
const seen = new Set();
const out = [];
for (const w of raw) {
const lw = w.toLowerCase();
if (STOPWORDS.has(lw) || seen.has(lw)) { continue; }
seen.add(lw);
out.push(w);
if (out.length >= 12) { break; }
}
return out;
}
/** Resolve the ripgrep binary bundled with the editor (dev and packaged paths differ). */
let _rgPath; // cached: string | null
function rgPath() {
if (_rgPath !== undefined) { return _rgPath; }
const root = vscode.env.appRoot;
const candidates = [
path.join(root, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg'),
path.join(root, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', process.platform + '-' + process.arch, 'rg'),
path.join(root, 'node_modules.asar.unpacked', '@vscode', 'ripgrep', 'bin', 'rg')
];
_rgPath = candidates.find((c) => { try { return fs.existsSync(c); } catch { return false; } }) || null;
return _rgPath;
}
/** Files (absolute paths) under cwd whose CONTENT contains the literal term. Empty on any failure. */
function rgFiles(term, cwd) {
return new Promise((resolve) => {
const bin = rgPath();
if (!bin || !cwd) { resolve([]); return; }
const args = [
'--files-with-matches', '--no-messages', '--no-config', '-i', '-F',
'--max-filesize', '1M', '--max-count', '1',
'-g', '!**/node_modules/**', '-g', '!**/.git/**', '-g', '!**/out/**',
'-g', '!**/dist/**', '-g', '!**/*.map', '-g', '!**/*.min.*',
'-e', term, '.'
];
let out = '';
let done = false;
const finish = (paths) => { if (!done) { done = true; resolve(paths); } };
try {
const child = cp.spawn(bin, args, { cwd });
const timer = setTimeout(() => { try { child.kill(); } catch { /* noop */ } finish([]); }, 4000);
child.stdout.on('data', (d) => { out += d.toString(); });
child.on('error', () => { clearTimeout(timer); finish([]); });
child.on('close', () => {
clearTimeout(timer);
finish(out.split('\n').map((s) => s.trim()).filter(Boolean).map((rel) => path.resolve(cwd, rel)));
});
} catch { finish([]); }
});
}
/**
* Files whose content contains each term, for every term at once. ripgrep where the editor has one;
* a bounded scan of the workspace's text files where it does not (LevelCode in the browser). Returns
* one array of absolute paths per term, in order.
*/
async function contentHits(terms, dir) {
if (host.caps.ripgrep) { return Promise.all(terms.map((kw) => rgFiles(kw, dir))); }
const hits = terms.map(() => []);
if (!dir) { return hits; }
const prefix = dir.endsWith('/') ? dir : dir + '/';
let uris = [];
// Scoped to `dir` BEFORE the cap: across the whole workspace, another folder's files could use up all 400.
try { uris = await host.findFiles(new vscode.RelativePattern(host.uriFor(dir), '**/*'), '{**/node_modules/**,**/.git/**,**/out/**,**/dist/**,**/*.map,**/*.min.*}', 400); } catch { return hits; }
const needles = terms.map((t) => t.toLowerCase());
const deadline = Date.now() + 4000; // the same budget rgFiles gives ripgrep
for (const uri of uris) {
if (Date.now() > deadline) { break; }
const abs = uri.path;
if (!abs.startsWith(prefix)) { continue; }
try {
const st = await host.stat(abs);
if (!st || !st.isFile || st.size > 1024 * 1024) { continue; }
const bytes = await host.readBytes(abs);
if (bytes.subarray(0, 8000).includes(0)) { continue; }
const text = new TextDecoder('utf-8').decode(bytes).toLowerCase();
needles.forEach((n, i) => { if (text.includes(n)) { hits[i].push(abs); } });
} catch { /* unreadable: not a hit */ }
}
return hits;
}
/** Scope retrieval to the active file's top-level sub-project, so unrelated sibling trees
* (e.g. a vendored source dump) don't pollute results. Returns the search dir + rel prefix. */
function activeProjectScope() {