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/providers.js
CHANGED
|
@@ -21,63 +21,13 @@
|
|
|
21
21
|
// (`buildRunnerProviders`, below); it never prices a round (F113: rates
|
|
22
22
|
// passthrough is dead), and never decides a budget.
|
|
23
23
|
|
|
24
|
+
import { listModels } from './modellist.js';
|
|
25
|
+
import { SHAPE_IDS } from './providershapes.js';
|
|
24
26
|
import { createRequire } from 'node:module';
|
|
25
27
|
|
|
26
28
|
const require = createRequire(import.meta.url);
|
|
27
29
|
const { AnthropicProvider, OpenAIProvider, GeminiProvider } = require('bare-agent/providers');
|
|
28
30
|
|
|
29
|
-
/**
|
|
30
|
-
* Today's anthropic-api tier table — EXACTLY the `DEFAULT_TIER_MODELS` both
|
|
31
|
-
* runners hardcoded before this factory existed. Any change to these two
|
|
32
|
-
* values is a change to shipped anthropic-api behaviour, not a refactor.
|
|
33
|
-
*/
|
|
34
|
-
export const ANTHROPIC_TIER_MODELS = Object.freeze({
|
|
35
|
-
sonnet: 'claude-sonnet-5',
|
|
36
|
-
haiku: 'claude-haiku-4-5-20251001',
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* DeepSeek (F149/F150) is a SINGLE model, not a tiered family — hamr's
|
|
41
|
-
* ruling above: one secondary provider, not a menu. Both tiers map to the
|
|
42
|
-
* same model id ON PURPOSE: `resolveWorkerModel`/a runner's `providerFor
|
|
43
|
-
* (tier)` only ever LOOK UP a tier's model id (src/job.js:818-827,
|
|
44
|
-
* scripts/run-u.mjs's `providerFor`) — neither assumes the tiers name
|
|
45
|
-
* different models — so repeating the one id is the honest representation
|
|
46
|
-
* of a single-model provider, not a second model masquerading as `haiku`.
|
|
47
|
-
* If DeepSeek (or a future openai-api entrant) ever ships a genuinely
|
|
48
|
-
* cheaper tier model, this is where it would earn its own `haiku` id.
|
|
49
|
-
*
|
|
50
|
-
* MODEL SWAP (F171, item 34 L16, hamr 2026-09-13): `deepseek-chat` is no
|
|
51
|
-
* longer served — a live `/models` call returned only `deepseek-flash` and
|
|
52
|
-
* `deepseek-v4-pro`. Switched to `deepseek-flash` (DeepSeek V4.1). F149's
|
|
53
|
-
* `legacyMaxTokens` measurement (asked 64 output tokens, got 665/608) was
|
|
54
|
-
* taken on `deepseek-chat` and is UNVERIFIED on `deepseek-flash` — kept on
|
|
55
|
-
* below as the fail-safe (over-capping direction is safe; see F171).
|
|
56
|
-
*/
|
|
57
|
-
export const OPENAI_TIER_MODELS = Object.freeze({
|
|
58
|
-
sonnet: 'deepseek-flash',
|
|
59
|
-
haiku: 'deepseek-flash',
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Gemini (PRD item 31.3, hamr 2026-09-09: "anthropic, openai, gemini drop
|
|
64
|
-
* ollama for now"). Two real tiers, unlike DeepSeek's single model: `flash` is
|
|
65
|
-
* the cheap tier by design, so `haiku` maps to it honestly rather than
|
|
66
|
-
* repeating one id.
|
|
67
|
-
*
|
|
68
|
-
* ADMITTED-PENDING-PROBE. The probe rule stands (PRD item 28 ruling (d),
|
|
69
|
-
* 2026-09-09): no endpoint is in the menu without its own clean paid probe,
|
|
70
|
-
* and `gemini-api` has ZERO runs. `anthropic-api` is the original (~167
|
|
71
|
-
* archived runs) and `openai-api` was probed green through the shipped runner
|
|
72
|
-
* (run mtu12vks, $1.64/$4, F157); gemini owes that fire and has not paid it.
|
|
73
|
-
* `PROBE_STATUS` below is the machine-readable form of that debt — nothing in
|
|
74
|
-
* here silently pretends the provider is proven.
|
|
75
|
-
*/
|
|
76
|
-
export const GEMINI_TIER_MODELS = Object.freeze({
|
|
77
|
-
sonnet: 'gemini-2.5-pro',
|
|
78
|
-
haiku: 'gemini-2.5-flash',
|
|
79
|
-
});
|
|
80
|
-
|
|
81
31
|
/**
|
|
82
32
|
* Which admitted providers have paid for their own end-to-end probe, and which
|
|
83
33
|
* have not. Read by the runner to print a loud marker; never read to REFUSE —
|
|
@@ -91,64 +41,10 @@ export const PROBE_STATUS = Object.freeze({
|
|
|
91
41
|
'gemini-api': Object.freeze({ probed: false, evidence: 'ZERO runs — admitted to the menu, owes its own paid probe (PRD item 31.3)' }),
|
|
92
42
|
});
|
|
93
43
|
|
|
94
|
-
/**
|
|
95
|
-
* Per-MODEL request-key gating (F149, live-measured 2026-09-09): DeepSeek
|
|
96
|
-
* silently IGNORES the modern `max_completion_tokens` key — asked for 64
|
|
97
|
-
* output tokens, it returned 665/608 — and only honours the legacy
|
|
98
|
-
* `max_tokens` key. `bare-agent`'s `OpenAIProvider` already exposes the
|
|
99
|
-
* switch (`legacyMaxTokens`, BA-24; no model-name sniffing inside
|
|
100
|
-
* bare-agent itself — "the caller declares the dialect"), so THIS table is
|
|
101
|
-
* bareloop's routing of it, keyed per model, never a global default. An
|
|
102
|
-
* output cap that does not bind is a money hazard: a reasoning model can
|
|
103
|
-
* run to its own ceiling on every round, silently, so getting this wrong
|
|
104
|
-
* for even one model defeats every budget that model's tier sets.
|
|
105
|
-
*
|
|
106
|
-
* A model with no entry here gets `{}` — bare-agent's own default
|
|
107
|
-
* (`max_completion_tokens`, GPT-5-safe) applies, unmodified.
|
|
108
|
-
*
|
|
109
|
-
* F171 (2026-09-13): `deepseek-chat` was retired and swapped for
|
|
110
|
-
* `deepseek-flash` (item 34 L16). This gating was measured on
|
|
111
|
-
* `deepseek-chat` only and carried forward onto `deepseek-flash`
|
|
112
|
-
* UNVERIFIED — re-verify before any paid DeepSeek run relies on it.
|
|
113
|
-
*/
|
|
114
|
-
const OPENAI_MODEL_OPTIONS = Object.freeze({
|
|
115
|
-
'deepseek-flash': Object.freeze({ legacyMaxTokens: true }),
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Per-MODEL JUDGE-ONLY request options (F192, hamr ruling A 2026-10-09). deepseek-flash
|
|
120
|
-
* thinks by default and bills the reasoning as output: run mv117wde's rubric judge locate
|
|
121
|
-
* truncated at JUDGE_MAX_TOKENS=4000 four times, and a probe measured 1835 of 2409
|
|
122
|
-
* completion tokens as reasoning. The judge extracts quotes and decides nothing, so it
|
|
123
|
-
* runs with thinking off. These are PER-CALL options (bare-agent >=0.49 forwards
|
|
124
|
-
* `thinking` verbatim to `body.thinking`), never constructor options: the judge may be
|
|
125
|
-
* the very same provider instance as the worker (`buildRunnerProviders` reuses it), and
|
|
126
|
-
* the worker is deliberately left as it was. A model with no entry gets nothing; the
|
|
127
|
-
* field is never sent to a model not listed here (OpenAI/Anthropic would reject or
|
|
128
|
-
* misread it). Read through `judgeCallOptions` only — one owner.
|
|
129
|
-
*/
|
|
130
|
-
const OPENAI_JUDGE_CALL_OPTIONS = Object.freeze({
|
|
131
|
-
'deepseek-flash': Object.freeze({ thinking: Object.freeze({ type: 'disabled' }) }),
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The per-call options the rubric JUDGE's model calls carry for this provider
|
|
136
|
-
* instance, from the allow-list above. Empty for any provider/model not listed.
|
|
137
|
-
* @param {any} provider a constructed bare-agent provider
|
|
138
|
-
* @returns {Record<string, any>}
|
|
139
|
-
*/
|
|
140
|
-
export function judgeCallOptions(provider) {
|
|
141
|
-
if (!(provider instanceof OpenAIProvider)) return {};
|
|
142
|
-
return OPENAI_JUDGE_CALL_OPTIONS[/** @type {keyof typeof OPENAI_JUDGE_CALL_OPTIONS} */ (provider.model)] ?? {};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
44
|
/**
|
|
146
45
|
* @typedef {object} ProviderTableEntry
|
|
147
46
|
* @property {new (options: any) => any} ctor the bare-agent provider class
|
|
148
|
-
* @property {string} envKey the environment variable this provider reads its key from
|
|
149
|
-
* @property {Readonly<Record<string, string>>} tiers tier name -> model id
|
|
150
47
|
* @property {string} endpointKey the CONSTRUCTOR OPTION NAME this provider reads its endpoint from — NOT assumed to be `baseUrl` (see below)
|
|
151
|
-
* @property {(model: string|undefined) => Record<string, any>} paramsFor per-model request-key/param gating, applied on TOP of {exposeErrorBody:true, apiKey, model, <endpointKey>?}
|
|
152
48
|
*/
|
|
153
49
|
|
|
154
50
|
// ── WHY `endpointKey` IS A FIELD AND NOT THE LITERAL `baseUrl` ──────────────
|
|
@@ -178,35 +74,16 @@ export function judgeCallOptions(provider) {
|
|
|
178
74
|
// the menu holds only http-shaped API providers, and the FIRST thing that
|
|
179
75
|
// changes when Ollama is admitted is the one line nobody would think to look at.
|
|
180
76
|
|
|
181
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* The three client classes bare-agent exports, chosen by shape id. NO key name, model id or per-model gating lives here: a row's
|
|
79
|
+
* key is its own env name (src/providerrows.js), a job carries its own model, and the old max_tokens spelling is the ROW's tick
|
|
80
|
+
* (`legacyMaxTokens`, `makeProvider`).
|
|
81
|
+
* @type {Readonly<Record<string, ProviderTableEntry>>}
|
|
82
|
+
*/
|
|
182
83
|
const PROVIDER_TABLE = Object.freeze({
|
|
183
|
-
'anthropic-api': Object.freeze({
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
tiers: ANTHROPIC_TIER_MODELS,
|
|
187
|
-
endpointKey: 'baseUrl',
|
|
188
|
-
// Anthropic has only ever had one request key for the output cap
|
|
189
|
-
// (`max_tokens`) — nothing to gate here, ever (unlike OpenAI-shaped
|
|
190
|
-
// backends, which fork on `max_tokens` vs `max_completion_tokens`).
|
|
191
|
-
paramsFor: () => ({}),
|
|
192
|
-
}),
|
|
193
|
-
'openai-api': Object.freeze({
|
|
194
|
-
ctor: OpenAIProvider,
|
|
195
|
-
envKey: 'OPENAI_API_KEY',
|
|
196
|
-
tiers: OPENAI_TIER_MODELS,
|
|
197
|
-
endpointKey: 'baseUrl',
|
|
198
|
-
paramsFor: (model) => OPENAI_MODEL_OPTIONS[/** @type {string} */ (model)] ?? {},
|
|
199
|
-
}),
|
|
200
|
-
'gemini-api': Object.freeze({
|
|
201
|
-
ctor: GeminiProvider,
|
|
202
|
-
envKey: 'GEMINI_API_KEY',
|
|
203
|
-
tiers: GEMINI_TIER_MODELS,
|
|
204
|
-
endpointKey: 'baseUrl',
|
|
205
|
-
// Gemini carries no `max_tokens`/`max_completion_tokens` fork of its own —
|
|
206
|
-
// bare-agent's GeminiProvider maps the cap internally. Nothing to gate, and
|
|
207
|
-
// an empty object is the honest statement of that, not an omission.
|
|
208
|
-
paramsFor: () => ({}),
|
|
209
|
-
}),
|
|
84
|
+
'anthropic-api': Object.freeze({ ctor: AnthropicProvider, endpointKey: 'baseUrl' }),
|
|
85
|
+
'openai-api': Object.freeze({ ctor: OpenAIProvider, endpointKey: 'baseUrl' }),
|
|
86
|
+
'gemini-api': Object.freeze({ ctor: GeminiProvider, endpointKey: 'baseUrl' }),
|
|
210
87
|
});
|
|
211
88
|
|
|
212
89
|
/**
|
|
@@ -232,7 +109,7 @@ export function resolveProvider(providerName) {
|
|
|
232
109
|
/**
|
|
233
110
|
* Report whether a resolved API key VALUE (not its presence — every call
|
|
234
111
|
* site already presence-checks separately) carries a shape an HTTP header
|
|
235
|
-
* value cannot: F181 (`docs/
|
|
112
|
+
* value cannot: F181 (`docs/wiki/FINDINGS.md`) — a two-line secret-store
|
|
236
113
|
* entry put the key plus a trailing metadata line into `OPENAI_API_KEY`
|
|
237
114
|
* (`"<key>\nmeta"`), the presence-only check at every door passed, and Node
|
|
238
115
|
* crashed `TypeError [ERR_INVALID_CHAR]` inside its own header-encode at the
|
|
@@ -266,15 +143,15 @@ export function apiKeyProblem(value) {
|
|
|
266
143
|
}
|
|
267
144
|
|
|
268
145
|
/**
|
|
269
|
-
* Construct ONE provider instance for `providerName`/`model`, applying
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
146
|
+
* Construct ONE provider instance for `providerName`/`model`, applying `exposeErrorBody: true` (F153 — every constructed
|
|
147
|
+
* provider, unconditionally). `baseUrl` (PRD item 28, ruling (d), 2026-09-09: admitted in v1) is forwarded only when given.
|
|
148
|
+
* `legacyMaxTokens` is the Settings ROW's "old max_tokens" tick: a server that ignores `max_completion_tokens` (F149) does not
|
|
149
|
+
* bind the output cap until it is on. It is forwarded only for `openai-api` and only when `true` — nothing here sniffs a host
|
|
150
|
+
* or a model id to decide it.
|
|
274
151
|
* @param {string} providerName
|
|
275
|
-
* @param {{apiKey?: string, model?: string, baseUrl?: string}} [options]
|
|
152
|
+
* @param {{apiKey?: string, model?: string, baseUrl?: string, legacyMaxTokens?: boolean}} [options]
|
|
276
153
|
*/
|
|
277
|
-
export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
|
|
154
|
+
export function makeProvider(providerName, { apiKey, model, baseUrl, legacyMaxTokens } = {}) {
|
|
278
155
|
const entry = resolveProvider(providerName);
|
|
279
156
|
return new entry.ctor({
|
|
280
157
|
exposeErrorBody: true,
|
|
@@ -287,105 +164,59 @@ export function makeProvider(providerName, { apiKey, model, baseUrl } = {}) {
|
|
|
287
164
|
// is spelled differently would otherwise IGNORE the endpoint silently and
|
|
288
165
|
// run against its own default; see the note above the table.
|
|
289
166
|
...(baseUrl !== undefined ? { [entry.endpointKey]: baseUrl } : {}),
|
|
290
|
-
...
|
|
167
|
+
...(providerName === 'openai-api' && legacyMaxTokens === true ? { legacyMaxTokens: true } : {}),
|
|
291
168
|
});
|
|
292
169
|
}
|
|
293
170
|
|
|
294
171
|
/**
|
|
295
|
-
* Build the `{provider, providerFor, judgeProvider}` triple both runners
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
* (the caller runs `resolveWorkerModel` itself, exactly as both runners did
|
|
299
|
-
* before this factory existed — that decision stays where each runner's own
|
|
300
|
-
* `--model` flag/spec precedence lives, not duplicated in here).
|
|
301
|
-
*
|
|
302
|
-
* THE JUDGE IS NO LONGER PINNED TO ANTHROPIC (PRD item 32.2, hamr 2026-09-10:
|
|
303
|
-
* *"what i care about is that judge becomes llm agnostic and not set to one
|
|
304
|
-
* model or provider"*). It is constructed through this same factory, from a
|
|
305
|
-
* `judgeProviderName`/`judgeModel` pair the CALLER resolved (`resolveJudge`,
|
|
306
|
-
* src/judged.js — the spec's signed `judge` override, else the job's own worker
|
|
307
|
-
* provider and model). Item 28's pin, and item 28 part (2)'s deferral of a
|
|
308
|
-
* signed `judge:{provider,model}` field, both close here.
|
|
172
|
+
* Build the `{provider, providerFor, judgeProvider}` triple both authoring runners need (`src/authorrun.js` and
|
|
173
|
+
* `src/panel/authorsession.js` call it) — ONE seam instead of two copies of the same construction. `model` is the
|
|
174
|
+
* already-RESOLVED worker model id (the caller picked the Settings line).
|
|
309
175
|
*
|
|
310
|
-
*
|
|
311
|
-
* may
|
|
312
|
-
*
|
|
313
|
-
* have exactly one. The caller reads the resolved judge provider's own `envKey`
|
|
314
|
-
* and hands the key in — the same contract the worker key already has here.
|
|
176
|
+
* THE JUDGE IS NOT PINNED (PRD item 32.2): it is constructed through this same factory from a `judgeProviderName`/`judgeModel`
|
|
177
|
+
* pair the CALLER resolved. `judgeApiKey` stays a SEPARATE argument: the judge may be a different provider from the worker,
|
|
178
|
+
* and each key is read by the caller from its own row.
|
|
315
179
|
* @param {object} args
|
|
316
|
-
* @param {string} args.providerName
|
|
317
|
-
* @param {string|undefined} args.apiKey the WORKER's key, read from
|
|
180
|
+
* @param {string} args.providerName the worker's API shape id
|
|
181
|
+
* @param {string|undefined} args.apiKey the WORKER's key, read from its row's env name
|
|
318
182
|
* @param {string} args.model the already-resolved worker model id
|
|
319
|
-
* @param {
|
|
320
|
-
* @param {
|
|
321
|
-
* @param {string|undefined} args.judgeApiKey the judge's own key
|
|
183
|
+
* @param {string|undefined} [args.baseUrl] the worker row's Base URL
|
|
184
|
+
* @param {boolean} [args.legacyMaxTokens] the worker row's old max_tokens tick
|
|
185
|
+
* @param {string|undefined} args.judgeApiKey the judge's own key
|
|
322
186
|
* @param {string} args.judgeModel the resolved judge model id
|
|
323
|
-
* @param {string} [args.judgeProviderName] the resolved judge
|
|
324
|
-
* @param {string|undefined} [args.judgeBaseUrl] the judge
|
|
187
|
+
* @param {string} [args.judgeProviderName] the resolved judge shape; defaults to `providerName`
|
|
188
|
+
* @param {string|undefined} [args.judgeBaseUrl] the judge row's endpoint
|
|
189
|
+
* @param {boolean} [args.judgeLegacyMaxTokens] the judge row's old max_tokens tick (defaults to the worker's when the judge shares its provider)
|
|
325
190
|
*/
|
|
326
191
|
export function buildRunnerProviders({
|
|
327
|
-
providerName, apiKey, model,
|
|
328
|
-
judgeProviderName = providerName, judgeBaseUrl = undefined,
|
|
192
|
+
providerName, apiKey, model, baseUrl, legacyMaxTokens = false, judgeApiKey, judgeModel,
|
|
193
|
+
judgeProviderName = providerName, judgeBaseUrl = undefined, judgeLegacyMaxTokens = undefined,
|
|
329
194
|
}) {
|
|
330
|
-
const provider = makeProvider(providerName, { apiKey, model, baseUrl });
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
));
|
|
338
|
-
// the judge goes through the SAME table as the worker — one place names a
|
|
339
|
-
// provider identity, which is the entire reason this factory exists. Reusing
|
|
340
|
-
// the worker instance when the identity matches exactly is not an optimisation
|
|
341
|
-
// for its own sake: a second instance against the same endpoint/model is a
|
|
342
|
-
// second prompt-cache prefix, and cache writes are ~41% of the bill.
|
|
195
|
+
const provider = makeProvider(providerName, { apiKey, model, baseUrl, legacyMaxTokens });
|
|
196
|
+
// a plan step's `model` is the one schema token `sonnet` (STEP_MODELS, src/plan.js): it means THE JOB'S worker model
|
|
197
|
+
const providerFor = (/** @type {string} */ _tier) => provider;
|
|
198
|
+
// the judge goes through the SAME table as the worker. Reusing the worker instance when the identity matches exactly is not an
|
|
199
|
+
// optimisation for its own sake: a second instance against the same endpoint/model is a second prompt-cache prefix, and cache
|
|
200
|
+
// writes are ~41% of the bill.
|
|
201
|
+
const judgeLegacy = judgeLegacyMaxTokens ?? (judgeProviderName === providerName ? legacyMaxTokens : false);
|
|
343
202
|
const judgeProvider = (judgeProviderName === providerName && judgeModel === model && judgeApiKey === apiKey
|
|
344
|
-
&& judgeBaseUrl === baseUrl)
|
|
203
|
+
&& judgeBaseUrl === baseUrl && judgeLegacy === legacyMaxTokens)
|
|
345
204
|
? provider
|
|
346
|
-
: makeProvider(judgeProviderName, { apiKey: judgeApiKey, model: judgeModel, baseUrl: judgeBaseUrl });
|
|
205
|
+
: makeProvider(judgeProviderName, { apiKey: judgeApiKey, model: judgeModel, baseUrl: judgeBaseUrl, legacyMaxTokens: judgeLegacy });
|
|
347
206
|
return { provider, providerFor, judgeProvider };
|
|
348
207
|
}
|
|
349
208
|
|
|
350
209
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
* header — never a completion/chat endpoint, so this never spends a token.
|
|
357
|
-
* @param {string} providerName
|
|
358
|
-
* @param {string} [baseUrl]
|
|
359
|
-
* @returns {{url: string, headers: Record<string,string>}|null} `null` when
|
|
360
|
-
* this provider's models-list shape is not known (never guessed at)
|
|
361
|
-
*/
|
|
362
|
-
function modelsListRequest(providerName, baseUrl) {
|
|
363
|
-
if (providerName === 'anthropic-api') {
|
|
364
|
-
return {
|
|
365
|
-
url: `${baseUrl || 'https://api.anthropic.com/v1'}/models`,
|
|
366
|
-
headers: { 'x-api-key': '', 'anthropic-version': '2023-06-01' },
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
if (providerName === 'openai-api') {
|
|
370
|
-
return { url: `${baseUrl || 'https://api.openai.com/v1'}/models`, headers: {} };
|
|
371
|
-
}
|
|
372
|
-
return null;
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/**
|
|
376
|
-
* $0 reachability check: a GET against `providerName`'s own models-list
|
|
377
|
-
* endpoint, server-side, with the key in a header — NEVER a completion
|
|
378
|
-
* request, so it never spends a token (PANEL-BUILD.md P3 build item 7: "the
|
|
379
|
-
* person must get feedback BEFORE drafting if the chosen model can't
|
|
380
|
-
* work"). Reused, unmodified, by the panel's model-readiness route today
|
|
381
|
-
* (`src/panel/authorroutes.js`) and by the future Settings providers
|
|
382
|
-
* table's own "test" column (PANEL-BUILD.md P4) — ONE function, not two
|
|
383
|
-
* hand-typed copies of the same probe.
|
|
210
|
+
* $0 reachability check: ONE GET of the provider's models list (`src/modellist.js`, the one owner of
|
|
211
|
+
* that call), server-side, with the key in a header — NEVER a completion request, so it never spends
|
|
212
|
+
* a token (PANEL-BUILD.md P3 build item 7: "the person must get feedback BEFORE drafting if the
|
|
213
|
+
* chosen model can't work"). Used by the panel's model-readiness route (`src/panel/authorroutes.js`);
|
|
214
|
+
* Settings > Providers' Test and Refresh call `listModels` directly. A blank `baseUrl` makes no call.
|
|
384
215
|
*
|
|
385
|
-
* NEVER returns the key, never logs it — the key only ever appears in a
|
|
386
|
-
*
|
|
216
|
+
* NEVER returns the key, never logs it — the key only ever appears in a request header sent
|
|
217
|
+
* straight to the provider's own host.
|
|
387
218
|
* @param {object} o
|
|
388
|
-
* @param {string} o.providerName
|
|
219
|
+
* @param {string} o.providerName an API shape id
|
|
389
220
|
* @param {string} o.apiKey
|
|
390
221
|
* @param {string} [o.model] when given, the response is also checked for this model id
|
|
391
222
|
* @param {string} [o.baseUrl]
|
|
@@ -396,39 +227,16 @@ function modelsListRequest(providerName, baseUrl) {
|
|
|
396
227
|
export async function checkProviderReachable({
|
|
397
228
|
providerName, apiKey, model, baseUrl, fetchImpl = fetch, timeoutMs = 4000,
|
|
398
229
|
}) {
|
|
399
|
-
|
|
400
|
-
if (!req) {
|
|
230
|
+
if (!SHAPE_IDS.includes(providerName)) {
|
|
401
231
|
return {
|
|
402
232
|
reachable: false, modelListed: null, status: 'unsupported-provider',
|
|
403
233
|
note: `no models-list check known for "${providerName}"`,
|
|
404
234
|
};
|
|
405
235
|
}
|
|
406
|
-
const
|
|
407
|
-
if (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
const controller = new AbortController();
|
|
411
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
412
|
-
try {
|
|
413
|
-
// GET ONLY — no body, no completion/chat call, no tokens spent.
|
|
414
|
-
const res = await fetchImpl(req.url, { method: 'GET', headers, signal: controller.signal });
|
|
415
|
-
if (!res.ok) {
|
|
416
|
-
return { reachable: false, modelListed: null, status: `HTTP ${res.status}`, note: `models list returned HTTP ${res.status}` };
|
|
417
|
-
}
|
|
418
|
-
/** @type {any} */
|
|
419
|
-
let body = null;
|
|
420
|
-
try { body = await res.json(); } catch { body = null; }
|
|
421
|
-
const ids = Array.isArray(body?.data) ? body.data.map((/** @type {any} */ m) => m?.id).filter(Boolean) : [];
|
|
422
|
-
const modelListed = model ? ids.includes(model) : null;
|
|
423
|
-
return { reachable: true, modelListed, status: 'ok', note: null };
|
|
424
|
-
} catch (/** @type {any} */ e) {
|
|
425
|
-
const cls = e?.name === 'AbortError' ? 'timeout' : (e?.code || e?.name || 'network-error');
|
|
426
|
-
// connection refused surfaces as fetch's bare TypeError ("fetch failed") — say it plainly
|
|
427
|
-
const refused = e?.name === 'TypeError' || e?.code === 'ECONNREFUSED' || e?.cause?.code === 'ECONNREFUSED';
|
|
428
|
-
return { reachable: false, modelListed: null, status: String(cls), note: refused ? `nothing answering at ${req.url}` : `request failed: ${cls}` };
|
|
429
|
-
} finally {
|
|
430
|
-
clearTimeout(timer);
|
|
431
|
-
}
|
|
236
|
+
const r = await listModels({ shape: providerName, baseUrl: baseUrl ?? '', key: apiKey, fetchImpl, timeoutMs });
|
|
237
|
+
if (r.ok) return { reachable: true, modelListed: model ? r.models.includes(model) : null, status: 'ok', note: null };
|
|
238
|
+
const note = r.status.startsWith('HTTP ') ? `models list returned ${r.status}` : (r.detail ?? r.why);
|
|
239
|
+
return { reachable: false, modelListed: null, status: r.status, note };
|
|
432
240
|
}
|
|
433
241
|
|
|
434
242
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// rewritten-from: fwdloop src/providershapes.js@f9449e8
|
|
2
|
+
// PANEL-BUILD.md P8 — the API shapes the Settings > Providers dropdown offers, ONE owner.
|
|
3
|
+
// A shape is a wire protocol, never a place: no web address lives here (each row's Base URL
|
|
4
|
+
// is typed in Settings). Only a shape `makeProvider` (src/providers.js) can really build is listed.
|
|
5
|
+
// Kept in its own file so config.js can validate a shape without importing providers.js.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `id` is the value saved in config.json (and the signed spec's `provider`).
|
|
9
|
+
* @type {readonly {id: string, label: string}[]}
|
|
10
|
+
*/
|
|
11
|
+
export const SHAPES = Object.freeze([
|
|
12
|
+
Object.freeze({ id: 'anthropic-api', label: 'Anthropic' }),
|
|
13
|
+
Object.freeze({ id: 'openai-api', label: 'OpenAI-compatible' }),
|
|
14
|
+
Object.freeze({ id: 'gemini-api', label: 'Gemini' }),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/** @type {readonly string[]} */
|
|
18
|
+
export const SHAPE_IDS = Object.freeze(SHAPES.map((s) => s.id));
|
package/src/replay.js
CHANGED
|
@@ -1520,7 +1520,7 @@ export function replayRun(spineEvents, auditEvents = [], { runId = null, auditAv
|
|
|
1520
1520
|
// never hidden. `null` when there is nothing to compare (a resumed run,
|
|
1521
1521
|
// where a gap is expected and already explained; or `thisFileSpend`
|
|
1522
1522
|
// itself unknown) or when the two already agree within $0.0001.
|
|
1523
|
-
spendMismatch: (!resumed && spentUsd !== null && thisFileSpend.value !== null && Math.abs(spentUsd - thisFileSpend.value) >
|
|
1523
|
+
spendMismatch: (!resumed && spentUsd !== null && thisFileSpend.value !== null && Math.abs(spentUsd - thisFileSpend.value) > 1e-4)
|
|
1524
1524
|
? { spentUsd, thisFileUsd: thisFileSpend.value, diffUsd: spentUsd - thisFileSpend.value }
|
|
1525
1525
|
: null,
|
|
1526
1526
|
timelineKind,
|
package/src/reuse.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Layer 3 — the D7 ENVELOPE and the REUSE RUNNER (design record
|
|
2
|
-
// docs/
|
|
2
|
+
// docs/logs/2026-08-01-layer-3-reuse-design.md, D3/D5/D6/D7 + R1).
|
|
3
3
|
//
|
|
4
4
|
// hamr's sentence is the whole specification of this file, verbatim:
|
|
5
5
|
//
|
package/src/root.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Layer R — the root: the within-run ratchet (LAYERS.md item 2; design record
|
|
2
|
-
// docs/
|
|
2
|
+
// docs/logs/2026-07-19-layer-r-design.md). One state that survives attempts
|
|
3
3
|
// inside ONE run so a never-green run stops repeating itself (F21). Role is
|
|
4
4
|
// CONTINUITY ONLY — F39 measured that hand-delivered state buys no conversion;
|
|
5
5
|
// the semantic converter is Layer 2's job, not this module's.
|
package/src/run.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// smoke, the ONE budget ledger (cap-not-estimate; unpriced is never free, F6),
|
|
4
4
|
// and the decision-ready stops. It composes the plan flow below it and
|
|
5
5
|
// interprets nothing itself.
|
|
6
|
-
// Design record: docs/
|
|
6
|
+
// Design record: docs/logs/2026-07-12-n2-headless-loop-design.md.
|
|
7
7
|
//
|
|
8
8
|
// The legacy operator-authored `steps[]` path was DELETED 2026-07-26 (PRD
|
|
9
9
|
// v1.32): the drafting call, config-v1, the per-step interpret loops and the
|
|
@@ -219,7 +219,7 @@ async function primitiveSmoke(workdir) {
|
|
|
219
219
|
* frozen A0 baseline arm has to be exactly today, so a guard firing under a disabled
|
|
220
220
|
* shim would make the baseline a treatment arm. The default-flip belongs to a
|
|
221
221
|
* paid contrast nobody has approved yet (the `layerRoot` precedent, F41).
|
|
222
|
-
* @param {boolean} [opts.scout=true] operator-only switch (docs/
|
|
222
|
+
* @param {boolean} [opts.scout=true] operator-only switch (docs/logs/SCOUT-CONTRAST.md),
|
|
223
223
|
* forwarded verbatim to the plan flow: `false` on a fresh run (no `resumeSeed`) skips the
|
|
224
224
|
* read-only survey and records `scout-skipped {reason:'operator-off'}` instead — the
|
|
225
225
|
* planner drafts from `(no scout notes)`. Runner territory exactly like `readShim`: the
|
|
@@ -282,7 +282,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
282
282
|
// blind-instrument failure this programme keeps logging. Only the guard runs
|
|
283
283
|
// here; the flag itself is threaded onward to the plan flow as written.
|
|
284
284
|
readShimArm(readShim);
|
|
285
|
-
// The scout switch's guard, same class and same door (docs/
|
|
285
|
+
// The scout switch's guard, same class and same door (docs/logs/SCOUT-CONTRAST.md):
|
|
286
286
|
// a mis-coerced value would run under the wrong label with no way to recover which arm
|
|
287
287
|
// actually fired from the results afterwards.
|
|
288
288
|
if (typeof scout !== 'boolean') throw new TypeError(`scout: expected boolean, got ${JSON.stringify(scout)}`);
|
package/src/selection.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Layer 3 — D3's DISPLAY half: the workflow LISTING, and the prompt that asks for a
|
|
2
|
-
// pick (design record docs/
|
|
2
|
+
// pick (design record docs/logs/2026-08-01-layer-3-reuse-design.md, D3).
|
|
3
3
|
//
|
|
4
4
|
// hamr's ruling, verbatim: *"i think there should be a listing for workflows names that
|
|
5
5
|
// user can choose or auto-choose, or do we want llm to always decide (we still need to
|
package/src/source.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import { createHash } from 'node:crypto';
|
|
26
26
|
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
27
27
|
import { mkdir, readdir, readFile, writeFile, lstat, stat, chmod, access, copyFile, cp, open, rm, realpath, readlink, symlink, constants as fsConstants } from 'node:fs/promises';
|
|
28
|
-
import { dirname, join, relative, resolve, sep, basename } from 'node:path';
|
|
28
|
+
import { dirname, join, relative, resolve, sep, basename, isAbsolute } from 'node:path';
|
|
29
29
|
import { git } from './kinds.js';
|
|
30
30
|
import { addWorktree, hideBareloopDir } from './worktree.js';
|
|
31
31
|
import { MAX_BUFFER } from './kinds.js';
|
|
@@ -1160,3 +1160,66 @@ export function nonRepoSourceMessage(kind) {
|
|
|
1160
1160
|
if (kind === 'folder') return "Source is a plain folder, not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
|
|
1161
1161
|
return "Source is not a code project (no git repo found). bareloop can't check this kind of job yet. Nothing spent.";
|
|
1162
1162
|
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* P7 (docs/product/PANEL-BUILD.md, "Inputs"): prove the NUMBERED INPUTS at $0, before any token. Line 1 is the repo
|
|
1166
|
+
* (an absolute path to an existing folder); lines 2+ are files or folders INSIDE that repo, repo-relative. A line
|
|
1167
|
+
* that is not one of those is a named red naming its line number — never a throw, never a silent skip:
|
|
1168
|
+
* - a `search:` line or a URL, and a path that leaves the repo: "only files inside the repo for now" (search and
|
|
1169
|
+
* outside-the-repo inputs arrive with the plain-folder piece);
|
|
1170
|
+
* - a path that does not exist, or that git does not track (a repo job would not see it);
|
|
1171
|
+
* - the front door's own refusals for a file: an environment file by NAME, a symlink, a non-text file, an oversize
|
|
1172
|
+
* file. A known secret shape in its content is NOT a refusal (masked where recorded, as the P6 worktree door does).
|
|
1173
|
+
* A folder value is checked for existence and tracking only (its files are the repo's own, which the worktree door
|
|
1174
|
+
* already masks). Reads the tree; writes nothing.
|
|
1175
|
+
* @param {{n: number, label: string, value: string}[]} inputs the parsed Inputs (`parseInputs`)
|
|
1176
|
+
* @returns {Promise<{ok: true, repo: string, root: string|null}|{ok: false, error: string}>} `repo` = line 1's absolute path; `root` = the git root it sits in (null when line 1 alone was given and it is not in a git repo)
|
|
1177
|
+
*/
|
|
1178
|
+
export async function proveInputFiles(inputs) {
|
|
1179
|
+
const first = inputs[0];
|
|
1180
|
+
if (!first) return { ok: false, error: 'Inputs, line 1: the repo is required' };
|
|
1181
|
+
/** @param {number} n @param {string} msg @returns {{ok: false, error: string}} */
|
|
1182
|
+
const bad = (n, msg) => ({ ok: false, error: `Inputs, line ${n}: ${msg}` });
|
|
1183
|
+
if (/^https?:\/\//i.test(first.value) || !isAbsolute(first.value)) return bad(1, 'the repo must be an absolute path, like /home/me/myrepo');
|
|
1184
|
+
const repo = resolve(first.value);
|
|
1185
|
+
let st;
|
|
1186
|
+
try { st = lstatSync(repo); } catch { return bad(1, `${repo} does not exist`); }
|
|
1187
|
+
// line 1 alone is the repo the job works on; whether it IS a code repo is the source door's own refusal (and
|
|
1188
|
+
// `nonRepoSourceMessage`) downstream — only lines 2+ need a git tree to be proven against
|
|
1189
|
+
const found = st.isDirectory() ? nearestGitAncestor(repo) : null;
|
|
1190
|
+
if (inputs.length === 1) return { ok: true, repo, root: found && !found.isFile ? found.dir : null };
|
|
1191
|
+
if (!st.isDirectory()) return bad(1, `${repo} is not a folder — line 1 is the repo`);
|
|
1192
|
+
if (found === null || found.isFile) return bad(1, `${repo} is not inside a git repo, so the other inputs cannot be checked`);
|
|
1193
|
+
const root = found.dir;
|
|
1194
|
+
const rootReal = await realpath(root);
|
|
1195
|
+
const repoReal = await realpath(repo);
|
|
1196
|
+
for (const inp of inputs.slice(1)) {
|
|
1197
|
+
const { n, label, value } = inp;
|
|
1198
|
+
if (/^search$/i.test(label) || /^[a-z][a-z0-9+.-]*:\/\//i.test(value)) return bad(n, 'only files inside the repo for now');
|
|
1199
|
+
// like-for-like: resolve against the repo as the REAL filesystem sees it, so a repo reached through a symlinked parent
|
|
1200
|
+
// compares against `rootReal` in the same spelling (a symlinked INPUT FILE is still refused below, by lstat)
|
|
1201
|
+
const abs = resolve(repoReal, value);
|
|
1202
|
+
const outside = (/** @type {string} */ p) => !(p === rootReal || p.startsWith(`${rootReal}${sep}`));
|
|
1203
|
+
if (outside(abs)) return bad(n, 'only files inside the repo for now');
|
|
1204
|
+
let ls;
|
|
1205
|
+
try { ls = await lstat(abs); } catch { return bad(n, `${value} does not exist in the repo`); }
|
|
1206
|
+
if (ls.isSymbolicLink()) return bad(n, `${value} is a symlink — refused, never followed`);
|
|
1207
|
+
let real;
|
|
1208
|
+
try { real = await realpath(abs); } catch { return bad(n, `${value} does not exist in the repo`); }
|
|
1209
|
+
if (outside(real)) return bad(n, 'only files inside the repo for now');
|
|
1210
|
+
const rel = relative(rootReal, abs).split(sep).join('/');
|
|
1211
|
+
const tracked = await git(root, ['ls-files', '-z', '--', rel === '' ? '.' : rel]);
|
|
1212
|
+
if (!tracked.ok || tracked.out.split('\0').filter(Boolean).length === 0) {
|
|
1213
|
+
return bad(n, `${value} is not tracked by git — a repo job only sees tracked files; commit it first`);
|
|
1214
|
+
}
|
|
1215
|
+
if (ls.isDirectory()) continue;
|
|
1216
|
+
if (ENV_FILE.test(basename(abs))) return bad(n, `${value} is an environment file — refused by NAME, whatever it contains (secrets never enter a job)`);
|
|
1217
|
+
if (ls.size > MAX_BUFFER) return bad(n, `${value} is over the ${MAX_BUFFER}B per-file ceiling`);
|
|
1218
|
+
const buf = await readFile(abs);
|
|
1219
|
+
if (hasNulByte(buf.subarray(0, 8192))) return bad(n, `${value} is not a text file — text only for now`);
|
|
1220
|
+
// a secret-shaped CONTENT is not refused (P6 worktree repo door, hamr 2026-10-05 Q2=A, same ruling for inputs): the file is
|
|
1221
|
+
// the person's own, left in place; bareloop masks a secret wherever IT records one (`redactSecrets`, the one inventory) and
|
|
1222
|
+
// never copies this file's text into a record — only its path travels. `.env` by name and symlinks stay refused above.
|
|
1223
|
+
}
|
|
1224
|
+
return { ok: true, repo, root };
|
|
1225
|
+
}
|
package/src/text.js
CHANGED
|
@@ -191,6 +191,19 @@ export function callCasualty(err) {
|
|
|
191
191
|
if (err instanceof HaltError) return null;
|
|
192
192
|
const e = /** @type {any} */ (err);
|
|
193
193
|
if (e?.code === 'ETIMEDOUT' || e?.name === 'TimeoutError') return String(e?.message ?? e);
|
|
194
|
+
// F217 — a DROPPED CONNECTION is the same class as an idle timeout: a call that produced nothing usable. Two REAL
|
|
195
|
+
// shapes reach this seam out of bare-agent 0.49.0's OpenAIProvider (read in node_modules/bare-agent/src/provider-
|
|
196
|
+
// openai.js `_request` + provider-http.js), both thrown straight through `Loop.run` (loop.js rethrows non-Halt errors):
|
|
197
|
+
// 1. `req.on('error', reject)` passes node's RAW socket error unwrapped: `Error{code:'ECONNRESET', message:'socket
|
|
198
|
+
// hang up'}` (the server closed before any response).
|
|
199
|
+
// 2. `guardResponseSettles` (BA-25), the connection cut after the headers: `ProviderError{code:'PROVIDER_ERROR',
|
|
200
|
+
// retryable:true, context:{bound:'transport'}}`, message "response stream aborted/error/close before the body
|
|
201
|
+
// completed". An HTTP-status ProviderError never carries `bound:'transport'`, so it is not admitted here.
|
|
202
|
+
// Other raw codes (ECONNREFUSED, ENOTFOUND, EPIPE...) reach the same raw path but name a wrong URL / no network, not a
|
|
203
|
+
// connection that DROPPED; they are deliberately not admitted (they re-raise loudly, as before).
|
|
204
|
+
if (e?.code === 'ECONNRESET' || e?.context?.bound === 'transport') {
|
|
205
|
+
return `the connection to the provider dropped (${String(e?.message ?? e)})`;
|
|
206
|
+
}
|
|
194
207
|
return null;
|
|
195
208
|
}
|
|
196
209
|
|