backlens 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,11 +57,13 @@ In the Compare tab, save a snapshot, change your code, restart, use the same pag
57
57
 
58
58
  Every issue has **Explain** and **Suggest fix**. A fix goes: a preview of exactly what would be sent and to whom, the model's answer as a diff, **Apply and check** (only on files with no uncommitted changes, with a backup), then backlens rebuilds if needed, restarts your app, replays the request that showed the issue and runs your tests. Only a fix that removes the issue and keeps the tests green is marked proven. Keep it, or revert to the exact files you had.
59
59
 
60
- Who answers is chosen per issue, with the reasons shown:
60
+ Who answers is set up in **AI settings**, all from the dashboard. The first-run setup asks too.
61
61
 
62
- - **Local model** (free, private): any model in [Ollama](https://ollama.com). Picked by default when Ollama is running.
63
- - **Recorded answers** (free): no model; fixes come from `.backlens/ai-fixtures.json`. For trying the flow and for tests.
64
- - **Claude with your own key**: set `ANTHROPIC_API_KEY` before starting backlens. Explaining uses Claude Haiku 5.5; fixes use Sonnet 5.5 or Opus 5.5 depending on how hard the issue looks (files involved, issue type, test coverage, your past results), one step up after a failed check. Missing indexes never use a model. A daily budget (default $1) is enforced before sending.
62
+ - **Local model** (free, private): runs on your computer through [Ollama](https://ollama.com). The card walks you through it: a link to install Ollama, then it notices when Ollama is running, suggests the model that fits your memory (`qwen2.5-coder` 3B for 8 GB, 7B for 16 GB, 14B for 32 GB) and downloads it with a progress bar. Small models explain well; their fixes are hit-and-miss, and every fix is checked anyway.
63
+ - **Claude with your own key**: paste the key and backlens checks it for free before saving it in your user folder (`~/.config/backlens`, readable by you only), never in the project. `ANTHROPIC_API_KEY` in the environment wins if set. Explaining uses Claude Haiku 5.5; fixes use Sonnet 5.5 or Opus 5.5 depending on how hard the issue looks (files involved, issue type, test coverage, your past results), one step up after a failed check. A daily budget (default $1) is enforced before sending.
64
+ - **No model**: explanations in backlens's own words.
65
+
66
+ Some fixes never use a model: running independent calls together (`Promise.all`) and missing indexes are written by backlens itself, the same way every time.
65
67
 
66
68
  Values that look like secrets are masked, `.env` files and `node_modules` are never read, and requests kept for replay stay in memory only. Replaying a POST, PUT or DELETE runs it again against your dev database, so it is off unless you allow it in AI settings.
67
69
 
@@ -0,0 +1,56 @@
1
+ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ /**
6
+ * The user's Claude API key, pasted in the dashboard. It is kept outside every project, in the
7
+ * user's config folder, readable by the user only, so it can never be committed with the code.
8
+ * ANTHROPIC_API_KEY in the environment wins over the saved key. The key never goes back to the
9
+ * browser: the dashboard only sees its last four characters.
10
+ */
11
+
12
+ export function configDir() {
13
+ if (process.env.BACKLENS_HOME) return process.env.BACKLENS_HOME;
14
+ if (process.platform === 'win32' && process.env.APPDATA) return join(process.env.APPDATA, 'backlens');
15
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'backlens');
16
+ }
17
+
18
+ const file = () => join(configDir(), 'credentials.json');
19
+
20
+ /** A loose shape check, so a pasted sentence or a key from another service is caught early. */
21
+ export function looksLikeKey(key) {
22
+ return typeof key === 'string' && /^sk-ant-[A-Za-z0-9_-]{20,300}$/.test(key);
23
+ }
24
+
25
+ export function savedKey() {
26
+ try {
27
+ const key = JSON.parse(readFileSync(file(), 'utf8')).anthropicApiKey;
28
+ return looksLikeKey(key) ? key : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function saveKey(key) {
35
+ if (!looksLikeKey(key)) throw new Error('That does not look like a Claude API key. It starts with sk-ant-.');
36
+ const dir = configDir();
37
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ writeFileSync(file(), `${JSON.stringify({ anthropicApiKey: key }, null, 2)}\n`, { mode: 0o600 });
39
+ // mode only applies when the file is created; tighten an existing one too.
40
+ try {
41
+ chmodSync(file(), 0o600);
42
+ } catch {}
43
+ }
44
+
45
+ export function removeKey() {
46
+ rmSync(file(), { force: true });
47
+ }
48
+
49
+ /** The key to use and where it came from: the environment first, then the saved one. */
50
+ export function currentKey() {
51
+ if (process.env.ANTHROPIC_API_KEY) return { key: process.env.ANTHROPIC_API_KEY, source: 'env' };
52
+ const key = savedKey();
53
+ return key ? { key, source: 'saved' } : { key: null, source: null };
54
+ }
55
+
56
+ export const lastFour = (key) => (key ? key.slice(-4) : '');
package/ai/fixes.js CHANGED
@@ -6,7 +6,8 @@ import { dirname, join, relative } from 'node:path';
6
6
  import { checkTrace } from '../analyze.js';
7
7
  import { ensureIgnore } from '../config.js';
8
8
  import { buildContext, insideProject } from './context.js';
9
- import { anthropicStatus, ask, issuePrompt, ollamaStatus } from './providers.js';
9
+ import { looksLikeKey, removeKey, saveKey } from './credentials.js';
10
+ import { anthropicStatus, ask, issuePrompt, LOCAL_MODELS, ollamaStatus, startPull, stopPull, testKey } from './providers.js';
10
11
  import { route } from './router.js';
11
12
  import { promiseAll } from './templates.js';
12
13
 
@@ -19,7 +20,7 @@ import { promiseAll } from './templates.js';
19
20
  */
20
21
 
21
22
  // Replaying a write sends it again, so it changes the dev database twice: reads only unless asked.
22
- const DEFAULTS = { provider: null, ollamaModel: 'qwen2.5-coder:3b', dailyBudgetUsd: 1, replay: true, replayWrites: false };
23
+ const DEFAULTS = { provider: null, ollamaModel: null, dailyBudgetUsd: 1, replay: true, replayWrites: false };
23
24
  const EDIT_REPAIRS = 2;
24
25
  const TYPE_REPAIRS = 2;
25
26
 
@@ -70,10 +71,13 @@ export function createAI(deps) {
70
71
  } catch {}
71
72
 
72
73
  async function resolvedSettings() {
73
- // Nothing is ever paid by default: the local model if it is installed, recorded answers otherwise.
74
- if (settings.provider) return settings;
75
74
  const local = await ollamaStatus();
76
- return { ...settings, provider: local.running && local.models.includes(settings.ollamaModel) ? 'ollama' : 'mock' };
75
+ // No model picked yet: one already downloaded if there is one, else the size this machine suits.
76
+ const ollamaModel =
77
+ settings.ollamaModel || LOCAL_MODELS.map((m) => m.name).find((n) => local.models.includes(n)) || local.models.find((n) => /coder/.test(n)) || local.recommended;
78
+ // Nothing is ever paid by default: the local model if it is ready, recorded answers otherwise.
79
+ const provider = settings.provider || (local.running && local.models.includes(ollamaModel) ? 'ollama' : 'mock');
80
+ return { ...settings, ollamaModel, provider };
77
81
  }
78
82
 
79
83
  function spentToday() {
@@ -85,7 +89,7 @@ export function createAI(deps) {
85
89
 
86
90
  async function status() {
87
91
  const s = await resolvedSettings();
88
- return { settings: s, ollama: await ollamaStatus(), anthropic: anthropicStatus(), spentTodayUsd: spentToday(), history: readLines(historyFile).length };
92
+ return { settings: s, configured: Boolean(settings.provider), ollama: await ollamaStatus(), anthropic: anthropicStatus(), spentTodayUsd: spentToday(), history: readLines(historyFile).length };
89
93
  }
90
94
 
91
95
  function saveSettings(next) {
@@ -135,7 +139,7 @@ export function createAI(deps) {
135
139
  }
136
140
 
137
141
  const view = (job) => {
138
- const { ctx, issue, backups, convo, abort, ...rest } = job;
142
+ const { ctx, issue, backups, convo, abort, afterTrace, ...rest } = job;
139
143
  return { ...rest, now: Date.now(), issueTitle: issue.title, issueRoute: issue.route, spentTodayUsd: spentToday(), budgetUsd: settings.dailyBudgetUsd };
140
144
  };
141
145
 
@@ -206,7 +210,20 @@ export function createAI(deps) {
206
210
  replace = cleanCode(e.new_code);
207
211
  const ranges = shown.get(file);
208
212
  if (from < 1 || to < from || to > lines.length || !ranges.some((r) => from >= r.from && to <= r.to)) {
209
- rejected.push({ file, from, to, reason: `lines ${from}-${to} are outside what you were shown in ${file} (${ranges.map((r) => `${r.from}-${r.to}`).join(', ')})` });
213
+ // Small models often number later edits as if the earlier ones were already applied.
214
+ const shift = ok
215
+ .filter((o) => o.file === file && o.to < from)
216
+ .reduce((n, o) => n + o.replace.split('\n').length - (o.to - o.from + 1), 0);
217
+ const was = { from: from - shift, to: to - shift };
218
+ const shifted = shift !== 0 && ranges.some((r) => was.from >= r.from && was.to <= r.to);
219
+ rejected.push({
220
+ file,
221
+ from,
222
+ to,
223
+ reason: shifted
224
+ ? `lines ${from}-${to} are numbered as if your earlier edit were already applied. Number every edit by the original lines shown: these look like lines ${was.from}-${was.to}`
225
+ : `lines ${from}-${to} are outside what you were shown in ${file} (${ranges.map((r) => `${r.from}-${r.to}`).join(', ')})`,
226
+ });
210
227
  continue;
211
228
  }
212
229
  } else if (typeof e.find === 'string' && e.find) {
@@ -370,6 +387,7 @@ export function createAI(deps) {
370
387
  job.stage = ok.length ? 'proposed' : 'no-edits';
371
388
  } catch (err) {
372
389
  job.error = err.message;
390
+ job.errorCode = err.code || null;
373
391
  job.stage = 'error';
374
392
  }
375
393
  })();
@@ -451,6 +469,12 @@ export function createAI(deps) {
451
469
  return { ok: r.code === 0, errors: r.out };
452
470
  }
453
471
 
472
+ /** What a request cost: the numbers shown before and after a fix. */
473
+ function stats(trace) {
474
+ const count = (kind) => trace.spans.filter((x) => x.kind === kind).length;
475
+ return { status: trace.status, ms: trace.duration, queries: count('db'), redis: count('redis'), http: count('http') };
476
+ }
477
+
454
478
  function replay(job, payload, port) {
455
479
  return new Promise((resolve) => {
456
480
  const timer = setTimeout(() => {
@@ -515,6 +539,9 @@ export function createAI(deps) {
515
539
  r.status = 'skip';
516
540
  r.detail = settings.replay ? 'the request that showed this issue is no longer in memory; use the page again to record a fresh one' : 'replay is off in AI settings';
517
541
  } else {
542
+ // The app has just restarted, so its first request pays for cold caches and new connections.
543
+ // A read is sent once to warm up, and the second one is measured.
544
+ if (!isWrite) await replay(job, payload, deps.appPort());
518
545
  const trace = await replay(job, payload, deps.appPort());
519
546
  if (!trace) {
520
547
  r.status = 'fail';
@@ -527,6 +554,8 @@ export function createAI(deps) {
527
554
  const before = original ? `${original.status} in ${original.duration} ms, ${original.spans.length} calls` : '';
528
555
  r.status = !still && sameClass ? 'done' : 'fail';
529
556
  r.detail = `before: ${before}\nafter: ${trace.status} in ${trace.duration} ms, ${trace.spans.length} calls${still ? ', issue still there' : ', issue gone'}`;
557
+ job.compare = { before: original ? stats(original) : null, after: stats(trace), issueGone: !still, warmed: !isWrite };
558
+ job.afterTrace = trace;
530
559
  if (trace.status === 401 && original && original.status !== 401) r.detail += '\nThe recorded login may have expired: sign in again and use the page once more.';
531
560
  if (r.status === 'fail') return finish(job, false, still ? 'The issue is still there after the change.' : `The request now answers ${trace.status}.`);
532
561
  }
@@ -568,6 +597,8 @@ export function createAI(deps) {
568
597
  append(historyFile, { at: new Date().toISOString(), type: job.issue.type, tier: job.route.tier || job.route.kind, model: job.route.model, verified: Boolean(job.verified), kept: true });
569
598
  cleanup(job);
570
599
  job.stage = 'kept';
600
+ // The measured request joins the normal list, and the issue leaves it until a request shows it again.
601
+ if (deps.markFixed) deps.markFixed(job.issue.key, job.afterTrace && job.compare && job.compare.issueGone ? job.afterTrace : null);
571
602
  ev(job, 'done', 'Kept. Review it in your editor and commit when ready.');
572
603
  return view(job);
573
604
  }
@@ -612,5 +643,30 @@ export function createAI(deps) {
612
643
  if (waiter) waiter(trace);
613
644
  }
614
645
 
615
- return { status, saveSettings, explain, startFix, getFix, send, apply, keep, revert, escalate, stop, onReplayTrace, replayEnabled: () => settings.replay };
646
+ /** Tests a pasted key (free) and saves it only if Claude accepts it. */
647
+ async function setKey(key) {
648
+ key = typeof key === 'string' ? key.trim() : '';
649
+ if (!looksLikeKey(key)) throw new Error('That does not look like a Claude API key. It starts with sk-ant-.');
650
+ const problem = await testKey(key);
651
+ if (problem) throw new Error(problem);
652
+ saveKey(key);
653
+ return status();
654
+ }
655
+
656
+ async function forgetKey() {
657
+ removeKey();
658
+ return status();
659
+ }
660
+
661
+ async function pullModel(model) {
662
+ startPull(model);
663
+ return status();
664
+ }
665
+
666
+ async function stopPullModel() {
667
+ stopPull();
668
+ return status();
669
+ }
670
+
671
+ return { setKey, forgetKey, pullModel, stopPullModel, status, saveSettings, explain, startFix, getFix, send, apply, keep, revert, escalate, stop, onReplayTrace, replayEnabled: () => settings.replay };
616
672
  }
package/ai/providers.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
- import { join } from 'node:path';
2
+ import { totalmem } from 'node:os';
3
+ import { delimiter, join } from 'node:path';
4
+ import { currentKey, lastFour } from './credentials.js';
3
5
  import { costUsd } from './router.js';
4
6
 
5
7
  /**
@@ -29,6 +31,7 @@ Risks or assumptions, or why there are no edits.
29
31
 
30
32
  Rules for edits:
31
33
  - An EDIT replaces whole lines, from the first to the last number given (inclusive), using the numbers shown. The code block is everything that goes there instead; it can have more or fewer lines. An empty code block deletes the lines.
34
+ - Every EDIT uses the original line numbers shown, even after an earlier EDIT adds or removes lines. Never renumber.
32
35
  - Write as many EDIT blocks as needed, or none if no change is safe. CONFIDENCE is high, medium or low.
33
36
  - Only edit lines you were shown, in files you were shown. Edits must not overlap.
34
37
  - Change as little as possible. Keep the code's style, names and types. Do not change behaviour: same results, same errors, same order of side effects. Only remove repeated work or run independent work together.
@@ -132,16 +135,95 @@ function mock(task, issue, root) {
132
135
  // ---- ollama ---------------------------------------------------------------------------
133
136
  export const OLLAMA = 'http://127.0.0.1:11434';
134
137
 
138
+ /** Coding models in sizes for 8, 16 and 32 GB of memory; download sizes as Ollama lists them. */
139
+ export const LOCAL_MODELS = [
140
+ { name: 'qwen2.5-coder:3b', sizeGb: 1.9, minRamGb: 0, note: 'fits 8 GB. Explains well; fixes are hit-and-miss.' },
141
+ { name: 'qwen2.5-coder:7b', sizeGb: 4.7, minRamGb: 14, note: 'needs 16 GB. Better fixes, still check them.' },
142
+ { name: 'qwen2.5-coder:14b', sizeGb: 9.0, minRamGb: 28, note: 'needs 32 GB. The best of the three.' },
143
+ ];
144
+
145
+ /** The biggest model this machine's memory can hold alongside the app and an editor. */
146
+ export function recommendedModel(ramGb = totalmem() / 1024 ** 3) {
147
+ return [...LOCAL_MODELS].reverse().find((m) => ramGb >= m.minRamGb) || LOCAL_MODELS[0];
148
+ }
149
+
150
+ /** Whether the Ollama app or command is on this machine, even when it is not running. */
151
+ function ollamaInstalled() {
152
+ const exe = process.platform === 'win32' ? 'ollama.exe' : 'ollama';
153
+ const places = [
154
+ ...(process.env.PATH || '').split(delimiter).map((d) => join(d, exe)),
155
+ '/Applications/Ollama.app',
156
+ join(process.env.HOME || '', 'Applications', 'Ollama.app'),
157
+ join(process.env.LOCALAPPDATA || '', 'Programs', 'Ollama', 'ollama.exe'),
158
+ ];
159
+ return places.some((p) => p && existsSync(p));
160
+ }
161
+
135
162
  export async function ollamaStatus() {
163
+ const ramGb = Math.round(totalmem() / 1024 ** 3);
164
+ const base = { ramGb, recommended: recommendedModel(ramGb).name, catalog: LOCAL_MODELS, pull: pullState };
136
165
  try {
137
166
  const res = await fetch(`${OLLAMA}/api/tags`, { signal: AbortSignal.timeout(1500) });
138
167
  const body = await res.json();
139
- return { running: true, models: (body.models || []).map((m) => m.name) };
168
+ return { ...base, installed: true, running: true, models: (body.models || []).map((m) => m.name) };
140
169
  } catch {
141
- return { running: false, models: [] };
170
+ return { ...base, installed: ollamaInstalled(), running: false, models: [] };
142
171
  }
143
172
  }
144
173
 
174
+ // ---- downloading a model, started from the dashboard ------------------------------------
175
+ let pullState = null;
176
+ let pullAbort = null;
177
+
178
+ /** Starts downloading a model through Ollama's own API; progress is read from ollamaStatus().pull. */
179
+ export function startPull(model) {
180
+ if (!/^[\w.-]{1,60}(:[\w.-]{1,40})?$/.test(model || '')) throw new Error('Not a model name.');
181
+ if (pullState && !pullState.done) throw new Error(`Already downloading ${pullState.model}.`);
182
+ pullAbort = new AbortController();
183
+ pullState = { model, status: 'starting', completed: 0, total: 0, done: false, error: null };
184
+ const state = pullState;
185
+ (async () => {
186
+ try {
187
+ const res = await fetch(`${OLLAMA}/api/pull`, {
188
+ method: 'POST',
189
+ headers: { 'content-type': 'application/json' },
190
+ body: JSON.stringify({ model, stream: true }),
191
+ signal: pullAbort.signal,
192
+ });
193
+ if (!res.ok) throw new Error(`Ollama answered ${res.status}.`);
194
+ const decoder = new TextDecoder();
195
+ let buffer = '';
196
+ // Ollama reports each layer separately; the big one is the model itself, so show the largest.
197
+ for await (const chunk of res.body) {
198
+ buffer += decoder.decode(chunk, { stream: true });
199
+ let nl;
200
+ while ((nl = buffer.indexOf('\n')) >= 0) {
201
+ const line = buffer.slice(0, nl).trim();
202
+ buffer = buffer.slice(nl + 1);
203
+ if (!line) continue;
204
+ const part = JSON.parse(line);
205
+ if (part.error) throw new Error(part.error);
206
+ state.status = part.status || state.status;
207
+ if (part.total && part.total >= state.total) {
208
+ state.total = part.total;
209
+ state.completed = part.completed || 0;
210
+ }
211
+ }
212
+ }
213
+ state.done = true;
214
+ state.status = 'success';
215
+ } catch (err) {
216
+ state.done = true;
217
+ state.error = err.name === 'AbortError' ? 'Download stopped.' : `Download failed: ${err.message}`;
218
+ }
219
+ })();
220
+ return state;
221
+ }
222
+
223
+ export function stopPull() {
224
+ if (pullAbort) pullAbort.abort();
225
+ }
226
+
145
227
  /** Streams the answer, reporting tokens as they arrive, then parses the finished JSON. */
146
228
  async function ollama(task, messages, model, onProgress, signal) {
147
229
  let res;
@@ -163,10 +245,11 @@ async function ollama(task, messages, model, onProgress, signal) {
163
245
  });
164
246
  } catch (err) {
165
247
  if (signal && signal.aborted) throw new Error('Stopped.');
166
- throw new Error('Ollama is not running. Start it (brew services start ollama) and try again.');
248
+ throw new Error('Ollama is not running. Open the Ollama app, then try again.');
167
249
  }
168
250
  if (!res.ok) {
169
251
  const body = await res.json().catch(() => ({}));
252
+ if (/not found/i.test(body.error || '')) throw new Error(`The local model ${model} is not downloaded yet. Download it in AI settings.`);
170
253
  throw new Error(body.error ? `Ollama: ${body.error}` : `Ollama answered ${res.status}.`);
171
254
  }
172
255
  let text = '';
@@ -217,23 +300,46 @@ function safeJson(text) {
217
300
  }
218
301
 
219
302
  // ---- anthropic (the user's own key) -------------------------------------------------------
303
+ // A client per key, so pasting or removing a key in the dashboard takes effect at once.
220
304
  let sdk = null;
221
- async function client() {
222
- if (!sdk) {
305
+ async function client(key = currentKey().key) {
306
+ if (!sdk || sdk.key !== key) {
307
+ let mod;
223
308
  try {
224
- const mod = await import('@anthropic-ai/sdk');
225
- sdk = { Anthropic: mod.default, client: new mod.default() };
309
+ mod = await import('@anthropic-ai/sdk');
226
310
  } catch (err) {
227
- throw new Error(`Could not start the Claude client: ${err.message}. Set ANTHROPIC_API_KEY, or run "ant auth login".`);
311
+ throw new Error(`Could not load the Claude client: ${err.message}.`);
312
+ }
313
+ try {
314
+ sdk = { key, Anthropic: mod.default, client: new mod.default(key ? { apiKey: key } : {}) };
315
+ } catch {
316
+ throw new Error('No Claude API key. Paste one in AI settings.');
228
317
  }
229
318
  }
230
319
  return sdk;
231
320
  }
232
321
 
322
+ /** Where the key comes from. The key itself is never returned, only its last four characters. */
233
323
  export function anthropicStatus() {
234
- if (process.env.ANTHROPIC_API_KEY) return { configured: true, source: 'ANTHROPIC_API_KEY' };
235
- if (process.env.ANTHROPIC_AUTH_TOKEN) return { configured: true, source: 'ANTHROPIC_AUTH_TOKEN' };
236
- return { configured: false, source: 'an "ant auth login" profile, if you have one' };
324
+ const { key, source } = currentKey();
325
+ if (key) return { configured: true, source, last4: lastFour(key) };
326
+ if (process.env.ANTHROPIC_AUTH_TOKEN) return { configured: true, source: 'token', last4: '' };
327
+ return { configured: false, source: null, last4: '' };
328
+ }
329
+
330
+ /** Checks a key by listing models, which costs nothing. Returns null when it works, else the reason. */
331
+ export async function testKey(key) {
332
+ const { Anthropic, client: c } = await client(key);
333
+ try {
334
+ await c.models.list({ limit: 1 });
335
+ return null;
336
+ } catch (err) {
337
+ if (err instanceof Anthropic.AuthenticationError) return 'Claude rejected this key.';
338
+ if (err instanceof Anthropic.PermissionDeniedError) return 'This key is not allowed to use the API.';
339
+ if (err instanceof Anthropic.APIConnectionError) return 'Could not reach the Claude API. Check your connection.';
340
+ if (err instanceof Anthropic.APIError) return `Claude error ${err.status}: ${err.message}`;
341
+ return err.message;
342
+ }
237
343
  }
238
344
 
239
345
  async function anthropic(task, messages, routeInfo) {
@@ -254,9 +360,14 @@ async function anthropic(task, messages, routeInfo) {
254
360
  ? await c.messages.create(params)
255
361
  : await c.beta.messages.create({ ...params, betas: ['server-side-fallback-2026-07-01'], fallbacks: 'default' });
256
362
  } catch (err) {
257
- if (err instanceof Anthropic.AuthenticationError) throw new Error('Claude rejected the key. Check ANTHROPIC_API_KEY.');
363
+ if (err instanceof Anthropic.AuthenticationError) throw new Error('Claude rejected the key. Paste a new one in AI settings.');
258
364
  if (err instanceof Anthropic.RateLimitError) throw new Error('Claude rate limit reached. Wait a minute and try again.');
259
- if (err instanceof Anthropic.APIError) throw new Error(`Claude error ${err.status}: ${err.message}`);
365
+ // The SDK's message carries the raw JSON body; show the API's own sentence instead.
366
+ const said = err?.error?.error?.message || err.message;
367
+ if (err instanceof Anthropic.APIError && /credit balance/i.test(said)) {
368
+ throw Object.assign(new Error('Your Anthropic account has no credit left, so nothing was sent or charged. Add credits in the Anthropic Console, then try again.'), { code: 'billing' });
369
+ }
370
+ if (err instanceof Anthropic.APIError) throw new Error(`Claude error ${err.status}: ${said}`);
260
371
  throw err;
261
372
  }
262
373
  const cost = costUsd(routeInfo.tier, response.usage);
package/analyze.js CHANGED
@@ -236,7 +236,11 @@ function percentile(values, p) {
236
236
  }
237
237
 
238
238
  /** Routes and issues across every trace kept. */
239
- export function summarize(traces, known = [], advice = []) {
239
+ /**
240
+ * fixed: issue key -> when a fix for it was kept. Such an issue is hidden until a request after
241
+ * that moment shows it again.
242
+ */
243
+ export function summarize(traces, known = [], advice = [], fixed = new Map()) {
240
244
  const routes = new Map();
241
245
  const issues = new Map();
242
246
  for (const trace of traces) {
@@ -259,6 +263,7 @@ export function summarize(traces, known = [], advice = []) {
259
263
  agg.seen++;
260
264
  agg.costTotal += issue.cost;
261
265
  agg.example = trace.id;
266
+ agg.lastAt = Math.max(agg.lastAt || 0, trace.at || 0);
262
267
  }
263
268
  }
264
269
  // Index advice: one issue per route that ran the query. Its location skips wrapper lines that
@@ -281,8 +286,9 @@ export function summarize(traces, known = [], advice = []) {
281
286
  const span = trace.spans.find((s) => s.kind === 'db' && normalizeSql(s.name) === sql);
282
287
  if (!span) continue;
283
288
  const route = routeOf(trace.method, trace.url, trace.template);
284
- if (!perRoute.has(route)) perRoute.set(route, { seen: 0, example: trace.id, span });
289
+ if (!perRoute.has(route)) perRoute.set(route, { seen: 0, example: trace.id, span, lastAt: 0 });
285
290
  perRoute.get(route).seen++;
291
+ perRoute.get(route).lastAt = Math.max(perRoute.get(route).lastAt, trace.at || 0);
286
292
  }
287
293
  const big = a.rows >= 1000;
288
294
  const name = `${a.table}_${a.columns.join('_')}_idx`.slice(0, 63);
@@ -302,6 +308,7 @@ export function summarize(traces, known = [], advice = []) {
302
308
  route,
303
309
  seen: info.seen,
304
310
  example: info.example,
311
+ lastAt: info.lastAt,
305
312
  costTotal: 0,
306
313
  });
307
314
  }
@@ -311,6 +318,12 @@ export function summarize(traces, known = [], advice = []) {
311
318
  for (const route of known) {
312
319
  if (!routes.has(route)) routes.set(route, { route, calls: 0, durations: [], queries: 0, redis: 0, http: 0, errors: 0, issueKeys: new Set(), last: 0 });
313
320
  }
321
+ for (const [key, issue] of issues) {
322
+ if (fixed.has(key) && fixed.get(key) >= (issue.lastAt || 0)) {
323
+ issues.delete(key);
324
+ routes.get(issue.route)?.issueKeys.delete(key);
325
+ }
326
+ }
314
327
  const routeList = [...routes.values()]
315
328
  .map((r) => ({
316
329
  route: r.route,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlens",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Run your Node backend through backlens and see every request, every query, and what is wrong with them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/server.js CHANGED
@@ -30,6 +30,8 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
30
30
  let config = manualArgv ? { app: null, test: null, autoRestart: false } : loadConfig(root);
31
31
 
32
32
  const traces = [];
33
+ // Issue key -> when its fix was kept (see summarize).
34
+ const fixedAt = new Map();
33
35
  let received = 0;
34
36
  const known = new Map();
35
37
  const advice = new Map();
@@ -207,15 +209,15 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
207
209
 
208
210
  // From the dashboard.
209
211
  if (req.method === 'GET' && url.pathname === '/api/state') {
210
- const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()]);
212
+ const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
211
213
  const recent = traces
212
214
  .slice(-300)
213
215
  .reverse()
214
- .map((t) => ({ id: t.id, at: t.at, method: t.method, url: t.url, route: routeOf(t.method, t.url, t.template), status: t.status, duration: t.duration, spans: t.spans.length, aborted: Boolean(t.aborted) }));
216
+ .map((t) => ({ id: t.id, at: t.at, method: t.method, url: t.url, route: routeOf(t.method, t.url, t.template), status: t.status, duration: t.duration, spans: t.spans.length, aborted: Boolean(t.aborted), afterFix: Boolean(t.afterFix) }));
215
217
  return json(res, 200, { routes, issues, recent, total: traces.length, received });
216
218
  }
217
219
  if (req.method === 'GET' && url.pathname === '/api/map') {
218
- const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
220
+ const { issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
219
221
  return json(res, 200, { ...mapOf(traces, issues), advice: [...advice.values()].map((a) => ({ table: a.table, columns: a.columns })) });
220
222
  }
221
223
  if (req.method === 'GET' && url.pathname.startsWith('/api/trace/')) {
@@ -228,12 +230,13 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
228
230
  traces.length = 0;
229
231
  received = 0;
230
232
  advice.clear();
233
+ fixedAt.clear();
231
234
  return json(res, 200, { ok: true });
232
235
  }
233
236
  if (req.method === 'POST' && url.pathname === '/api/snapshot') {
234
237
  const name = (url.searchParams.get('name') || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
235
238
  if (!name) return json(res, 400, { error: 'Give the snapshot a name.' });
236
- const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()]);
239
+ const { routes, issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
237
240
  mkdirSync(snapshotDir, { recursive: true });
238
241
  writeFileSync(join(snapshotDir, `${name}.json`), `${JSON.stringify({ name, at: Date.now(), requests: traces.length, routes: routes.filter((r) => r.calls), issues: issues.length }, null, 2)}\n`);
239
242
  return json(res, 200, { ok: true, name });
@@ -306,6 +309,10 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
306
309
  const p = url.pathname;
307
310
  if (req.method === 'GET' && p === '/api/ai/status') return json(res, 200, await ai.status());
308
311
  if (req.method === 'POST' && p === '/api/ai/settings') return json(res, 200, { settings: ai.saveSettings(body) });
312
+ if (req.method === 'POST' && p === '/api/ai/key') return json(res, 200, await ai.setKey(body.key));
313
+ if (req.method === 'POST' && p === '/api/ai/key/remove') return json(res, 200, await ai.forgetKey());
314
+ if (req.method === 'POST' && p === '/api/ai/ollama/pull') return json(res, 200, await ai.pullModel(body.model));
315
+ if (req.method === 'POST' && p === '/api/ai/ollama/stop') return json(res, 200, await ai.stopPullModel());
309
316
  if (req.method === 'POST' && p === '/api/ai/explain') return json(res, 200, await ai.explain(body.key));
310
317
  if (req.method === 'POST' && p === '/api/ai/fix') return json(res, 200, await ai.startFix(body.key));
311
318
  const m = p.match(/^\/api\/ai\/fix\/([0-9a-f-]{36})(?:\/(send|apply|keep|revert|escalate|stop))?$/);
@@ -356,10 +363,19 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
356
363
  pauseWatch: (on) => (watchPaused = on),
357
364
  testsTail: () => tests.lines.slice(-14).map((l) => l.text).join('\n'),
358
365
  getIssue: (key) => {
359
- const { issues } = summarize(traces, [...known.keys()], [...advice.values()]);
366
+ const { issues } = summarize(traces, [...known.keys()], [...advice.values()], fixedAt);
360
367
  return issues.find((i) => i.key === key) || (testRun.report ? testRun.report.issues.find((i) => i.key === key) : null);
361
368
  },
362
369
  getTrace: (id) => traces.find((t) => t.id === id) || testRun.traces.find((t) => t.id === id),
370
+ markFixed: (key, after) => {
371
+ fixedAt.set(key, Date.now());
372
+ if (after) {
373
+ const { replayOf, ...trace } = after;
374
+ traces.push({ ...trace, afterFix: true });
375
+ received++;
376
+ if (traces.length > KEEP) replays.delete(traces.shift().id);
377
+ }
378
+ },
363
379
  getReplay: (id) => replays.get(id),
364
380
  restartApp: async () => {
365
381
  app.port = null;
package/ui.html CHANGED
@@ -152,6 +152,20 @@
152
152
  .conf.high { background: #12301f; color: #8de0b0; } .conf.medium { background: var(--warn-bg); color: var(--warn); } .conf.low { background: #3a1a1c; color: #f3a6a6; }
153
153
  .opt-card { display: block; border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; cursor: pointer; }
154
154
  .opt-card:has(input:checked) { border-color: var(--accent); background: #ffffff05; }
155
+ .opt-card { cursor: default; } .opt-card > label { cursor: pointer; }
156
+ .opt-card a { color: var(--accent); }
157
+ a.btn { text-decoration: none; display: inline-block; }
158
+ .compare-card { border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin: 4px 0 14px; }
159
+ .compare-card h4 { margin: 0 0 8px; font-size: 13px; }
160
+ .compare-card table { width: auto; border-collapse: collapse; font: 13px var(--mono); }
161
+ .compare-card th { text-align: left; font-weight: 400; color: var(--sub); padding: 3px 18px 3px 0; }
162
+ .compare-card td { padding: 3px 10px 3px 0; }
163
+ .compare-card .arrow { color: var(--sub); }
164
+ .compare-card .better { color: #4ade80; } .compare-card .worse { color: #f87171; } .compare-card .same { color: var(--text); }
165
+ .compare-card .delta { font-size: 12px; }
166
+ .field input[type=password] { flex: 1; font: 13px var(--mono); color: var(--text); background: var(--bg); border: 1px solid var(--line-2); border-radius: 6px; padding: 6px 8px; }
167
+ .progress { height: 6px; border-radius: 3px; background: var(--raise); overflow: hidden; margin: 10px 0 0; }
168
+ .progress > div { height: 100%; background: var(--accent); transition: width .4s; }
155
169
  .opt-card b { font-size: 13px; } .opt-card small { display: block; color: var(--sub); font-size: 12px; margin-top: 4px; }
156
170
  .field { display: flex; align-items: center; gap: 10px; margin: 10px 0; font-size: 13px; color: var(--sub); }
157
171
  .field input[type=number], .field select { font: 13px var(--mono); color: var(--text); background: var(--bg); border: 1px solid var(--line-2); border-radius: 6px; padding: 5px 8px; }
@@ -357,7 +371,7 @@ function renderRequests() {
357
371
  return `<h2>${routeFilter ? `<button class="ghost" type="button" id="unfilter">${esc(routeFilter)} ✕</button>` : `latest ${list.length} requests`}</h2><table>
358
372
  <thead><tr><th>when</th><th>request</th><th class="n">status</th><th class="n">time</th><th class="n hide-sm">calls</th></tr></thead>
359
373
  <tbody>${list.map((t) => `<tr data-trace="${esc(t.id)}" class="${openTrace && openTrace.trace.id === t.id ? 'sel' : ''}">
360
- <td class="muted">${ago(t.at)}</td><td><span class="method">${esc(t.method)}</span><span class="route">${esc(t.url)}</span></td>
374
+ <td class="muted">${ago(t.at)}</td><td><span class="method">${esc(t.method)}</span><span class="route">${esc(t.url)}</span>${t.afterFix ? ' <span class="chip live">after fix</span>' : ''}</td>
361
375
  <td class="n ${statusClass(t.status)}">${t.aborted ? 'cancelled' : t.status}</td><td class="n">${ms(t.duration)}</td><td class="n hide-sm muted">${t.spans}</td></tr>`).join('')}</tbody></table>`;
362
376
  }
363
377
 
@@ -423,6 +437,11 @@ function renderSetup() {
423
437
  <div class="hintline">A script that rebuilds on change (like dev or start:dev) means you never press restart.</div></fieldset>
424
438
  <fieldset><legend>Run the tests with</legend>${group(scripts, isTest, (n, c) => opt('test', n, c, n === guessTest), guessTest)}${opt('test', '', 'no tests', !guessTest)}</fieldset>
425
439
  <label class="toggle" style="margin-bottom:20px"><input type="checkbox" name="auto" ${current.autoRestart ? 'checked' : ''} /> restart the app when a file changes (for scripts that do not watch)</label>
440
+ ${cfg.needsSetup ? `<fieldset><legend>Explain and fix issues with</legend>
441
+ <label class="opt"><input type="radio" name="ai" value="ollama" checked /><span>Local model</span><code>free, private, runs on this computer</code></label>
442
+ <label class="opt"><input type="radio" name="ai" value="anthropic" /><span>Claude</span><code>your own API key, strongest fixes</code></label>
443
+ <label class="opt"><input type="radio" name="ai" value="" /><span>Later</span><code>change it any time in AI settings</code></label>
444
+ <div class="hintline">The next screen walks you through it. Nothing is downloaded or paid without a click.</div></fieldset>` : ''}
426
445
  <div class="row-actions"><button class="primary" type="submit">${cfg.needsSetup ? 'Start app' : 'Save and restart'}</button>${!cfg.needsSetup ? '<button class="ghost" type="button" id="setup-cancel">Cancel</button>' : ''}<span class="error" role="alert">${esc(setupError)}</span></div>
427
446
  </form>`;
428
447
  }
@@ -691,7 +710,10 @@ function feedItem(e) {
691
710
  if (e.kind === 'ask') {
692
711
  const secs = e.status === 'running' && e.startedAt && fixJob ? Math.max(0.1, (fixJob.now - e.startedAt) / 1000 + (Date.now() - fixFetchedAt) / 1000) : 0;
693
712
  const live = e.status === 'running' ? `${secs.toFixed(0)} s${e.tokens ? `, ${e.tokens} tokens, ${(e.tokens / secs).toFixed(1)} per second` : ', reading the code…'}` : esc(e.detail || '');
694
- return `<li class="fi">${dot}<div><div class="fh"><b>${esc(e.title)}</b>${t}</div><div class="sub2">${live}</div>${e.status === 'running' ? '<button class="ghost" type="button" id="fix-stop" style="margin-top:6px">Stop</button>' : ''}</div></li>`;
713
+ // A local model under ~8 tokens a second takes minutes per answer: say why, and what helps.
714
+ const slow = e.status === 'running' && fixJob.route && fixJob.route.kind === 'ollama' && secs > 15 && e.tokens / secs < 8;
715
+ const hint = slow ? `<div class="sub2 muted">The local model is slow on this computer (it shares memory with your other apps). Closing some speeds it up; Claude answers in seconds.</div>` : '';
716
+ return `<li class="fi">${dot}<div><div class="fh"><b>${esc(e.title)}</b>${t}</div><div class="sub2">${live}</div>${hint}${e.status === 'running' ? '<button class="ghost" type="button" id="fix-stop" style="margin-top:6px">Stop</button>' : ''}</div></li>`;
695
717
  }
696
718
  if (e.kind === 'verdict') return `<li class="fi">${dot}<div class="verdict ${e.status === 'done' ? 'good' : e.status === 'warn' ? 'meh' : 'bad'}">${esc(e.title)}</div></li>`;
697
719
  const verb = e.kind === 'read' ? 'Read' : e.kind === 'repair' ? '↻' : '';
@@ -726,6 +748,7 @@ function renderFix() {
726
748
  el.innerHTML = html; return;
727
749
  }
728
750
  html += `<ol class="feed">${j.events.map(feedItem).join('')}${j.stage === 'error' ? `<li class="fi"><span class="dot bad">●</span><div class="verdict bad">${esc(j.error)}</div></li>` : ''}</ol>`;
751
+ html += compareCard(j);
729
752
  const act = [];
730
753
  if (j.stage === 'proposed') act.push('<button class="primary" type="button" id="fix-apply">Apply and check</button><button class="ghost" type="button" id="fix-close-2">Discard</button>');
731
754
  if (j.stage === 'no-edits') act.push(`<span class="note">No change was applied.</span>${escalateBtn(j)}`);
@@ -740,7 +763,29 @@ function renderFix() {
740
763
  el.scrollTop = atBottom || busy ? el.scrollHeight : keepScroll;
741
764
  }
742
765
 
766
+ /** The same request before and after the change, side by side. */
767
+ function compareCard(j) {
768
+ const c = j.compare;
769
+ if (!c || !c.before || !['verified', 'failed', 'unverified', 'kept', 'reverted'].includes(j.stage)) return '';
770
+ const pct = (a, b) => (a ? Math.round(((b - a) / a) * 100) : 0);
771
+ const row = (label, a, b, fmt = (x) => x, lowerIsBetter = true) => {
772
+ const d = pct(a, b);
773
+ const tone = a === b ? 'same' : (b < a) === lowerIsBetter ? 'better' : 'worse';
774
+ return `<tr><th>${label}</th><td>${fmt(a)}</td><td class="arrow">→</td><td class="${tone}">${fmt(b)}</td><td class="${tone} delta">${a === b ? '' : `${d > 0 ? '+' : ''}${d}%`}</td></tr>`;
775
+ };
776
+ const kind = esc((j.issueTitle || '').split(':')[0]);
777
+ return `<section class="compare-card"><h4>Same request, before and after the change</h4><table>
778
+ ${row('time', c.before.ms, c.after.ms, (x) => ms(x))}
779
+ ${row('database queries', c.before.queries, c.after.queries)}
780
+ ${c.before.redis || c.after.redis ? row('redis calls', c.before.redis, c.after.redis) : ''}
781
+ ${c.before.http || c.after.http ? row('outgoing http', c.before.http, c.after.http) : ''}
782
+ <tr><th>status</th><td>${c.before.status}</td><td class="arrow">→</td><td class="${c.before.status === c.after.status ? 'same' : 'worse'}">${c.after.status}</td><td></td></tr>
783
+ <tr><th>issue</th><td>${kind || 'found'}</td><td class="arrow">→</td><td class="${c.issueGone ? 'better' : 'worse'}">${c.issueGone ? 'gone' : 'still there'}</td><td></td></tr>
784
+ </table><p class="note">One request each${c.warmed ? ', the after one measured once the restarted app was warm' : ''}. Query counts are exact; times vary a few ms from run to run.</p></section>`;
785
+ }
786
+
743
787
  function escalateBtn(j) {
788
+ if (j.errorCode === 'billing') return `<a class="btn primary" href="https://console.anthropic.com/settings/billing" target="_blank" rel="noopener">Add credits</a>`;
744
789
  return j.attempt === 0 && j.route.kind === 'anthropic' ? `<button class="ghost" type="button" id="fix-escalate">Try a stronger model</button>` : '';
745
790
  }
746
791
 
@@ -753,25 +798,105 @@ async function pollFix() {
753
798
  }
754
799
  setInterval(pollFix, 600);
755
800
 
801
+ let aiNotice = '';
802
+ const gb = (n) => `${n.toFixed(1)} GB`;
803
+
804
+ /** The local model card's body: install, open, download, then ready. Polled while the panel is open. */
805
+ function localBody(o, s) {
806
+ if (!o.running && !o.installed) {
807
+ return `<small>Runs an AI model on this computer: free, and your code never leaves it. Install Ollama, open it, and this card updates by itself.</small>
808
+ <span class="field"><a class="btn primary" href="https://ollama.com/download" target="_blank" rel="noopener">Get Ollama</a><span class="muted">waiting for Ollama…</span></span>`;
809
+ }
810
+ if (!o.running) {
811
+ return `<small>Ollama is installed but not running. Open the Ollama app; this card updates by itself.</small>
812
+ <span class="field"><span class="muted">waiting for Ollama…</span></span>`;
813
+ }
814
+ const names = [...new Set([...o.catalog.map((m) => m.name), ...o.models])];
815
+ const info = (n) => o.catalog.find((m) => m.name === n);
816
+ const label = (n) => {
817
+ const m = info(n);
818
+ const have = o.models.includes(n);
819
+ return `${n}${n === o.recommended ? ' (best for this computer)' : ''}${have ? ' · downloaded' : m ? ` · ${gb(m.sizeGb)} download` : ''}`;
820
+ };
821
+ const chosen = s.ollamaModel;
822
+ const ready = o.models.includes(chosen);
823
+ const pull = o.pull && o.pull.model === chosen ? o.pull : null;
824
+ let action = '';
825
+ if (pull && !pull.done) {
826
+ const pct = pull.total ? Math.floor((pull.completed / pull.total) * 100) : 0;
827
+ action = `<div class="progress"><div style="width:${pct}%"></div></div>
828
+ <span class="field"><span class="muted">${pull.total ? `${pct}% of ${gb(pull.total / 1024 ** 3)}` : esc(pull.status)}</span><button class="ghost" type="button" id="ai-pull-stop">Stop</button></span>`;
829
+ } else if (!ready) {
830
+ const m = info(chosen);
831
+ action = `<span class="field"><button class="primary" type="button" id="ai-pull" data-model="${esc(chosen)}">Download ${esc(chosen)}${m ? ` (${gb(m.sizeGb)})` : ''}</button>
832
+ ${pull && pull.error ? `<span class="error">${esc(pull.error)}</span>` : ''}</span>`;
833
+ }
834
+ const note = info(chosen) ? `<small>${esc(chosen)} ${esc(info(chosen).note)} This computer has ${o.ramGb} GB of memory.</small>` : '';
835
+ return `<small>${ready ? 'Ready. Code never leaves this machine.' : 'Ollama is running. Download a model to use it.'}</small>
836
+ <span class="field">model <select name="ollamaModel">${names.map((n) => `<option value="${esc(n)}" ${n === chosen ? 'selected' : ''}>${esc(label(n))}</option>`).join('')}</select></span>
837
+ ${action}${note}`;
838
+ }
839
+
840
+ function claudeBody(a, s) {
841
+ const keyLine = a.configured
842
+ ? `<span class="field"><span>Using the key ending in <code>${esc(a.last4 || '…')}</code>, ${a.source === 'env' ? 'from ANTHROPIC_API_KEY' : a.source === 'saved' ? 'saved on this computer' : 'from your environment'}.</span>${a.source === 'saved' ? '<button class="ghost" type="button" id="ai-key-remove">Remove</button>' : ''}</span>`
843
+ : `<span class="field"><input type="password" id="ai-key" placeholder="sk-ant-…" autocomplete="off" spellcheck="false" /><button class="primary" type="button" id="ai-key-save">Check and save</button></span>
844
+ <small>Create one at <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener">console.anthropic.com</a>. Checking it is free. It is saved in your user folder (~/.config/backlens), never in this project, and never shown again.</small>`;
845
+ return `<small>The strongest fixes. backlens picks Haiku, Sonnet or Opus per issue and shows the cost before sending.</small>
846
+ ${keyLine}
847
+ <span class="field">daily budget $ <input type="number" name="dailyBudgetUsd" min="0" step="0.5" value="${s.dailyBudgetUsd}" /> <span>spent today ${usd(aiStatus.spentTodayUsd)}</span></span>`;
848
+ }
849
+
756
850
  async function renderAISettings() {
757
851
  const el = $('#aiset');
758
852
  try { aiStatus = await (await api('/api/ai/status')).json(); } catch { el.innerHTML = '<p class="error">Could not load AI settings.</p>'; return; }
759
853
  const s = aiStatus.settings, o = aiStatus.ollama, a = aiStatus.anthropic;
760
- const models = o.models.length ? o.models : [s.ollamaModel];
761
- el.innerHTML = `<button class="ghost close" type="button" id="aiset-close">Close</button><h2>AI settings</h2><p class="muted" style="font-size:13px">Who explains issues and suggests fixes. Saved in .backlens/ai.json.</p>
854
+ el.innerHTML = `<button class="ghost close" type="button" id="aiset-close">Close</button><h2>AI settings</h2><p class="muted" style="font-size:13px">Who explains issues and suggests fixes. Changes save as you make them.</p>
855
+ <p class="muted" style="font-size:12px">Some fixes never need a model: running independent calls together (Promise.all) and missing indexes are written by backlens itself.</p>
762
856
  <form id="aiset-form"><section>
763
- <label class="opt-card"><input type="radio" name="provider" value="ollama" ${s.provider === 'ollama' ? 'checked' : ''}/> <b>Local model</b> <span class="chip">free</span> <span class="chip">private</span>
764
- <small>${o.running ? `Ollama is running. Code never leaves this machine.` : 'Ollama is not running. Install it from ollama.com, or brew install ollama, then brew services start ollama.'}</small>
765
- <span class="field">model <select name="ollamaModel">${models.map((m) => `<option ${m === s.ollamaModel ? 'selected' : ''}>${esc(m)}</option>`).join('')}</select></span></label>
766
- <label class="opt-card"><input type="radio" name="provider" value="mock" ${s.provider === 'mock' ? 'checked' : ''}/> <b>Recorded answers</b> <span class="chip">free</span>
767
- <small>No model at all. Explain uses backlens's own words; fixes come from .backlens/ai-fixtures.json. For trying the flow.</small></label>
768
- <label class="opt-card"><input type="radio" name="provider" value="anthropic" ${s.provider === 'anthropic' ? 'checked' : ''}/> <b>Claude, with your own key</b> <span class="chip">paid by you</span>
769
- <small>${a.configured ? `Key found in ${esc(a.source)}.` : `No key found. Set ANTHROPIC_API_KEY before starting backlens, or use ${esc(a.source)}.`} backlens picks Haiku, Sonnet or Opus per issue and shows the cost before sending.</small>
770
- <span class="field">daily budget $ <input type="number" name="dailyBudgetUsd" min="0" step="0.5" value="${s.dailyBudgetUsd}" /> <span>spent today ${usd(aiStatus.spentTodayUsd)}</span></span></label>
857
+ <div class="opt-card ${s.provider === 'ollama' ? 'on' : ''}"><label><input type="radio" name="provider" value="ollama" ${s.provider === 'ollama' ? 'checked' : ''}/> <b>Local model</b> <span class="chip">free</span> <span class="chip">private</span></label>
858
+ <div id="ai-local">${localBody(o, s)}</div></div>
859
+ <div class="opt-card ${s.provider === 'anthropic' ? 'on' : ''}"><label><input type="radio" name="provider" value="anthropic" ${s.provider === 'anthropic' ? 'checked' : ''}/> <b>Claude, with your own key</b> <span class="chip">paid by you</span></label>
860
+ <div id="ai-claude">${claudeBody(a, s)}</div></div>
861
+ <div class="opt-card ${s.provider === 'mock' ? 'on' : ''}"><label><input type="radio" name="provider" value="mock" ${s.provider === 'mock' ? 'checked' : ''}/> <b>No model</b> <span class="chip">free</span></label>
862
+ <small>Explanations in backlens's own words. Fixes only where backlens can write them itself.</small></div>
771
863
  </section>
772
864
  <label class="toggle" style="margin:6px 0 6px"><input type="checkbox" name="replay" ${s.replay ? 'checked' : ''}/> keep recent requests in memory so a fix can be replayed (never written to disk, never sent to a model)</label>
773
865
  <label class="toggle" style="margin:0 0 16px"><input type="checkbox" name="replayWrites" ${s.replayWrites ? 'checked' : ''}/> also replay requests that change data (POST, PUT, DELETE): they run again against your dev database</label>
774
- <div class="actions"><button class="primary" type="submit">Save</button><span class="muted" style="font-size:12px;align-self:center">${aiStatus.history} fix${aiStatus.history === 1 ? '' : 'es'} in local history</span></div></form>`;
866
+ <div class="actions"><span class="${aiNotice.startsWith('!') ? 'error' : 'muted'}" role="status" style="font-size:12px;align-self:center">${esc(aiNotice.replace(/^!/, ''))}</span><span class="muted" style="font-size:12px;align-self:center;margin-left:auto">${aiStatus.history} fix${aiStatus.history === 1 ? '' : 'es'} in local history</span></div></form>`;
867
+ }
868
+
869
+ // While the panel is open, follow Ollama starting and model downloads without redrawing the key box.
870
+ setInterval(async () => {
871
+ if ($('#aiset').hidden || !aiStatus) return;
872
+ const o = aiStatus.ollama;
873
+ const waiting = !o.running || (o.pull && !o.pull.done) || !o.models.includes(aiStatus.settings.ollamaModel);
874
+ if (!waiting) return;
875
+ try {
876
+ const next = await (await api('/api/ai/status')).json();
877
+ const finished = o.pull && !o.pull.done && next.ollama.pull && next.ollama.pull.done;
878
+ aiStatus = next;
879
+ if (finished) { aiNotice = next.ollama.pull.error ? '!' + next.ollama.pull.error : `${next.ollama.pull.model} downloaded.`; return renderAISettings(); }
880
+ const box = $('#ai-local');
881
+ if (box && !box.contains(document.activeElement)) box.innerHTML = localBody(next.ollama, next.settings);
882
+ } catch {}
883
+ }, 1500);
884
+
885
+ async function saveAISettings() {
886
+ const f = new FormData($('#aiset-form'));
887
+ try {
888
+ await aiPost('/api/ai/settings', { provider: f.get('provider'), ollamaModel: f.get('ollamaModel') || aiStatus.settings.ollamaModel, dailyBudgetUsd: f.get('dailyBudgetUsd'), replay: f.get('replay') === 'on', replayWrites: f.get('replayWrites') === 'on' });
889
+ aiNotice = 'Saved.';
890
+ } catch (err) { aiNotice = '!' + err.message; }
891
+ renderAISettings();
892
+ }
893
+ document.addEventListener('change', (e) => { if (e.target.closest('#aiset-form') && e.target.id !== 'ai-key') saveAISettings(); });
894
+
895
+ async function aiAction(button, path, body, done) {
896
+ button.disabled = true;
897
+ aiNotice = '';
898
+ try { await aiPost(path, body); aiNotice = done; } catch (err) { aiNotice = '!' + err.message; }
899
+ renderAISettings();
775
900
  }
776
901
 
777
902
  document.addEventListener('click', async (e) => {
@@ -807,16 +932,21 @@ document.addEventListener('click', async (e) => {
807
932
  if (action === 'keep' || action === 'revert') { lastTotal = -1; poll(); }
808
933
  return;
809
934
  }
810
- if (e.target.closest('#ai-open')) { $('#aiset').hidden = false; renderAISettings(); return; }
935
+ if (e.target.closest('#ai-pull')) { const b = e.target.closest('#ai-pull'); return aiAction(b, '/api/ai/ollama/pull', { model: b.dataset.model }, ''); }
936
+ if (e.target.closest('#ai-pull-stop')) return aiAction(e.target.closest('#ai-pull-stop'), '/api/ai/ollama/stop', {}, '');
937
+ if (e.target.closest('#ai-key-save')) {
938
+ const input = $('#ai-key');
939
+ const key = input.value.trim();
940
+ input.value = '';
941
+ if (!key) { aiNotice = '!Paste a key first.'; return renderAISettings(); }
942
+ aiNotice = 'Checking the key…';
943
+ $('#ai-key-save').textContent = 'Checking…';
944
+ return aiAction(e.target.closest('#ai-key-save'), '/api/ai/key', { key }, 'Key works and is saved. Pick Claude above to use it.');
945
+ }
946
+ if (e.target.closest('#ai-key-remove')) return aiAction(e.target.closest('#ai-key-remove'), '/api/ai/key/remove', {}, 'Key removed from this computer.');
947
+ if (e.target.closest('#ai-open')) { aiNotice = ''; $('#aiset').hidden = false; renderAISettings(); return; }
811
948
  if (e.target.closest('#aiset-close')) { $('#aiset').hidden = true; return; }
812
949
  });
813
- document.addEventListener('submit', async (e) => {
814
- if (e.target.id !== 'aiset-form') return;
815
- e.preventDefault();
816
- const f = new FormData(e.target);
817
- await aiPost('/api/ai/settings', { provider: f.get('provider'), ollamaModel: f.get('ollamaModel'), dailyBudgetUsd: f.get('dailyBudgetUsd'), replay: f.get('replay') === 'on', replayWrites: f.get('replayWrites') === 'on' });
818
- renderAISettings();
819
- });
820
950
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { if (!$('#aiset').hidden) $('#aiset').hidden = true; else if (fixJob && !['thinking', 'verifying', 'reverting'].includes(fixJob.stage)) { fixJob = null; renderFix(); } } });
821
951
 
822
952
  function render() {
@@ -955,10 +1085,27 @@ document.addEventListener('submit', async (e) => {
955
1085
  const res = await api('/api/config', { method: 'POST', body: JSON.stringify({ app: f.get('app'), test: f.get('test') || null, autoRestart: f.get('auto') === 'on' }) });
956
1086
  if (!res.ok) { setupError = (await res.json()).error || 'Could not save.'; render(); return; }
957
1087
  showSetup = false;
1088
+ const ai = f.get('ai');
1089
+ if (ai) {
1090
+ try { await aiPost('/api/ai/settings', { provider: ai }); } catch {}
1091
+ }
958
1092
  await loadConfig();
959
1093
  testInfo = null;
960
1094
  render();
961
1095
  pollApp();
1096
+ // Open the AI settings when the chosen helper still needs a step (Ollama, a model, a key).
1097
+ if (ai) {
1098
+ try {
1099
+ const st = await (await api('/api/ai/status')).json();
1100
+ const ready = ai === 'ollama' ? st.ollama.running && st.ollama.models.includes(st.settings.ollamaModel) : st.anthropic.configured;
1101
+ if (!ready) { aiNotice = ''; $('#aiset').hidden = false; renderAISettings(); }
1102
+ } catch {}
1103
+ }
1104
+ });
1105
+ document.addEventListener('submit', (e) => {
1106
+ if (e.target.id !== 'aiset-form') return;
1107
+ e.preventDefault();
1108
+ if (document.activeElement && document.activeElement.id === 'ai-key') $('#ai-key-save').click();
962
1109
  });
963
1110
  document.addEventListener('click', async (e) => {
964
1111
  if (e.target.closest('#setup-open') || e.target.closest('#setup-open-2')) { showSetup = true; render(); return; }