backlens 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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) {
@@ -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) {
@@ -612,5 +629,30 @@ export function createAI(deps) {
612
629
  if (waiter) waiter(trace);
613
630
  }
614
631
 
615
- return { status, saveSettings, explain, startFix, getFix, send, apply, keep, revert, escalate, stop, onReplayTrace, replayEnabled: () => settings.replay };
632
+ /** Tests a pasted key (free) and saves it only if Claude accepts it. */
633
+ async function setKey(key) {
634
+ key = typeof key === 'string' ? key.trim() : '';
635
+ if (!looksLikeKey(key)) throw new Error('That does not look like a Claude API key. It starts with sk-ant-.');
636
+ const problem = await testKey(key);
637
+ if (problem) throw new Error(problem);
638
+ saveKey(key);
639
+ return status();
640
+ }
641
+
642
+ async function forgetKey() {
643
+ removeKey();
644
+ return status();
645
+ }
646
+
647
+ async function pullModel(model) {
648
+ startPull(model);
649
+ return status();
650
+ }
651
+
652
+ async function stopPullModel() {
653
+ stopPull();
654
+ return status();
655
+ }
656
+
657
+ return { setKey, forgetKey, pullModel, stopPullModel, status, saveSettings, explain, startFix, getFix, send, apply, keep, revert, escalate, stop, onReplayTrace, replayEnabled: () => settings.replay };
616
658
  }
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,7 +360,7 @@ 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
365
  if (err instanceof Anthropic.APIError) throw new Error(`Claude error ${err.status}: ${err.message}`);
260
366
  throw err;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backlens",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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
@@ -306,6 +306,10 @@ export function startServer({ port, root = process.cwd(), token, manualArgv = nu
306
306
  const p = url.pathname;
307
307
  if (req.method === 'GET' && p === '/api/ai/status') return json(res, 200, await ai.status());
308
308
  if (req.method === 'POST' && p === '/api/ai/settings') return json(res, 200, { settings: ai.saveSettings(body) });
309
+ if (req.method === 'POST' && p === '/api/ai/key') return json(res, 200, await ai.setKey(body.key));
310
+ if (req.method === 'POST' && p === '/api/ai/key/remove') return json(res, 200, await ai.forgetKey());
311
+ if (req.method === 'POST' && p === '/api/ai/ollama/pull') return json(res, 200, await ai.pullModel(body.model));
312
+ if (req.method === 'POST' && p === '/api/ai/ollama/stop') return json(res, 200, await ai.stopPullModel());
309
313
  if (req.method === 'POST' && p === '/api/ai/explain') return json(res, 200, await ai.explain(body.key));
310
314
  if (req.method === 'POST' && p === '/api/ai/fix') return json(res, 200, await ai.startFix(body.key));
311
315
  const m = p.match(/^\/api\/ai\/fix\/([0-9a-f-]{36})(?:\/(send|apply|keep|revert|escalate|stop))?$/);
package/ui.html CHANGED
@@ -152,6 +152,12 @@
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
+ .opt-card .btn { text-decoration: none; display: inline-block; }
158
+ .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; }
159
+ .progress { height: 6px; border-radius: 3px; background: var(--raise); overflow: hidden; margin: 10px 0 0; }
160
+ .progress > div { height: 100%; background: var(--accent); transition: width .4s; }
155
161
  .opt-card b { font-size: 13px; } .opt-card small { display: block; color: var(--sub); font-size: 12px; margin-top: 4px; }
156
162
  .field { display: flex; align-items: center; gap: 10px; margin: 10px 0; font-size: 13px; color: var(--sub); }
157
163
  .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; }
@@ -423,6 +429,11 @@ function renderSetup() {
423
429
  <div class="hintline">A script that rebuilds on change (like dev or start:dev) means you never press restart.</div></fieldset>
424
430
  <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
431
  <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>
432
+ ${cfg.needsSetup ? `<fieldset><legend>Explain and fix issues with</legend>
433
+ <label class="opt"><input type="radio" name="ai" value="ollama" checked /><span>Local model</span><code>free, private, runs on this computer</code></label>
434
+ <label class="opt"><input type="radio" name="ai" value="anthropic" /><span>Claude</span><code>your own API key, strongest fixes</code></label>
435
+ <label class="opt"><input type="radio" name="ai" value="" /><span>Later</span><code>change it any time in AI settings</code></label>
436
+ <div class="hintline">The next screen walks you through it. Nothing is downloaded or paid without a click.</div></fieldset>` : ''}
426
437
  <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
438
  </form>`;
428
439
  }
@@ -691,7 +702,10 @@ function feedItem(e) {
691
702
  if (e.kind === 'ask') {
692
703
  const secs = e.status === 'running' && e.startedAt && fixJob ? Math.max(0.1, (fixJob.now - e.startedAt) / 1000 + (Date.now() - fixFetchedAt) / 1000) : 0;
693
704
  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>`;
705
+ // A local model under ~8 tokens a second takes minutes per answer: say why, and what helps.
706
+ const slow = e.status === 'running' && fixJob.route && fixJob.route.kind === 'ollama' && secs > 15 && e.tokens / secs < 8;
707
+ 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>` : '';
708
+ 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
709
  }
696
710
  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
711
  const verb = e.kind === 'read' ? 'Read' : e.kind === 'repair' ? '↻' : '';
@@ -753,25 +767,105 @@ async function pollFix() {
753
767
  }
754
768
  setInterval(pollFix, 600);
755
769
 
770
+ let aiNotice = '';
771
+ const gb = (n) => `${n.toFixed(1)} GB`;
772
+
773
+ /** The local model card's body: install, open, download, then ready. Polled while the panel is open. */
774
+ function localBody(o, s) {
775
+ if (!o.running && !o.installed) {
776
+ 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>
777
+ <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>`;
778
+ }
779
+ if (!o.running) {
780
+ return `<small>Ollama is installed but not running. Open the Ollama app; this card updates by itself.</small>
781
+ <span class="field"><span class="muted">waiting for Ollama…</span></span>`;
782
+ }
783
+ const names = [...new Set([...o.catalog.map((m) => m.name), ...o.models])];
784
+ const info = (n) => o.catalog.find((m) => m.name === n);
785
+ const label = (n) => {
786
+ const m = info(n);
787
+ const have = o.models.includes(n);
788
+ return `${n}${n === o.recommended ? ' (best for this computer)' : ''}${have ? ' · downloaded' : m ? ` · ${gb(m.sizeGb)} download` : ''}`;
789
+ };
790
+ const chosen = s.ollamaModel;
791
+ const ready = o.models.includes(chosen);
792
+ const pull = o.pull && o.pull.model === chosen ? o.pull : null;
793
+ let action = '';
794
+ if (pull && !pull.done) {
795
+ const pct = pull.total ? Math.floor((pull.completed / pull.total) * 100) : 0;
796
+ action = `<div class="progress"><div style="width:${pct}%"></div></div>
797
+ <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>`;
798
+ } else if (!ready) {
799
+ const m = info(chosen);
800
+ action = `<span class="field"><button class="primary" type="button" id="ai-pull" data-model="${esc(chosen)}">Download ${esc(chosen)}${m ? ` (${gb(m.sizeGb)})` : ''}</button>
801
+ ${pull && pull.error ? `<span class="error">${esc(pull.error)}</span>` : ''}</span>`;
802
+ }
803
+ const note = info(chosen) ? `<small>${esc(chosen)} ${esc(info(chosen).note)} This computer has ${o.ramGb} GB of memory.</small>` : '';
804
+ return `<small>${ready ? 'Ready. Code never leaves this machine.' : 'Ollama is running. Download a model to use it.'}</small>
805
+ <span class="field">model <select name="ollamaModel">${names.map((n) => `<option value="${esc(n)}" ${n === chosen ? 'selected' : ''}>${esc(label(n))}</option>`).join('')}</select></span>
806
+ ${action}${note}`;
807
+ }
808
+
809
+ function claudeBody(a, s) {
810
+ const keyLine = a.configured
811
+ ? `<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>`
812
+ : `<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>
813
+ <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>`;
814
+ return `<small>The strongest fixes. backlens picks Haiku, Sonnet or Opus per issue and shows the cost before sending.</small>
815
+ ${keyLine}
816
+ <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>`;
817
+ }
818
+
756
819
  async function renderAISettings() {
757
820
  const el = $('#aiset');
758
821
  try { aiStatus = await (await api('/api/ai/status')).json(); } catch { el.innerHTML = '<p class="error">Could not load AI settings.</p>'; return; }
759
822
  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>
823
+ 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>
824
+ <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
825
  <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>
826
+ <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>
827
+ <div id="ai-local">${localBody(o, s)}</div></div>
828
+ <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>
829
+ <div id="ai-claude">${claudeBody(a, s)}</div></div>
830
+ <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>
831
+ <small>Explanations in backlens's own words. Fixes only where backlens can write them itself.</small></div>
771
832
  </section>
772
833
  <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
834
  <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>`;
835
+ <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>`;
836
+ }
837
+
838
+ // While the panel is open, follow Ollama starting and model downloads without redrawing the key box.
839
+ setInterval(async () => {
840
+ if ($('#aiset').hidden || !aiStatus) return;
841
+ const o = aiStatus.ollama;
842
+ const waiting = !o.running || (o.pull && !o.pull.done) || !o.models.includes(aiStatus.settings.ollamaModel);
843
+ if (!waiting) return;
844
+ try {
845
+ const next = await (await api('/api/ai/status')).json();
846
+ const finished = o.pull && !o.pull.done && next.ollama.pull && next.ollama.pull.done;
847
+ aiStatus = next;
848
+ if (finished) { aiNotice = next.ollama.pull.error ? '!' + next.ollama.pull.error : `${next.ollama.pull.model} downloaded.`; return renderAISettings(); }
849
+ const box = $('#ai-local');
850
+ if (box && !box.contains(document.activeElement)) box.innerHTML = localBody(next.ollama, next.settings);
851
+ } catch {}
852
+ }, 1500);
853
+
854
+ async function saveAISettings() {
855
+ const f = new FormData($('#aiset-form'));
856
+ try {
857
+ 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' });
858
+ aiNotice = 'Saved.';
859
+ } catch (err) { aiNotice = '!' + err.message; }
860
+ renderAISettings();
861
+ }
862
+ document.addEventListener('change', (e) => { if (e.target.closest('#aiset-form') && e.target.id !== 'ai-key') saveAISettings(); });
863
+
864
+ async function aiAction(button, path, body, done) {
865
+ button.disabled = true;
866
+ aiNotice = '';
867
+ try { await aiPost(path, body); aiNotice = done; } catch (err) { aiNotice = '!' + err.message; }
868
+ renderAISettings();
775
869
  }
776
870
 
777
871
  document.addEventListener('click', async (e) => {
@@ -807,16 +901,21 @@ document.addEventListener('click', async (e) => {
807
901
  if (action === 'keep' || action === 'revert') { lastTotal = -1; poll(); }
808
902
  return;
809
903
  }
810
- if (e.target.closest('#ai-open')) { $('#aiset').hidden = false; renderAISettings(); return; }
904
+ if (e.target.closest('#ai-pull')) { const b = e.target.closest('#ai-pull'); return aiAction(b, '/api/ai/ollama/pull', { model: b.dataset.model }, ''); }
905
+ if (e.target.closest('#ai-pull-stop')) return aiAction(e.target.closest('#ai-pull-stop'), '/api/ai/ollama/stop', {}, '');
906
+ if (e.target.closest('#ai-key-save')) {
907
+ const input = $('#ai-key');
908
+ const key = input.value.trim();
909
+ input.value = '';
910
+ if (!key) { aiNotice = '!Paste a key first.'; return renderAISettings(); }
911
+ aiNotice = 'Checking the key…';
912
+ $('#ai-key-save').textContent = 'Checking…';
913
+ return aiAction(e.target.closest('#ai-key-save'), '/api/ai/key', { key }, 'Key works and is saved. Pick Claude above to use it.');
914
+ }
915
+ if (e.target.closest('#ai-key-remove')) return aiAction(e.target.closest('#ai-key-remove'), '/api/ai/key/remove', {}, 'Key removed from this computer.');
916
+ if (e.target.closest('#ai-open')) { aiNotice = ''; $('#aiset').hidden = false; renderAISettings(); return; }
811
917
  if (e.target.closest('#aiset-close')) { $('#aiset').hidden = true; return; }
812
918
  });
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
919
  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
920
 
822
921
  function render() {
@@ -955,10 +1054,27 @@ document.addEventListener('submit', async (e) => {
955
1054
  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
1055
  if (!res.ok) { setupError = (await res.json()).error || 'Could not save.'; render(); return; }
957
1056
  showSetup = false;
1057
+ const ai = f.get('ai');
1058
+ if (ai) {
1059
+ try { await aiPost('/api/ai/settings', { provider: ai }); } catch {}
1060
+ }
958
1061
  await loadConfig();
959
1062
  testInfo = null;
960
1063
  render();
961
1064
  pollApp();
1065
+ // Open the AI settings when the chosen helper still needs a step (Ollama, a model, a key).
1066
+ if (ai) {
1067
+ try {
1068
+ const st = await (await api('/api/ai/status')).json();
1069
+ const ready = ai === 'ollama' ? st.ollama.running && st.ollama.models.includes(st.settings.ollamaModel) : st.anthropic.configured;
1070
+ if (!ready) { aiNotice = ''; $('#aiset').hidden = false; renderAISettings(); }
1071
+ } catch {}
1072
+ }
1073
+ });
1074
+ document.addEventListener('submit', (e) => {
1075
+ if (e.target.id !== 'aiset-form') return;
1076
+ e.preventDefault();
1077
+ if (document.activeElement && document.activeElement.id === 'ai-key') $('#ai-key-save').click();
962
1078
  });
963
1079
  document.addEventListener('click', async (e) => {
964
1080
  if (e.target.closest('#setup-open') || e.target.closest('#setup-open-2')) { showSetup = true; render(); return; }