bareloop 0.31.0 → 0.33.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/CHANGELOG.md +151 -0
- package/README.md +4 -1
- package/bareloop.context.md +232 -85
- package/bin/bareloop.mjs +0 -1
- package/package.json +1 -1
- package/src/authorflow.js +5 -3
- package/src/authorjob.js +3 -1
- package/src/authorrun.js +49 -11
- package/src/authorscout.js +10 -6
- package/src/bundlerun.js +266 -0
- package/src/cli.js +35 -357
- package/src/config.js +135 -0
- package/src/interviewrun.js +13 -4
- package/src/invoke.js +22 -0
- package/src/judged.js +5 -3
- package/src/keysfile.js +156 -0
- package/src/ledger.js +26 -0
- package/src/monthly.js +422 -0
- package/src/panel/authorroutes.js +61 -16
- package/src/panel/authorsession.js +46 -28
- package/src/panel/index.html +287 -13
- package/src/panel/server.js +58 -51
- package/src/panel/settingsroutes.js +230 -0
- package/src/planrun.js +7 -3
- package/src/providerrows.js +301 -0
- package/src/providers.js +3 -1
- package/src/run.js +7 -2
- package/src/runlist.js +92 -7
- package/src/source.js +8 -3
- package/src/u-watchdog.mjs +296 -0
- package/src/userrun.js +230 -54
- package/types/authorflow.d.ts +8 -2
- package/types/authorjob.d.ts +6 -1
- package/types/authorrun.d.ts +5 -0
- package/types/authorscout.d.ts +11 -2
- package/types/bundlerun.d.ts +26 -0
- package/types/cli.d.ts +2 -1
- package/types/config.d.ts +48 -0
- package/types/interviewrun.d.ts +5 -0
- package/types/invoke.d.ts +8 -0
- package/types/judged.d.ts +5 -1
- package/types/keysfile.d.ts +90 -0
- package/types/ledger.d.ts +14 -0
- package/types/monthly.d.ts +227 -0
- package/types/panel/authorroutes.d.ts +4 -2
- package/types/panel/authorsession.d.ts +7 -15
- package/types/panel/server.d.ts +10 -3
- package/types/panel/settingsroutes.d.ts +14 -0
- package/types/planrun.d.ts +13 -1
- package/types/providerrows.d.ts +229 -0
- package/types/run.d.ts +14 -1
- package/types/runlist.d.ts +66 -3
- package/types/source.d.ts +8 -3
- package/types/userrun.d.ts +54 -0
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
// `src/panel/server.js`'s POST routes). Nothing here changes what a step
|
|
21
21
|
// means or what an answer does — only where the answer comes from.
|
|
22
22
|
|
|
23
|
+
import { applyConfiguredKey, judgeRatesFor, keyNameFor, modelChoiceFor, ratesFor, rowsForHome } from '../providerrows.js';
|
|
24
|
+
import { ConfigError } from '../config.js';
|
|
23
25
|
import {
|
|
24
26
|
mkdirSync, existsSync, writeFileSync, readdirSync,
|
|
25
27
|
} from 'node:fs';
|
|
26
|
-
import { join, resolve } from 'node:path';
|
|
28
|
+
import { join, resolve, dirname } from 'node:path';
|
|
27
29
|
import {
|
|
28
30
|
prepareSource, proveDestination, missingDependencies, looksLikeRepoSource,
|
|
29
31
|
} from '../source.js';
|
|
@@ -46,20 +48,6 @@ import { closeJudges } from '../kinds.js';
|
|
|
46
48
|
import { redactSecrets } from '../validate.js';
|
|
47
49
|
import { tallyCalls } from '../text.js';
|
|
48
50
|
|
|
49
|
-
/**
|
|
50
|
-
* The job card's "Model" select — a fixed, small, source-grounded mapping
|
|
51
|
-
* from a display id to the `{provider, baseUrl?}` pair `src/interviewrun.js`
|
|
52
|
-
* itself documents (that script's own `--provider openai-api --base-url
|
|
53
|
-
* https://api.deepseek.com/v1` usage line for DeepSeek): the panel builds no
|
|
54
|
-
* provider table of its own, this is a UI convenience over the SAME two
|
|
55
|
-
* provider identities the CLI already resolves through `resolveProvider`.
|
|
56
|
-
* @type {Readonly<Record<string, {provider: string, baseUrl?: string}>>}
|
|
57
|
-
*/
|
|
58
|
-
export const MODEL_OPTIONS = Object.freeze({
|
|
59
|
-
'claude-sonnet-5': Object.freeze({ provider: 'anthropic-api' }),
|
|
60
|
-
'deepseek-flash': Object.freeze({ provider: 'openai-api', baseUrl: 'https://api.deepseek.com/v1' }),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
51
|
/**
|
|
64
52
|
* Plain-language labels for the library's own `onPhase(name, …)` calls
|
|
65
53
|
* (`src/authorjob.js`/`src/authorflow.js`) — build item 3's progress
|
|
@@ -126,17 +114,18 @@ export function jobNameTaken(name, opts = {}) {
|
|
|
126
114
|
* creates a session or spends anything — never inside the async pipeline,
|
|
127
115
|
* so a bad card refuses synchronously, in the same HTTP response.
|
|
128
116
|
* @param {any} card
|
|
117
|
+
* @param {{jobsDir?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card
|
|
129
118
|
* @returns {{ok: true}|{ok: false, error: string}}
|
|
130
119
|
*/
|
|
131
|
-
export function validateJobCard(card) {
|
|
120
|
+
export function validateJobCard(card, opts = {}) {
|
|
132
121
|
if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
|
|
133
122
|
const { jobName, checkType, model, goal, source, destination, success, guardrails, capUsd } = card;
|
|
134
123
|
if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
|
|
135
|
-
if (jobNameTaken(jobName,
|
|
124
|
+
if (jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
|
|
136
125
|
return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
|
|
137
126
|
}
|
|
138
127
|
if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
|
|
139
|
-
if (!
|
|
128
|
+
if (!modelChoiceFor(opts.rows ?? [], model)) return { ok: false, error: 'Model must be one of the Names in Settings > Providers' };
|
|
140
129
|
for (const [field, label] of [[goal, 'Goal'], [source, 'Source'], [destination, 'Destination'], [success, 'Success'], [guardrails, 'Guardrails']]) {
|
|
141
130
|
if (typeof field !== 'string' || field.trim() === '') return { ok: false, error: `${label} is required` };
|
|
142
131
|
}
|
|
@@ -155,7 +144,7 @@ export function validateJobCard(card) {
|
|
|
155
144
|
* is the ONE thing a person answers next; `answer()`/`send()`/`revise()`/
|
|
156
145
|
* `signPrepare()` are the only ways in.
|
|
157
146
|
* @param {any} card the validated job card (see {@link validateJobCard})
|
|
158
|
-
* @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, timeoutMs?: number,
|
|
147
|
+
* @param {{env?: Record<string,string|undefined>, sessionsRoot?: string, home?: string, timeoutMs?: number,
|
|
159
148
|
* scout?: any, generate?: Function, confirmGenerate?: Function, authorFn?: Function,
|
|
160
149
|
* prepareSigningFn?: Function}} [deps] the last five are TEST SEAMS ONLY — see the note
|
|
161
150
|
* just above where each is read, below.
|
|
@@ -164,6 +153,8 @@ export function validateJobCard(card) {
|
|
|
164
153
|
export function createSession(card, deps = {}) {
|
|
165
154
|
const env = deps.env ?? process.env;
|
|
166
155
|
const sessionsRoot = deps.sessionsRoot ?? join(process.env.HOME ?? '/tmp', '.config', 'bareloop', 'panel-sessions');
|
|
156
|
+
// config.json lives beside the sessions dir (~/.config/bareloop) unless a home is injected
|
|
157
|
+
const configHome = deps.home ?? dirname(sessionsRoot);
|
|
167
158
|
const timeoutMs = deps.timeoutMs ?? 300_000;
|
|
168
159
|
// TEST SEAMS ONLY (never set by `src/panel/authorroutes.js`'s real caller):
|
|
169
160
|
// override the declaration composer and/or `prepareSigning` itself so a
|
|
@@ -309,13 +300,28 @@ export function createSession(card, deps = {}) {
|
|
|
309
300
|
|
|
310
301
|
async function run() {
|
|
311
302
|
// ── keys, at $0, before anything is prepared ────────────────────────
|
|
312
|
-
const
|
|
303
|
+
const keyRows = rowsForHome(configHome);
|
|
304
|
+
const modelChoice = modelChoiceFor(keyRows, card.model);
|
|
305
|
+
if (!modelChoice) { refuse(`Model "${card.model}" is not a Name in Settings > Providers. Stopped — nothing spent.`); return; }
|
|
313
306
|
let providerEntry;
|
|
314
307
|
try { providerEntry = resolveProvider(modelChoice.provider); } catch (e) { refuse(/** @type {Error} */ (e).message); return; }
|
|
315
|
-
|
|
316
|
-
|
|
308
|
+
// P4a item 4 — the key variable the person picked in Settings stands in for the built-in one
|
|
309
|
+
const keyCfg = keyRows;
|
|
310
|
+
const draftEnv = applyConfiguredKey(env, modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name);
|
|
311
|
+
const draftKeyName = keyNameFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name).name;
|
|
312
|
+
const apiKey = draftEnv[providerEntry.envKey];
|
|
313
|
+
if (!apiKey) { refuse(`${draftKeyName} not set. Stopped — nothing spent.`); return; }
|
|
314
|
+
// the customer's own price on this row (USD per 1M in config.json), resolved once here at $0 and
|
|
315
|
+
// handed to every model call this session makes; a bad price refuses, never falls back to the guess
|
|
316
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
317
|
+
let draftPrice = null;
|
|
318
|
+
try { draftPrice = ratesFor(modelChoice.provider, modelChoice.baseUrl, keyCfg, modelChoice.name); } catch (e) {
|
|
319
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
320
|
+
refuse(`${e.message}. Stopped — nothing spent.`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
317
323
|
const keyProblem = apiKeyProblem(apiKey);
|
|
318
|
-
if (keyProblem) { refuse(`${
|
|
324
|
+
if (keyProblem) { refuse(`${draftKeyName} ${keyProblem}. Stopped — nothing spent.`); return; }
|
|
319
325
|
|
|
320
326
|
const verdictType = card.checkType === 'rubric' ? 'soft-green' : 'green';
|
|
321
327
|
const into = join(outDir, 'source-seed');
|
|
@@ -389,6 +395,8 @@ export function createSession(card, deps = {}) {
|
|
|
389
395
|
description: `${card.jobName} — authored through the bareloop panel (${verdictType}, ${LANG})`,
|
|
390
396
|
provider: modelChoice.provider,
|
|
391
397
|
...(modelChoice.baseUrl ? { baseUrl: modelChoice.baseUrl } : {}),
|
|
398
|
+
// the Name is the model id: signed into the spec only when it is not the provider's own default
|
|
399
|
+
...(modelChoice.name !== providerEntry.tiers.sonnet ? { model: modelChoice.name } : {}),
|
|
392
400
|
cadence: { unit: 'day', every: 1 },
|
|
393
401
|
budgetUsd: card.capUsd,
|
|
394
402
|
...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
|
|
@@ -414,7 +422,7 @@ export function createSession(card, deps = {}) {
|
|
|
414
422
|
const draftJudge = (() => {
|
|
415
423
|
try { return resolveJobJudge(draft, modelChoice.provider, resolveWorkerModel); } catch { return { provider: modelChoice.provider, model: providerEntry.tiers.sonnet }; }
|
|
416
424
|
})();
|
|
417
|
-
const MODEL =
|
|
425
|
+
const MODEL = modelChoice.name;
|
|
418
426
|
const { provider } = buildRunnerProviders({
|
|
419
427
|
providerName: modelChoice.provider, apiKey, model: MODEL, tierModels: providerEntry.tiers, baseUrl: modelChoice.baseUrl,
|
|
420
428
|
judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: modelChoice.provider, judgeBaseUrl: modelChoice.baseUrl,
|
|
@@ -424,8 +432,8 @@ export function createSession(card, deps = {}) {
|
|
|
424
432
|
// `provider` for the model boundary) and/or `scout` (bypassing the real
|
|
425
433
|
// paid scout) so it can drive the REAL confirm turn/ask()/revise/hash
|
|
426
434
|
// machinery below with a deterministic fake, never a live provider call.
|
|
427
|
-
const generate = deps.generate ?? makeLoopGenerate(provider);
|
|
428
|
-
const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM });
|
|
435
|
+
const generate = deps.generate ?? makeLoopGenerate(provider, { rates: draftPrice?.rates ?? null });
|
|
436
|
+
const confirmGenerate = deps.confirmGenerate ?? makeLoopGenerate(provider, { system: CONFIRM_SYSTEM, rates: draftPrice?.rates ?? null });
|
|
429
437
|
|
|
430
438
|
state.phase = 'drafting';
|
|
431
439
|
state.progressLabel = 'drafting';
|
|
@@ -439,6 +447,7 @@ export function createSession(card, deps = {}) {
|
|
|
439
447
|
questions: questionsFor(verdictType),
|
|
440
448
|
writeScope, provider, generate,
|
|
441
449
|
ceilingUsd: card.capUsd,
|
|
450
|
+
rates: draftPrice?.rates ?? null,
|
|
442
451
|
onPhase, onCall,
|
|
443
452
|
ask, confirmGenerate, isRepo: true, langResult,
|
|
444
453
|
...(deps.scout ? { scout: deps.scout } : {}),
|
|
@@ -472,8 +481,17 @@ export function createSession(card, deps = {}) {
|
|
|
472
481
|
const judges = closeJudges(spec.closeDecl);
|
|
473
482
|
const judge = judges ? resolveJobJudge(spec, modelChoice.provider, resolveWorkerModel) : null;
|
|
474
483
|
let judgeProvider = null;
|
|
484
|
+
/** @type {ReturnType<typeof ratesFor>} */
|
|
485
|
+
let judgePrice = null;
|
|
475
486
|
if (judge) {
|
|
476
|
-
|
|
487
|
+
try {
|
|
488
|
+
judgePrice = judgeRatesFor(judge, modelChoice.provider, modelChoice.baseUrl, draftPrice, keyCfg);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
if (!(e instanceof ConfigError)) throw e;
|
|
491
|
+
refuse(`${e.message} — refusing before gate 4 spends anything`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
const judgeKeyValue = env.JUDGE_API_KEY ?? (judge.provider === modelChoice.provider ? draftEnv : applyConfiguredKey(env, judge.provider, undefined, keyCfg, judge.model))[resolveProvider(judge.provider).envKey];
|
|
477
495
|
const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
|
|
478
496
|
if (!judgeKeyValue || judgeKeyProblem) {
|
|
479
497
|
refuse(`the judge key is not usable (${judgeKeyProblem ?? 'not set'}) — refusing before gate 4 spends anything`);
|
|
@@ -488,7 +506,7 @@ export function createSession(card, deps = {}) {
|
|
|
488
506
|
const signing = await prepareSigningFn({
|
|
489
507
|
spec, workdir: prep.tree, seedRef: authored.seedRef, timeoutMs,
|
|
490
508
|
shellCapUsd: spec.budgetUsd, ceilingUsd: card.capUsd, priorCalls: [...metered],
|
|
491
|
-
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system }) : null,
|
|
509
|
+
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
|
|
492
510
|
judgeModel: judge?.model ?? null,
|
|
493
511
|
onJudgeCost: (c) => onCall({ label: `${c.label}:${c.id}`, costUsd: c.costUsd, unpricedRounds: c.unpricedRounds }),
|
|
494
512
|
});
|
package/src/panel/index.html
CHANGED
|
@@ -330,12 +330,9 @@
|
|
|
330
330
|
.expand-field:focus{height:52px;white-space:normal;}
|
|
331
331
|
.radio-row{display:flex;gap:14px;align-items:center;margin-bottom:10px;}
|
|
332
332
|
.radio-row label{display:flex;align-items:center;gap:5px;font-size:13px;color:var(--text);margin:0;text-transform:none;}
|
|
333
|
-
.cap-row{display:grid;grid-template-columns:1fr 1fr
|
|
333
|
+
.cap-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:6px;margin-bottom:6px;}
|
|
334
334
|
.cap-field label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:1px;}
|
|
335
335
|
.cap-field input{width:100%;padding:5px 6px;box-sizing:border-box;}
|
|
336
|
-
@media (max-width: 899px){
|
|
337
|
-
.cap-row{grid-template-columns:1fr;}
|
|
338
|
-
}
|
|
339
336
|
.btn.primary{background:var(--primary-bg);color:var(--primary-text);border-color:var(--primary-border);font-weight:700;}
|
|
340
337
|
.btn.primary::before,.btn.primary::after{color:var(--primary-text);opacity:0.6;}
|
|
341
338
|
.btn:disabled{opacity:0.5;cursor:not-allowed;}
|
|
@@ -377,6 +374,32 @@
|
|
|
377
374
|
.actions-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:stretch;}
|
|
378
375
|
.actions-row > .btn{flex:1 1 auto;}
|
|
379
376
|
|
|
377
|
+
/* Settings view (P4a) — same tokens as the rest of the page; the mockup's settings styles */
|
|
378
|
+
.settings-view{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;}
|
|
379
|
+
.settings-header{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:2px solid var(--border-strong);background:var(--panel);}
|
|
380
|
+
.settings-header h2{margin:0;font-size:15px;display:inline;}
|
|
381
|
+
.settings-header h2::before{content:"┤ ";color:var(--text-faint);}
|
|
382
|
+
.settings-header h2::after{content:" ├";color:var(--text-faint);}
|
|
383
|
+
.money-strip{display:grid;grid-template-columns:repeat(3, 1fr);gap:12px;align-items:end;padding:10px;border:1px solid var(--border-strong);background:var(--panel2);}
|
|
384
|
+
.money-item{min-width:0;}
|
|
385
|
+
.money-item label{display:block;font-size:11px;font-weight:700;color:var(--text-dim);margin-bottom:2px;}
|
|
386
|
+
.money-value{font-size:16px;font-weight:700;padding:6px 0;overflow-wrap:anywhere;}
|
|
387
|
+
.money-item input{width:100%;height:44px;box-sizing:border-box;}
|
|
388
|
+
.keyfile-strip{position:relative;border:1px solid var(--border-strong);padding:12px 12px 8px;background:var(--panel2);margin:14px 0;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;}
|
|
389
|
+
.keyfile-strip::before{content:"┤ KEYS ├";position:absolute;top:-9px;left:10px;background:var(--panel2);padding:0 4px;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;}
|
|
390
|
+
.keyfile-strip-main{display:flex;flex-direction:column;gap:3px;flex:1;min-width:220px;}
|
|
391
|
+
.keyfile-strip-actions{display:flex;gap:6px;flex:none;}
|
|
392
|
+
.keyfile-strip code{background:var(--bg);border:1px solid var(--border-strong);padding:1px 5px;font-size:11px;color:var(--amber);overflow-wrap:anywhere;}
|
|
393
|
+
.pv-table td,.pv-table th{white-space:nowrap;vertical-align:top;}
|
|
394
|
+
.pv-table select,.pv-table input{min-height:32px;max-width:190px;box-sizing:border-box;}
|
|
395
|
+
.pv-table .hint{display:block;margin-top:3px;}
|
|
396
|
+
/* the Test cell keeps ONE width whatever the result says ("testing…", "OK · 312 ms", a long failure note) */
|
|
397
|
+
.pv-table td.pv-test-cell{width:180px;min-width:180px;max-width:180px;}
|
|
398
|
+
.pv-table .pv-test-result{white-space:normal;overflow-wrap:anywhere;}
|
|
399
|
+
.table-wrap{overflow-x:auto;border:1px solid var(--border-strong);background:var(--panel2);max-width:100%;}
|
|
400
|
+
@media (max-width: 899px){
|
|
401
|
+
.money-strip{grid-template-columns:1fr 1fr;}
|
|
402
|
+
}
|
|
380
403
|
@media (max-width: 899px){
|
|
381
404
|
.main{flex-direction:column;}
|
|
382
405
|
.left-pane{width:100%;border-right:none;border-bottom:1px solid var(--border-strong);}
|
|
@@ -402,6 +425,7 @@
|
|
|
402
425
|
</div>
|
|
403
426
|
<div class="topbar-right">
|
|
404
427
|
<span class="hint" id="host-line"></span>
|
|
428
|
+
<button class="btn small" type="button" id="btn-settings" data-testid="btn-settings">⚙ Settings</button>
|
|
405
429
|
<button class="theme-toggle" id="theme-toggle" type="button" aria-label="Toggle light/dark theme">☽</button>
|
|
406
430
|
</div>
|
|
407
431
|
</header>
|
|
@@ -431,10 +455,7 @@
|
|
|
431
455
|
</div>
|
|
432
456
|
<div class="field">
|
|
433
457
|
<label for="jf-model">Model</label>
|
|
434
|
-
<select id="jf-model">
|
|
435
|
-
<option value="claude-sonnet-5">claude-sonnet-5 · Anthropic</option>
|
|
436
|
-
<option value="deepseek-flash">deepseek-flash · DeepSeek</option>
|
|
437
|
-
</select>
|
|
458
|
+
<select id="jf-model" data-testid="jf-model"></select>
|
|
438
459
|
<!-- build item 5 (2026-09-28) — ONE line under Model, not two:
|
|
439
460
|
key + reachability collapsed into a single readiness read. -->
|
|
440
461
|
<div class="jf-readiness unknown" id="jf-model-status" data-testid="jf-model-status"></div>
|
|
@@ -472,15 +493,12 @@
|
|
|
472
493
|
<div class="cap-field">
|
|
473
494
|
<label for="jf-cap-money">Cap $</label>
|
|
474
495
|
<input id="jf-cap-money" type="text">
|
|
496
|
+
<div class="hint" id="jf-cap-note" data-testid="cap-note"></div>
|
|
475
497
|
</div>
|
|
476
498
|
<div class="cap-field">
|
|
477
499
|
<label for="jf-cap-time">Time cap (min)</label>
|
|
478
500
|
<input id="jf-cap-time" type="text" placeholder="or blank = no wall">
|
|
479
501
|
</div>
|
|
480
|
-
<div class="cap-field">
|
|
481
|
-
<label for="jf-price">Token price $</label>
|
|
482
|
-
<input id="jf-price" type="text" placeholder="est." disabled>
|
|
483
|
-
</div>
|
|
484
502
|
</div>
|
|
485
503
|
<button class="btn primary" type="button" id="chat-start-btn" data-testid="chat-start" disabled>Start drafting</button>
|
|
486
504
|
<div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
|
|
@@ -620,6 +638,74 @@
|
|
|
620
638
|
|
|
621
639
|
</div>
|
|
622
640
|
|
|
641
|
+
<!-- SETTINGS VIEW (P4a) — reads/writes ~/.config/bareloop/config.json through
|
|
642
|
+
/api/settings/*; every call carries the per-start token. -->
|
|
643
|
+
<section class="settings-view" id="settings-view" aria-label="Settings" hidden>
|
|
644
|
+
<div class="settings-header">
|
|
645
|
+
<button class="btn small" type="button" id="settings-back" data-testid="settings-back">← Back</button>
|
|
646
|
+
<h2>Settings</h2>
|
|
647
|
+
</div>
|
|
648
|
+
|
|
649
|
+
<div class="tabrow" role="tablist" aria-label="Settings views">
|
|
650
|
+
<button role="tab" id="tab-providers" aria-controls="panel-providers" aria-selected="true" data-testid="settings-tab-providers">Providers</button>
|
|
651
|
+
<button role="tab" id="tab-money" aria-controls="panel-money" aria-selected="false" data-testid="settings-tab-money">Money & limits</button>
|
|
652
|
+
</div>
|
|
653
|
+
|
|
654
|
+
<div class="pane-body">
|
|
655
|
+
<!-- PROVIDERS TAB (P4b: one row per key in the keys file that has a value) -->
|
|
656
|
+
<section id="panel-providers" class="tabpanel active" role="tabpanel" aria-labelledby="tab-providers">
|
|
657
|
+
<div class="keyfile-strip" data-testid="keyfile-box">
|
|
658
|
+
<div class="keyfile-strip-main">
|
|
659
|
+
<span>Put <code>NAME=key</code> lines in <code id="pv-keyfile-path">~/.config/bareloop/.env</code>, then Reload keys — every key that has a value gets a row. Only names are listed; keys never reach this page.</span>
|
|
660
|
+
<span class="hint" id="pv-keyfile-note" data-testid="keyfile-warning"></span>
|
|
661
|
+
</div>
|
|
662
|
+
<div class="keyfile-strip-actions">
|
|
663
|
+
<button class="btn small" type="button" id="btn-reload-keys" data-testid="btn-reload-keys">Reload keys</button>
|
|
664
|
+
</div>
|
|
665
|
+
</div>
|
|
666
|
+
<div class="table-wrap" data-testid="provider-table-wrap">
|
|
667
|
+
<table class="pv-table" data-testid="provider-table">
|
|
668
|
+
<thead>
|
|
669
|
+
<tr><th>Key</th><th>Name</th><th>API shape</th><th>Base URL</th><th>Test</th><th>Tokens used</th><th>Balance</th></tr>
|
|
670
|
+
</thead>
|
|
671
|
+
<tbody id="pv-rows"></tbody>
|
|
672
|
+
</table>
|
|
673
|
+
</div>
|
|
674
|
+
<div id="pv-shell" data-testid="pv-shell"></div>
|
|
675
|
+
<div class="hint" id="pv-note" data-testid="pv-note" style="margin-top:6px;">Name is the model id Chat uses. Base URL blank = the API shape's own host. Test asks the provider for its model list — it costs nothing.</div>
|
|
676
|
+
</section>
|
|
677
|
+
|
|
678
|
+
<!-- MONEY & LIMITS TAB -->
|
|
679
|
+
<section id="panel-money" class="tabpanel" role="tabpanel" aria-labelledby="tab-money" hidden>
|
|
680
|
+
<div class="money-strip" data-testid="money-strip">
|
|
681
|
+
<div class="money-item">
|
|
682
|
+
<label>$ month / to date</label>
|
|
683
|
+
<div class="money-value" id="ml-usd" data-testid="ml-usd">…</div>
|
|
684
|
+
</div>
|
|
685
|
+
<div class="money-item">
|
|
686
|
+
<label>Tokens month / to date</label>
|
|
687
|
+
<div class="money-value" id="ml-tokens" data-testid="ml-tokens">…</div>
|
|
688
|
+
</div>
|
|
689
|
+
<div class="money-item">
|
|
690
|
+
<label for="ml-money">Monthly money limit ($)</label>
|
|
691
|
+
<input id="ml-money" type="text" placeholder="blank = no limit" data-testid="ml-money">
|
|
692
|
+
</div>
|
|
693
|
+
</div>
|
|
694
|
+
<div class="hint" id="ml-note" data-testid="ml-note" style="margin-top:6px;">Limits are yours. The agent can never change them. A run whose cap is more than what is left this month does not start.</div>
|
|
695
|
+
|
|
696
|
+
<h4 style="margin-top:20px;font-size:13px;">Per-provider breakdown</h4>
|
|
697
|
+
<div class="table-wrap">
|
|
698
|
+
<table data-testid="provider-breakdown-table">
|
|
699
|
+
<thead>
|
|
700
|
+
<tr><th>Provider</th><th>$ month / to date</th><th>minutes month / to date</th><th>tokens</th></tr>
|
|
701
|
+
</thead>
|
|
702
|
+
<tbody id="ml-breakdown"></tbody>
|
|
703
|
+
</table>
|
|
704
|
+
</div>
|
|
705
|
+
</section>
|
|
706
|
+
</div>
|
|
707
|
+
</section>
|
|
708
|
+
|
|
623
709
|
</div>
|
|
624
710
|
|
|
625
711
|
<script>
|
|
@@ -2672,6 +2758,156 @@
|
|
|
2672
2758
|
});
|
|
2673
2759
|
})();
|
|
2674
2760
|
|
|
2761
|
+
// ---- Settings view (PANEL-BUILD.md P4a) ----
|
|
2762
|
+
(function(){
|
|
2763
|
+
var mainView = document.getElementById("main-view");
|
|
2764
|
+
var settingsView = document.getElementById("settings-view");
|
|
2765
|
+
var limitInput = document.getElementById("ml-money");
|
|
2766
|
+
var noteEl = document.getElementById("ml-note");
|
|
2767
|
+
var NOTE_DEFAULT = noteEl.textContent;
|
|
2768
|
+
// table cells: an "at least" figure keeps its marker as a "≥" prefix on that number — never dropped
|
|
2769
|
+
function moneyCell(n, atLeast){ return (atLeast ? "≥" : "") + panelMoney(n); }
|
|
2770
|
+
// wall minutes: "unknown" when no run had a known wall (never a clean 0); 1 decimal under 100, else a whole number with a thousands separator
|
|
2771
|
+
function minutesCell(ms, atLeast){
|
|
2772
|
+
if(ms === null || ms === undefined) return "unknown";
|
|
2773
|
+
var m = ms / 60000;
|
|
2774
|
+
var txt = m < 100 ? m.toFixed(1) : Math.round(m).toLocaleString("en-US");
|
|
2775
|
+
return (atLeast ? "≥" : "") + txt + " min";
|
|
2776
|
+
}
|
|
2777
|
+
var keepNote = false;
|
|
2778
|
+
function loadMoney(){
|
|
2779
|
+
authorGet("/api/settings/money").then(function(r){
|
|
2780
|
+
if(!r || !r.ok){ noteEl.textContent = "could not read your spend"; return; }
|
|
2781
|
+
document.getElementById("ml-usd").textContent = moneyCell(r.monthUsd, r.monthAtLeast) + " / " + moneyCell(r.totalUsd, r.totalAtLeast);
|
|
2782
|
+
document.getElementById("ml-tokens").textContent = tokensText(r.monthTokens) + " / " + tokensText(r.totalTokens);
|
|
2783
|
+
if(document.activeElement !== limitInput){
|
|
2784
|
+
limitInput.value = r.monthlyLimitUsd === null ? "" : r.monthlyLimitUsd.toFixed(2);
|
|
2785
|
+
}
|
|
2786
|
+
var rows = r.byProvider.map(function(p){
|
|
2787
|
+
return "<tr><td>" + escapeXml(p.label)
|
|
2788
|
+
+ "</td><td>" + escapeXml(moneyCell(p.monthUsd, p.monthAtLeast) + " / " + moneyCell(p.totalUsd, p.totalAtLeast))
|
|
2789
|
+
+ "</td><td>" + escapeXml(minutesCell(p.monthWallMs, p.monthWallAtLeast) + " / " + minutesCell(p.totalWallMs, p.totalWallAtLeast))
|
|
2790
|
+
+ "</td><td>" + escapeXml(tokensText(p.tokens)) + "</td></tr>";
|
|
2791
|
+
}).join("");
|
|
2792
|
+
document.getElementById("ml-breakdown").innerHTML = rows || '<tr><td colspan="4" class="hint">no runs yet</td></tr>';
|
|
2793
|
+
if(keepNote){ keepNote = false; return; }
|
|
2794
|
+
noteEl.textContent = r.configProblem ? r.configProblem : NOTE_DEFAULT;
|
|
2795
|
+
}).catch(function(){ noteEl.textContent = "could not read your spend"; });
|
|
2796
|
+
}
|
|
2797
|
+
document.getElementById("btn-settings").addEventListener("click", function(){
|
|
2798
|
+
mainView.hidden = true;
|
|
2799
|
+
settingsView.hidden = false;
|
|
2800
|
+
loadProviders();
|
|
2801
|
+
loadMoney();
|
|
2802
|
+
});
|
|
2803
|
+
document.getElementById("settings-back").addEventListener("click", function(){
|
|
2804
|
+
settingsView.hidden = true;
|
|
2805
|
+
mainView.hidden = false;
|
|
2806
|
+
});
|
|
2807
|
+
document.getElementById("tab-money").addEventListener("click", loadMoney);
|
|
2808
|
+
|
|
2809
|
+
// ---- Providers tab (P4b): one row per key in the keys file that has a value ----
|
|
2810
|
+
var pvRows = document.getElementById("pv-rows");
|
|
2811
|
+
var pvNote = document.getElementById("pv-note");
|
|
2812
|
+
var PV_NOTE_DEFAULT = pvNote.textContent;
|
|
2813
|
+
function tokensText(n){
|
|
2814
|
+
if(!n) return "0";
|
|
2815
|
+
if(n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
|
2816
|
+
if(n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
|
2817
|
+
return String(n);
|
|
2818
|
+
}
|
|
2819
|
+
function loadProviders(){
|
|
2820
|
+
authorGet("/api/settings/providers").then(function(r){
|
|
2821
|
+
if(!r || !r.ok){ pvNote.textContent = "could not read providers"; return; }
|
|
2822
|
+
var kf = r.keysFile;
|
|
2823
|
+
document.getElementById("pv-keyfile-path").textContent = kf.path;
|
|
2824
|
+
document.getElementById("pv-keyfile-note").textContent = kf.warning ? ("⚠ " + kf.warning) : "";
|
|
2825
|
+
pvRows.innerHTML = r.rows.length ? r.rows.map(function(row){
|
|
2826
|
+
var e = escapeXml(row.envName);
|
|
2827
|
+
var shapeOpts = r.shapes.map(function(s){
|
|
2828
|
+
return '<option value="' + escapeXml(s.id) + '"' + (s.id === row.shape ? " selected" : "") + ">" + escapeXml(s.label) + "</option>";
|
|
2829
|
+
}).join("");
|
|
2830
|
+
var bal = row.balance.kind === "note"
|
|
2831
|
+
? '<input type="text" class="pv-balance" placeholder="type by hand" value="' + (row.balance.usd === null ? "" : escapeXml(row.balance.usd.toFixed(2))) + '"><div class="hint">a note, never checked</div>'
|
|
2832
|
+
: (row.balance.kind === "fetch" ? '<span class="pv-balance-fetch" data-env="' + e + '">…</span>' : "—");
|
|
2833
|
+
return '<tr data-testid="provider-row-' + e + '" data-env="' + e + '">'
|
|
2834
|
+
+ "<td><code>" + e + "</code>"
|
|
2835
|
+
+ '<div class="hint" data-testid="pv-status-' + e + '">' + escapeXml(row.keyStatus) + "</div></td>"
|
|
2836
|
+
+ '<td><input type="text" class="pv-name" data-testid="pv-name-' + e + '" placeholder="model id" value="' + escapeXml(row.name) + '"></td>'
|
|
2837
|
+
+ '<td><select class="pv-shape" data-testid="pv-shape-' + e + '">' + shapeOpts + "</select></td>"
|
|
2838
|
+
+ '<td><input type="text" class="pv-url" data-testid="pv-url-' + e + '" placeholder="' + escapeXml(row.placeholder || "the shape\'s own host") + '" value="' + escapeXml(row.baseUrl) + '"></td>'
|
|
2839
|
+
+ '<td class="pv-test-cell">' + (row.canTest
|
|
2840
|
+
? '<button class="btn small pv-test" type="button" data-testid="test-connection-' + e + '">Test</button><div class="hint pv-test-result" data-testid="test-result-' + e + '"></div>'
|
|
2841
|
+
: '<span class="hint">not available</span>') + "</td>"
|
|
2842
|
+
+ '<td data-testid="pv-tokens-' + e + '">' + escapeXml(tokensText(row.tokens)) + "</td>"
|
|
2843
|
+
+ "<td>" + bal + "</td></tr>";
|
|
2844
|
+
}).join("") : '<tr><td colspan="7" class="hint" data-testid="pv-empty">no keys with a value yet — add NAME=key to the keys file, then Reload keys</td></tr>';
|
|
2845
|
+
pvRows.querySelectorAll(".pv-balance-fetch").forEach(function(fetchEl){
|
|
2846
|
+
authorGet("/api/settings/providers/balance?env=" + encodeURIComponent(fetchEl.getAttribute("data-env"))).then(function(b){
|
|
2847
|
+
fetchEl.textContent = b && b.text ? b.text : ("— " + ((b && b.note) || "unavailable"));
|
|
2848
|
+
}).catch(function(){ fetchEl.textContent = "— unavailable"; });
|
|
2849
|
+
});
|
|
2850
|
+
// a key exported only in the shell has no row: say so, by NAME, once per name
|
|
2851
|
+
document.getElementById("pv-shell").innerHTML = (r.shellOnly || []).map(function(n){
|
|
2852
|
+
return '<div class="hint" data-testid="pv-shell-' + escapeXml(n) + '">' + escapeXml(n) + " is set in your shell but not in ~/.config/bareloop/.env — add it there to use it here and in Chat.</div>";
|
|
2853
|
+
}).join("");
|
|
2854
|
+
pvNote.textContent = r.configProblem ? r.configProblem : PV_NOTE_DEFAULT;
|
|
2855
|
+
document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
|
|
2856
|
+
}).catch(function(){ pvNote.textContent = "could not read providers"; });
|
|
2857
|
+
}
|
|
2858
|
+
pvRows.addEventListener("change", function(e){
|
|
2859
|
+
var t = e.target;
|
|
2860
|
+
var tr = t.closest("tr");
|
|
2861
|
+
if(!tr) return;
|
|
2862
|
+
if(t.classList.contains("pv-balance")){
|
|
2863
|
+
authorPost("/api/settings/providers/balance-note", { usd: t.value.trim() === "" ? null : t.value.trim() }).then(function(r){
|
|
2864
|
+
pvNote.textContent = r.body && r.body.ok ? "Saved — balance note." : ((r.body && r.body.error) || "could not save");
|
|
2865
|
+
}).catch(function(){ pvNote.textContent = "could not save"; });
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
if(!(t.classList.contains("pv-name") || t.classList.contains("pv-shape") || t.classList.contains("pv-url"))) return;
|
|
2869
|
+
var env = tr.getAttribute("data-env");
|
|
2870
|
+
authorPost("/api/settings/providers/row", {
|
|
2871
|
+
envName: env,
|
|
2872
|
+
name: tr.querySelector(".pv-name").value,
|
|
2873
|
+
shape: tr.querySelector(".pv-shape").value,
|
|
2874
|
+
baseUrl: tr.querySelector(".pv-url").value,
|
|
2875
|
+
}).then(function(r){
|
|
2876
|
+
pvNote.textContent = r.body && r.body.ok ? ("Saved — " + env + ".") : ((r.body && r.body.error) || "could not save");
|
|
2877
|
+
// no re-render: it would steal focus mid-edit; Chat's Model menu just re-reads
|
|
2878
|
+
document.dispatchEvent(new CustomEvent("bareloop-providers-changed"));
|
|
2879
|
+
}).catch(function(){ pvNote.textContent = "could not save"; });
|
|
2880
|
+
});
|
|
2881
|
+
pvRows.addEventListener("click", function(e){
|
|
2882
|
+
var t = e.target;
|
|
2883
|
+
if(!t.classList || !t.classList.contains("pv-test")) return;
|
|
2884
|
+
var out = t.parentNode.querySelector(".pv-test-result");
|
|
2885
|
+
out.textContent = "testing…"; t.disabled = true;
|
|
2886
|
+
authorPost("/api/settings/providers/test", { envName: t.closest("tr").getAttribute("data-env") }).then(function(r){
|
|
2887
|
+
var b = r.body || {};
|
|
2888
|
+
out.textContent = b.reachable ? ("OK" + (b.ms !== null && b.ms !== undefined ? " · " + b.ms + " ms" : "")) : ("failed — " + (b.note || b.status || b.error || "no answer"));
|
|
2889
|
+
}).catch(function(){ out.textContent = "failed — no answer"; }).then(function(){ t.disabled = false; });
|
|
2890
|
+
});
|
|
2891
|
+
document.getElementById("btn-reload-keys").addEventListener("click", loadProviders);
|
|
2892
|
+
wireTabs([document.getElementById("tab-providers"), document.getElementById("tab-money")]);
|
|
2893
|
+
document.getElementById("tab-providers").addEventListener("click", loadProviders);
|
|
2894
|
+
// Auto-save on a human edit (change = blur or Enter): POST carries the per-start token; the chat has no route here
|
|
2895
|
+
limitInput.addEventListener("change", function(){
|
|
2896
|
+
var text = limitInput.value.trim();
|
|
2897
|
+
authorPost("/api/settings/money", { monthlyLimitUsd: text === "" ? null : text }).then(function(r){
|
|
2898
|
+
var msg = r.body && r.body.ok
|
|
2899
|
+
? (r.body.monthlyLimitUsd === null ? "Saved — no monthly limit." : "Saved — monthly limit " + panelMoney(r.body.monthlyLimitUsd) + ".")
|
|
2900
|
+
: ((r.body && r.body.error) || "could not save");
|
|
2901
|
+
keepNote = true;
|
|
2902
|
+
loadMoney();
|
|
2903
|
+
noteEl.textContent = msg;
|
|
2904
|
+
document.dispatchEvent(new CustomEvent("bareloop-limit-changed"));
|
|
2905
|
+
}).catch(function(){ noteEl.textContent = "could not save"; });
|
|
2906
|
+
});
|
|
2907
|
+
// verification convenience, like the mockup: ?settings=1 opens Settings on load
|
|
2908
|
+
if(location.search.indexOf("settings=1") !== -1){ document.getElementById("btn-settings").click(); }
|
|
2909
|
+
})();
|
|
2910
|
+
|
|
2675
2911
|
// ---- Chat tab: job-card authoring (PANEL-BUILD.md P3) ----
|
|
2676
2912
|
(function(){
|
|
2677
2913
|
var newBtn = document.getElementById("chat-new-btn");
|
|
@@ -2756,6 +2992,23 @@
|
|
|
2756
2992
|
startBtn.disabled = !capOk || sessionLive || !keyOk;
|
|
2757
2993
|
}
|
|
2758
2994
|
moneyCapInput.addEventListener("input", refreshStartEnabled);
|
|
2995
|
+
// P4a — the monthly-limit note sits right under the cap, as the person types.
|
|
2996
|
+
// Plain text, no button; the wording comes from the server (one library check).
|
|
2997
|
+
var capNoteEl = document.getElementById("jf-cap-note");
|
|
2998
|
+
var capNoteTimer = null;
|
|
2999
|
+
function refreshCapNote(){
|
|
3000
|
+
var v = Number(moneyCapInput.value);
|
|
3001
|
+
if(moneyCapInput.value.trim() === "" || !Number.isFinite(v) || v <= 0){ capNoteEl.textContent = ""; return; }
|
|
3002
|
+
authorGet("/api/author/monthly-check?cap=" + encodeURIComponent(String(v))).then(function(r){
|
|
3003
|
+
capNoteEl.textContent = (r && r.refusal) ? r.refusal : "";
|
|
3004
|
+
}).catch(function(){ capNoteEl.textContent = ""; });
|
|
3005
|
+
}
|
|
3006
|
+
moneyCapInput.addEventListener("input", function(){
|
|
3007
|
+
if(capNoteTimer){ clearTimeout(capNoteTimer); }
|
|
3008
|
+
capNoteTimer = setTimeout(refreshCapNote, 250);
|
|
3009
|
+
});
|
|
3010
|
+
// the limit changed in Settings: re-read the note for whatever cap is typed
|
|
3011
|
+
document.addEventListener("bareloop-limit-changed", refreshCapNote);
|
|
2759
3012
|
document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){
|
|
2760
3013
|
r.addEventListener("change", function(){
|
|
2761
3014
|
var isRubric = document.querySelector('input[name="jf-verdict"]:checked').value === "rubric";
|
|
@@ -2806,6 +3059,13 @@
|
|
|
2806
3059
|
}
|
|
2807
3060
|
function refreshModelStatus(){
|
|
2808
3061
|
var modelId = modelSelect.value;
|
|
3062
|
+
if(modelId === ""){
|
|
3063
|
+
keyOk = false;
|
|
3064
|
+
modelStatusEl.className = "jf-readiness bad";
|
|
3065
|
+
modelStatusEl.textContent = "[✗] no model — give a key a Name in Settings > Providers";
|
|
3066
|
+
refreshStartEnabled();
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
2809
3069
|
authorGet("/api/author/model-check?model=" + encodeURIComponent(modelId)).then(function(r){
|
|
2810
3070
|
var line = modelStatusLine(r, modelId);
|
|
2811
3071
|
keyOk = line.keyOk;
|
|
@@ -2820,7 +3080,21 @@
|
|
|
2820
3080
|
});
|
|
2821
3081
|
}
|
|
2822
3082
|
modelSelect.addEventListener("change", refreshModelStatus);
|
|
2823
|
-
|
|
3083
|
+
// P4b — the Model menu IS the Settings rows: every Name, in keys-file order (a blank Name
|
|
3084
|
+
// is never offered). Re-read whenever Settings loads or saves, keeping the pick if it survives.
|
|
3085
|
+
function loadModels(){
|
|
3086
|
+
authorGet("/api/author/models").then(function(r){
|
|
3087
|
+
var models = r && r.ok ? r.models : [];
|
|
3088
|
+
var was = modelSelect.value;
|
|
3089
|
+
modelSelect.innerHTML = models.length ? models.map(function(m){
|
|
3090
|
+
return '<option value="' + escapeXml(m.id) + '">' + escapeXml(m.id) + " · " + escapeXml(m.envName) + "</option>";
|
|
3091
|
+
}).join("") : '<option value="">no model — add a Name in Settings</option>';
|
|
3092
|
+
if(models.some(function(m){ return m.id === was; })) modelSelect.value = was;
|
|
3093
|
+
refreshModelStatus();
|
|
3094
|
+
}).catch(function(){ refreshModelStatus(); });
|
|
3095
|
+
}
|
|
3096
|
+
document.addEventListener("bareloop-providers-changed", loadModels);
|
|
3097
|
+
loadModels();
|
|
2824
3098
|
|
|
2825
3099
|
// build item 2 — one plain line per event, no "(progress)" jargon
|
|
2826
3100
|
// suffix: `onPhase` no longer posts its own bubble (see
|