bareloop 0.40.0 → 0.42.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +96 -1
  2. package/README.md +13 -21
  3. package/bareloop.context.md +141 -128
  4. package/package.json +5 -4
  5. package/src/authorflow.js +346 -38
  6. package/src/authoring.js +92 -7
  7. package/src/authorjob.js +82 -20
  8. package/src/authorreadout.js +89 -5
  9. package/src/authorrun.js +39 -30
  10. package/src/authorscout.js +112 -4
  11. package/src/bundle.js +7 -4
  12. package/src/bundlerun.js +4 -1
  13. package/src/cli.js +1 -1
  14. package/src/config.js +129 -29
  15. package/src/declaredclose.js +23 -12
  16. package/src/draftspend.js +4 -4
  17. package/src/facts.js +317 -0
  18. package/src/factwords.js +129 -0
  19. package/src/index.js +2 -4
  20. package/src/inputlook.js +301 -0
  21. package/src/interviewrun.js +17 -25
  22. package/src/job.js +15 -8
  23. package/src/jobblock.js +7 -2
  24. package/src/judged.js +11 -43
  25. package/src/keysfile.js +15 -7
  26. package/src/kinds.js +86 -5
  27. package/src/labelscan.js +170 -0
  28. package/src/modellist.js +84 -0
  29. package/src/monthly.js +23 -43
  30. package/src/pagefetch.js +125 -0
  31. package/src/panel/authorroutes.js +34 -16
  32. package/src/panel/authorsession.js +161 -80
  33. package/src/panel/index.html +553 -152
  34. package/src/panel/jobplan.js +34 -11
  35. package/src/panel/runroutes.js +3 -0
  36. package/src/panel/server.js +54 -18
  37. package/src/panel/settingsroutes.js +232 -173
  38. package/src/planrun.js +5 -4
  39. package/src/providerrows.js +226 -141
  40. package/src/providers.js +58 -332
  41. package/src/providershapes.js +18 -0
  42. package/src/replay.js +1 -1
  43. package/src/rubricstep.js +804 -0
  44. package/src/source.js +166 -1
  45. package/src/thinking.js +130 -0
  46. package/src/u-readout.js +1 -1
  47. package/src/userrun.js +64 -75
  48. package/types/authorflow.d.ts +272 -18
  49. package/types/authoring.d.ts +29 -1
  50. package/types/authorjob.d.ts +9 -7
  51. package/types/authorreadout.d.ts +27 -2
  52. package/types/authorscout.d.ts +72 -2
  53. package/types/config.d.ts +23 -6
  54. package/types/declaredclose.d.ts +4 -2
  55. package/types/draftspend.d.ts +8 -8
  56. package/types/facts.d.ts +71 -0
  57. package/types/factwords.d.ts +42 -0
  58. package/types/index.d.ts +1 -1
  59. package/types/inputlook.d.ts +238 -0
  60. package/types/job.d.ts +5 -5
  61. package/types/jobblock.d.ts +8 -1
  62. package/types/judged.d.ts +5 -36
  63. package/types/keysfile.d.ts +15 -4
  64. package/types/labelscan.d.ts +38 -0
  65. package/types/modellist.d.ts +50 -0
  66. package/types/monthly.d.ts +4 -3
  67. package/types/pagefetch.d.ts +71 -0
  68. package/types/panel/authorsession.d.ts +20 -11
  69. package/types/panel/jobplan.d.ts +12 -4
  70. package/types/panel/server.d.ts +18 -6
  71. package/types/providerrows.d.ts +192 -99
  72. package/types/providers.d.ts +34 -159
  73. package/types/providershapes.d.ts +10 -0
  74. package/types/rubricstep.d.ts +286 -0
  75. package/types/source.d.ts +94 -0
  76. package/types/thinking.d.ts +90 -0
  77. package/types/userrun.d.ts +17 -1
@@ -1,26 +1,35 @@
1
- // PANEL-BUILD.md P4a — Settings routes (`/api/settings/*`). A separate module from the
1
+ // rewritten-from: fwdloop src/panel/settings.js@f9449e8 (the Providers routes; the Money routes are bareloop's own)
2
+ // PANEL-BUILD.md P4a / P8 — Settings routes (`/api/settings/*`). A separate module from the
2
3
  // authoring routes on purpose: the chat/authoring session can reach NO route here (it has
3
4
  // no path to this file's handlers), and every route here is behind the same human-click
4
5
  // guard (per-server-start token + Origin/Host, `checkHumanGuard`) — GET too, because the
5
6
  // pages these serve name key variables and spend.
6
7
  //
7
- // Providers (P4b): the rows are the keys file's filled lines; Name / API shape / Base URL save to
8
- // config.json `keys.<ENV NAME>` (src/providerrows.js is the one owner of the rows).
8
+ // Providers (P8): the rows are the keys file's filled lines (plus a shell-only key that config
9
+ // already names); API shape / Base URL / old-max_tokens save to config.json `keys.<ENV NAME>`
10
+ // and the model lines to `models.<ENV NAME>` (src/providerrows.js is the one owner of the rows).
11
+ // Test and Refresh are ONE free GET of `<Base URL>/models` (src/modellist.js), never a completion,
12
+ // and write no spend row. Every refusal is a plain sentence in `say`; the page shows `say`, never
13
+ // a code or an HTTP status.
9
14
  //
10
15
  // Every write goes to `~/.config/bareloop/config.json` through `src/config.js` (the one
11
16
  // writer); the monthly limit is a person's own number, set by hand — nothing here (and
12
17
  // nothing the agent can reach) raises it on their behalf. Key VALUES never appear in any
13
18
  // response: names and found / not set only.
14
19
  import { checkHumanGuard } from './authorroutes.js';
15
- import { readConfig, updateConfig, ConfigError } from '../config.js';
20
+ import {
21
+ readConfig, updateConfig, ConfigError, providerFieldProblem, cleanBaseUrl, modelsProblem,
22
+ } from '../config.js';
16
23
  import { spendSummary, monthlyLimitOf } from '../monthly.js';
17
24
  import { spawn as realSpawn } from 'node:child_process';
18
25
  import { loadKeysEnv, keysFilePath, keysHome, filledKeyNames, ensureKeysFile } from '../keysfile.js';
19
- import { SHAPES, PRESET_KEY_NAMES, keyRows, endpointOf, defaultUrlOf, usableKey } from '../providerrows.js';
20
- import { apiKeyProblem, checkProviderReachable } from '../providers.js';
26
+ import { SHAPES } from '../providershapes.js';
27
+ import { keyRows, effectiveModels, usableKey, rowNamesOf } from '../providerrows.js';
28
+ import { apiKeyProblem } from '../providers.js';
29
+ import { listModels } from '../modellist.js';
21
30
 
22
- /** DeepSeek's own balance endpoint (same host as its models list). */
23
- const DEEPSEEK_BALANCE_URL = 'https://api.deepseek.com/user/balance';
31
+ const BROKEN_CONFIG = 'Your settings file (config.json) cannot be read. Fix or remove it, then look again.';
32
+ const BAD_KEY = 'The key has a space or a line break in it. Fix it in your keys file, then reload keys.';
24
33
 
25
34
  /**
26
35
  * `openFolderSpawn` / `platform` are test seams for the Open keys folder button (a real caller passes
@@ -36,11 +45,209 @@ export function createSettingsRoutes(opts) {
36
45
  * module; values never do.
37
46
  * @returns {ReturnType<typeof loadKeysEnv>} */
38
47
  const keys = () => loadKeysEnv({ env: opts.env, home: opts.home });
39
- /** the Providers rows: keys with a value in the file, over the saved settings
40
- * @param {Record<string, any>} config */
41
- const currentRows = (config) => keyRows({ filled: filledKeyNames(opts.home), config });
42
- /** @param {string} u @returns {string} */
43
- const hostOf = (u) => { try { return new URL(u).hostname; } catch { return ''; } };
48
+
49
+ /**
50
+ * The row names: every key in the keys file with a value, plus a key only the SHELL holds when
51
+ * config already names it (`keys.<ENV>` or `models.<ENV>`).
52
+ * @param {Record<string, any>} config
53
+ * @returns {{ filled: string[], shellOnly: string[], all: string[] }}
54
+ */
55
+ function rowNames(config) {
56
+ const filled = filledKeyNames(opts.home);
57
+ const all = rowNamesOf(filled, config, opts.env ?? process.env);
58
+ return { filled, shellOnly: all.filter((n) => !filled.includes(n)), all };
59
+ }
60
+ /** config.json, or a plain-words problem (never read as "nothing set"). @returns {{config: Record<string, any>, problem: string|null}} */
61
+ function config() {
62
+ const c = readConfig({ home: opts.home });
63
+ return c.problem ? { config: {}, problem: BROKEN_CONFIG } : { config: c.config, problem: null };
64
+ }
65
+ /** @param {string} refused @param {string} say @param {number} [status] */
66
+ const refuse = (refused, say, status = 400) => ({ status, body: { ok: false, refused, say } });
67
+ /** @param {number} status @param {any} body */
68
+ const reply = (status, body) => ({ status, body });
69
+ /** The row a request names, or null: only a key that is a row is a provider. @param {unknown} slot @param {Record<string, any>} cfg */
70
+ const slotOf = (slot, cfg) => (typeof slot === 'string' && rowNames(cfg).all.includes(slot) ? slot : null);
71
+
72
+ /** One row's key for a header only; or `{why}` in plain words. @param {string} slot @param {ReturnType<typeof loadKeysEnv>} k @returns {{ key?: string, why?: string }} */
73
+ function keyFor(slot, k) {
74
+ if (k.unreadable) return { why: String(k.warning) };
75
+ const value = k.env[slot];
76
+ if (value === undefined || value === '') return { why: 'No key set for this provider.' };
77
+ if (value !== 'null' && apiKeyProblem(value)) return { why: BAD_KEY };
78
+ return { key: /** @type {string} */ (usableKey(value)) };
79
+ }
80
+
81
+ /**
82
+ * Test and Refresh: the ONE free GET (src/modellist.js) for a row. `{unknown:true}` when the slot is not a row.
83
+ * @param {unknown} rawSlot
84
+ * @returns {Promise<{unknown: true}|{ok: true, models: string[], ms: number}|{ok: false, why: string}>}
85
+ */
86
+ async function listFor(rawSlot) {
87
+ const c = config();
88
+ const k = keys();
89
+ const slot = slotOf(rawSlot, c.config);
90
+ if (!slot) return { unknown: true };
91
+ if (c.problem) return { ok: false, why: c.problem };
92
+ const row = keyRows({ filled: [slot], config: c.config })[0];
93
+ // the address comes first: a row with no address has nothing to send yet
94
+ if (row.baseUrl.trim() === '') return { ok: false, why: `Type a Base URL for ${slot} in Settings.` };
95
+ const kf = keyFor(slot, k);
96
+ if (kf.why) return { ok: false, why: kf.why };
97
+ const r = await listModels({ shape: row.provider, baseUrl: row.baseUrl, key: /** @type {string} */ (kf.key), fetchImpl });
98
+ return r.ok ? r : { ok: false, why: r.why };
99
+ }
100
+
101
+ /** The Providers GET. */
102
+ function providers() {
103
+ const k = keys();
104
+ const c = config();
105
+ const names = rowNames(c.config);
106
+ const rows = keyRows({ filled: names.all, config: c.config });
107
+ const spend = spendSummary({ home: opts.home, now: opts.now, rows });
108
+ return reply(200, {
109
+ ok: true,
110
+ keysFile: { path: keysFilePath(opts.home), exists: k.exists, refusal: k.warning },
111
+ configProblem: c.problem,
112
+ shapes: SHAPES.map((x) => ({ id: x.id, label: x.label })),
113
+ rows: rows.map((r) => {
114
+ const raw = k.env[r.envName];
115
+ let keyStatus = 'not set';
116
+ if (k.unreadable) keyStatus = 'refused — see above';
117
+ else if (raw && raw !== 'null' && apiKeyProblem(raw)) keyStatus = BAD_KEY;
118
+ else if (names.filled.includes(r.envName)) keyStatus = 'set in file';
119
+ else if (names.shellOnly.includes(r.envName)) keyStatus = 'found in shell';
120
+ const own = spend.tokensByModel[r.envName] ?? {};
121
+ return {
122
+ slot: r.envName, label: r.envName, envVar: r.envName, keyStatus,
123
+ shape: r.provider, baseUrl: r.baseUrl, legacyMaxTokens: r.legacyMaxTokens,
124
+ tokens: spend.tokensByRow[r.envName] ?? 0,
125
+ models: r.models.map(({ model, use, inPerM, outPerM }) => ({
126
+ model, use, ...(inPerM === undefined ? {} : { inPerM }), ...(outPerM === undefined ? {} : { outPerM }),
127
+ // this model's own tokens from the spend rows that name it; absent = no row names it (never 0)
128
+ ...(Object.hasOwn(own, model) ? { tokens: own[model] } : {}),
129
+ })),
130
+ };
131
+ }),
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Save one model line: {slot, model, use?, inPerM?, outPerM?}. A field left out keeps the line's value; a price
137
+ * `null` clears it. The WHOLE list is judged by `modelsProblem` (the one rule) before anything is written.
138
+ * @param {any} body
139
+ */
140
+ function modelRoute(body) {
141
+ const c = config();
142
+ const k = keys();
143
+ const slot = slotOf(body?.slot, c.config);
144
+ if (!slot) return refuse('unknown-provider', 'That provider is not known.');
145
+ if (c.problem) return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
146
+ if (Object.hasOwn(body, 'cachedInPerM')) {
147
+ return refuse('bad-price', 'Cached input has no price of its own: it is priced as input. Nothing was saved.');
148
+ }
149
+ if (typeof body.model !== 'string') return refuse('bad-model', 'Pick a model line. Nothing was saved.');
150
+ const list = effectiveModels(slot, c.config).map((m) => ({ ...m }));
151
+ /** @type {Record<string, any>|undefined} */
152
+ let line = list.find((m) => m.model === body.model);
153
+ if (!line) { line = { model: body.model, use: false }; list.push(/** @type {any} */ (line)); }
154
+ if (Object.hasOwn(body, 'use')) line.use = body.use;
155
+ for (const f of /** @type {const} */ (['inPerM', 'outPerM'])) {
156
+ if (!Object.hasOwn(body, f)) continue;
157
+ if (body[f] === null) delete line[f];
158
+ else line[f] = body[f];
159
+ }
160
+ const problem = modelsProblem(list);
161
+ if (problem) return refuse('bad-model', problem);
162
+ try {
163
+ updateConfig({ models: { [slot]: list } }, { home: opts.home });
164
+ } catch (e) {
165
+ if (!(e instanceof ConfigError)) throw e;
166
+ return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
167
+ }
168
+ return reply(200, { ok: true, slot });
169
+ }
170
+
171
+ /** Save a row's shape, Base URL and old-max_tokens tick. Every field is judged first; write only if all pass. @param {any} body */
172
+ function providerRoute(body) {
173
+ const c = config();
174
+ const slot = slotOf(body?.slot, c.config);
175
+ if (!slot) return refuse('unknown-provider', 'That provider is not known.');
176
+ if (c.problem) return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
177
+ /** @type {Record<string, string>} */
178
+ const say = { shape: 'Pick an API shape from the list.', baseUrl: 'The Base URL must start with http:// or https://.', legacyMaxTokens: 'The legacy max_tokens tick must be on or off.' };
179
+ /** @type {Record<string, string|boolean|null>} */
180
+ const patch = {};
181
+ for (const key of Object.keys(say)) {
182
+ if (!Object.hasOwn(body, key)) continue;
183
+ const v = body[key];
184
+ const clearUrl = key === 'baseUrl' && v === '';
185
+ if (!clearUrl && providerFieldProblem(key, v) !== null) return refuse('bad-provider', `${say[key]} Nothing was saved.`);
186
+ patch[key] = clearUrl ? null : (key === 'baseUrl' ? cleanBaseUrl(v) : v);
187
+ }
188
+ if (Object.keys(patch).length === 0) return refuse('bad-provider', 'Nothing to save.');
189
+ try {
190
+ updateConfig({ keys: { [slot]: patch } }, { home: opts.home });
191
+ } catch (e) {
192
+ if (!(e instanceof ConfigError)) throw e;
193
+ return refuse('config-broken', `${BROKEN_CONFIG} Nothing was saved.`);
194
+ }
195
+ return reply(200, { ok: true, slot });
196
+ }
197
+
198
+ /**
199
+ * Open the FOLDER of the server's own keys file (never a file, never an editor, never a path from the request) in the OS file manager:
200
+ * argv array, no shell, detached. Another OS, a failed spawn, or a test process with no injected opener: `opened:false` + the path,
201
+ * which the page shows as text.
202
+ * @param {any} body
203
+ * @returns {Promise<{status: number, body: any}>}
204
+ */
205
+ function openKeysFolder(body) {
206
+ if (body !== null && typeof body === 'object' && Object.keys(body).length > 0) {
207
+ return Promise.resolve(refuse('no-path-allowed', 'The keys folder is opened from the panel\'s own settings; a path cannot be sent. Nothing was opened.'));
208
+ }
209
+ const dir = keysHome(opts.home);
210
+ const cannot = () => reply(200, { ok: true, opened: false, path: dir });
211
+ const platform = opts.platform ?? process.platform;
212
+ const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
213
+ // under a test process (NODE_ENV=test or `node --test`) nothing is spawned unless a test injected an opener
214
+ const underTest = process.env.NODE_ENV === 'test' || process.env.NODE_TEST_CONTEXT !== undefined;
215
+ const opener = opts.openFolderSpawn ?? (underTest ? null : realSpawn);
216
+ if (!cmd || !opener) return Promise.resolve(cannot());
217
+ return new Promise((resolve) => {
218
+ let child;
219
+ try { child = opener(cmd, [dir], { detached: true, stdio: 'ignore' }); } catch { resolve(cannot()); return; }
220
+ let settled = false;
221
+ child.once('error', () => { if (!settled) { settled = true; resolve(cannot()); } });
222
+ child.once('spawn', () => {
223
+ if (settled) return;
224
+ settled = true;
225
+ if (typeof child.unref === 'function') child.unref();
226
+ resolve(reply(200, { ok: true, opened: true, path: dir }));
227
+ });
228
+ });
229
+ }
230
+
231
+ /**
232
+ * The POST routes that answer asynchronously.
233
+ * @param {string} pathname
234
+ * @param {any} body
235
+ * @returns {Promise<{status: number, body: any}>|{status: number, body: any}}
236
+ */
237
+ function postRoute(pathname, body) {
238
+ if (pathname === '/api/settings/provider') return providerRoute(body);
239
+ if (pathname === '/api/settings/model') return modelRoute(body);
240
+ if (pathname === '/api/settings/open-keys-folder') return openKeysFolder(body);
241
+ return listFor(body?.slot).then((r) => {
242
+ if ('unknown' in r) return refuse('unknown-provider', 'That provider is not known.');
243
+ if (pathname === '/api/settings/test') return reply(200, r.ok ? { ok: true, ms: r.ms } : r);
244
+ // /api/settings/models
245
+ if (!r.ok) return reply(200, r);
246
+ const ids = r.models;
247
+ return reply(200, ids.length === 0 ? { ok: false, why: 'The provider did not list any models.' } : { ok: true, models: ids });
248
+ });
249
+ }
250
+
44
251
  /**
45
252
  * @param {import('node:http').IncomingMessage} req
46
253
  * @param {import('node:http').ServerResponse} res
@@ -61,7 +268,8 @@ export function createSettingsRoutes(opts) {
61
268
  if (pathname === '/api/settings/money') {
62
269
  if (req.method === 'GET') {
63
270
  const cfg = readConfig({ home: opts.home });
64
- const s = spendSummary({ home: opts.home, now: opts.now });
271
+ const rows = keyRows({ filled: rowNames(cfg.config).all, config: cfg.config });
272
+ const s = spendSummary({ home: opts.home, now: opts.now, rows });
65
273
  /** @type {number|null} */
66
274
  let limit = null;
67
275
  let configProblem = cfg.problem;
@@ -109,171 +317,22 @@ export function createSettingsRoutes(opts) {
109
317
  return true;
110
318
  }
111
319
 
112
- // ── Providers (P4b): one row per key in the keys file that has a value. ──
320
+ // ── Providers (P8) ──
113
321
  if (pathname === '/api/settings/providers' && req.method === 'GET') {
114
- // a missing keys file is created with the five empty preset lines (never edited if present)
115
- ensureKeysFile(PRESET_KEY_NAMES, opts.home);
116
- const cfg = readConfig({ home: opts.home });
117
- const k = keys();
118
- const rows = currentRows(cfg.config);
119
- const s = spendSummary({ home: opts.home, now: opts.now, rows });
120
- const note = cfg.config.anthropicBalanceNote;
121
- // a preset key exported only in the shell gets no row (the rows are the keys file): NAMES the
122
- // person can act on, read off the RAW shell env — never a value, never a length
123
- const shellEnv = opts.env ?? process.env;
124
- const filled = new Set(filledKeyNames(opts.home));
125
- const shellOnly = PRESET_KEY_NAMES.filter((n) => typeof shellEnv[n] === 'string' && shellEnv[n] !== '' && !filled.has(n));
126
- send(200, {
127
- ok: true,
128
- shellOnly,
129
- keysFile: { path: keysFilePath(opts.home), exists: k.exists, names: k.names, warning: k.warning },
130
- configProblem: cfg.problem,
131
- shapes: SHAPES.map((x) => ({ id: x.id, label: x.label })),
132
- rows: rows.map((r) => {
133
- const raw = k.env[r.envName];
134
- const problem = raw ? apiKeyProblem(raw) : null;
135
- const isDeepseek = r.provider === 'openai-api' && hostOf(endpointOf(r.provider, r.baseUrl)) === 'api.deepseek.com';
136
- return {
137
- envName: r.envName, name: r.name, shape: r.provider, baseUrl: r.baseUrl,
138
- placeholder: defaultUrlOf(r.provider),
139
- keyStatus: !raw ? 'not set' : (raw === 'null' ? 'no key needed' : (problem ? `bad shape (${problem})` : 'found')),
140
- canTest: r.provider !== 'gemini-api',
141
- priceInPerM: typeof r.priceInPerM === 'number' ? r.priceInPerM : null,
142
- priceOutPerM: typeof r.priceOutPerM === 'number' ? r.priceOutPerM : null,
143
- tokens: s.tokensByRow[r.envName] ?? 0,
144
- balance: r.provider === 'anthropic-api'
145
- ? { kind: 'note', usd: typeof note === 'number' && Number.isFinite(note) ? note : null }
146
- : (isDeepseek ? { kind: 'fetch' } : { kind: 'none' }),
147
- };
148
- }),
149
- });
150
- return true;
151
- }
152
-
153
- if (pathname === '/api/settings/providers/row' && req.method === 'POST') {
154
- const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
155
- if (!row) { send(400, { ok: false, error: 'unknown key — add NAME=key to your keys file and reload keys' }); return true; }
156
- // only the three settings a row has; each is optional in the body, the whole triple is stored
157
- /** @type {Record<string, string|number|null>} */
158
- const patch = {
159
- name: typeof body?.name === 'string' ? body.name.trim() : row.name,
160
- shape: typeof body?.shape === 'string' ? body.shape : row.provider,
161
- baseUrl: typeof body?.baseUrl === 'string' ? body.baseUrl.trim().replace(/\/+$/, '') : row.baseUrl,
162
- };
163
- // the two prices (USD per 1M tokens): absent = unchanged; blank / null = not set (the field is removed,
164
- // never written as 0); otherwise a finite number >= 0 — anything else is refused and nothing is saved
165
- for (const f of /** @type {const} */ (['priceInPerM', 'priceOutPerM'])) {
166
- if (!Object.hasOwn(body ?? {}, f)) continue;
167
- const v = body[f];
168
- const label = f === 'priceInPerM' ? 'In' : 'Out';
169
- if (v === null || (typeof v === 'string' && v.trim() === '')) { patch[f] = null; continue; }
170
- const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+(\.\d+)?$/.test(v.trim()) ? Number(v.trim()) : NaN);
171
- if (!(Number.isFinite(n) && n >= 0)) {
172
- send(400, { ok: false, error: `the ${label} price must be a number, 0 or more (USD per 1M tokens), or blank for not set` });
173
- return true;
174
- }
175
- patch[f] = n;
176
- }
177
- // a price is both fields or neither — ratesFor refuses a half-set one at run time, so never save half
178
- const after = (/** @type {'priceInPerM'|'priceOutPerM'} */ f) => (Object.hasOwn(patch, f) ? patch[f] !== null : row[f] !== undefined);
179
- if (after('priceInPerM') !== after('priceOutPerM')) {
180
- send(400, { ok: false, error: 'set both prices (In and Out, USD per 1M tokens) or neither — a half-set price is refused' });
181
- return true;
182
- }
183
- try {
184
- updateConfig({ keys: { [row.envName]: patch } }, { home: opts.home });
185
- } catch (e) {
186
- if (!(e instanceof ConfigError)) throw e;
187
- send(400, { ok: false, error: e.message });
188
- return true;
189
- }
190
- send(200, { ok: true, envName: row.envName, ...patch });
191
- return true;
192
- }
193
-
194
- if (pathname === '/api/settings/open-keys-folder' && req.method === 'POST') {
195
- // opens the FOLDER (never the file, never an editor) in the file manager; argv array, no shell, detached.
196
- // Another OS, or a failed spawn: say so and hand back the path, which the page then shows as text.
197
- const dir = keysHome(opts.home);
198
- const platform = opts.platform ?? process.platform;
199
- const cmd = platform === 'linux' ? 'xdg-open' : (platform === 'darwin' ? 'open' : null);
200
- const cannot = () => send(200, { ok: true, opened: false, path: dir });
201
- if (!cmd) { cannot(); return true; }
202
- let child;
203
- try {
204
- child = (opts.openFolderSpawn ?? realSpawn)(cmd, [dir], { detached: true, stdio: 'ignore' });
205
- } catch { cannot(); return true; }
206
- let settled = false;
207
- child.once('error', () => { if (!settled) { settled = true; cannot(); } });
208
- child.once('spawn', () => {
209
- if (settled) return;
210
- settled = true;
211
- if (typeof child.unref === 'function') child.unref();
212
- send(200, { ok: true, opened: true, path: dir });
213
- });
214
- return true;
215
- }
216
-
217
- if (pathname === '/api/settings/providers/balance-note' && req.method === 'POST') {
218
- // the Anthropic balance is a NOTE the person types; no check ever reads it
219
- const raw = body?.usd;
220
- const usd = raw === null || raw === '' || raw === undefined ? null : Number(raw);
221
- if (usd !== null && !(Number.isFinite(usd) && usd >= 0)) { send(400, { ok: false, error: 'the balance must be a number, 0 or more' }); return true; }
222
- try {
223
- updateConfig({ anthropicBalanceNote: usd }, { home: opts.home });
224
- } catch (e) {
225
- if (!(e instanceof ConfigError)) throw e;
226
- send(400, { ok: false, error: e.message });
227
- return true;
228
- }
229
- send(200, { ok: true, usd });
322
+ // a missing keys file is created with its two comment lines (never edited if present)
323
+ ensureKeysFile(opts.home);
324
+ const r = providers();
325
+ send(r.status, r.body);
230
326
  return true;
231
327
  }
232
-
233
- if (pathname === '/api/settings/providers/test' && req.method === 'POST') {
234
- const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === body?.envName);
235
- if (!row) { send(400, { ok: false, error: 'unknown key' }); return true; }
236
- const raw = keys().env[row.envName];
237
- if (!raw) { send(200, { ok: true, reachable: false, status: 'no key', note: `${row.envName} not set`, ms: null }); return true; }
238
- const problem = apiKeyProblem(raw);
239
- if (problem) { send(200, { ok: true, reachable: false, status: 'bad key', note: `${row.envName} ${problem}`, ms: null }); return true; }
240
- const started = Date.now();
241
- // $0: one GET of the models list with THIS row's shape + URL (never a completion), key only in a header
242
- checkProviderReachable({
243
- providerName: row.provider, apiKey: usableKey(raw), baseUrl: row.baseUrl.trim() !== '' ? row.baseUrl : undefined, fetchImpl, timeoutMs: 4000,
244
- }).then((r) => {
245
- send(200, { ok: true, reachable: r.reachable, status: r.status, note: r.note, ms: Date.now() - started });
246
- });
247
- return true;
248
- }
249
-
250
- if (pathname === '/api/settings/providers/balance' && req.method === 'GET') {
251
- // DeepSeek only: fetched server-side from the provider (key in a header, never returned)
252
- const q = new URL(/** @type {string} */ (req.url), 'http://127.0.0.1').searchParams;
253
- const row = currentRows(readConfig({ home: opts.home }).config).find((r) => r.envName === q.get('env'));
254
- if (!row || row.provider !== 'openai-api' || hostOf(endpointOf(row.provider, row.baseUrl)) !== 'api.deepseek.com') { send(404, { ok: false, error: 'not found' }); return true; }
255
- const raw = keys().env[row.envName];
256
- if (!raw || apiKeyProblem(raw)) { send(200, { ok: true, text: null, note: 'no usable key' }); return true; }
257
- const controller = new AbortController();
258
- const timer = setTimeout(() => controller.abort(), 4000);
259
- fetchImpl(DEEPSEEK_BALANCE_URL, { method: 'GET', headers: { Authorization: `Bearer ${raw}` }, signal: controller.signal })
260
- .then(async (res) => {
261
- if (!res.ok) { send(200, { ok: true, text: null, note: `HTTP ${res.status}` }); return; }
262
- /** @type {any} */
263
- let j = null;
264
- try { j = await res.json(); } catch { j = null; }
265
- const infos = Array.isArray(j?.balance_infos) ? j.balance_infos : [];
266
- const parts = infos
267
- .filter((/** @type {any} */ b) => b && typeof b.currency === 'string' && b.total_balance !== undefined)
268
- .map((/** @type {any} */ b) => `${String(b.total_balance)} ${b.currency}`);
269
- send(200, { ok: true, text: parts.length ? parts.join(' + ') : null, note: parts.length ? null : 'balance not reported' });
270
- })
271
- .catch((/** @type {any} */ e) => { send(200, { ok: true, text: null, note: `request failed: ${e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'error')}` }); })
272
- .finally(() => clearTimeout(timer));
328
+ const postPaths = ['/api/settings/provider', '/api/settings/model', '/api/settings/models', '/api/settings/test', '/api/settings/open-keys-folder'];
329
+ if (req.method === 'POST' && postPaths.includes(pathname)) {
330
+ Promise.resolve(postRoute(pathname, body)).then((r) => send(r.status, r.body));
273
331
  return true;
274
332
  }
275
333
 
276
- send(404, { ok: false, error: 'not found' });
334
+ const r = refuse('no-such-settings-route', 'Settings cannot do that. Keys are typed into your keys file by hand.', 404);
335
+ send(r.status, r.body);
277
336
  return true;
278
337
  }
279
338
  return { handle };
package/src/planrun.js CHANGED
@@ -45,6 +45,7 @@ import { defaultJudgeLoop } from './judged.js';
45
45
  export { rateSourceFields };
46
46
  import { createClock, isWallTimeout } from './clock.js';
47
47
  import { isDeclaredClose, runDeclaredStages, validateCloseDecl, closeGrade, HUMAN_PAUSE, HITL_PAUSE, HITL_DECISION_RED } from './declaredclose.js';
48
+ import { inputLabelsOf } from './rubricstep.js';
48
49
  import {
49
50
  seedAtHead, seedListing, changedSet, GAP_TRIM_MARKER, GATE_AUDIT_FILE, ARBITER_BOOK_STORES,
50
51
  HUMAN_KIND, HUMAN_DECISIONS, normalizeHumanRuling, resolveHumanRuling,
@@ -1685,7 +1686,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1685
1686
  // ride, so `runJob`'s ONE ledger accounts a close's spend exactly as it accounts
1686
1687
  // an attempt's (a budget funds the attempt PLUS its close), and a null cost trips
1687
1688
  // the same F6 pricing halt rather than being laundered into $0.
1688
- judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates }) : null,
1689
+ judgeLoop: judgeProvider ? (/** @type {{system: string}} */ o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgeRates, thinking: job.thinking === true }) : null,
1689
1690
  // the seam's other half (PRD item 32.1) — WHICH model that loop drives. Passed
1690
1691
  // through untouched: this module resolves no judge, exactly as it constructs no
1691
1692
  // provider. `runJudgedFloor` stops as a wiring gap when it is absent rather than
@@ -1886,7 +1887,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
1886
1887
  },
1887
1888
  decisionReady: true,
1888
1889
  decision: `The close minted ${outcome} and it STANDS. What happens to the work is yours: accept it, rerun it with your own words as the gap, or pause and come back.`
1889
- + (held ? ' This green was rendered by the judged floor, so it has earned no reuse until you accept it.' : ''),
1890
+ + (held ? ' This is a rubric (soft-green) job, so its green earns no reuse until you accept it, whether or not a judge ran.' : ''),
1890
1891
  options: [...HUMAN_DECISIONS],
1891
1892
  meaning: 'not a verdict — the verdict is already minted and nothing an answer does can change it; this records a disposition',
1892
1893
  });
@@ -2036,7 +2037,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2036
2037
  }
2037
2038
  const listed = await seedListing(workdir, s.seedRef);
2038
2039
  const v = listed.stop === null
2039
- ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope, jobLines: Array.isArray(job.jobLines) ? job.jobLines : null })
2040
+ ? validateCloseDecl(job.closeDecl, { at: 'closeDecl', listing: listed.files, verdictType: job.verdictType, writeScope: job.writeScope, jobLines: Array.isArray(job.jobLines) ? job.jobLines : null , inputLabels: inputLabelsOf(job.inputs) })
2040
2041
  : { ok: false, grounded: false, reds: [{ code: 'listing-unreadable', path: 'closeDecl', detail: listed.stop }] };
2041
2042
  emit('close-decl', {
2042
2043
  genre: job.closeDecl.genre,
@@ -2301,7 +2302,7 @@ export async function runPlan(job, { workdir, provider, nativeProvider, provider
2301
2302
  // chain, node_modules/bareguard/src/gate.js) — i.e. only the residual
2302
2303
  // case of an in-fence path that still carries one of these segments.
2303
2304
  tools: { denyArgPatterns: { write: [FORBIDDEN_WRITE_SEGMENT_PATTERN], edit: [FORBIDDEN_WRITE_SEGMENT_PATTERN] } },
2304
- budget: { maxCostUsd: Math.max(remainingUsd(), 0.0001) },
2305
+ budget: { maxCostUsd: Math.max(remainingUsd(), 1e-4) },
2305
2306
  limits: { maxTurns },
2306
2307
  audit: { path: auditPath },
2307
2308
  // The gate audit is the THIRD persistent, append-only channel (beside the