bare-agent 0.25.0 → 0.26.2

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.
@@ -1,7 +1,7 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.25.0 | Node.js >= 18 | zero required deps (`bareguard ^0.9.0` optional peer for governance) | Apache 2.0
4
+ > v0.26.2 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.13.0` optional peer for governance) | Apache 2.0
5
5
  >
6
6
  > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
7
7
 
@@ -59,6 +59,7 @@ Eight entry points:
59
59
  | Catch typed errors programmatically | ProviderError, ToolError, TimeoutError, CircuitOpenError |
60
60
  | Cache identical planner calls | Planner({ cacheTTL: 60000 }) |
61
61
  | Stream CLIPipe output in real-time | CLIPipeProvider({ onChunk: fn }) |
62
+ | Get real usage + cost from a CLI provider | CLIPipeProvider({ parse: 'claude-json' }) |
62
63
  | Browse the web (inline snapshots) | createBrowsingTools + Loop |
63
64
  | Browse the web (token-efficient, disk-based) | `barebrowse` CLI session — snapshots to `.barebrowse/*.yml` |
64
65
  | Assess website privacy risk | createBrowsingTools + Loop (requires `npm install wearehere`) |
@@ -739,9 +740,11 @@ new Ollama({ model: 'llama3.2', url: 'http://localhost:11434' })
739
740
  // CLIPipe — pipe prompts to any CLI tool via stdin/stdout
740
741
  new CLIPipe({ command: 'claude', args: ['--print'], systemPromptFlag: '--system-prompt', timeout: 30000 })
741
742
  new CLIPipe({ command: 'ollama', args: ['run', 'llama3.2'] })
743
+ // CLIPipe structured output (v0.26.0+) — map a CLI's JSON envelope to real usage + cost
744
+ new CLIPipe({ command: 'claude', args: ['-p', '--output-format', 'json'], parse: 'claude-json' })
742
745
  ```
743
746
 
744
- All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. CLIPipe always returns `toolCalls: []` and zero usage (CLI tools don't report tokens), and omits `model`.
747
+ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, costUsd? }`. The optional `model` (v0.16.1+) is the id the response was produced by — Loop prefers it over `provider.model` for cost accounting. By default CLIPipe returns `toolCalls: []` and zero usage (CLI tools don't report tokens) and omits `model`. **Structured output (v0.26.0+):** set `parse: 'claude-json'` (a preset for `claude -p --output-format json`) — or a `(stdout) => Partial<GenerateResult>` function for any other CLI — and CLIPipe maps the CLI's JSON envelope onto real `usage`, `model`, and `costUsd`, throwing `ProviderError` on a malformed/error envelope (never a silent raw-text fall-back). `costUsd` (optional `GenerateResult` field) is an **authoritative** per-call price the provider reports itself; when finite the Loop prefers it over the internal rate-table `estimateCost`, so a CLI-piped run enforces a bareguard USD cap with no local pricing table (a `0` counts as priced, distinct from null/unpriced). `toolCalls` stays `[]` regardless (CLIPipe is tool-free).
745
748
 
746
749
  **Temperature graceful degradation (BA-10).** Newer models reject ANY non-default `temperature` with a `400` (`claude-sonnet-5`: `` `temperature` is deprecated for this model. ``; OpenAI o1/gpt-5-class: `Unsupported value: 'temperature' … Only the default (1) …`). All four providers detect that specific 400 (message names `temperature` as unsupported/deprecated AND a temperature was sent), **drop the param, warn once per instance, and retry once** — so a call that would otherwise throw succeeds at the model's default temperature. Keyed off the API error text, not a model list. A genuine out-of-range 400 is NOT degraded (it re-throws — dropping it would mask a caller bug). When a drop happens the result carries `temperatureDropped: true` (an optional `GenerateResult`/`Loop.run` field) so a caller can report the effective temperature — `recurse`'s `refineLeaf` uses it for an honest receipt. Dormant on models that accept temperature (byte-identical to before).
747
750
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.25.0",
3
+ "version": "0.26.2",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -80,7 +80,7 @@
80
80
  "cron-parser": "^4.9.0"
81
81
  },
82
82
  "peerDependencies": {
83
- "bareguard": "^0.9.0",
83
+ "bareguard": ">=0.9.0 <0.13.0",
84
84
  "better-sqlite3": ">=9.0.0"
85
85
  },
86
86
  "peerDependenciesMeta": {
@@ -101,7 +101,7 @@
101
101
  },
102
102
  "devDependencies": {
103
103
  "@types/node": "^22.19.19",
104
- "bareguard": "^0.9.0",
104
+ "bareguard": ">=0.9.0 <0.13.0",
105
105
  "litectx": "^0.26.0",
106
106
  "typescript": "^5.7.0"
107
107
  }
@@ -27,9 +27,9 @@ export type GateDecision = {
27
27
  */
28
28
  rule?: string | undefined;
29
29
  /**
30
- * - Human-readable reason.
30
+ * - Human-readable reason (bareguard's Decision emits null when absent).
31
31
  */
32
- reason?: string | undefined;
32
+ reason?: string | null | undefined;
33
33
  /**
34
34
  * - Arbitrary structured context.
35
35
  */
@@ -21,7 +21,7 @@ const { HaltError } = require('./errors');
21
21
  * @property {string} [outcome] - 'allow' when permitted.
22
22
  * @property {string} [severity] - 'halt' for halt-severity denials.
23
23
  * @property {string} [rule] - The matched rule name.
24
- * @property {string} [reason] - Human-readable reason.
24
+ * @property {string | null} [reason] - Human-readable reason (bareguard's Decision emits null when absent).
25
25
  * @property {Record<string, any>} [context] - Arbitrary structured context.
26
26
  */
27
27
 
package/src/loop.js CHANGED
@@ -153,6 +153,23 @@ function estimateCost(model, usage) {
153
153
  return Number.isFinite(cost) ? cost : null;
154
154
  }
155
155
 
156
+ /**
157
+ * Resolve the priced USD for a round. A provider MAY report its own authoritative `costUsd` on the
158
+ * GenerateResult (e.g. CLIPipeProvider `parse:'claude-json'` surfacing the claude CLI's own
159
+ * `total_cost_usd` — a real price with NO local rate table). When present as a FINITE number it wins
160
+ * over the rate-table estimate — including `0`, a valid priced value (a subscription/marginal-$0 run),
161
+ * which stays 'priced', never demoted to the null/unpriced sentinel. A non-finite provider cost
162
+ * (±Inf/NaN) is NOT a price → fall through to estimateCost (same couldn't-price guard as above).
163
+ * @param {any} result - the GenerateResult from provider.generate()
164
+ * @param {string|null} model
165
+ * @param {Usage|null} usage
166
+ * @returns {number|null}
167
+ */
168
+ function resolveRoundCost(result, model, usage) {
169
+ if (result && Number.isFinite(result.costUsd)) return result.costUsd;
170
+ return estimateCost(model, usage);
171
+ }
172
+
156
173
  // R-C6: default instruction for the provider-bound `ctx.summarize` lent to the assemble seam.
157
174
  const DEFAULT_SUMMARY_INSTRUCTION =
158
175
  'You are a precise conversation summarizer. Produce a concise, factual summary of the following ' +
@@ -472,7 +489,7 @@ class Loop {
472
489
  const result = await loop.provider.generate(prompt, [], { temperature: 0, ...genOpts });
473
490
  const usage = (result && result.usage) || null;
474
491
  const model = (result && result.model) || loop.provider.model || null;
475
- const cost = estimateCost(model, usage);
492
+ const cost = resolveRoundCost(result, model, usage);
476
493
  if (cost !== null) { totalCost += cost; pricedAny = true; }
477
494
  addUsage(usage); // summarize tokens are real spend → count them in the cumulative meter
478
495
  metrics.context.summaries++; // §3.6 CE-activity rollup
@@ -616,7 +633,7 @@ class Loop {
616
633
  // Prefer the model the response reports (robust when provider.model is absent or varies per
617
634
  // response — e.g. FallbackProvider, or a CircuitBreaker-wrapped provider that drops .model).
618
635
  const model = result.model || this.provider.model || null;
619
- const roundCost = estimateCost(model, lastUsage);
636
+ const roundCost = resolveRoundCost(result, model, lastUsage);
620
637
  if (roundCost !== null) totalCost += roundCost;
621
638
 
622
639
  // Meter this round: count the turn, accumulate the four token tiers, and classify pricing —
@@ -30,6 +30,10 @@ export type CLIPipeOptions = {
30
30
  * - Called with each stdout chunk as it streams.
31
31
  */
32
32
  onChunk?: ((chunk: string) => void) | undefined;
33
+ /**
34
+ * - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
35
+ */
36
+ parse?: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | undefined;
33
37
  };
34
38
  /** @typedef {import('../types').Message} Message */
35
39
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -43,6 +47,7 @@ export type CLIPipeOptions = {
43
47
  * @property {number} [timeout=30000] - Timeout in milliseconds.
44
48
  * @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
45
49
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
50
+ * @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
46
51
  */
47
52
  export class CLIPipeProvider {
48
53
  /**
@@ -58,6 +63,7 @@ export class CLIPipeProvider {
58
63
  timeout: number;
59
64
  systemPromptFlag: string | null;
60
65
  onChunk: ((chunk: string) => void) | null;
66
+ parse: "claude-json" | ((stdout: string) => Partial<GenerateResult>) | null;
61
67
  /**
62
68
  * Generate a response by piping messages to the CLI command.
63
69
  * @param {Message[]} messages - Conversation messages in OpenAI format.
@@ -70,6 +76,15 @@ export class CLIPipeProvider {
70
76
  * @throws {Error} `[CLIPipeProvider] process produced no output` — when stdout is empty.
71
77
  */
72
78
  generate(messages: Message[], tools?: ToolDef[], options?: Record<string, any>): Promise<GenerateResult>;
79
+ /**
80
+ * Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
81
+ * The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
82
+ * ProviderError — never a silent fall-back to raw text.
83
+ * @param {string} stdout - Trimmed stdout from the CLI.
84
+ * @returns {GenerateResult}
85
+ * @throws {ProviderError} On non-JSON stdout, or an error envelope (`is_error` / non-success subtype).
86
+ */
87
+ _parseClaudeJson(stdout: string): GenerateResult;
73
88
  /**
74
89
  * Convert OpenAI-format messages to a plain text prompt.
75
90
  * @param {Message[]} messages
@@ -16,6 +16,7 @@ const { ProviderError } = require('./errors');
16
16
  * @property {number} [timeout=30000] - Timeout in milliseconds.
17
17
  * @property {string} [systemPromptFlag] - CLI flag for system prompt (e.g. '--system'). When set, system messages are extracted and passed via this flag instead of stdin.
18
18
  * @property {(chunk: string) => void} [onChunk] - Called with each stdout chunk as it streams.
19
+ * @property {'claude-json'|((stdout: string) => Partial<GenerateResult>)} [parse] - Opt-in structured-output parser for stdout. Default (unset) returns stdout verbatim as `text` with zero usage (no behavior change). `'claude-json'` is a shipped preset for `claude -p --output-format json`: it maps the CLI's result envelope onto `GenerateResult` (text←`result`, usage←`usage.*`, model←first `modelUsage` key, costUsd←`total_cost_usd`) and throws `ProviderError` on malformed JSON or an error envelope (`is_error`/non-success subtype). A function is the CLI-agnostic escape hatch: it receives trimmed stdout and returns a partial `GenerateResult` (merged over defaults); throw to signal a parse failure.
19
20
  */
20
21
 
21
22
  class CLIPipeProvider {
@@ -33,6 +34,10 @@ class CLIPipeProvider {
33
34
  this.timeout = options.timeout ?? 30000;
34
35
  this.systemPromptFlag = options.systemPromptFlag || null;
35
36
  this.onChunk = options.onChunk || null;
37
+ if (options.parse != null && options.parse !== 'claude-json' && typeof options.parse !== 'function') {
38
+ throw new Error("[CLIPipeProvider] options.parse must be 'claude-json' or a function");
39
+ }
40
+ this.parse = options.parse || null;
36
41
  }
37
42
 
38
43
  /**
@@ -61,14 +66,77 @@ class CLIPipeProvider {
61
66
  }
62
67
 
63
68
  const prompt = this._formatPrompt(promptMessages);
64
- const text = await this._spawn(prompt, extraArgs);
69
+ const stdout = await this._spawn(prompt, extraArgs);
70
+
71
+ if (this.parse === 'claude-json') return this._parseClaudeJson(stdout);
72
+ if (typeof this.parse === 'function') {
73
+ const partial = this.parse(stdout) || {};
74
+ return {
75
+ text: '',
76
+ toolCalls: [],
77
+ ...partial,
78
+ usage: { inputTokens: 0, outputTokens: 0, ...(partial.usage || {}) },
79
+ };
80
+ }
65
81
  return {
66
- text,
82
+ text: stdout,
67
83
  toolCalls: [],
68
84
  usage: { inputTokens: 0, outputTokens: 0 },
69
85
  };
70
86
  }
71
87
 
88
+ /**
89
+ * Map the `claude -p --output-format json` result envelope onto a normalized GenerateResult.
90
+ * The caller explicitly opted into structured output, so a malformed or error envelope is a LOUD
91
+ * ProviderError — never a silent fall-back to raw text.
92
+ * @param {string} stdout - Trimmed stdout from the CLI.
93
+ * @returns {GenerateResult}
94
+ * @throws {ProviderError} On non-JSON stdout, or an error envelope (`is_error` / non-success subtype).
95
+ */
96
+ _parseClaudeJson(stdout) {
97
+ let obj;
98
+ try {
99
+ obj = JSON.parse(stdout);
100
+ } catch (_) {
101
+ const preview = stdout.length > 200 ? `${stdout.slice(0, 200)}…` : stdout;
102
+ throw new ProviderError(`[CLIPipeProvider] parse:'claude-json' expected JSON on stdout, got: ${preview}`, /** @type {any} */ ({ status: 0 }));
103
+ }
104
+ if (!obj || typeof obj !== 'object') {
105
+ throw new ProviderError(`[CLIPipeProvider] parse:'claude-json' expected a JSON object, got ${obj === null ? 'null' : typeof obj}`, /** @type {any} */ ({ status: 0 }));
106
+ }
107
+ if (obj.is_error === true || obj.subtype !== 'success') {
108
+ const detail = typeof obj.result === 'string' ? obj.result : JSON.stringify(obj.result ?? null);
109
+ throw new ProviderError(`[CLIPipeProvider] claude CLI reported failure (subtype='${obj.subtype}'): ${detail}`, /** @type {any} */ ({ status: 0 }));
110
+ }
111
+
112
+ const u = (obj.usage && typeof obj.usage === 'object') ? obj.usage : {};
113
+ /** @type {import('../types').Usage} */
114
+ const usage = {
115
+ inputTokens: Number(u.input_tokens) || 0,
116
+ outputTokens: Number(u.output_tokens) || 0,
117
+ };
118
+ // Absent cache tiers mean the model didn't cache — omit rather than emit a synthetic 0 (per Usage docs).
119
+ if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
120
+ if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
121
+
122
+ // `modelUsage` is an object keyed by model id (e.g. {"claude-opus-4-8[1m]": {...}}) — take the first key.
123
+ const model = (obj.modelUsage && typeof obj.modelUsage === 'object')
124
+ ? (Object.keys(obj.modelUsage)[0] ?? null)
125
+ : null;
126
+
127
+ /** @type {GenerateResult} */
128
+ const result = {
129
+ text: typeof obj.result === 'string' ? obj.result : '',
130
+ toolCalls: [],
131
+ usage,
132
+ model,
133
+ };
134
+ // The CLI's own price is authoritative (subscription runs report an equivalent cost even at $0
135
+ // marginal) — feeds bareguard's USD axis with no local rate table. Only a finite number counts.
136
+ if (Number.isFinite(obj.total_cost_usd)) result.costUsd = obj.total_cost_usd;
137
+ return result;
138
+ }
139
+
72
140
  /**
73
141
  * Convert OpenAI-format messages to a plain text prompt.
74
142
  * @param {Message[]} messages
@@ -97,39 +165,65 @@ class CLIPipeProvider {
97
165
 
98
166
  let stdout = '';
99
167
  let stderr = '';
100
- let killed = false;
101
168
 
102
- child.stdout.on('data', d => { stdout += d; this.onChunk?.(d.toString()); });
103
- child.stderr.on('data', d => { stderr += d; });
104
-
105
- child.on('error', err => {
106
- reject(new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${err.message}`, /** @type {any} */ ({ status: 0 })));
107
- });
108
-
109
- child.on('close', code => {
110
- if (killed) return; // timeout already rejected
169
+ // Settle exactly once, no matter which combination of events fires. 'close' can be
170
+ // withheld indefinitely when the CLI spawns a grandchild that inherits its stdio pipes
171
+ // (the child exits, but the pipes stay open) — observed live as a generate() promise
172
+ // that never settled. Every path below funnels through settle().
173
+ let settled = false;
174
+ /** @type {NodeJS.Timeout[]} */
175
+ const timers = [];
176
+ const later = (fn, ms) => { timers.push(setTimeout(fn, ms)); };
177
+ const settle = (/** @type {Error|null} */ err, text = '') => {
178
+ if (settled) return;
179
+ settled = true;
180
+ for (const t of timers) clearTimeout(t);
181
+ if (err) reject(err); else resolve(text);
182
+ };
183
+
184
+ const finish = (/** @type {number|null} */ code) => {
111
185
  if (code !== 0) {
112
- return reject(new ProviderError(`[CLIPipeProvider] process exited with code ${code}: ${stderr.trim()}`, /** @type {any} */ ({ status: code })));
186
+ // The claude CLI reports errors on STDOUT (a JSON envelope) with stderr often
187
+ // empty — fall back to a stdout tail so the operator never sees a blank reason.
188
+ const detail = stderr.trim() || (stdout.trim() ? `(stderr empty) stdout: ${stdout.trim().slice(-400)}` : '');
189
+ return settle(new ProviderError(`[CLIPipeProvider] process exited with code ${code}: ${detail}`, /** @type {any} */ ({ status: code })));
113
190
  }
114
191
  const text = stdout.trim();
115
192
  if (!text) {
116
- return reject(new ProviderError('[CLIPipeProvider] process produced no output', /** @type {any} */ ({ status: 0 })));
193
+ return settle(new ProviderError('[CLIPipeProvider] process produced no output', /** @type {any} */ ({ status: 0 })));
194
+ }
195
+ settle(null, text);
196
+ };
197
+
198
+ child.stdout.on('data', d => {
199
+ stdout += d;
200
+ try {
201
+ this.onChunk?.(d.toString());
202
+ } catch (err) {
203
+ // an observer callback must fail the call loudly, never crash the host process
204
+ settle(new ProviderError(`[CLIPipeProvider] onChunk callback threw: ${/** @type {Error} */ (err).message}`, /** @type {any} */ ({ status: 0 })));
117
205
  }
118
- resolve(text);
119
206
  });
207
+ child.stderr.on('data', d => { stderr += d; });
120
208
 
121
- // Timeout handling
122
- const timer = setTimeout(() => {
123
- killed = true;
209
+ child.on('error', err => {
210
+ settle(new ProviderError(`[CLIPipeProvider] failed to spawn "${this.command}": ${err.message}`, /** @type {any} */ ({ status: 0 })));
211
+ });
212
+
213
+ // Primary completion path: all stdio drained.
214
+ child.on('close', code => finish(code));
215
+
216
+ // Fallback: the process exited but 'close' is being held open by inherited pipes.
217
+ // Give real drainage a short grace, then finish with what has arrived — a bounded
218
+ // wait, never a hang.
219
+ child.on('exit', code => later(() => finish(code), 2000));
220
+
221
+ later(() => {
124
222
  child.kill('SIGTERM');
125
- setTimeout(() => {
126
- try { child.kill('SIGKILL'); } catch (_) {}
127
- }, 1000);
128
- reject(new ProviderError(`[CLIPipeProvider] timed out after ${this.timeout}ms`, /** @type {any} */ ({ status: 0 })));
223
+ setTimeout(() => { try { child.kill('SIGKILL'); } catch (_) {} }, 1000).unref?.();
224
+ settle(new ProviderError(`[CLIPipeProvider] timed out after ${this.timeout}ms`, /** @type {any} */ ({ status: 0 })));
129
225
  }, this.timeout);
130
226
 
131
- child.on('close', () => clearTimeout(timer));
132
-
133
227
  // Write prompt to stdin — catch errors silently (process may exit early)
134
228
  child.stdin.on('error', () => {});
135
229
  child.stdin.end(prompt);
package/types/index.d.ts CHANGED
@@ -86,6 +86,14 @@ export interface GenerateResult {
86
86
  * temperature, not the one requested — callers reporting an effective temperature must honor this.
87
87
  */
88
88
  temperatureDropped?: boolean;
89
+ /**
90
+ * Authoritative per-call cost in USD, reported by the provider itself — e.g. CLIPipeProvider
91
+ * `parse:'claude-json'` surfacing the claude CLI's own `total_cost_usd`, a real price with no local
92
+ * rate table. When a FINITE number the Loop prefers it over `estimateCost` and treats the round as
93
+ * priced (feeding bareguard's USD budget axis). `0` is a valid priced value (a subscription/marginal-$0
94
+ * run) — distinct from omitted/null, which means "couldn't price" and falls back to the rate table.
95
+ */
96
+ costUsd?: number;
89
97
  }
90
98
 
91
99
  /** A conversation message in OpenAI chat format. */