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.
- package/CHANGELOG.md +96 -1
- package/README.md +13 -21
- package/bareloop.context.md +141 -128
- package/package.json +5 -4
- package/src/authorflow.js +346 -38
- package/src/authoring.js +92 -7
- package/src/authorjob.js +82 -20
- package/src/authorreadout.js +89 -5
- package/src/authorrun.js +39 -30
- package/src/authorscout.js +112 -4
- package/src/bundle.js +7 -4
- package/src/bundlerun.js +4 -1
- package/src/cli.js +1 -1
- package/src/config.js +129 -29
- package/src/declaredclose.js +23 -12
- package/src/draftspend.js +4 -4
- package/src/facts.js +317 -0
- package/src/factwords.js +129 -0
- package/src/index.js +2 -4
- package/src/inputlook.js +301 -0
- package/src/interviewrun.js +17 -25
- package/src/job.js +15 -8
- package/src/jobblock.js +7 -2
- package/src/judged.js +11 -43
- package/src/keysfile.js +15 -7
- package/src/kinds.js +86 -5
- package/src/labelscan.js +170 -0
- package/src/modellist.js +84 -0
- package/src/monthly.js +23 -43
- package/src/pagefetch.js +125 -0
- package/src/panel/authorroutes.js +34 -16
- package/src/panel/authorsession.js +161 -80
- package/src/panel/index.html +553 -152
- package/src/panel/jobplan.js +34 -11
- package/src/panel/runroutes.js +3 -0
- package/src/panel/server.js +54 -18
- package/src/panel/settingsroutes.js +232 -173
- package/src/planrun.js +5 -4
- package/src/providerrows.js +226 -141
- package/src/providers.js +58 -332
- package/src/providershapes.js +18 -0
- package/src/replay.js +1 -1
- package/src/rubricstep.js +804 -0
- package/src/source.js +166 -1
- package/src/thinking.js +130 -0
- package/src/u-readout.js +1 -1
- package/src/userrun.js +64 -75
- package/types/authorflow.d.ts +272 -18
- package/types/authoring.d.ts +29 -1
- package/types/authorjob.d.ts +9 -7
- package/types/authorreadout.d.ts +27 -2
- package/types/authorscout.d.ts +72 -2
- package/types/config.d.ts +23 -6
- package/types/declaredclose.d.ts +4 -2
- package/types/draftspend.d.ts +8 -8
- package/types/facts.d.ts +71 -0
- package/types/factwords.d.ts +42 -0
- package/types/index.d.ts +1 -1
- package/types/inputlook.d.ts +238 -0
- package/types/job.d.ts +5 -5
- package/types/jobblock.d.ts +8 -1
- package/types/judged.d.ts +5 -36
- package/types/keysfile.d.ts +15 -4
- package/types/labelscan.d.ts +38 -0
- package/types/modellist.d.ts +50 -0
- package/types/monthly.d.ts +4 -3
- package/types/pagefetch.d.ts +71 -0
- package/types/panel/authorsession.d.ts +20 -11
- package/types/panel/jobplan.d.ts +12 -4
- package/types/panel/server.d.ts +18 -6
- package/types/providerrows.d.ts +192 -99
- package/types/providers.d.ts +34 -159
- package/types/providershapes.d.ts +10 -0
- package/types/rubricstep.d.ts +286 -0
- package/types/source.d.ts +94 -0
- package/types/thinking.d.ts +90 -0
- package/types/userrun.d.ts +17 -1
package/src/authorrun.js
CHANGED
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
// in; a draft missing one, or naming one the provider factory
|
|
102
102
|
// does not know, dies here loud, listing the known table.
|
|
103
103
|
import { keysForDoor } from './keysfile.js';
|
|
104
|
-
import {
|
|
104
|
+
import { judgeRatesFor, judgeRowFor, keyNameFor, keyValueOf, ratesFor, ratesOfLine, resolveWorker, rowsForHome } from './providerrows.js';
|
|
105
105
|
import { ConfigError } from './config.js';
|
|
106
106
|
import {
|
|
107
107
|
readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, statSync,
|
|
@@ -120,11 +120,13 @@ import { defaultJudgeLoop, resolveJobJudge } from './judged.js';
|
|
|
120
120
|
import { validateJob, jobSpecHash, resolveWorkerModel } from './job.js';
|
|
121
121
|
import { scanSecrets, redactSecrets } from './validate.js';
|
|
122
122
|
import { detectLanguage } from './detectlang.js';
|
|
123
|
+
import { RUBRIC_LANG } from './authoring.js';
|
|
124
|
+
import { rubricGoal } from './rubricstep.js';
|
|
123
125
|
import { moveFile } from './movefile.js';
|
|
124
126
|
import { closeJudges, GATE_AUDIT_FILE } from './kinds.js';
|
|
125
127
|
import { resolveProvider, buildRunnerProviders, apiKeyProblem } from './providers.js';
|
|
126
128
|
import { commandFor } from './invoke.js';
|
|
127
|
-
import { readSourceManifest, missingDependencies, nonRepoSourceMessage } from './source.js';
|
|
129
|
+
import { readSourceManifest, missingDependencies, nonRepoSourceMessage, isRubricFilesManifest } from './source.js';
|
|
128
130
|
import { tallyCalls } from './text.js';
|
|
129
131
|
import {
|
|
130
132
|
declarationLines, rubricLines, calibrationLines, parseCeiling, ceilingLine, crashRecord, phaseLine,
|
|
@@ -327,6 +329,9 @@ export async function main(argv, deps = {}) {
|
|
|
327
329
|
// folder has no genre to detect) and the branch further down that decides
|
|
328
330
|
// which of the two paths this run actually takes.
|
|
329
331
|
const IS_REPO_SOURCE = manifestRead.manifest.kind === 'repo';
|
|
332
|
+
// PART 1a: a soft-green job whose source froze the person's own local FILES (`proveRubricInputs` left `inputs` in the
|
|
333
|
+
// manifest) is NOT the plain-folder stop below: it is authored against the hidden tree with the text genre, no scout.
|
|
334
|
+
const RUBRIC_FILES = !IS_REPO_SOURCE && VERDICT === 'soft-green' && isRubricFilesManifest(manifestRead.manifest);
|
|
330
335
|
|
|
331
336
|
// ── LANGUAGE, DETECTED — never asked (PRD item 33 M3, ruling 3) ──────────────
|
|
332
337
|
// $0, no provider, run BEFORE the API key is even read — this script must
|
|
@@ -361,7 +366,7 @@ export async function main(argv, deps = {}) {
|
|
|
361
366
|
// the language pick (the old repo-only "worse than before" ask is retired
|
|
362
367
|
// entirely as of 2026-09-28 — see src/authorflow.js's own retirement note).
|
|
363
368
|
let langResult = /** @type {ReturnType<typeof detectLanguage>|null} */ (null);
|
|
364
|
-
let LANG = 'none-detected';
|
|
369
|
+
let LANG = RUBRIC_FILES ? RUBRIC_LANG : 'none-detected';
|
|
365
370
|
if (IS_REPO_SOURCE) {
|
|
366
371
|
langResult = detectLanguage(SOURCE);
|
|
367
372
|
// AMBIGUOUS NO LONGER DIES (PRD item 33 M3 piece 4, step S4): the confirm
|
|
@@ -466,13 +471,15 @@ export async function main(argv, deps = {}) {
|
|
|
466
471
|
try { return resolveProvider(draft?.provider); } catch (e) { return die(/** @type {Error} */ (e).message); }
|
|
467
472
|
})();
|
|
468
473
|
const PROVIDER_NAME = /** @type {string} */ (draft.provider);
|
|
469
|
-
// The
|
|
470
|
-
//
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
const
|
|
474
|
+
// The Settings LINE this authoring run works on (P8): the draft's own `model` on a ticked, priced line of a key row on the
|
|
475
|
+
// draft's API shape (and Base URL, when it names one). The row is the key, the Base URL and the old-max_tokens tick. Nothing is
|
|
476
|
+
// built in: a draft naming no model, or a model no row ticks, stops here at $0.
|
|
477
|
+
const keyCfg = rowsForHome(keys.home, env);
|
|
478
|
+
const W = resolveWorker(keyCfg, draft);
|
|
479
|
+
if (!W.ok) { err(W.say.includes('Nothing was spent') ? W.say : `${W.say} Nothing was spent.`); throw new ExitSignal(2); }
|
|
480
|
+
const MODEL = W.model;
|
|
481
|
+
const baseUrl = W.baseUrl;
|
|
482
|
+
const AUTHOR_ROW = W.row;
|
|
476
483
|
|
|
477
484
|
// THE JUDGE THIS RUN'S OWN WORKER RESOLVES TO (PRD item 32.1) — resolved ONCE,
|
|
478
485
|
// here, from the DRAFT, and reused for BOTH the compose-time stamp (the
|
|
@@ -508,12 +515,9 @@ export async function main(argv, deps = {}) {
|
|
|
508
515
|
|
|
509
516
|
// Secrets load from the environment; they never enter argv (a command line is
|
|
510
517
|
// world-readable on /proc) and they are never printed.
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
const
|
|
514
|
-
const authorEnv = applyConfiguredKey(env, PROVIDER_NAME, baseUrl, keyCfg);
|
|
515
|
-
const AUTHOR_ENV_KEY = keyNameFor(PROVIDER_NAME, baseUrl, keyCfg).name;
|
|
516
|
-
const apiKey = authorEnv[/** @type {NonNullable<typeof providerEntry>} */ (providerEntry).envKey];
|
|
518
|
+
// The key is the chosen row's own variable (P8: no built-in name). Names only.
|
|
519
|
+
const AUTHOR_ENV_KEY = AUTHOR_ROW.envName;
|
|
520
|
+
const apiKey = keyValueOf(env, AUTHOR_ENV_KEY);
|
|
517
521
|
if (!apiKey) { err(`${AUTHOR_ENV_KEY} not set (secrets load from the environment — never the tree, never argv)`); throw new ExitSignal(2); }
|
|
518
522
|
// F181 — a key that carries a line break/control char/stray whitespace (a
|
|
519
523
|
// two-line secret-store entry, e.g.) reads as "set" by the presence check
|
|
@@ -527,17 +531,17 @@ export async function main(argv, deps = {}) {
|
|
|
527
531
|
// loop, confirm turn, calibration judge). A bad price refuses here; it never falls back to the guess.
|
|
528
532
|
/** @type {ReturnType<typeof ratesFor>} */
|
|
529
533
|
let authorPrice = null;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
throw new ExitSignal(2);
|
|
534
|
+
{
|
|
535
|
+
const al = ratesOfLine(W.line);
|
|
536
|
+
authorPrice = al ? { ...al, envName: AUTHOR_ROW.envName } : null;
|
|
534
537
|
}
|
|
535
538
|
/** The judge's key follows the RESOLVED judge provider's own env var, with
|
|
536
539
|
* `JUDGE_API_KEY` as the role-named override in front (PRD item 32.3) — the same
|
|
537
540
|
* contract `scripts/run-u.mjs` keeps, so one story covers both surfaces. When the
|
|
538
541
|
* judge IS the authoring provider this is the key already read above. */
|
|
542
|
+
const judgeRowOf = (/** @type {{provider: string, model?: string|null}} */ j) => judgeRowFor(keyCfg, j, PROVIDER_NAME, AUTHOR_ROW);
|
|
539
543
|
const judgeKeyFor = (/** @type {string} */ providerName) => (
|
|
540
|
-
env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ?
|
|
544
|
+
env.JUDGE_API_KEY ?? (providerName === PROVIDER_NAME ? apiKey : keyValueOf(env, judgeRowFor(keyCfg, { provider: providerName }, PROVIDER_NAME, AUTHOR_ROW)?.envName))
|
|
541
545
|
);
|
|
542
546
|
|
|
543
547
|
/** F6 — an unpriced call makes the TOTAL unknown, and unknown is reported as
|
|
@@ -579,7 +583,7 @@ export async function main(argv, deps = {}) {
|
|
|
579
583
|
// one) — the real judge identity is not resolved until after `validateJob`,
|
|
580
584
|
// further down, where a second call takes only its `.judgeProvider`.
|
|
581
585
|
const { provider } = buildRunnerProviders({
|
|
582
|
-
providerName: PROVIDER_NAME, apiKey, model: MODEL,
|
|
586
|
+
providerName: PROVIDER_NAME, apiKey, model: MODEL, baseUrl, legacyMaxTokens: AUTHOR_ROW.legacyMaxTokens,
|
|
583
587
|
judgeApiKey: apiKey, judgeModel: MODEL, judgeProviderName: PROVIDER_NAME, judgeBaseUrl: baseUrl,
|
|
584
588
|
});
|
|
585
589
|
emit('author-start', { runid, source: SOURCE, lang: LANG, verdictType: VERDICT, provider: PROVIDER_NAME, model: MODEL, baseUrl: baseUrl ?? null, job: draft?.job ?? null, timeoutMs: TIMEOUT_MS, ceilingUsd: CEILING_USD });
|
|
@@ -835,7 +839,7 @@ export async function main(argv, deps = {}) {
|
|
|
835
839
|
// build can close. The stop fires HERE, immediately, at $0, no scout, no
|
|
836
840
|
// confirm turn, no model call — a plain folder's spine is exactly
|
|
837
841
|
// `author-start` → `author-end{outcome:'not-authored', stop:'non-code-source'}`.
|
|
838
|
-
if (!IS_REPO_SOURCE) {
|
|
842
|
+
if (!IS_REPO_SOURCE && !RUBRIC_FILES) {
|
|
839
843
|
const message = nonRepoSourceMessage(manifestRead.manifest.kind);
|
|
840
844
|
out(`\n${message}`);
|
|
841
845
|
const red = {
|
|
@@ -897,7 +901,7 @@ export async function main(argv, deps = {}) {
|
|
|
897
901
|
// so every path that reaches this call is a repo job. `langResult`
|
|
898
902
|
// travels through unchanged — its `ambiguous` shape is what the confirm
|
|
899
903
|
// turn's own $0 half (D7) asks about, before the scout.
|
|
900
|
-
ask, confirmGenerate, isRepo:
|
|
904
|
+
ask, confirmGenerate, isRepo: !RUBRIC_FILES, langResult,
|
|
901
905
|
});
|
|
902
906
|
const authoredFile = writeOut('authored.json', authored);
|
|
903
907
|
emit('authored', { ok: authored.ok, stop: authored.stop, seedRef: authored.seedRef, cost: authored.cost, reds: authored.reds });
|
|
@@ -1009,7 +1013,11 @@ export async function main(argv, deps = {}) {
|
|
|
1009
1013
|
// predates the confirm turn's own `ask` wiring (`authored.confirmed` is
|
|
1010
1014
|
// `null` for every caller that runs no confirm turn) — the draft's own
|
|
1011
1015
|
// goal (if any) then stands untouched.
|
|
1012
|
-
if (
|
|
1016
|
+
if (RUBRIC_FILES && Array.isArray(draft.jobLines) && draft.jobLines.length > 0) {
|
|
1017
|
+
// PART 1a: the worker's goal for a rubric job on files is written by code from the person's own numbered lines and the
|
|
1018
|
+
// frozen input files the manifest recorded (src/rubricstep.js); the confirmed goal sentence rides as the plan line
|
|
1019
|
+
draft.goal = redactSecrets(rubricGoal({ jobLines: draft.jobLines, files: manifestRead.manifest?.inputs ?? [], confirmedGoal: authored.confirmed?.goal ?? null, closeDecl: authored.closeDecl }));
|
|
1020
|
+
} else if (authored.confirmed?.goal) {
|
|
1013
1021
|
draft.goal = redactSecrets(String(authored.confirmed.goal));
|
|
1014
1022
|
}
|
|
1015
1023
|
// ── 2. the operator's half + the authored half → one spec ──────────────────
|
|
@@ -1115,7 +1123,7 @@ export async function main(argv, deps = {}) {
|
|
|
1115
1123
|
const judgeKeyValue = judgeKeyFor(judge.provider);
|
|
1116
1124
|
const judgeKeyProblem = judgeKeyValue ? apiKeyProblem(judgeKeyValue) : null;
|
|
1117
1125
|
if (judgeKeyProblem) {
|
|
1118
|
-
const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' :
|
|
1126
|
+
const judgeEnvName = env.JUDGE_API_KEY ? 'JUDGE_API_KEY' : (judgeRowOf(judge)?.envName ?? judge.provider);
|
|
1119
1127
|
err(`${judgeEnvName} ${judgeKeyProblem} — refusing rather than crashing mid-call (never trimmed or repaired; fix the value at its source)`);
|
|
1120
1128
|
// F186 — the scout above already ran and may have left its own gate
|
|
1121
1129
|
// audit in the tree. Under `process.exit()` this path used to skip
|
|
@@ -1149,9 +1157,10 @@ export async function main(argv, deps = {}) {
|
|
|
1149
1157
|
let judgeProvider = null;
|
|
1150
1158
|
if (judge) {
|
|
1151
1159
|
judgeProvider = buildRunnerProviders({
|
|
1152
|
-
providerName: PROVIDER_NAME, apiKey, model: MODEL,
|
|
1160
|
+
providerName: PROVIDER_NAME, apiKey, model: MODEL, baseUrl, legacyMaxTokens: AUTHOR_ROW.legacyMaxTokens,
|
|
1153
1161
|
judgeApiKey: judgeKeyFor(judge.provider), judgeModel: judge.model, judgeProviderName: judge.provider,
|
|
1154
|
-
judgeBaseUrl: judge.provider === PROVIDER_NAME ? baseUrl : undefined,
|
|
1162
|
+
judgeBaseUrl: judge.provider === PROVIDER_NAME ? baseUrl : (judgeRowOf(judge)?.baseUrl.trim() || undefined),
|
|
1163
|
+
judgeLegacyMaxTokens: judge.provider === PROVIDER_NAME ? AUTHOR_ROW.legacyMaxTokens : (judgeRowOf(judge)?.legacyMaxTokens ?? false),
|
|
1155
1164
|
}).judgeProvider;
|
|
1156
1165
|
}
|
|
1157
1166
|
if (judge) {
|
|
@@ -1170,7 +1179,7 @@ export async function main(argv, deps = {}) {
|
|
|
1170
1179
|
// number would silently widen it.
|
|
1171
1180
|
ceilingUsd: CEILING_USD,
|
|
1172
1181
|
priorCalls: [...metered],
|
|
1173
|
-
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null }) : null,
|
|
1182
|
+
judgeLoop: judgeProvider ? (o) => defaultJudgeLoop({ provider: judgeProvider, system: o.system, rates: judgePrice?.rates ?? null, thinking: spec.thinking === true }) : null,
|
|
1174
1183
|
// the seam's other half (PRD item 32.1): the graded set is STAMPED with
|
|
1175
1184
|
// this, and the close later refuses to grade under any other identity.
|
|
1176
1185
|
judgeModel: judge?.model ?? null,
|
|
@@ -1297,7 +1306,7 @@ export async function main(argv, deps = {}) {
|
|
|
1297
1306
|
+ 'and run-u will enforce the remainder (Cap $ minus this) as ITS ceiling — pass --draft-spent-usd exactly as shown below, never a rounded or re-typed figure.');
|
|
1298
1307
|
}
|
|
1299
1308
|
out('\nTo run it (the same signature and gates as any other job — nothing here bypasses them):');
|
|
1300
|
-
out(` ${
|
|
1309
|
+
out(` ${AUTHOR_ENV_KEY}=... ${commandFor('run-u', deps.invokedAs)} --spec ${specFile} --approve ${hash}${draftSpentUsd > 0 ? ` --draft-spent-usd ${draftSpentUsd}${draftIncomplete ? ' --draft-spend-incomplete' : ''}` : ''}`);
|
|
1301
1310
|
emit('author-end', { outcome: 'prepared', specHash: hash });
|
|
1302
1311
|
}
|
|
1303
1312
|
}
|
package/src/authorscout.js
CHANGED
|
@@ -56,6 +56,7 @@ import { TOOL_BY_VERB, CTX_TOOLS, createCtxTools, toolAction, strategyFor } from
|
|
|
56
56
|
import { redactSecrets, SECRET_PATTERNS } from './validate.js';
|
|
57
57
|
import { extractArtifact, priceOf, scrubRaw, stampRaw, tallyCalls, capStop, callCasualty } from './text.js';
|
|
58
58
|
import { PROVIDER_TIMEOUT_MS } from './clock.js';
|
|
59
|
+
import { lookReadTools, lookReportSchema, lookPrompt, verifyLookReport, labelListsOf, LOOK_REPORT_TOOL } from './inputlook.js';
|
|
59
60
|
|
|
60
61
|
const require = createRequire(import.meta.url);
|
|
61
62
|
const { Loop, wireGate } = require('bare-agent');
|
|
@@ -375,7 +376,7 @@ const defaultLoop = (/** @type {any} */ { system, policy, onLlmResult, provider,
|
|
|
375
376
|
* @param {{workdir: string, provider?: any,
|
|
376
377
|
* rounds?: number, minBytes?: number, blobMax?: number, maxTokens?: number, ctx?: boolean,
|
|
377
378
|
* attempts?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
|
|
378
|
-
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number,
|
|
379
|
+
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
|
|
379
380
|
* createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
|
|
380
381
|
* rates?: {in: number, out: number}|null, callOptions?: Record<string, any>,
|
|
381
382
|
* createSurveyor?: (o: {workdir: string, granted: readonly string[], ctx: boolean}) => Promise<any>}} o
|
|
@@ -400,7 +401,7 @@ export async function runAuthorScout({
|
|
|
400
401
|
onCall = () => {},
|
|
401
402
|
createLoop = defaultLoop,
|
|
402
403
|
rates = null,
|
|
403
|
-
// the drafter's per-call model options (`
|
|
404
|
+
// the drafter's per-call model options (`thinkingCallOptions`, src/thinking.js; e.g. thinking off) — {} = nothing extra, byte-identical
|
|
404
405
|
callOptions = {},
|
|
405
406
|
createSurveyor = defaultSurveyor,
|
|
406
407
|
}) {
|
|
@@ -436,8 +437,8 @@ export async function runAuthorScout({
|
|
|
436
437
|
// metered without the report going out: exactly the pairing invariant the
|
|
437
438
|
// raw already rides. It REPORTS and decides nothing; the ceiling still binds
|
|
438
439
|
// through `capStop` between calls, untouched.
|
|
439
|
-
// bare-agent's per-run `
|
|
440
|
-
onCall({ ...call, ...(r?.
|
|
440
|
+
// bare-agent's per-run `thinkingStatus` rides the reporting seam only, never the stored entry (see makeCostBook.add)
|
|
441
|
+
onCall({ ...call, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
|
|
441
442
|
return raws.push(scrubRaw({ label, attempt, text: r?.text })) - 1;
|
|
442
443
|
};
|
|
443
444
|
// TIGHTEN-ONLY: an operator may lower the ceiling, never raise it. A garbage
|
|
@@ -656,6 +657,113 @@ export async function runAuthorScout({
|
|
|
656
657
|
}
|
|
657
658
|
}
|
|
658
659
|
|
|
660
|
+
// ── THE LOOK AT A RUBRIC JOB'S INPUTS (PART 1c, docs/product/ITEM33-BUILD.md "3. The look") ─────────────────────────────────────
|
|
661
|
+
//
|
|
662
|
+
// The same pattern as the repo survey above, aimed at the person's own input text instead of a repository: a FIXED read-only grant
|
|
663
|
+
// (search the input, read a slice around a hit - closures over in-memory text, so there is nothing to fence), a hard round bound, a
|
|
664
|
+
// reserved final round whose ONLY tool is the answer form (no more looking), the spend metered per call into the same `calls` the
|
|
665
|
+
// drafting book absorbs, and the answer through a strict tool call (never free prose with JSON inside). The result is shaped like a
|
|
666
|
+
// survey (`state`, `facts`, `calls`, `raws`, `budgetStop`) so the drafting pipeline absorbs it exactly as it absorbs the repo scout.
|
|
667
|
+
|
|
668
|
+
/** the look's hard round bound */
|
|
669
|
+
export const LOOK_ROUNDS = AUTHOR_SCOUT_ROUNDS;
|
|
670
|
+
|
|
671
|
+
export const LOOK_SYSTEM = 'You are a careful analyst setting up a checkable job. You are given the person\'s job and the text of their inputs. '
|
|
672
|
+
+ 'You can only search and read that text - you cannot write, run anything or change anything. You have a hard, small number of turns. '
|
|
673
|
+
+ 'Your single deliverable is a report of which facts the job asks for can be CHECKED in the input, with the label that sits right before each value (picked from the list you are shown), and what you could not find. '
|
|
674
|
+
+ 'You do not judge whether a result is good and you do not decide what is done with the job.';
|
|
675
|
+
|
|
676
|
+
export const LOOK_RECOVERY_PROMPT = 'You are out of looking turns. Report NOW, from what you have already read: call the report tool once with everything you can check and everything you could not find.';
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Run the look. `createLoop` is the same seam the repo scout has; nothing else here needs a live process.
|
|
680
|
+
* @param {{texts: Record<string, string>, inputs: {n: number, label: string}[], jobLines: {n: number, text: string, rule?: string, rules?: string[]}[],
|
|
681
|
+
* provider?: any, rounds?: number, maxTokens?: number, ceilingUsd?: number|null, callTimeoutMs?: number,
|
|
682
|
+
* onCall?: (call: {label: string, costUsd: number|null, unpricedRounds: number, thinkingStatus?: string}) => void,
|
|
683
|
+
* createLoop?: (o: {system: string, policy: any, onLlmResult: any, provider: any, rates?: {in: number, out: number}}) => any,
|
|
684
|
+
* rates?: {in: number, out: number}|null, callOptions?: Record<string, any>,
|
|
685
|
+
* prove?: (source: string, phrase: string) => number}} o
|
|
686
|
+
* @returns {Promise<{state: 'PRESENT'|'ABSENT', facts: any, reason: string|null, cause: string|null, meta: any, raw: string,
|
|
687
|
+
* raws: ReturnType<typeof scrubRaw>[], calls: {label: string, costUsd: number|null, unpricedRounds: number}[],
|
|
688
|
+
* budgetStop: 'cap-halt'|'pricing-red'|null}>}
|
|
689
|
+
*/
|
|
690
|
+
export async function runInputLook({
|
|
691
|
+
texts, inputs, jobLines, provider = null, rounds = LOOK_ROUNDS, maxTokens = AUTHOR_SCOUT_MAX_TOKENS, ceilingUsd = null,
|
|
692
|
+
callTimeoutMs = AUTHOR_CALL_TIMEOUT_MS, onCall = () => {}, createLoop = defaultLoop, rates = null, callOptions = {}, prove,
|
|
693
|
+
}) {
|
|
694
|
+
const callTimeout = Number.isFinite(Number(callTimeoutMs)) && Number(callTimeoutMs) > 0
|
|
695
|
+
? Math.min(Math.trunc(Number(callTimeoutMs)), AUTHOR_CALL_TIMEOUT_MS) : AUTHOR_CALL_TIMEOUT_MS;
|
|
696
|
+
const priced = rates ? { rates } : {};
|
|
697
|
+
/** @type {{label: string, costUsd: number|null, unpricedRounds: number}[]} */
|
|
698
|
+
const calls = [];
|
|
699
|
+
/** @type {ReturnType<typeof scrubRaw>[]} */
|
|
700
|
+
const raws = [];
|
|
701
|
+
const record = (/** @type {string} */ label, /** @type {any} */ r) => {
|
|
702
|
+
const call = { label, ...priceOf(r) };
|
|
703
|
+
calls.push(call);
|
|
704
|
+
onCall({ ...call, ...(r?.thinkingStatus === undefined ? {} : { thinkingStatus: r.thinkingStatus }) });
|
|
705
|
+
raws.push(scrubRaw({ label, attempt: 1, text: r?.text }));
|
|
706
|
+
};
|
|
707
|
+
const settled = async (/** @type {Promise<any>} */ p) => {
|
|
708
|
+
try { return await p; } catch (e) {
|
|
709
|
+
const reason = callCasualty(e);
|
|
710
|
+
if (reason === null) throw e;
|
|
711
|
+
return { error: reason };
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
const { scans, lists } = labelListsOf(texts);
|
|
715
|
+
/** @type {{calls: any[]}} */
|
|
716
|
+
const box = { calls: [] };
|
|
717
|
+
/** @type {any} */
|
|
718
|
+
let loop = null;
|
|
719
|
+
const reportTool = {
|
|
720
|
+
name: LOOK_REPORT_TOOL,
|
|
721
|
+
description: 'Deliver the report. Call this exactly once, with the whole report as its arguments.',
|
|
722
|
+
parameters: lookReportSchema(),
|
|
723
|
+
execute: async (/** @type {any} */ args) => { box.calls.push(args); if (loop) loop.stop(); return 'received'; },
|
|
724
|
+
};
|
|
725
|
+
const tools = [...lookReadTools(texts), reportTool];
|
|
726
|
+
const absent = (/** @type {string} */ reason, /** @type {string} */ cause, /** @type {'cap-halt'|'pricing-red'|null} */ budgetStop = null) => ({
|
|
727
|
+
state: /** @type {'ABSENT'} */ ('ABSENT'), facts: null, reason, cause, meta: { rounds: seen, bounded }, raw: '', raws, calls, budgetStop,
|
|
728
|
+
});
|
|
729
|
+
let seen = 0;
|
|
730
|
+
let bounded = false;
|
|
731
|
+
const halt0 = capStop({ ceilingUsd, ...tallyCalls(calls) });
|
|
732
|
+
if (halt0) return absent(`the look was never asked for: the $${ceilingUsd} ceiling was already spent`, SURVEY_CAUSES.NOT_FUNDED, halt0);
|
|
733
|
+
|
|
734
|
+
const metered = async (/** @type {any} */ arg) => {
|
|
735
|
+
if ((arg?.kind ?? 'turn') === 'turn') {
|
|
736
|
+
seen += 1;
|
|
737
|
+
if (seen >= rounds) { bounded = true; loop.stop(); }
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
loop = createLoop({ system: LOOK_SYSTEM, policy: undefined, onLlmResult: metered, provider, ...priced });
|
|
741
|
+
const r = await settled(loop.run([{ role: 'user', content: lookPrompt({ jobLines, labels: Object.keys(texts), lists }) }], tools,
|
|
742
|
+
{ cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions }));
|
|
743
|
+
record('input-look', r);
|
|
744
|
+
/** @type {any} */
|
|
745
|
+
let last = r;
|
|
746
|
+
if (box.calls.length === 0 && !r?.error) {
|
|
747
|
+
// THE RESERVED FINAL ROUND: no more looking - the only tool is the answer form
|
|
748
|
+
const halt = capStop({ ceilingUsd, ...tallyCalls(calls) });
|
|
749
|
+
if (halt) return absent(`the look stopped on the cost ceiling before it reported: ${halt}`, SURVEY_CAUSES.NOT_FUNDED, halt);
|
|
750
|
+
const recovery = createLoop({ system: LOOK_SYSTEM, policy: undefined, onLlmResult: undefined, provider, ...priced });
|
|
751
|
+
const rMsgs = Array.isArray(r?.msgs) && r.msgs.length ? (r.msgs[0]?.role === 'system' ? r.msgs.slice(1) : r.msgs) : [];
|
|
752
|
+
const only = { ...reportTool, execute: async (/** @type {any} */ args) => { box.calls.push(args); recovery.stop(); return 'received'; } };
|
|
753
|
+
last = await settled(recovery.run([...rMsgs, { role: 'user', content: LOOK_RECOVERY_PROMPT }], [only],
|
|
754
|
+
{ cacheMessages: true, maxTokens, timeoutMs: callTimeout, ...callOptions }));
|
|
755
|
+
record('input-look-recovery', last);
|
|
756
|
+
}
|
|
757
|
+
if (box.calls.length === 0) {
|
|
758
|
+
return absent(last?.error ? `the look call failed: ${redactSecrets(String(last.error))}` : 'the look ended without a report', last?.error ? SURVEY_CAUSES.CALL_FAILED : SURVEY_CAUSES.EMPTY);
|
|
759
|
+
}
|
|
760
|
+
const report = verifyLookReport(box.calls[0], { scans, jobLines, ...(prove ? { prove } : {}) });
|
|
761
|
+
return {
|
|
762
|
+
state: 'PRESENT', reason: null, cause: null, meta: { rounds: seen, bounded }, raw: '', raws, calls, budgetStop: null,
|
|
763
|
+
facts: { rubricFiles: true, inputs: inputs.map((i) => ({ n: i.n, label: i.label })), look: report },
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
|
|
659
767
|
// ── L1: THE MECHANICAL SEED LISTING ──────────────────────────────────────────
|
|
660
768
|
|
|
661
769
|
/** per-path full-listing cap; over it, names give way to counted directories */
|
package/src/bundle.js
CHANGED
|
@@ -459,13 +459,16 @@ export function exportBundle({ spec, closeScripts, registryDir, outDir, bareloop
|
|
|
459
459
|
'',
|
|
460
460
|
'## Running this bundle',
|
|
461
461
|
'',
|
|
462
|
-
' ./node_modules/.bin/bareloop run . --repo <path> [--budget N] [--wall MIN] [--approve <bundleHash>]',
|
|
462
|
+
' ./node_modules/.bin/bareloop run . --repo <path> [--model <id>] [--budget N] [--wall MIN] [--approve <bundleHash>]',
|
|
463
463
|
'',
|
|
464
464
|
'Answer these, in the order `bareloop run` asks them:',
|
|
465
465
|
'',
|
|
466
|
-
|
|
467
|
-
'
|
|
468
|
-
'
|
|
466
|
+
"1. Provider key — put your provider's API key in your keys file (the NAME your",
|
|
467
|
+
' Settings row uses), never on the command line. A bundle signed without a',
|
|
468
|
+
' model needs `--model <id>` of a line ticked in Settings > Providers (with',
|
|
469
|
+
' both prices), plus `--provider <KEY>` when two keys tick it. With neither,',
|
|
470
|
+
' `bareloop run` prints these questions and the bundleHash again, and spends',
|
|
471
|
+
' nothing.',
|
|
469
472
|
`2. Approve — the FIRST run on this machine requires --approve ${hash}`,
|
|
470
473
|
" (this bundle's own signed hash, shown above) to prove a human read and",
|
|
471
474
|
' accepted it before anything runs. Once that first run GREENS, the',
|
package/src/bundlerun.js
CHANGED
|
@@ -204,13 +204,16 @@ export async function bundleMain(args, {
|
|
|
204
204
|
workdir: worktree,
|
|
205
205
|
seed,
|
|
206
206
|
approve: approveHash,
|
|
207
|
+
// an exported bundle follows the run-u rule: a spec that names no model takes `--model <id>` (+ `--provider <KEY>` when two keys tick it)
|
|
208
|
+
...(typeof flags.model === 'string' ? { model: flags.model } : {}),
|
|
209
|
+
...(typeof flags.provider === 'string' ? { providerKey: flags.provider } : {}),
|
|
207
210
|
deps: { ...deps, env, out, err, ...(keysHome === undefined ? {} : { keysHome }) },
|
|
208
211
|
bundle: {
|
|
209
212
|
runid,
|
|
210
213
|
runDir,
|
|
211
214
|
// the bare re-invocation prefix; the leg's OWN tightened ceilings (only those the person passed) ride as
|
|
212
215
|
// DATA so the engine, which knows the halt kind and the spend, spells the printed line. Text only.
|
|
213
|
-
invoke: `bareloop run ${bundleDirArg} --repo ${repo}`,
|
|
216
|
+
invoke: `bareloop run ${bundleDirArg} --repo ${repo}${typeof flags.model === 'string' ? ` --model ${flags.model}` : ''}${typeof flags.provider === 'string' ? ` --provider ${flags.provider}` : ''}`,
|
|
214
217
|
tightened: {
|
|
215
218
|
...(envelope.budgetUsd === undefined ? {} : { budgetUsd: envelope.budgetUsd }),
|
|
216
219
|
...(flags.wall === undefined ? {} : { wallMin: Number(flags.wall) }),
|
package/src/cli.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// run-u` drives (`src/userrun.js`): providers, keys (the keys file / env, any provider the
|
|
8
8
|
// bundle's spec names), judge, monthly limit and readout are that engine's. The test seam is
|
|
9
9
|
// the engine's too: `deps.provider` (and `providerFor`/`judgeProvider`) supplies a scripted
|
|
10
|
-
// provider and skips the real-key check (same as `tests/planrun
|
|
10
|
+
// provider and skips the real-key check (same as `tests/planrun-*.test.js`'s pattern).
|
|
11
11
|
//
|
|
12
12
|
// Money/arbiter rules the bundle door keeps: `--budget`/`--wall` only TIGHTEN a bundle's
|
|
13
13
|
// signed ceilings (`checkEnvelope`, M1); a bundle's `bundleHash` (the human-facing
|
package/src/config.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
//
|
|
1
|
+
// rewritten-from: fwdloop src/config.js@f9449e8 (validators `modelsProblem`, `providerFieldProblem`, `cleanBaseUrl`)
|
|
2
|
+
// PANEL-BUILD.md P4a item 2 / P8 — the ONE reader/writer of `~/.config/bareloop/config.json`.
|
|
2
3
|
//
|
|
3
|
-
// Every panel/CLI setting lives in this one file (R5): the monthly $ limit
|
|
4
|
-
//
|
|
4
|
+
// Every panel/CLI setting lives in this one file (R5): the monthly $ limit and the Providers
|
|
5
|
+
// rows' API shape / Base URL / old-max_tokens tick (`keys`) and each row's model lines (`models`).
|
|
5
6
|
// Key VALUES never live here — they live in the keys file (`src/keysfile.js`) and the
|
|
6
7
|
// shell env. The write path refuses a document that carries a secret-shaped string
|
|
7
8
|
// (the ONE inventory: `sweepSecretLiterals`, src/validate.js).
|
|
8
9
|
//
|
|
9
10
|
// Shape (unknown fields are kept, never dropped):
|
|
10
|
-
// { "monthlyLimitUsd": <number>, // absent = no limit, no check
|
|
11
|
-
// "
|
|
12
|
-
// "
|
|
11
|
+
// { "monthlyLimitUsd": <number > 0>, // absent = no limit, no check
|
|
12
|
+
// "keys": { "<ENV NAME>": { "shape": "<shape id>", "baseUrl": "http(s)://…", "legacyMaxTokens": true|false } },
|
|
13
|
+
// "models": { "<ENV NAME>": [ { "model": "<id>", "use": true|false, "inPerM": n, "outPerM": n } ] } }
|
|
14
|
+
// An older file's `keys.<ENV>.{name,priceInPerM,priceOutPerM}` is tolerated and read as ONE model
|
|
15
|
+
// line (`effectiveModels`, src/providerrows.js); the first write of that row's `models` list removes them.
|
|
16
|
+
// `modelsProblem` is the ONE rule for a row's model lines: a price is a PAIR or neither, a ticked
|
|
17
|
+
// line needs both. Nothing here sniffs a host or a model id.
|
|
13
18
|
//
|
|
14
|
-
// A missing file = defaults. An UNREADABLE file is reported (`problem`), never
|
|
19
|
+
// A missing file = defaults. An UNREADABLE or INVALID file is reported (`problem`), never
|
|
15
20
|
// papered over — the monthly check REFUSES on it rather than silently running with
|
|
16
21
|
// no limit. Written atomically (tmp + rename), mode 600.
|
|
17
22
|
import {
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
import { join, dirname } from 'node:path';
|
|
21
26
|
import { homedir } from 'node:os';
|
|
22
27
|
import { sweepSecretLiterals } from './validate.js';
|
|
28
|
+
import { SHAPE_IDS } from './providershapes.js';
|
|
23
29
|
|
|
24
30
|
/** A plain-words config failure (bad value, secret-shaped string, unreadable file). */
|
|
25
31
|
export class ConfigError extends Error {}
|
|
@@ -55,22 +61,106 @@ export function readConfig(opts = {}) {
|
|
|
55
61
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
56
62
|
return { config: {}, exists: true, problem: `${file} must hold a JSON object — fix or remove it` };
|
|
57
63
|
}
|
|
64
|
+
const bad = docProblem(parsed);
|
|
65
|
+
if (bad) return { config: {}, exists: true, problem: `${file}: ${bad} — fix or remove it` };
|
|
58
66
|
return { config: parsed, exists: true, problem: null };
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
|
|
71
|
+
/** The fields of a key row this module validates (others are kept untouched). */
|
|
72
|
+
const KEY_FIELDS = Object.freeze(['shape', 'baseUrl', 'legacyMaxTokens']);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One key-row field's value problem in plain words, or null when fine.
|
|
76
|
+
* @param {string} field `model` | `shape` | `baseUrl` | `legacyMaxTokens`
|
|
77
|
+
* @param {unknown} v
|
|
78
|
+
* @returns {string|null}
|
|
79
|
+
*/
|
|
80
|
+
export function providerFieldProblem(field, v) {
|
|
81
|
+
if (field === 'model') {
|
|
82
|
+
if (typeof v !== 'string' || v.length === 0 || v.length > 200 || /\s/.test(v)) return 'must be a model id: 1 to 200 characters, no spaces';
|
|
83
|
+
} else if (field === 'shape') {
|
|
84
|
+
if (typeof v !== 'string' || !SHAPE_IDS.includes(v)) return `must be one of ${SHAPE_IDS.join(', ')}`;
|
|
85
|
+
} else if (field === 'legacyMaxTokens') {
|
|
86
|
+
if (typeof v !== 'boolean') return 'must be true or false';
|
|
87
|
+
} else if (field === 'baseUrl') {
|
|
88
|
+
if (typeof v !== 'string' || !(v === '' || /^https?:\/\/\S+$/.test(v))) return 'must be empty or an http(s) address';
|
|
89
|
+
} else return 'is not a provider field';
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A Base URL as stored: trailing slashes dropped. @param {string} v @returns {string} */
|
|
94
|
+
export const cleanBaseUrl = (v) => v.replace(/\/+$/, '');
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* THE one rule for a row's model lines, used by config validation and the Settings routes. A price
|
|
98
|
+
* is a PAIR (`inPerM` and `outPerM`, each a finite number >= 0 — 0 / 0 typed is a chosen price) or
|
|
99
|
+
* neither (null / absent). A ticked line (`use`) needs both. Plain words, or null.
|
|
100
|
+
* @param {unknown} list
|
|
101
|
+
* @returns {string|null}
|
|
102
|
+
*/
|
|
103
|
+
export function modelsProblem(list) {
|
|
104
|
+
if (!Array.isArray(list)) return 'The model list must be a list of lines.';
|
|
105
|
+
const seen = new Set();
|
|
106
|
+
for (const line of list) {
|
|
107
|
+
if (line === null || typeof line !== 'object' || Array.isArray(line)) return 'Each model line must be an object.';
|
|
108
|
+
const l = /** @type {Record<string, any>} */ (line);
|
|
109
|
+
const bad = providerFieldProblem('model', l.model);
|
|
110
|
+
if (bad) return `The model name ${bad}.`;
|
|
111
|
+
if (seen.has(l.model)) return `The model "${l.model}" is listed twice.`;
|
|
112
|
+
seen.add(l.model);
|
|
113
|
+
if (typeof l.use !== 'boolean') return 'Use must be on or off.';
|
|
114
|
+
const has = (/** @type {string} */ f) => l[f] !== undefined && l[f] !== null;
|
|
115
|
+
const isNum = (/** @type {unknown} */ v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
|
116
|
+
if (has('inPerM') !== has('outPerM')) return 'Fill both price boxes or clear both.';
|
|
117
|
+
if (has('inPerM') && !(isNum(l.inPerM) && isNum(l.outPerM))) return 'A price must be a number, 0 or more.';
|
|
118
|
+
if (l.use && !has('inPerM')) return 'Fill both prices to tick this model.';
|
|
119
|
+
for (const k of Object.keys(l)) if (!['model', 'use', 'inPerM', 'outPerM'].includes(k)) return `A model line has no "${k}" field.`;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The first problem in a document's known fields, in plain words, or null.
|
|
126
|
+
* @param {Record<string, any>} doc
|
|
127
|
+
* @returns {string|null}
|
|
128
|
+
*/
|
|
129
|
+
function docProblem(doc) {
|
|
130
|
+
// monthlyLimitUsd is NOT judged here: `monthlyLimitOf` (src/monthly.js) owns its read-time sentence
|
|
131
|
+
if (doc.keys !== undefined) {
|
|
132
|
+
if (doc.keys === null || typeof doc.keys !== 'object' || Array.isArray(doc.keys)) return 'keys must be an object of key variable names';
|
|
133
|
+
for (const [env, row] of Object.entries(doc.keys)) {
|
|
134
|
+
if (!ENV_NAME_RE.test(env)) return `"${env}" is not a key variable name`;
|
|
135
|
+
if (row === null || typeof row !== 'object' || Array.isArray(row)) return `keys.${env} must be an object`;
|
|
136
|
+
for (const f of KEY_FIELDS) {
|
|
137
|
+
if (!Object.hasOwn(row, f)) continue;
|
|
138
|
+
const bad = providerFieldProblem(f, /** @type {Record<string, any>} */ (row)[f]);
|
|
139
|
+
if (bad) return `keys.${env}.${f} ${bad}`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (doc.models !== undefined) {
|
|
144
|
+
if (doc.models === null || typeof doc.models !== 'object' || Array.isArray(doc.models)) return 'models must be an object of key variable names';
|
|
145
|
+
for (const [env, list] of Object.entries(doc.models)) {
|
|
146
|
+
if (!ENV_NAME_RE.test(env)) return `models: "${env}" is not a key variable name`;
|
|
147
|
+
const bad = modelsProblem(list);
|
|
148
|
+
if (bad) return `models.${env}: ${bad}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
64
153
|
|
|
65
154
|
/**
|
|
66
155
|
* Apply a patch and write. `null` deletes a field. Top-level fields shallow-merge;
|
|
67
|
-
* `keys` merges per key variable name, then per field
|
|
68
|
-
* written; nothing is written on a refusal.
|
|
156
|
+
* `keys` merges per key variable name, then per field; `models.<ENV>` is REPLACED WHOLE (`null`
|
|
157
|
+
* clears it). Validated before anything is written; nothing is written on a refusal.
|
|
69
158
|
*
|
|
70
|
-
* Patch fields understood: `monthlyLimitUsd` (finite > 0), `
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
159
|
+
* Patch fields understood: `monthlyLimitUsd` (finite > 0), `keys.<ENV>.{shape,baseUrl,legacyMaxTokens}`,
|
|
160
|
+
* `models.<ENV>` (a list judged by {@link modelsProblem}). The first write of a row's `models` list
|
|
161
|
+
* removes that row's older `name` / `priceInPerM` / `priceOutPerM` in the SAME write (never two
|
|
162
|
+
* sources); a retired top-level `anthropicBalanceNote` is dropped on any write. Any other field is
|
|
163
|
+
* kept, but the whole document is swept for secret-shaped strings first.
|
|
74
164
|
* @param {Record<string, any>} patch
|
|
75
165
|
* @param {{ home?: string }} [opts]
|
|
76
166
|
* @returns {Record<string, any>} the document as written
|
|
@@ -81,16 +171,14 @@ export function updateConfig(patch, opts = {}) {
|
|
|
81
171
|
/** @type {Record<string, any>} */
|
|
82
172
|
const next = { ...cur.config };
|
|
83
173
|
for (const [k, v] of Object.entries(patch)) {
|
|
84
|
-
if (k === 'keys') continue;
|
|
174
|
+
if (k === 'keys' || k === 'models') continue;
|
|
85
175
|
if (v === null) { delete next[k]; continue; }
|
|
86
176
|
if (k === 'monthlyLimitUsd' && !(typeof v === 'number' && Number.isFinite(v) && v > 0)) {
|
|
87
177
|
throw new ConfigError('monthlyLimitUsd must be a number above 0 (remove the limit to have none)');
|
|
88
178
|
}
|
|
89
|
-
if (k === 'anthropicBalanceNote' && !(typeof v === 'number' && Number.isFinite(v) && v >= 0)) {
|
|
90
|
-
throw new ConfigError('anthropicBalanceNote must be a number, 0 or more');
|
|
91
|
-
}
|
|
92
179
|
next[k] = v;
|
|
93
180
|
}
|
|
181
|
+
delete next.anthropicBalanceNote;
|
|
94
182
|
if (patch.keys !== undefined) {
|
|
95
183
|
if (patch.keys === null || typeof patch.keys !== 'object' || Array.isArray(patch.keys)) {
|
|
96
184
|
throw new ConfigError('keys must be an object of key variable names');
|
|
@@ -104,21 +192,33 @@ export function updateConfig(patch, opts = {}) {
|
|
|
104
192
|
const merged = { ...(keys[envName] && typeof keys[envName] === 'object' ? keys[envName] : {}) };
|
|
105
193
|
for (const [fk, fv] of Object.entries(/** @type {Record<string, any>} */ (fields))) {
|
|
106
194
|
if (fv === null) { delete merged[fk]; continue; }
|
|
107
|
-
|
|
108
|
-
throw new ConfigError(`keys.${envName}.name must be a model id (no spaces, up to 100 characters)`);
|
|
109
|
-
}
|
|
110
|
-
if (fk === 'shape' && !SHAPE_IDS.includes(fv)) {
|
|
111
|
-
throw new ConfigError(`keys.${envName}.shape must be one of ${SHAPE_IDS.join(', ')}`);
|
|
112
|
-
}
|
|
113
|
-
if (fk === 'baseUrl' && !(typeof fv === 'string' && (fv === '' || /^https?:\/\/\S+$/.test(fv)))) {
|
|
114
|
-
throw new ConfigError(`keys.${envName}.baseUrl must be blank or an http(s) URL`);
|
|
115
|
-
}
|
|
116
|
-
merged[fk] = fv;
|
|
195
|
+
merged[fk] = fk === 'baseUrl' && typeof fv === 'string' ? cleanBaseUrl(fv) : fv;
|
|
117
196
|
}
|
|
118
197
|
keys[envName] = merged;
|
|
119
198
|
}
|
|
120
199
|
next.keys = keys;
|
|
121
200
|
}
|
|
201
|
+
if (patch.models !== undefined) {
|
|
202
|
+
if (patch.models === null || typeof patch.models !== 'object' || Array.isArray(patch.models)) {
|
|
203
|
+
throw new ConfigError('models must be an object of key variable names');
|
|
204
|
+
}
|
|
205
|
+
/** @type {Record<string, any>} */
|
|
206
|
+
const models = { ...(next.models && typeof next.models === 'object' ? next.models : {}) };
|
|
207
|
+
for (const [envName, list] of Object.entries(patch.models)) {
|
|
208
|
+
if (!ENV_NAME_RE.test(envName)) throw new ConfigError(`"${envName}" is not a key variable name`);
|
|
209
|
+
if (list === null) { delete models[envName]; continue; }
|
|
210
|
+
models[envName] = list;
|
|
211
|
+
const row = next.keys?.[envName];
|
|
212
|
+
if (row && typeof row === 'object') {
|
|
213
|
+
// one source: the older single-model fields leave with the first saved list
|
|
214
|
+
const { name: _n, priceInPerM: _i, priceOutPerM: _o, ...rest } = row;
|
|
215
|
+
next.keys = { ...next.keys, [envName]: rest };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
next.models = models;
|
|
219
|
+
}
|
|
220
|
+
const problem = docProblem(next);
|
|
221
|
+
if (problem) throw new ConfigError(problem);
|
|
122
222
|
/** @type {string[]} */
|
|
123
223
|
const secretPaths = [];
|
|
124
224
|
sweepSecretLiterals(next, (_code, path) => { secretPaths.push(path); });
|