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 +6 -4
- package/ai/credentials.js +56 -0
- package/ai/fixes.js +50 -8
- package/ai/providers.js +119 -13
- package/package.json +1 -1
- package/server.js +4 -0
- package/ui.html +136 -20
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
|
|
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):
|
|
63
|
-
- **
|
|
64
|
-
- **
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
235
|
-
if (
|
|
236
|
-
return { configured:
|
|
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.
|
|
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
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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
|
-
<
|
|
764
|
-
<
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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"><
|
|
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-
|
|
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; }
|