bareloop 0.39.0 → 0.41.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 +60 -0
- package/bareloop.context.md +137 -112
- package/package.json +2 -2
- package/src/authorflow.js +333 -58
- package/src/authoring.js +65 -2
- package/src/authorjob.js +57 -9
- package/src/authorreadout.js +50 -2
- package/src/authorrun.js +30 -27
- package/src/authorscout.js +9 -6
- package/src/bridges.js +1 -1
- package/src/bundle.js +7 -4
- package/src/bundlerun.js +4 -1
- package/src/calibrate.js +1 -1
- package/src/cardauthor.js +4 -1
- package/src/cli.js +1 -1
- package/src/config.js +129 -29
- package/src/declaredclose.js +4 -3
- package/src/draftspend.js +6 -1
- package/src/index.js +2 -4
- package/src/interviewrun.js +73 -52
- package/src/job.js +50 -11
- package/src/jobblock.js +140 -0
- package/src/judged.js +12 -44
- package/src/keysfile.js +15 -7
- package/src/kinds.js +2 -2
- package/src/ledger.js +1 -1
- package/src/modellist.js +84 -0
- package/src/monthly.js +23 -43
- package/src/panel/authorroutes.js +34 -19
- package/src/panel/authorsession.js +187 -72
- package/src/panel/index.html +699 -215
- package/src/panel/jobplan.js +107 -0
- package/src/panel/runroutes.js +3 -0
- package/src/panel/server.js +102 -28
- package/src/panel/settingsroutes.js +232 -173
- package/src/planrun.js +6 -6
- package/src/promptregisters.js +1 -1
- package/src/providerrows.js +226 -141
- package/src/providers.js +58 -250
- package/src/providershapes.js +18 -0
- package/src/replay.js +1 -1
- package/src/reuse.js +1 -1
- package/src/root.js +1 -1
- package/src/run.js +3 -3
- package/src/selection.js +1 -1
- package/src/source.js +64 -1
- package/src/text.js +13 -0
- package/src/thinking.js +130 -0
- package/src/transport.js +1 -1
- package/src/userrun.js +53 -76
- package/types/authorflow.d.ts +258 -39
- package/types/authoring.d.ts +7 -1
- package/types/authorjob.d.ts +35 -3
- package/types/authorreadout.d.ts +17 -1
- package/types/authorscout.d.ts +5 -3
- package/types/config.d.ts +23 -6
- package/types/declaredclose.d.ts +4 -1
- package/types/draftspend.d.ts +8 -2
- package/types/index.d.ts +1 -1
- package/types/job.d.ts +6 -6
- package/types/jobblock.d.ts +102 -0
- package/types/judged.d.ts +5 -36
- package/types/keysfile.d.ts +15 -4
- package/types/modellist.d.ts +50 -0
- package/types/monthly.d.ts +4 -3
- package/types/panel/authorsession.d.ts +44 -15
- package/types/panel/jobplan.d.ts +58 -0
- package/types/panel/server.d.ts +34 -4
- package/types/planrun.d.ts +1 -1
- package/types/providerrows.d.ts +192 -99
- package/types/providers.d.ts +35 -116
- package/types/providershapes.d.ts +10 -0
- package/types/run.d.ts +1 -1
- package/types/source.d.ts +26 -0
- package/types/thinking.d.ts +90 -0
- package/types/userrun.d.ts +6 -1
package/src/job.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Job-spec validator — the OPERATOR-owned half of the config story (N1,
|
|
2
|
-
// design record: docs/
|
|
2
|
+
// design record: docs/logs/2026-07-12-n1-job-close-schema-design.md).
|
|
3
3
|
// A job spec is the arbiter's rulebook: the close chain, the budget, the
|
|
4
4
|
// outer write fence, the escalation route. It is pure declarative data —
|
|
5
5
|
// no freeform code is expressible anywhere in it — and it validates
|
|
@@ -167,7 +167,7 @@ export const CONDITION_KEYS = Object.freeze(['providerPath', 'closeVerbosity', '
|
|
|
167
167
|
// `steps` stays in the field list ONLY so a retired spec reds by name
|
|
168
168
|
// (`shape-retired`) instead of falling through to a generic unknown-field —
|
|
169
169
|
// the operator gets told what happened, not just that something is wrong.
|
|
170
|
-
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools'];
|
|
170
|
+
const JOB_FIELDS = ['schema', 'job', 'description', 'provider', 'baseUrl', 'conditions', 'cadence', 'budgetUsd', 'maxWallMs', 'closeTimeoutMs', 'model', 'thinking', 'judge', 'writeScope', 'steps', 'escalation', 'goal', 'verdictType', 'close', 'closeDecl', 'checks', 'tools', 'jobLines', 'inputs'];
|
|
171
171
|
/** the exact fields of a signed `judge` override — nested objects red unknown
|
|
172
172
|
* keys too, because no smuggling level exists in a signed spec */
|
|
173
173
|
const JUDGE_FIELDS = ['provider', 'model'];
|
|
@@ -329,7 +329,12 @@ export function validateJob(input, { shellCapUsd = 2 } = {}) {
|
|
|
329
329
|
// on THAT model, not merely on the goal/close it runs against. The judge
|
|
330
330
|
// model stays library-pinned pending recalibration — out of scope here.
|
|
331
331
|
if (spec.model !== undefined && !isNonEmptyString(spec.model)) {
|
|
332
|
-
red('invalid-value', 'model', 'non-empty string — exact
|
|
332
|
+
red('invalid-value', 'model', 'non-empty string — the exact model id of a line ticked in Settings > Providers');
|
|
333
|
+
}
|
|
334
|
+
// THINKING (P8) — the job's "Thinking on" tick, OPTIONAL: absent means off (and an old spec keeps the hash it always had); present it is
|
|
335
|
+
// signed like `model`. It covers DRAFTING and the rubric JUDGE only — the worker is never handed a thinking option. A boolean, nothing else.
|
|
336
|
+
if (spec.thinking !== undefined && typeof spec.thinking !== 'boolean') {
|
|
337
|
+
red('invalid-value', 'thinking', 'true or false — the job\'s "Thinking on" tick (absent = off)');
|
|
333
338
|
}
|
|
334
339
|
|
|
335
340
|
// JUDGE — PRD item 32.1 (hamr, 2026-09-10: the judge "defaults to whatever
|
|
@@ -717,6 +722,39 @@ function validateStagedClose(stages, red) {
|
|
|
717
722
|
* @param {Red[]} reds direct access for structured request-reds
|
|
718
723
|
*/
|
|
719
724
|
function validatePlanShape(spec, red, reds) {
|
|
725
|
+
// P7: the person's own words, signed beside the model's goal sentence. Exact fields (no smuggling level), numbered 1..n
|
|
726
|
+
// in order. Optional: a spec that predates the job block has neither and keeps its hash.
|
|
727
|
+
if (spec.jobLines !== undefined) {
|
|
728
|
+
if (!Array.isArray(spec.jobLines) || spec.jobLines.length === 0) red('invalid-value', 'jobLines', 'a non-empty array of {n, text, rule}');
|
|
729
|
+
else {
|
|
730
|
+
spec.jobLines.forEach((/** @type {any} */ l, /** @type {number} */ i) => {
|
|
731
|
+
const at = `jobLines[${i}]`;
|
|
732
|
+
if (!isObj(l)) { red('invalid-value', at, '{n, text, rule}'); return; }
|
|
733
|
+
for (const key of Object.keys(l)) if (!['n', 'text', 'rule', 'rules'].includes(key)) red('unknown-field', `${at}.${key}`, 'job line fields: n, text, rule, rules');
|
|
734
|
+
if (l.n !== i + 1) red('invalid-value', `${at}.n`, `lines are numbered 1, 2, 3 in order — expected ${i + 1}`);
|
|
735
|
+
if (!isNonEmptyString(l.text)) red('invalid-value', `${at}.text`, 'non-empty text');
|
|
736
|
+
if (typeof l.rule !== 'string') red('invalid-value', `${at}.rule`, 'a string ("" when the line has no ~ rule)');
|
|
737
|
+
// optional, beside `rule`: the same `~` lines one by one; it must join back to `rule` exactly
|
|
738
|
+
if (l.rules !== undefined && (!Array.isArray(l.rules) || l.rules.some((/** @type {any} */ r) => !isNonEmptyString(r)) || l.rules.join('; ') !== l.rule)) {
|
|
739
|
+
red('invalid-value', `${at}.rules`, 'an array of the ~ lines that joins with "; " to exactly `rule`');
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
if (spec.inputs !== undefined) {
|
|
745
|
+
if (!Array.isArray(spec.inputs) || spec.inputs.length === 0) red('invalid-value', 'inputs', 'a non-empty array of {n, label, value}');
|
|
746
|
+
else {
|
|
747
|
+
spec.inputs.forEach((/** @type {any} */ x, /** @type {number} */ i) => {
|
|
748
|
+
const at = `inputs[${i}]`;
|
|
749
|
+
if (!isObj(x)) { red('invalid-value', at, '{n, label, value}'); return; }
|
|
750
|
+
for (const key of Object.keys(x)) if (!['n', 'label', 'value'].includes(key)) red('unknown-field', `${at}.${key}`, 'input fields: n, label, value');
|
|
751
|
+
if (x.n !== i + 1) red('invalid-value', `${at}.n`, `inputs are numbered 1, 2, 3 in order — expected ${i + 1}`);
|
|
752
|
+
if (!isNonEmptyString(x.label)) red('invalid-value', `${at}.label`, 'non-empty text');
|
|
753
|
+
if (!isNonEmptyString(x.value)) red('invalid-value', `${at}.value`, 'non-empty text');
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
720
758
|
if (spec.goal === undefined) red('missing-required', 'goal');
|
|
721
759
|
else if (!isNonEmptyString(spec.goal)) red('invalid-value', 'goal', 'non-empty text — the goal is what the agent plans against');
|
|
722
760
|
|
|
@@ -757,7 +795,7 @@ function validatePlanShape(spec, red, reds) {
|
|
|
757
795
|
// class the user picked (PRD v1.57 §2), so the gate cannot check D5 without
|
|
758
796
|
// it. An unknown or locked class is refused there, on its own axis, beside
|
|
759
797
|
// the counted `request-red` above.
|
|
760
|
-
const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope });
|
|
798
|
+
const cd = validateCloseDecl(spec.closeDecl, { at: 'closeDecl', deferListing: true, verdictType: spec.verdictType, writeScope: spec.writeScope, jobLines: Array.isArray(spec.jobLines) ? spec.jobLines : null });
|
|
761
799
|
for (const r of cd.reds) reds.push(r);
|
|
762
800
|
if (demanded !== undefined && !CLASS_BY_CLOSE.declared.includes(demanded)) {
|
|
763
801
|
// the same laundering guard as the close-type hierarchy, one level up: a
|
|
@@ -886,11 +924,11 @@ export function jobSpecHash(job) {
|
|
|
886
924
|
|
|
887
925
|
/**
|
|
888
926
|
* The fields a "Reuse workflow" run may change: where the work lands (the write fence, `writeScope`, and a
|
|
889
|
-
* `destination`/`source` where a spec ever carries them) and the two ceilings. Everything else in a signed spec is
|
|
927
|
+
* `destination`/`source` where a spec ever carries them), the `inputs` the person pointed at (P7: Inputs is open on a reuse) and the two ceilings. Everything else in a signed spec is
|
|
890
928
|
* the WORKFLOW — goal, checks, guardrails, close (the worker model is the third kind, {@link WORKFLOW_KEY_IDENTITY_FIELDS}). ONE list, shared by {@link workflowKey} and by the panel's
|
|
891
929
|
* reuse (so what the key ignores is exactly what the person may edit, never two spellings).
|
|
892
930
|
*/
|
|
893
|
-
export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs']);
|
|
931
|
+
export const REUSE_OPEN_SPEC_FIELDS = Object.freeze(['source', 'destination', 'writeScope', 'budgetUsd', 'maxWallMs', 'inputs']);
|
|
894
932
|
|
|
895
933
|
/**
|
|
896
934
|
* The worker-identity fields {@link workflowKey} ALSO ignores (hamr 2026-10-04: Model is open on a reuse; the Name
|
|
@@ -951,15 +989,15 @@ export function checkApproval(job, approvals) {
|
|
|
951
989
|
* mismatched flag is a mistake worth stopping on, never a silent swap); the
|
|
952
990
|
* same id from both is not a conflict. Extracted from scripts/run-u.mjs so
|
|
953
991
|
* the decision is testable without running the script or touching a provider.
|
|
954
|
-
* @param {{ specModel?: string, flagModel?: string,
|
|
992
|
+
* @param {{ specModel?: string, flagModel?: string, suppliedModel?: string }} args
|
|
955
993
|
* `specModel` — `job.model` if the signed spec carries one, else undefined.
|
|
956
994
|
* `flagModel` — the model id an explicit `--model <tier>` resolved to, else
|
|
957
995
|
* undefined (a flag that was never passed, not merely a default tier name).
|
|
958
|
-
* `
|
|
996
|
+
* `suppliedModel` — a model the CALLER supplies when neither spec nor flag says (bareloop keeps none of its own).
|
|
959
997
|
* @returns {{ model: string, source: 'spec'|'flag'|'default' }}
|
|
960
|
-
* @throws {Error} when the spec names a model and the flag names a different one
|
|
998
|
+
* @throws {Error} when the spec names a model and the flag names a different one, or when no model is named at all
|
|
961
999
|
*/
|
|
962
|
-
export function resolveWorkerModel({ specModel, flagModel,
|
|
1000
|
+
export function resolveWorkerModel({ specModel, flagModel, suppliedModel }) {
|
|
963
1001
|
if (specModel !== undefined) {
|
|
964
1002
|
if (flagModel !== undefined && flagModel !== specModel) {
|
|
965
1003
|
throw new Error(`spec names model "${specModel}" but --model resolved to "${flagModel}" — the signed spec wins; a mismatched --model flag is refused, never silently overridden. Drop --model or point it at the same model.`);
|
|
@@ -967,5 +1005,6 @@ export function resolveWorkerModel({ specModel, flagModel, defaultModel }) {
|
|
|
967
1005
|
return { model: specModel, source: 'spec' };
|
|
968
1006
|
}
|
|
969
1007
|
if (flagModel !== undefined) return { model: flagModel, source: 'flag' };
|
|
970
|
-
|
|
1008
|
+
if (suppliedModel === undefined) throw new Error('no model is named — a job carries its own model, or the run is given --model <id> (one ticked in Settings > Providers with both prices)');
|
|
1009
|
+
return { model: suppliedModel, source: 'default' };
|
|
971
1010
|
}
|
package/src/jobblock.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// The "job" block's parser (P7, docs/product/PANEL-BUILD.md "Addendum 2026-10-10"): ONE owner of how the person's
|
|
2
|
+
// own multi-line words become numbered job lines and numbered inputs. The panel server AND the CLI interview both
|
|
3
|
+
// call these two functions, so the two doors can never read the same text two ways.
|
|
4
|
+
//
|
|
5
|
+
// Both are pure and $0: a malformed block is a named red `{ok: false, error}`, never a throw and never a guess.
|
|
6
|
+
|
|
7
|
+
/** @typedef {{verdict: 'PASS'|'FAIL', text: string}} JobExample a `~ PASS:` / `~ FAIL:` line (rubric only), kept with its job line */
|
|
8
|
+
/** @typedef {{n: number, text: string, rule: string, rules: string[], examples: JobExample[]}} JobLine one numbered job line; `rule` = its `~` lines joined with '; ' ('' when none), `rules` = those same `~` lines one by one (so an example's own '; ' survives) */
|
|
9
|
+
/** @typedef {{n: number, label: string, value: string}} JobInput one numbered `label: value` input line */
|
|
10
|
+
|
|
11
|
+
/** the joiner for consecutive `~` lines (fwd's joinGuardrails) */
|
|
12
|
+
export const RULE_JOINER = '; ';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse "The job" box. A plain line is a numbered job line (1, 2, 3...). A line starting `~` has no number and belongs
|
|
16
|
+
* to the numbered line above; consecutive `~` lines become ONE rule joined with `'; '`. A `~` line starting `PASS:` or
|
|
17
|
+
* `FAIL:` is also a judge example (kept with its line number); any other `~` line is a guardrail. Empty lines are
|
|
18
|
+
* ignored. A `~` before line 1 is a red.
|
|
19
|
+
* @param {unknown} text the box's raw text
|
|
20
|
+
* @returns {{ok: true, lines: JobLine[]}|{ok: false, error: string}}
|
|
21
|
+
*/
|
|
22
|
+
export function parseJobBlock(text) {
|
|
23
|
+
if (typeof text !== 'string') return { ok: false, error: 'The job is required' };
|
|
24
|
+
/** @type {JobLine[]} */
|
|
25
|
+
const lines = [];
|
|
26
|
+
/** @type {string[]} */
|
|
27
|
+
let rule = [];
|
|
28
|
+
const flush = () => { if (lines.length) { lines[lines.length - 1].rule = rule.join(RULE_JOINER); lines[lines.length - 1].rules = rule; } rule = []; };
|
|
29
|
+
let row = 0;
|
|
30
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
31
|
+
const t = raw.trim();
|
|
32
|
+
if (t === '') continue;
|
|
33
|
+
row += 1;
|
|
34
|
+
if (t.startsWith('~')) {
|
|
35
|
+
if (lines.length === 0) return { ok: false, error: `The job, line ${row}: a "~" line belongs to the numbered line above it, and there is none yet` };
|
|
36
|
+
const body = t.slice(1).trim();
|
|
37
|
+
if (body === '') continue;
|
|
38
|
+
rule.push(body);
|
|
39
|
+
const m = /^(PASS|FAIL):\s*(.*)$/i.exec(body);
|
|
40
|
+
if (m) {
|
|
41
|
+
if (m[2].trim() === '') return { ok: false, error: `The job, line ${row}: "${m[1].toUpperCase()}:" needs an example after it` };
|
|
42
|
+
lines[lines.length - 1].examples.push({ verdict: /** @type {'PASS'|'FAIL'} */ (m[1].toUpperCase()), text: m[2].trim() });
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
flush();
|
|
47
|
+
lines.push({ n: lines.length + 1, text: t, rule: '', rules: [], examples: [] });
|
|
48
|
+
}
|
|
49
|
+
flush();
|
|
50
|
+
if (lines.length === 0) return { ok: false, error: 'The job is required' };
|
|
51
|
+
return { ok: true, lines };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** a label: a word or two, no colon */
|
|
55
|
+
const LABEL_RE = /^[A-Za-z][A-Za-z0-9_ -]{0,39}$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parse the "Inputs" box: one `label: value` per line, numbered 1, 2, 3 in the order written. Empty lines are ignored.
|
|
59
|
+
* This only checks the SHAPE — what a value must point at (line 1 an absolute repo, lines 2+ tracked files inside it)
|
|
60
|
+
* is proven by the panel at $0.
|
|
61
|
+
* @param {unknown} text
|
|
62
|
+
* @returns {{ok: true, inputs: JobInput[]}|{ok: false, error: string}}
|
|
63
|
+
*/
|
|
64
|
+
export function parseInputs(text) {
|
|
65
|
+
if (typeof text !== 'string') return { ok: false, error: 'Inputs is required' };
|
|
66
|
+
/** @type {JobInput[]} */
|
|
67
|
+
const inputs = [];
|
|
68
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
69
|
+
const t = raw.trim();
|
|
70
|
+
if (t === '') continue;
|
|
71
|
+
const n = inputs.length + 1;
|
|
72
|
+
const i = t.indexOf(':');
|
|
73
|
+
const label = i < 0 ? '' : t.slice(0, i).trim();
|
|
74
|
+
const value = i < 0 ? '' : t.slice(i + 1).trim();
|
|
75
|
+
if (!LABEL_RE.test(label) || value === '') return { ok: false, error: `Inputs, line ${n}: write it as "label: value"` };
|
|
76
|
+
inputs.push({ n, label, value });
|
|
77
|
+
}
|
|
78
|
+
if (inputs.length === 0) return { ok: false, error: 'Inputs is required' };
|
|
79
|
+
return { ok: true, inputs };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The person's job lines as the drafter reads them (answer 1 of the interview): each numbered line, then its rule
|
|
84
|
+
* (the `~` lines joined) indented under it. One spelling, so the number the person typed beside a line is the number a
|
|
85
|
+
* stage's `fromLine` names.
|
|
86
|
+
* @param {JobLine[]} lines
|
|
87
|
+
* @returns {string}
|
|
88
|
+
*/
|
|
89
|
+
export function renderJobLines(lines) {
|
|
90
|
+
return lines.map((l) => `${l.n}. ${l.text}${l.rule ? `\n rule: ${l.rule}` : ''}`).join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The PASS:/FAIL: examples the person wrote, each kept with its line number (answer 2 of a rubric interview — what the
|
|
95
|
+
* calibration compile reads). Empty string when there are none.
|
|
96
|
+
* @param {JobLine[]} lines
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
export function renderExamples(lines) {
|
|
100
|
+
return lines.flatMap((l) => l.examples.map((e) => `line ${l.n} ${e.verdict}: ${e.text}`)).join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The inputs as the drafter is told about them: "the person pointed at these inputs: N label path".
|
|
105
|
+
* @param {JobInput[]} inputs
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function renderInputs(inputs) {
|
|
109
|
+
return `the person pointed at these inputs: ${inputs.map((i) => `${i.n} ${i.label} ${i.value}`).join(' · ')}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The two cross-checks between a parsed job block and the Check type: a RUBRIC job needs at least one `PASS:` and one
|
|
114
|
+
* `FAIL:` example (the calibration compile reads them); a DETERMINISTIC job has no judge, so an example line would be
|
|
115
|
+
* silently dropped — a red instead.
|
|
116
|
+
* @param {JobLine[]} lines
|
|
117
|
+
* @param {boolean} rubric
|
|
118
|
+
* @returns {string|null} the plain red, or null when the block fits the Check type
|
|
119
|
+
*/
|
|
120
|
+
export function jobBlockFitsCheck(lines, rubric) {
|
|
121
|
+
const all = lines.flatMap((l) => l.examples.map((e) => ({ n: l.n, ...e })));
|
|
122
|
+
if (!rubric) {
|
|
123
|
+
return all.length ? `The job, line ${all[0].n}: PASS:/FAIL: examples are for a Rubric check — a Deterministic job has no judge to read them` : null;
|
|
124
|
+
}
|
|
125
|
+
if (!all.some((e) => e.verdict === 'PASS') || !all.some((e) => e.verdict === 'FAIL')) {
|
|
126
|
+
return 'The job: a Rubric check needs at least one "~ PASS:" line and one "~ FAIL:" line under a job line';
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The signed `jobLines` field from parsed lines: the person's own words, `{n, text, rule, rules}` (examples stay inside the
|
|
133
|
+
* rule text, where the person wrote them). `rules` is the same `~` lines as an array, BESIDE the joined `rule` (never
|
|
134
|
+
* replacing it); it is omitted on a line with no `~` line, and an older spec lacks it entirely.
|
|
135
|
+
* @param {JobLine[]} lines
|
|
136
|
+
* @returns {{n: number, text: string, rule: string, rules?: string[]}[]}
|
|
137
|
+
*/
|
|
138
|
+
export function signedJobLines(lines) {
|
|
139
|
+
return lines.map((l) => ({ n: l.n, text: l.text, rule: l.rule, ...(l.rules.length ? { rules: [...l.rules] } : {}) }));
|
|
140
|
+
}
|
package/src/judged.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// THE JUDGED FLOOR'S CORE — softgreen module 1 (design record
|
|
2
|
-
// docs/
|
|
2
|
+
// docs/logs/2026-08-17-softgreen-review-door-design.md §4, plus the
|
|
3
3
|
// 2026-08-18 threshold addendum). POC: poc/softgreen-judge/poc.mjs — validated
|
|
4
4
|
// logic, rewritten here, never copied (graduation is a rewrite).
|
|
5
5
|
//
|
|
@@ -66,43 +66,11 @@
|
|
|
66
66
|
import { quoteIn } from 'bareguard';
|
|
67
67
|
import { createRequire } from 'node:module';
|
|
68
68
|
import { extractArtifact, priceOf, scrubRaw, rateSourceFields } from './text.js';
|
|
69
|
-
import { resolveProvider
|
|
69
|
+
import { resolveProvider } from './providers.js';
|
|
70
|
+
import { thinkingCallOptions } from './thinking.js';
|
|
70
71
|
|
|
71
72
|
const require = createRequire(import.meta.url);
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
-
* THE JUDGE'S HISTORICAL PIN. Until PRD item 32 (2026-09-10) this constant WAS
|
|
75
|
-
* the judge: every grading path imported it, so bareloop could run a worker on
|
|
76
|
-
* any provider and still judge only on Claude.
|
|
77
|
-
*
|
|
78
|
-
* It is no longer read by any grading path. The judge identity is now RESOLVED
|
|
79
|
-
* per job (`resolveJudge` below) and travels as data — `ctx.judgeModel` into
|
|
80
|
-
* `runJudgedFloor`, an explicit argument into the calibration gate, and
|
|
81
|
-
* `closeDecl.calibration.judgeModel` into the signed spec, exactly as before.
|
|
82
|
-
* Keeping the export is deliberate: it is part of the public API
|
|
83
|
-
* (`src/index.js`) and an adopter importing it should not break on a minor.
|
|
84
|
-
* What it means now is "the judge bareloop pinned before item 32", which is
|
|
85
|
-
* still the right DEFAULT for an anthropic-api job and still the only tier with
|
|
86
|
-
* established injection resistance upstream (BA-20's `INJECTION_BATTERY` is
|
|
87
|
-
* haiku-4.5 only).
|
|
88
|
-
*
|
|
89
|
-
* THE ENFORCEMENT IS UNCHANGED, and still lives in three wired places because a
|
|
90
|
-
* rule with no detector is prose (F45) — only the value being compared moved
|
|
91
|
-
* from this constant to the resolved identity:
|
|
92
|
-
* 1. the identity is STORED with the set it graded — `foldJudgedArtifacts`
|
|
93
|
-
* writes it as `closeDecl.calibration.judgeModel`, inside the spec, so
|
|
94
|
-
* `jobSpecHash` covers it by construction. Change the judge and every
|
|
95
|
-
* signed judged spec hashes differently: the signature dies, and re-signing
|
|
96
|
-
* is what re-runs the calibration gate.
|
|
97
|
-
* 2. `validateCloseDecl` REQUIRES it on any stored set — a set nobody can
|
|
98
|
-
* attribute to a judge is a floor nobody can tell apart from a moved one.
|
|
99
|
-
* 3. `runJudgedFloor` refuses to grade when the stored identity is not the one
|
|
100
|
-
* about to grade, naming both. It does not degrade in either direction:
|
|
101
|
-
* grading on the new judge judges against a floor nobody calibrated, and
|
|
102
|
-
* the old judge is not on offer.
|
|
103
|
-
*/
|
|
104
|
-
export const JUDGE_MODEL = 'claude-haiku-4-5';
|
|
105
|
-
|
|
106
74
|
/**
|
|
107
75
|
* RESOLVE WHICH MODEL GRADES THIS JOB (PRD item 32.1).
|
|
108
76
|
*
|
|
@@ -122,7 +90,7 @@ export const JUDGE_MODEL = 'claude-haiku-4-5';
|
|
|
122
90
|
* item 31.4 a green job runs on its own key alone, and this is what lets a
|
|
123
91
|
* JUDGED one do the same.
|
|
124
92
|
*
|
|
125
|
-
* There is deliberately NO third fallback to
|
|
93
|
+
* There is deliberately NO third fallback to a library-pinned model. A caller that cannot
|
|
126
94
|
* say which model will grade has not wired a judge, and the grading path stops
|
|
127
95
|
* as a wiring gap rather than grading under an identity nobody chose — the same
|
|
128
96
|
* answer an absent `judgeLoop` already gets. A silent default here would be the
|
|
@@ -198,15 +166,13 @@ export function resolveJudge({ specJudge, workerProvider, workerModel } = {}) {
|
|
|
198
166
|
*
|
|
199
167
|
* @param {{provider?: string, model?: string, judge?: any}} source draft or spec
|
|
200
168
|
* @param {string} fallbackProviderName used only when `source.provider` is absent
|
|
201
|
-
* @param {(o: {specModel?: string, flagModel?: string,
|
|
169
|
+
* @param {(o: {specModel?: string, flagModel?: string, suppliedModel?: string}) => {model: string, source: string}} resolveWorkerModelFn `resolveWorkerModel` from `./job.js`
|
|
202
170
|
* @returns {{provider: string, model: string}}
|
|
203
171
|
*/
|
|
204
172
|
export function resolveJobJudge(source, fallbackProviderName, resolveWorkerModelFn) {
|
|
205
173
|
const providerName = source?.provider ?? fallbackProviderName;
|
|
206
|
-
|
|
207
|
-
const { model } = resolveWorkerModelFn({
|
|
208
|
-
specModel: source?.model, flagModel: undefined, defaultModel: providerEntry.tiers.sonnet,
|
|
209
|
-
});
|
|
174
|
+
resolveProvider(providerName); // an unknown shape is the named throw, never a silent default
|
|
175
|
+
const { model } = resolveWorkerModelFn({ specModel: source?.model, flagModel: undefined });
|
|
210
176
|
return resolveJudge({ specJudge: source?.judge, workerProvider: providerName, workerModel: model });
|
|
211
177
|
}
|
|
212
178
|
|
|
@@ -1089,9 +1055,11 @@ function topLevelFunctionNames(artifactText) {
|
|
|
1089
1055
|
* so the priced seam is the only legal one.
|
|
1090
1056
|
* `rates` is the customer's own price for the judge's row (USD per 1K, `ratesFor`'s
|
|
1091
1057
|
* `rates`); null/absent = bare-agent's built-in guess, exactly as before.
|
|
1092
|
-
*
|
|
1058
|
+
* `thinking` is the job's SIGNED "Thinking on" tick (false/absent = off): the judge follows the signed value — so the calibration gate
|
|
1059
|
+
* (10/10) and the later grading run with the SAME tick — and bare-agent decides what each model can switch (src/thinking.js).
|
|
1060
|
+
* @param {{provider: any, system: string, rates?: {in: number, out: number}|null, thinking?: boolean}} o
|
|
1093
1061
|
* @returns {{run: (msgs: any[], tools: any[], opts?: any) => Promise<any>}} */
|
|
1094
|
-
export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
|
|
1062
|
+
export const defaultJudgeLoop = ({ provider, system, rates = null, thinking = false }) => {
|
|
1095
1063
|
const { Loop } = require('bare-agent');
|
|
1096
1064
|
// BA-21: the Loop's run RESULT carries no `rateSource` — only the per-call metering event
|
|
1097
1065
|
// does (`onLlmResult`). Capture it there and ride it on the result, so `runLocate` can
|
|
@@ -1107,7 +1075,7 @@ export const defaultJudgeLoop = ({ provider, system, rates = null }) => {
|
|
|
1107
1075
|
return {
|
|
1108
1076
|
run: async (/** @type {any[]} */ msgs, /** @type {any[]} */ tools, /** @type {any} */ opts) => {
|
|
1109
1077
|
seen = null;
|
|
1110
|
-
const res = await loop.run(msgs, tools, { ...
|
|
1078
|
+
const res = await loop.run(msgs, tools, { ...thinkingCallOptions(provider, thinking === true ? 'on' : 'off'), ...opts });
|
|
1111
1079
|
return res && typeof res === 'object' && seen ? { ...res, rateSource: /** @type {any} */ (seen).rateSource } : res;
|
|
1112
1080
|
},
|
|
1113
1081
|
};
|
package/src/keysfile.js
CHANGED
|
@@ -76,18 +76,24 @@ export function filledKeyNames(home) {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
|
|
79
|
+
* The two comment lines a fresh keys file is created with — no provider name, no preset key.
|
|
80
|
+
* Each key the person types becomes a row in Settings > Providers.
|
|
81
|
+
*/
|
|
82
|
+
export const KEYS_FILE_BODY = '# bareloop keys — type NAME=value by hand; this file is never shown or written by bareloop.\n'
|
|
83
|
+
+ '# One line per provider key, e.g. MY_PROVIDER_API_KEY=... ; each key becomes a row in Settings > Providers.\n';
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Create the keys file with {@link KEYS_FILE_BODY} when — and only when — it does not
|
|
87
|
+
* exist (mode 600, dir 700). An existing file is never touched.
|
|
82
88
|
* @param {string} [home]
|
|
83
89
|
* @returns {boolean} true if it was created
|
|
84
90
|
*/
|
|
85
|
-
export function ensureKeysFile(
|
|
91
|
+
export function ensureKeysFile(home) {
|
|
86
92
|
const file = keysFilePath(home);
|
|
87
93
|
if (existsSync(file)) return false;
|
|
88
|
-
mkdirSync(keysHome(home), { recursive: true });
|
|
94
|
+
mkdirSync(keysHome(home), { recursive: true, mode: 0o700 });
|
|
89
95
|
try {
|
|
90
|
-
writeFileSync(file,
|
|
96
|
+
writeFileSync(file, KEYS_FILE_BODY, { mode: 0o600, flag: 'wx' });
|
|
91
97
|
} catch (/** @type {any} */ e) {
|
|
92
98
|
if (e?.code === 'EEXIST') return false; // lost a race: the file is there, untouched
|
|
93
99
|
throw e;
|
|
@@ -101,6 +107,8 @@ export function ensureKeysFile(presetNames, home) {
|
|
|
101
107
|
* @property {string[]} names the NAMES the file defines (sorted; never values)
|
|
102
108
|
* @property {string|null} warning a plain-words warning (file readable by others, or
|
|
103
109
|
* unreadable), else null — a warning, never a refusal
|
|
110
|
+
* @property {boolean} [unreadable] true only when the file is there but could not be read (the Settings row then
|
|
111
|
+
* reads "refused — see above"); absent otherwise
|
|
104
112
|
* @property {Record<string,string|undefined>} env shell env with the file filling the
|
|
105
113
|
* names the shell does not set. THE ONLY place values leave this module.
|
|
106
114
|
*/
|
|
@@ -127,7 +135,7 @@ export function loadKeysEnv(opts = {}) {
|
|
|
127
135
|
try {
|
|
128
136
|
parsed = parseKeysText(readFileSync(file, 'utf8'));
|
|
129
137
|
} catch (/** @type {any} */ e) {
|
|
130
|
-
return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, env: { ...base } };
|
|
138
|
+
return { exists: true, names: [], warning: `${file} could not be read (${e?.code ?? 'error'})`, unreadable: true, env: { ...base } };
|
|
131
139
|
}
|
|
132
140
|
/** @type {Record<string,string|undefined>} */
|
|
133
141
|
const env = { ...base };
|
package/src/kinds.js
CHANGED
|
@@ -1776,7 +1776,7 @@ async function runJudgedFloor(stage, ctx) {
|
|
|
1776
1776
|
+ `${MAX_JUDGED_PATHS} — a judged stage buys one call per artifact, and a runaway list is a bill`, {}, STOP_FAULTS.CRASHED);
|
|
1777
1777
|
}
|
|
1778
1778
|
// 1b. THE TIER THE SIGNED SET WAS GRADED BY, against the tier this run would buy.
|
|
1779
|
-
//
|
|
1779
|
+
// the judge model has always SAID a bump forces a full recalibration; this is the
|
|
1780
1780
|
// detector, because a rule with no wired detector is prose (F45). The stamp comes
|
|
1781
1781
|
// off the SIGNED calibration (`calibration.judgeModel`, carried across by
|
|
1782
1782
|
// `declaredStages`), so a mismatch means exactly one thing: the pin moved under a
|
|
@@ -1799,7 +1799,7 @@ async function runJudgedFloor(stage, ctx) {
|
|
|
1799
1799
|
// model). The IDENTITY is checked before the seam and before the stamp, because
|
|
1800
1800
|
// an unwired identity makes both of the messages below unwritable: neither
|
|
1801
1801
|
// "would grade with X" nor "the judge is X" can be said by a stage that cannot
|
|
1802
|
-
// name X. There is deliberately NO fall-back
|
|
1802
|
+
// name X. There is deliberately NO library-pinned fall-back here — a run that
|
|
1803
1803
|
// cannot say which model grades has not wired a judge, and grading under an
|
|
1804
1804
|
// identity nobody chose is exactly the silent-ignore class this repo refuses
|
|
1805
1805
|
// everywhere else.
|
package/src/ledger.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The upstream ledger (module 4) — reactive lib/primitive incident monitoring.
|
|
2
|
-
// Design record: docs/
|
|
2
|
+
// Design record: docs/logs/2026-07-11-upstream-ledger-design.md (adaptlearn
|
|
3
3
|
// POC'd spec) + its 2026-07-13 addendum: the bareloop event mapping. One
|
|
4
4
|
// append-only JSONL both the consumer (the panel, N6) and the maintainer read;
|
|
5
5
|
// the A1/A2/A3 upstream-ask flow, mechanized — evidence in, human judgment out.
|
package/src/modellist.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// rewritten-from: fwdloop src/panel/settings.js@f9449e8 (`authHeaders`, `getWithKey`, `idsOf`, `listModels`)
|
|
2
|
+
// PANEL-BUILD.md P8 — the ONE `<baseUrl>/models` GET. Settings > Providers' Test and Refresh and the
|
|
3
|
+
// New job card's model-readiness check (`checkProviderReachable`, src/providers.js) all call it; no
|
|
4
|
+
// second copy of the probe exists. It is a GET of a models LIST, never a completion, so it spends
|
|
5
|
+
// no token and writes no spend row. The key goes in a header only and is never returned or logged.
|
|
6
|
+
// A redirect is NOT followed (`redirect:'manual'`), so the key is never sent on to another host.
|
|
7
|
+
// There is no default host: a blank Base URL makes no call.
|
|
8
|
+
|
|
9
|
+
/** The default deadline for the one GET. */
|
|
10
|
+
export const LIST_TIMEOUT_MS = 4000;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The key goes only in a header, in the form each shape expects.
|
|
14
|
+
* @param {string} shape an API shape id (src/providershapes.js)
|
|
15
|
+
* @param {string} key
|
|
16
|
+
* @returns {Record<string, string>}
|
|
17
|
+
*/
|
|
18
|
+
export function authHeaders(shape, key) {
|
|
19
|
+
if (shape === 'anthropic-api') return { 'x-api-key': key, 'anthropic-version': '2023-06-01' };
|
|
20
|
+
if (shape === 'gemini-api') return { 'x-goog-api-key': key };
|
|
21
|
+
return { Authorization: `Bearer ${key}` };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The model ids a provider's list answer holds, per API shape (`data[].id` for two shapes;
|
|
26
|
+
* `models[].name` minus its `models/` prefix for the third). Sorted, de-duplicated.
|
|
27
|
+
* @param {string} shape
|
|
28
|
+
* @param {any} body
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
export function idsOf(shape, body) {
|
|
32
|
+
const list = shape === 'gemini-api' ? body?.models : body?.data;
|
|
33
|
+
const key = shape === 'gemini-api' ? 'name' : 'id';
|
|
34
|
+
const ids = (Array.isArray(list) ? list : [])
|
|
35
|
+
.map((/** @type {any} */ m) => (typeof m?.[key] === 'string' ? m[key].replace(/^models\//, '') : ''))
|
|
36
|
+
.filter((id) => id !== '');
|
|
37
|
+
return [...new Set(ids)].sort();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {{ ok: true, models: string[], ms: number }
|
|
42
|
+
* | { ok: false, why: string, status: string, detail?: string }} ListResult
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* One free GET of `<baseUrl>/models` with the key in the header only and a deadline.
|
|
47
|
+
* Failure is data, never a throw: `why` is a plain sentence, `status` a short machine word
|
|
48
|
+
* (`no-base-url`, `redirect`, `HTTP <n>`, `timeout`, `unreachable`) for callers that need it.
|
|
49
|
+
* @param {object} o
|
|
50
|
+
* @param {string} o.shape an API shape id
|
|
51
|
+
* @param {string} o.baseUrl the row's Base URL ('' = no call)
|
|
52
|
+
* @param {string} o.key the key value (header only)
|
|
53
|
+
* @param {typeof fetch} [o.fetchImpl] test seam; defaults to the global `fetch`
|
|
54
|
+
* @param {number} [o.timeoutMs]
|
|
55
|
+
* @returns {Promise<ListResult>}
|
|
56
|
+
*/
|
|
57
|
+
export async function listModels({ shape, baseUrl, key, fetchImpl = fetch, timeoutMs = LIST_TIMEOUT_MS }) {
|
|
58
|
+
const base = typeof baseUrl === 'string' ? baseUrl.trim().replace(/\/+$/, '') : '';
|
|
59
|
+
if (base === '') return { ok: false, why: 'Type a Base URL.', status: 'no-base-url' };
|
|
60
|
+
const url = `${base}/models`;
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
const ctl = new AbortController();
|
|
63
|
+
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetchImpl(url, { method: 'GET', headers: authHeaders(shape, key), signal: ctl.signal, redirect: 'manual' });
|
|
66
|
+
if (res.status >= 300 && res.status < 400) {
|
|
67
|
+
return { ok: false, why: 'The provider answered with a redirect; the key was not sent on. Check the Base URL.', status: 'redirect' };
|
|
68
|
+
}
|
|
69
|
+
if (res.status === 401 || res.status === 403) return { ok: false, why: 'The provider refused the key.', status: `HTTP ${res.status}` };
|
|
70
|
+
if (!res.ok) return { ok: false, why: 'The provider answered with an error.', status: `HTTP ${res.status}` };
|
|
71
|
+
/** @type {any} */
|
|
72
|
+
let body = null;
|
|
73
|
+
try { body = await res.json(); } catch { body = null; }
|
|
74
|
+
return { ok: true, models: idsOf(shape, body), ms: Date.now() - started };
|
|
75
|
+
} catch (/** @type {any} */ e) {
|
|
76
|
+
if (e?.name === 'AbortError') return { ok: false, why: 'The provider did not answer in 4 seconds.', status: 'timeout' };
|
|
77
|
+
const cls = e?.code || e?.name || 'network-error';
|
|
78
|
+
// connection refused surfaces as fetch's bare TypeError ("fetch failed")
|
|
79
|
+
const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
|
|
80
|
+
return { ok: false, why: 'Could not reach the provider.', status: String(cls), detail: refused ? `nothing answering at ${url}` : `request failed: ${cls}` };
|
|
81
|
+
} finally {
|
|
82
|
+
clearTimeout(timer);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/monthly.js
CHANGED
|
@@ -33,37 +33,10 @@ import { readOrphanDraftSpends } from './draftspend.js';
|
|
|
33
33
|
// the per-leg / per-run spend readers live with the ledger (one home, no import cycle through the replay reader)
|
|
34
34
|
export { legSpend, runSpend, chainSpend };
|
|
35
35
|
|
|
36
|
-
// The Money tab's per-provider breakdown groups a run by the
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
/** @type {readonly {id: string, name: string}[]} */
|
|
41
|
-
const MONEY_LABELS = Object.freeze([
|
|
42
|
-
{ id: 'anthropic', name: 'Anthropic' }, { id: 'openai', name: 'OpenAI' },
|
|
43
|
-
{ id: 'gemini', name: 'Gemini' }, { id: 'deepseek', name: 'DeepSeek' },
|
|
44
|
-
]);
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Which known vendor a run's `(provider, baseUrl)` belongs to. `openai-api` with no override
|
|
48
|
-
* is OpenAI; with DeepSeek's host it is DeepSeek; with any OTHER override it is none (null —
|
|
49
|
-
* never silently pooled into OpenAI's figures).
|
|
50
|
-
* @param {string|null|undefined} provider
|
|
51
|
-
* @param {string|null|undefined} baseUrl
|
|
52
|
-
* @returns {string|null} a MONEY_LABELS id, or null
|
|
53
|
-
*/
|
|
54
|
-
function rowIdFor(provider, baseUrl) {
|
|
55
|
-
if (provider === 'anthropic-api') return 'anthropic';
|
|
56
|
-
if (provider === 'gemini-api') return 'gemini';
|
|
57
|
-
if (provider === 'openai-api') {
|
|
58
|
-
if (!baseUrl) return 'openai';
|
|
59
|
-
try {
|
|
60
|
-
const host = new URL(baseUrl).hostname;
|
|
61
|
-
if (host === 'api.deepseek.com') return 'deepseek';
|
|
62
|
-
} catch { /* an unparseable override is no known vendor */ }
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
36
|
+
// The Money tab's per-provider breakdown groups a run by the Providers ROW that served it (its
|
|
37
|
+
// key variable name, `src/providerrows.js` `findRow`); a run no row claims lands under its own
|
|
38
|
+
// `<shape> @ <baseUrl>`. This is HISTORY labelling for spend already recorded (an archived run
|
|
39
|
+
// keeps its provider whatever Settings holds today). No vendor name or host is known here.
|
|
67
40
|
|
|
68
41
|
/**
|
|
69
42
|
* One run's wall time off its spine: job-start -> job-end when the run ended (exact), else the
|
|
@@ -289,11 +262,11 @@ export function monthSpend(opts = {}) {
|
|
|
289
262
|
|
|
290
263
|
/**
|
|
291
264
|
* The Money tab's figures: all-time and this-month spend, and per-provider breakdown
|
|
292
|
-
* (
|
|
293
|
-
* pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
265
|
+
* (grouped by the matched Providers row's key variable name; a run on no row lands under its
|
|
266
|
+
* own provider name, never pooled into another row). Any leg with unknown spend makes the figure it belongs to
|
|
294
267
|
* an "at least".
|
|
295
268
|
* @param {{ home?: string, sessionsRoot?: string, now?: () => number, rows?: readonly import('./providerrows.js').KeyRow[] }} [opts] `rows` = the Providers rows: when given, `tokensByRow` sums each row's tokens
|
|
296
|
-
* @returns {{ tokensByRow: Record<string, number>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
269
|
+
* @returns {{ tokensByRow: Record<string, number>, tokensByModel: Record<string, Record<string, number>>, total: {usd: number, atLeast: boolean, tokens: number}, month: {usd: number, atLeast: boolean, tokens: number},
|
|
297
270
|
* byProvider: Record<string, {label: string, monthUsd: number, monthAtLeast: boolean, totalUsd: number, totalAtLeast: boolean, tokens: number, vouchedRounds: number, otherRounds: number, monthWallMs: number|null, monthWallAtLeast: boolean, totalWallMs: number|null, totalWallAtLeast: boolean}> }}
|
|
298
271
|
*/
|
|
299
272
|
export function spendSummary(opts = {}) {
|
|
@@ -307,22 +280,29 @@ export function spendSummary(opts = {}) {
|
|
|
307
280
|
const wallSeen = {};
|
|
308
281
|
/** total tokens of the runs each Providers row served (matched by shape + endpoint + model) @type {Record<string, number>} */
|
|
309
282
|
const tokensByRow = {};
|
|
283
|
+
/** tokens per model line of each row, from the spend rows that NAME the model; absent = no row names it (never 0) @type {Record<string, Record<string, number>>} */
|
|
284
|
+
const tokensByModel = {};
|
|
310
285
|
for (const leg of readLegs({ home: opts.home, sessionsRoot: opts.sessionsRoot })) {
|
|
311
286
|
let served = opts.rows ? findRow(opts.rows, { provider: leg.provider, baseUrl: leg.baseUrl, model: leg.model }) : null;
|
|
312
|
-
// an older spine records a model but no provider: it counts toward the row
|
|
313
|
-
//
|
|
287
|
+
// an older spine records a model but no provider: it counts toward the row with a model line EXACTLY
|
|
288
|
+
// named that model. Two rows naming it make it ambiguous, so it counts toward neither.
|
|
314
289
|
if (opts.rows && leg.provider === null && leg.model !== null) {
|
|
315
|
-
const named = opts.rows.filter((r) => r.
|
|
290
|
+
const named = opts.rows.filter((r) => r.models.some((l) => l.model === leg.model));
|
|
316
291
|
served = named.length === 1 ? named[0] : null;
|
|
317
292
|
}
|
|
318
|
-
if (served)
|
|
293
|
+
if (served) {
|
|
294
|
+
tokensByRow[served.envName] = (tokensByRow[served.envName] ?? 0) + leg.tokens;
|
|
295
|
+
if (typeof leg.model === 'string' && leg.model !== '') {
|
|
296
|
+
const own = (tokensByModel[served.envName] ??= {});
|
|
297
|
+
own[leg.model] = (own[leg.model] ?? 0) + leg.tokens;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
319
300
|
const inMonth = Number.isNaN(leg.at.getTime()) ? true : sameLocalMonth(leg.at, nowDate);
|
|
320
|
-
const id = rowIdFor(leg.provider, leg.baseUrl);
|
|
321
301
|
// an older spine carries a model but no provider: say so honestly, one row per model —
|
|
322
302
|
// never a provider guessed from the model's name
|
|
323
|
-
const notRecorded =
|
|
324
|
-
const key =
|
|
325
|
-
const label =
|
|
303
|
+
const notRecorded = !served && leg.provider === null;
|
|
304
|
+
const key = served ? served.envName : (notRecorded ? `not-recorded:${leg.model ?? ''}` : `other:${leg.provider}${leg.baseUrl ? ` @ ${leg.baseUrl}` : ''}`);
|
|
305
|
+
const label = served ? served.envName
|
|
326
306
|
: (notRecorded ? (leg.model ? `not recorded (model ${leg.model})` : 'not recorded') : key.slice('other:'.length));
|
|
327
307
|
const p = (byProvider[key] ??= { label, monthUsd: 0, monthAtLeast: false, totalUsd: 0, totalAtLeast: false, tokens: 0, vouchedRounds: 0, otherRounds: 0, monthWallMs: 0, monthWallAtLeast: false, totalWallMs: 0, totalWallAtLeast: false });
|
|
328
308
|
const w = (wallSeen[key] ??= { monthRuns: 0, monthKnown: 0, totalKnown: 0 });
|
|
@@ -349,7 +329,7 @@ export function spendSummary(opts = {}) {
|
|
|
349
329
|
if (w.totalKnown === 0) p.totalWallMs = null;
|
|
350
330
|
if (w.monthRuns > 0 && w.monthKnown === 0) p.monthWallMs = null;
|
|
351
331
|
}
|
|
352
|
-
return { tokensByRow, total, month, byProvider };
|
|
332
|
+
return { tokensByRow, tokensByModel, total, month, byProvider };
|
|
353
333
|
}
|
|
354
334
|
|
|
355
335
|
/**
|