bare-agent 0.38.1 → 0.40.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/README.md CHANGED
@@ -135,7 +135,7 @@ console.log(result.count, result.matchedIds); // a code-derived count + the id
135
135
 
136
136
  **Deps:** none required — the core imports nothing. Optional peers: `bareguard ^0.9.0` (governance), `better-sqlite3` (SQLite store); optional: `cron-parser`, `barebrowse`, `baremobile`, `wearehere`.
137
137
 
138
- This table is the map, not the manual — per-component wiring and API detail live in the [Integration Guide](bareagent.context.md) and [Usage Guide](docs/02-features/usage-guide.md).
138
+ This table is the map, not the manual — per-component wiring and API detail live in the [Integration Guide](bareagent.context.md) and [Usage Guide](docs/archive/usage-guide.md).
139
139
 
140
140
  ---
141
141
 
@@ -281,7 +281,7 @@ All wrappers support optional event streaming for intermediate results. See [`co
281
281
 
282
282
  Aurora replaced ~400 lines of hand-rolled orchestration with ~60 lines of bare-agent wiring — zero workarounds, zero framework plumbing, 100% domain logic.
283
283
 
284
- For wiring recipes and API details, see the **[Integration Guide](bareagent.context.md)** (LLM-optimized). For the full human guide — usage patterns, composition examples, and what bare-agent deliberately doesn't build in (with recipes to do it yourself), see the **[Usage Guide](docs/02-features/usage-guide.md)**. For error reference, see **[Error Guide](docs/02-features/errors.md)**. For release history, see **[CHANGELOG](CHANGELOG.md)**.
284
+ For wiring recipes and API details, see the **[Integration Guide](bareagent.context.md)** (LLM-optimized). For the full human guide — usage patterns, composition examples, and what bare-agent deliberately doesn't build in (with recipes to do it yourself), see the **[Usage Guide](docs/archive/usage-guide.md)**. For error reference, see **[Error Guide](docs/product/errors.md)**. For release history, see **[CHANGELOG](CHANGELOG.md)**.
285
285
 
286
286
  ## The bare ecosystem
287
287
 
@@ -1,9 +1,9 @@
1
1
  # bareagent — Integration Guide
2
2
 
3
3
  > For AI assistants and developers wiring bareagent into a project.
4
- > v0.38.1 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.14.0` optional peer for governance) | Apache 2.0
4
+ > v0.40.0 | Node.js >= 18 | zero required deps (`bareguard >=0.9.0 <0.14.0` optional peer for governance) | Apache 2.0
5
5
  >
6
- > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/02-features/usage-guide.md)
6
+ > Full human guide with composition examples, design philosophy, and recipes: [Usage Guide](docs/archive/usage-guide.md)
7
7
 
8
8
  ## What this is
9
9
 
@@ -891,7 +891,7 @@ All return `{ text, toolCalls, usage: { inputTokens, outputTokens }, model?, cos
891
891
 
892
892
  **Plaintext-key warning (Unreleased):** the OpenAI provider's `baseUrl` accepts `http://` (for local/OpenAI-compatible endpoints), but a `Bearer` key sent over plaintext http to a **non-loopback** host is exposed on the wire. The provider now warns once when that happens. Loopback hosts (`localhost`/`127.0.0.0/8`/`::1` — local proxies, Ollama-style endpoints) stay silent, since that's the legitimate keyless-local case. The header is **not** stripped (some local proxies want a key), so use `https` for any remote endpoint, or drop `apiKey` when the local endpoint needs none.
893
893
 
894
- **Cost estimation:** Loop automatically estimates USD cost per run based on model and token usage. The `cost` field appears in every `loop.run()` result and in `loop:done` stream events. **Pricing honesty (BA-21, v0.37.0):** a token-bearing round is ALWAYS priced (guesstimate-and-run — a null/unknown model no longer forces `unpriced`); rates resolve as caller-supplied `new Loop({ rates: { in, out } })` (authoritative) → a recognized Claude tier from the model id (`haiku`/`sonnet`) → the **Sonnet-tier** ceiling default ($0.003/$0.015 per 1K, no per-model table). Every metering payload carries a `rateSource: 'provider' | 'caller' | 'tier' | 'default' | null` field so a consumer can tell an authoritative price from a guess — and (BA-21 follow-up, v0.38.0) a recognized-tier guess (`'tier'`) from a blind ceiling fallback (`'default'`); a round priced off either guesstimate emits ONE loud `console.warn` per Loop instance naming the actual source (silenced by passing `rates`). To set your own rates, construct `new Loop({ rates })` — there is no longer a `COST_PER_1K` table to edit. The model is resolved as `result.model || provider.model` (v0.16.1+) — providers now echo the model in their `generate()` result, so cost accounting holds even when `provider.model` is absent or varies per response, e.g. behind `FallbackProvider` or `CircuitBreaker.wrapProvider` (the wrapper also preserves `model`/`name` passthrough props). Wire `onLlmResult` (via `wireGate`) and a `budget.maxCostUsd` cap then halts on token-heavy workloads too.
894
+ **Cost estimation:** Loop automatically estimates USD cost per run based on model and token usage. The `cost` field appears in every `loop.run()` result and in `loop:done` stream events. **Pricing honesty (BA-21, v0.37.0):** a token-bearing round is ALWAYS priced (guesstimate-and-run — a null/unknown model no longer forces `unpriced`); rates resolve as caller-supplied `new Loop({ rates: { in, out } })` (authoritative) → a recognized Claude tier from the model id (`haiku`/`sonnet`) → the **Sonnet-tier** ceiling default ($0.003/$0.015 per 1K, no per-model table). Every metering payload carries a `rateSource: 'provider' | 'caller' | 'tier' | 'default' | null` field so a consumer can tell an authoritative price from a guess — and (BA-21 follow-up, v0.38.0) a recognized-tier guess (`'tier'`) from a blind ceiling fallback (`'default'`); a round priced off either guesstimate emits ONE loud `console.warn` per Loop instance naming the actual source (silenced by passing `rates`). To set your own rates, construct `new Loop({ rates })` — there is no longer a `COST_PER_1K` table to edit. The model is resolved as `result.model || provider.model` (v0.16.1+) — providers now echo the model in their `generate()` result, so cost accounting holds even when `provider.model` is absent or varies per response, e.g. behind `FallbackProvider` or `CircuitBreaker.wrapProvider` (the wrapper also preserves `model`/`name` passthrough props). Wire `onLlmResult` (via `wireGate`) and a `budget.maxCostUsd` cap then halts on token-heavy workloads too. **Usage-null boundary (BA-24, v0.39.0):** `GenerateResult.usage` is `Usage | null` — every provider (incl. native CLIPipe session-close) now surfaces `usage: null` when the raw API response carried NO usage block, instead of manufacturing an all-zeros object. A manufactured zero object used to sail past `resolveRoundCost`'s honest-null guard and get priced as a confident $0 (`rateSource:'tier'`/`'default'`) — an unpriceable round now stays unpriced. A *present* block with an explicit `0` field (e.g. a cache-only round) is unaffected and still prices normally.
895
895
 
896
896
  ## Store options
897
897
 
@@ -991,7 +991,7 @@ These are deliberately NOT in bare-agent. Don't look for them — build them fro
991
991
  | **Heartbeat (ambient awareness)** | "Check if anything needs attention" scope is your domain | Scheduler recurring job where the LLM triages: `scheduler.add({ type: 'recurring', schedule: '30m', action: 'Check if anything needs attention' })`. |
992
992
  | **Cron** | **This IS built in** | Scheduler supports cron expressions (requires `cron-parser` peer dep) and relative schedules (`5s`, `30m`, `2h`, `1d`) natively. |
993
993
 
994
- For full recipes with code examples, see `docs/02-features/usage-guide.md` § "Patterns, Not Features".
994
+ For full recipes with code examples, see `docs/archive/usage-guide.md` § "Patterns, Not Features".
995
995
 
996
996
  ## Production usage
997
997
 
@@ -1010,7 +1010,7 @@ For full recipes with code examples, see `docs/02-features/usage-guide.md` § "P
1010
1010
  | Stream | — | — (deferred) |
1011
1011
  | CLIPipe | ✓ | — |
1012
1012
 
1013
- Both projects kept their own memory/store implementations. Neither needed multi-agent routing. Full multis eval: `docs/03-logs/bareagent-eval-multis.md`.
1013
+ Both projects kept their own memory/store implementations. Neither needed multi-agent routing. Full multis eval: `docs/logs/bareagent-eval-multis.md`.
1014
1014
 
1015
1015
  ## Examples
1016
1016
 
@@ -1048,6 +1048,7 @@ Stale example removed in 0.10.4: `examples/mcp-bridge-gov.js` (used a hard-coded
1048
1048
  17. **Halt-path `msgs` is sealed (v0.10.3+)** — when Loop catches `HaltError` mid-round, every dangling assistant `tool_calls.id` from the halted round gets a synthetic `{ role:'tool', tool_call_id, content: '[halted:<rule>]' }` appended so the returned `result.msgs` is valid OpenAI shape. Safe to feed back into another provider call without protocol errors. The `[halted:<rule>]` tag is lowercase — distinct from the legacy `[HALT:]` deny strings (removed in 0.10.0, do not match on the old form).
1049
1049
  18. **`HaltError` with no `rule` resolves to `halt:unknown` (v0.10.3+)** — `new HaltError('msg')` without a `{ rule }` option still produces a stable `result.error = 'halt:unknown'` and `loop:done{halted:true, rule:'unknown'}`. Pre-0.10.3 produced the literal `'halt:null'` which broke string-matching consumers. The `_reportError('halt', ...)` extra carries the same `rule:'unknown'` token.
1050
1050
  19. **Pricing is a two-tier guesstimate, not a per-model table (BA-21, v0.37.0)** — a recognized Claude tier in the model id prices at `haiku` ($0.001/$0.005) or `sonnet` ($0.003/$0.015) per 1K; anything else (incl. a null/unknown model) falls through to the **Sonnet-tier ceiling default** ($0.003/$0.015) and still gets priced (`rateSource: 'default'`), never silently `unpriced`. Both guesstimate cases emit one `console.warn` per Loop instance. The `rateSource` on every metering payload distinguishes them: `'tier'` (a recognized haiku/sonnet match — a confident guess) vs `'default'` (the blind ceiling for an unrecognized/absent model), alongside `'provider'`/`'caller'` (authoritative) and `null` (unpriced). If you use a model whose real rates differ and care about `result.cost` accuracy or `budget.maxCostUsd` enforcement via `onLlmResult`, pass `new Loop({ rates: { in, out, cacheReadMult?, cacheWriteMult? } })` — that's authoritative (`rateSource: 'caller'`) and silences the warning. There is no `COST_PER_1K` table to edit.
1051
+ 20. **A `GenerateResult` with `usage: null` means the provider reported no usage block at all — not a round that used zero tokens (BA-24, v0.39.0)** — every http/CLI provider used to coalesce an absent usage block into a truthy all-zeros `Usage` object (`field || 0` applied unconditionally), which `resolveRoundCost` then priced as a confident $0 instead of treating it as unpriceable. `GenerateResult.usage` is now typed `Usage | null`; `null` fires only when the raw API response carried no usage signal at all (checked field-by-field via `hasUsageSignal`, `src/provider-usage.js`), never on a present block whose fields are legitimately `0` (a cache-only round still prices normally). If your own code reads `result.usage.inputTokens` directly, guard for `null` first.
1051
1052
 
1052
1053
  ## Cross-language SDKs
1053
1054
 
@@ -12,4 +12,4 @@ Runnable reference scripts for bare-agent. Each is self-contained — the top-of
12
12
  | [`replay-job.js`](replay-job.js) | Supervised replay POC: record a browser task once with the LLM driving, then replay against fresh snapshots with the LLM as locator-only. Falls back to full reasoning when the locator misses, and patches the trace. |
13
13
  | [`litectx-as-store.mjs`](litectx-as-store.mjs) | RT-3 Store mount: swap the zero-dep `JsonFileStore` for litectx's ranked, graph-aware recall in one line — the host code never changes. Runs the JsonFileStore half always; runs the litectx half if `litectx` is installed, else prints the one-line swap. |
14
14
 
15
- For wiring recipes and API details see the [Integration Guide](../bareagent.context.md); for usage patterns and design philosophy see the [Usage Guide](../docs/02-features/usage-guide.md).
15
+ For wiring recipes and API details see the [Integration Guide](../bareagent.context.md); for usage patterns and design philosophy see the [Usage Guide](../docs/archive/usage-guide.md).
package/index.d.ts CHANGED
@@ -1,3 +1,57 @@
1
+ /**
2
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
3
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
4
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
5
+ */
6
+ export type ToolDef = import("./types").ToolDef;
7
+ /**
8
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
9
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
10
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
11
+ */
12
+ export type ToolCall = import("./types").ToolCall;
13
+ /**
14
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
15
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
16
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
17
+ */
18
+ export type Message = import("./types").Message;
19
+ /**
20
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
21
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
22
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
23
+ */
24
+ export type Provider = import("./types").Provider;
25
+ /**
26
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
27
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
28
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
29
+ */
30
+ export type GenerateResult = import("./types").GenerateResult;
31
+ /**
32
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
33
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
34
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
35
+ */
36
+ export type Usage = import("./types").Usage;
37
+ /**
38
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
39
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
40
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
41
+ */
42
+ export type RunMetrics = import("./types").RunMetrics;
43
+ /**
44
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
45
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
46
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
47
+ */
48
+ export type Store = import("./types").Store;
49
+ /**
50
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
51
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
52
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
53
+ */
54
+ export type Ctx = import("./types").Ctx;
1
55
  import { Loop } from "./src/loop";
2
56
  import { Planner } from "./src/planner";
3
57
  import { Evaluator } from "./src/evaluator";
package/index.js CHANGED
@@ -1,5 +1,20 @@
1
1
  'use strict';
2
2
 
3
+ /**
4
+ * Re-export the shared cross-cutting type shapes so adopters can name them when
5
+ * annotating their own wiring (tools arrays, providers, ctx, metrics). Declared
6
+ * in `types/index.d.ts`; surfaced here on the root entry so `types` resolves them.
7
+ * @typedef {import('./types').ToolDef} ToolDef
8
+ * @typedef {import('./types').ToolCall} ToolCall
9
+ * @typedef {import('./types').Message} Message
10
+ * @typedef {import('./types').Provider} Provider
11
+ * @typedef {import('./types').GenerateResult} GenerateResult
12
+ * @typedef {import('./types').Usage} Usage
13
+ * @typedef {import('./types').RunMetrics} RunMetrics
14
+ * @typedef {import('./types').Store} Store
15
+ * @typedef {import('./types').Ctx} Ctx
16
+ */
17
+
3
18
  const { Loop } = require('./src/loop');
4
19
  const { Planner } = require('./src/planner');
5
20
  const { Evaluator } = require('./src/evaluator');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-agent",
3
- "version": "0.38.1",
3
+ "version": "0.40.0",
4
4
  "files": [
5
5
  "index.js",
6
6
  "index.d.ts",
@@ -6,6 +6,11 @@ const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
8
  const { resolveTimeoutMs, applyRequestBounds } = require('./provider-http');
9
+ const { hasUsageSignal } = require('./provider-usage');
10
+
11
+ // BA-24: the raw Anthropic usage field names. Presence of any (even value 0) means the API reported a
12
+ // usage signal → build the object; absence of all means no signal → surface null (unpriceable).
13
+ const ANTHROPIC_USAGE_KEYS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
9
14
 
10
15
  /** @param {string} hostname @returns {boolean} */
11
16
  function isLoopbackHost(hostname) {
@@ -191,12 +196,16 @@ class AnthropicProvider {
191
196
  }),
192
197
  // Anthropic's `input_tokens` is ALREADY the uncached remainder (cached tokens are reported
193
198
  // separately, not folded in — verified live), so no subtraction here, unlike OpenAI/Gemini.
194
- usage: {
195
- inputTokens: data.usage?.input_tokens || 0,
196
- outputTokens: data.usage?.output_tokens || 0,
197
- cacheReadTokens: data.usage?.cache_read_input_tokens || 0,
198
- cacheCreationTokens: data.usage?.cache_creation_input_tokens || 0,
199
- },
199
+ // BA-24: honest null when the API returned no usage block (or an empty one) — do NOT manufacture
200
+ // an all-zeros object, which launders an unpriceable round into a $0 PRICED one.
201
+ usage: hasUsageSignal(data.usage, ANTHROPIC_USAGE_KEYS)
202
+ ? {
203
+ inputTokens: data.usage.input_tokens || 0,
204
+ outputTokens: data.usage.output_tokens || 0,
205
+ cacheReadTokens: data.usage.cache_read_input_tokens || 0,
206
+ cacheCreationTokens: data.usage.cache_creation_input_tokens || 0,
207
+ }
208
+ : null,
200
209
  ...(temperatureDropped && { temperatureDropped: true }),
201
210
  };
202
211
  }
@@ -5,12 +5,10 @@ export type ParsedEnvelope = {
5
5
  toolName?: string | undefined;
6
6
  toolArguments?: Record<string, any> | undefined;
7
7
  answer?: string | undefined;
8
- usage: import("../types").Usage;
8
+ usage: import("../types").Usage | null;
9
9
  model?: string | null | undefined;
10
10
  costUsd?: number | undefined;
11
11
  };
12
- /** @typedef {import('../types').Message} Message */
13
- /** @typedef {import('../types').ToolDef} ToolDef */
14
12
  /**
15
13
  * The JSON envelope the CLI is constrained to (claude `--json-schema`). `tool_call` carries the
16
14
  * name + args; `final_answer` carries prose. A closed `action` enum is the discriminator.
@@ -49,10 +47,10 @@ export function renderTranscript(messages: Message[]): string;
49
47
  * preset so the claude usage contract (token tiers, `modelUsage` first-key, `total_cost_usd`) lives
50
48
  * in exactly ONE place — a future CLI format change touches this function, not two copies.
51
49
  * @param {any} outer - the parsed outer CLI envelope (already validated non-null by the caller).
52
- * @returns {{usage: import('../types').Usage, model: string|null, costUsd?: number}}
50
+ * @returns {{usage: import('../types').Usage|null, model: string|null, costUsd?: number}}
53
51
  */
54
52
  export function mapClaudeMeta(outer: any): {
55
- usage: import("../types").Usage;
53
+ usage: import("../types").Usage | null;
56
54
  model: string | null;
57
55
  costUsd?: number;
58
56
  };
@@ -1,10 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  const { ProviderError } = require('./errors');
4
+ const { hasUsageSignal } = require('./provider-usage');
4
5
 
5
6
  /** @typedef {import('../types').Message} Message */
6
7
  /** @typedef {import('../types').ToolDef} ToolDef */
7
8
 
9
+ // BA-24: raw claude-CLI envelope usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null.
10
+ const CLAUDE_USAGE_KEYS = ['input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'];
11
+
8
12
  // CLIPipe tool-mode support (v0.32.0). A subscription CLI (`claude -p`, …) is a plain
9
13
  // TURN-provider: it takes text and returns text, with no native channel for a caller's tools. This
10
14
  // module adds Option C — SCHEMA-VALIDATED TOOL EMULATION: the caller's tools are described in the
@@ -124,23 +128,27 @@ function renderTranscript(messages) {
124
128
  * preset so the claude usage contract (token tiers, `modelUsage` first-key, `total_cost_usd`) lives
125
129
  * in exactly ONE place — a future CLI format change touches this function, not two copies.
126
130
  * @param {any} outer - the parsed outer CLI envelope (already validated non-null by the caller).
127
- * @returns {{usage: import('../types').Usage, model: string|null, costUsd?: number}}
131
+ * @returns {{usage: import('../types').Usage|null, model: string|null, costUsd?: number}}
128
132
  */
129
133
  function mapClaudeMeta(outer) {
130
- const u = (outer.usage && typeof outer.usage === 'object') ? outer.usage : {};
131
- /** @type {import('../types').Usage} */
132
- const usage = {
133
- inputTokens: Number(u.input_tokens) || 0,
134
- outputTokens: Number(u.output_tokens) || 0,
135
- };
134
+ // BA-24: an ABSENT/empty usage block usage null (unpriceable), not a manufactured all-zeros object.
135
+ // The absent-block case is usually rescued by an authoritative `total_cost_usd` (a subscription run
136
+ // reports one even at $0 marginal → priced at source 'provider'); when the CLI omits BOTH, the round
137
+ // is honestly unpriced instead of a silent $0. Same principle line 136 already applies to the cache
138
+ // tiers, lifted one level up to the whole block. A present block with an explicit 0 field stays priced.
139
+ const raw = hasUsageSignal(outer.usage, CLAUDE_USAGE_KEYS) ? outer.usage : null;
140
+ /** @type {import('../types').Usage|null} */
141
+ const usage = raw
142
+ ? { inputTokens: Number(raw.input_tokens) || 0, outputTokens: Number(raw.output_tokens) || 0 }
143
+ : null;
136
144
  // Absent cache tiers mean the model didn't cache — omit rather than emit a synthetic 0 (per Usage docs).
137
- if (Number.isFinite(u.cache_read_input_tokens)) usage.cacheReadTokens = u.cache_read_input_tokens;
138
- if (Number.isFinite(u.cache_creation_input_tokens)) usage.cacheCreationTokens = u.cache_creation_input_tokens;
145
+ if (usage && Number.isFinite(raw.cache_read_input_tokens)) usage.cacheReadTokens = raw.cache_read_input_tokens;
146
+ if (usage && Number.isFinite(raw.cache_creation_input_tokens)) usage.cacheCreationTokens = raw.cache_creation_input_tokens;
139
147
  // `modelUsage` is an object keyed by model id (e.g. {"claude-opus-4-8[1m]": {...}}) — take the first key.
140
148
  const model = (outer.modelUsage && typeof outer.modelUsage === 'object')
141
149
  ? (Object.keys(outer.modelUsage)[0] ?? null)
142
150
  : null;
143
- /** @type {{usage: import('../types').Usage, model: string|null, costUsd?: number}} */
151
+ /** @type {{usage: import('../types').Usage|null, model: string|null, costUsd?: number}} */
144
152
  const meta = { usage, model };
145
153
  // The CLI's own price is authoritative (a subscription run reports an equivalent cost even at $0
146
154
  // marginal) — feeds bareguard's USD axis with no local rate table. Only a finite number counts.
@@ -154,7 +162,7 @@ function mapClaudeMeta(outer) {
154
162
  * @property {string} [toolName]
155
163
  * @property {Record<string, any>} [toolArguments]
156
164
  * @property {string} [answer]
157
- * @property {import('../types').Usage} usage
165
+ * @property {import('../types').Usage|null} usage
158
166
  * @property {string|null} [model]
159
167
  * @property {number} [costUsd]
160
168
  */
@@ -1,6 +1,7 @@
1
1
  export type Message = import("../types").Message;
2
2
  export type ToolDef = import("../types").ToolDef;
3
3
  export type GenerateResult = import("../types").GenerateResult;
4
+ export type ParsedEnvelope = import("./provider-clipipe-tools.js").ParsedEnvelope;
4
5
  export type CLIPipeOptions = {
5
6
  /**
6
7
  * - CLI command to spawn (required).
@@ -142,11 +143,11 @@ export class CLIPipeProvider {
142
143
  toolProtocol: {
143
144
  name: string;
144
145
  turnArgs(systemPrompt: string): string[];
145
- parseResult(stdout: string): ParsedEnvelope;
146
+ parseResult(stdout: string): import("./provider-clipipe-tools").ParsedEnvelope;
146
147
  probe: {
147
148
  system: string;
148
149
  user: string;
149
- isCapable: (parsed: ParsedEnvelope) => boolean;
150
+ isCapable: (parsed: import("./provider-clipipe-tools").ParsedEnvelope) => boolean;
150
151
  };
151
152
  } | null;
152
153
  probeCapability: boolean;
@@ -8,6 +8,12 @@ const { createBridge, resolveSessionError, runSession } = require('./provider-cl
8
8
  /** @typedef {import('../types').Message} Message */
9
9
  /** @typedef {import('../types').ToolDef} ToolDef */
10
10
  /** @typedef {import('../types').GenerateResult} GenerateResult */
11
+ // `toolProtocol`'s inferred type references ParsedEnvelope, which is declared in
12
+ // provider-clipipe-tools.js. Without this alias the name is not in scope when tsc
13
+ // emits this file's .d.ts, so the declaration shipped a bare `ParsedEnvelope` that
14
+ // adopters could not resolve (TS2304) — invisible here because our own tsconfig
15
+ // sets skipLibCheck.
16
+ /** @typedef {import('./provider-clipipe-tools.js').ParsedEnvelope} ParsedEnvelope */
11
17
 
12
18
  /**
13
19
  * @typedef {object} CLIPipeOptions
@@ -217,13 +223,16 @@ class CLIPipeProvider {
217
223
  text: '',
218
224
  toolCalls: [],
219
225
  ...partial,
220
- usage: { inputTokens: 0, outputTokens: 0, ...(partial.usage || {}) },
226
+ // BA-24: a parse fn that reports usage is trusted (missing tiers fill to 0); one that reports
227
+ // none surfaces null (unpriceable) rather than a manufactured all-zeros object.
228
+ usage: partial.usage ? { inputTokens: 0, outputTokens: 0, ...(/** @type {any} */ (partial.usage)) } : null,
221
229
  };
222
230
  }
231
+ // BA-24: raw text mode carries NO token data ever — honest null (unpriceable), not a synthetic $0.
223
232
  return {
224
233
  text: stdout,
225
234
  toolCalls: [],
226
- usage: { inputTokens: 0, outputTokens: 0 },
235
+ usage: null,
227
236
  };
228
237
  }
229
238
 
@@ -333,12 +342,22 @@ class CLIPipeProvider {
333
342
  // captures a turn the CLI billed but never emitted as an event (measured — a bounded session's
334
343
  // cut-off turn). Summing the per-turn records is the fallback for a session we killed before its
335
344
  // result event. Either way the arithmetic is per-TURN, never per block-event (BA-17).
336
- const usage = (meta && r.final.usage) ? meta.usage : r.turns.reduce((/** @type {any} */ a, t) => ({
337
- inputTokens: a.inputTokens + (t.inputTokens || 0),
338
- outputTokens: a.outputTokens + (t.outputTokens || 0),
339
- cacheReadTokens: a.cacheReadTokens + (t.cacheReadTokens || 0),
340
- cacheCreationTokens: a.cacheCreationTokens + (t.cacheCreationTokens || 0),
341
- }), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 });
345
+ // BA-24: key on the NORMALIZED meta.usage (a signal-bearing block), not the raw r.final.usage — an
346
+ // absent/empty raw block now normalizes to null, so fall back to the per-turn sum rather than null.
347
+ // The per-turn sum is a real signal ONLY when turns actually streamed; a session that died before
348
+ // any turn (zero-turn native session, no final usage block) has NOTHING to sum, and reducing over
349
+ // an empty array would MANUFACTURE a truthy all-zeros object — the exact absence→$0-priced laundering
350
+ // BA-24 eliminates at every other site. So absence (no meta.usage AND no turns) surfaces null.
351
+ const usage = (meta && meta.usage)
352
+ ? meta.usage
353
+ : (r.turns.length > 0
354
+ ? r.turns.reduce((/** @type {any} */ a, t) => ({
355
+ inputTokens: a.inputTokens + (t.inputTokens || 0),
356
+ outputTokens: a.outputTokens + (t.outputTokens || 0),
357
+ cacheReadTokens: a.cacheReadTokens + (t.cacheReadTokens || 0),
358
+ cacheCreationTokens: a.cacheCreationTokens + (t.cacheCreationTokens || 0),
359
+ }), { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 })
360
+ : null);
342
361
 
343
362
  const { stopReason, error } = resolveSessionError({
344
363
  // A bridge/guard terminal is more specific than the turn backstop, so it wins the tag.
@@ -362,7 +381,9 @@ class CLIPipeProvider {
362
381
  // real but SHORT of the session total. Sending the difference makes a gate's token axis add up
363
382
  // to exactly what the CLI itself reports — where sending the total would double-count everything
364
383
  // already streamed, and sending zero would leave the axis quietly under-fed.
365
- const residual = subtractUsage(usage, r.turns);
384
+ // BA-24: a null usage (zero-turn session, no reported block) has no residual to reconcile — pass
385
+ // the honest null through to the meter rather than dereferencing null in subtractUsage.
386
+ const residual = usage ? subtractUsage(usage, r.turns) : null;
366
387
  if (this.onTurn) {
367
388
  try {
368
389
  await this.onTurn({
@@ -72,9 +72,9 @@ export class GeminiProvider {
72
72
  * (total = prompt + candidates + thoughts — confirmed against live usageMetadata). Implicit caching
73
73
  * has no separate write tier → cacheCreationTokens 0.
74
74
  * @param {any} u - raw `data.usageMetadata`
75
- * @returns {import('../types').Usage}
75
+ * @returns {import('../types').Usage|null}
76
76
  */
77
- _normalizeUsage(u: any): import("../types").Usage;
77
+ _normalizeUsage(u: any): import("../types").Usage | null;
78
78
  /**
79
79
  * @param {string} path
80
80
  * @param {Record<string, any>} body
@@ -6,6 +6,10 @@ const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
8
  const { resolveTimeoutMs, applyRequestBounds } = require('./provider-http');
9
+ const { hasUsageSignal } = require('./provider-usage');
10
+
11
+ // BA-24: raw Gemini usageMetadata fields. Any present (even 0) ⇒ a usage signal; none ⇒ null.
12
+ const GEMINI_USAGE_KEYS = ['promptTokenCount', 'candidatesTokenCount', 'thoughtsTokenCount', 'cachedContentTokenCount'];
9
13
 
10
14
  /** @typedef {import('../types').Message} Message */
11
15
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -168,9 +172,12 @@ class GeminiProvider {
168
172
  * (total = prompt + candidates + thoughts — confirmed against live usageMetadata). Implicit caching
169
173
  * has no separate write tier → cacheCreationTokens 0.
170
174
  * @param {any} u - raw `data.usageMetadata`
171
- * @returns {import('../types').Usage}
175
+ * @returns {import('../types').Usage|null}
172
176
  */
173
177
  _normalizeUsage(u) {
178
+ // BA-24: no usageMetadata (or an empty one) ⇒ null, not an all-zeros object (which would launder an
179
+ // unpriceable round into a $0 PRICED one). A present block with an explicit 0 field stays priced.
180
+ if (!hasUsageSignal(u, GEMINI_USAGE_KEYS)) return null;
174
181
  const cacheRead = u?.cachedContentTokenCount || 0;
175
182
  return {
176
183
  inputTokens: Math.max(0, (u?.promptTokenCount || 0) - cacheRead),
@@ -5,6 +5,10 @@ const { ProviderError } = require('./errors');
5
5
  const { requestWithTemperatureFallback } = require('./provider-temperature');
6
6
  const { normalizeStopReason } = require('./provider-stop-reason');
7
7
  const { resolveTimeoutMs, applyRequestBounds } = require('./provider-http');
8
+ const { hasUsageSignal } = require('./provider-usage');
9
+
10
+ // BA-24: raw Ollama usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
11
+ const OLLAMA_USAGE_KEYS = ['prompt_eval_count', 'eval_count'];
8
12
 
9
13
  /** @typedef {import('../types').Message} Message */
10
14
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -101,10 +105,11 @@ class OllamaProvider {
101
105
  // Like Gemini, Ollama has NO tool_use done_reason — a complete tool call returns `stop` (measured).
102
106
  // `hasToolCalls` lets the normalizer say so, instead of reporting a tool round as a clean finish.
103
107
  stopReason: normalizeStopReason(data.done_reason, 'ollama', { hasToolCalls: toolCalls.length > 0 }),
104
- usage: {
105
- inputTokens: data.prompt_eval_count || 0,
106
- outputTokens: data.eval_count || 0,
107
- },
108
+ // BA-24: honest null when the response carried no token counts — do NOT manufacture an all-zeros
109
+ // object (which launders an unpriceable round into a $0 PRICED one).
110
+ usage: hasUsageSignal(data, OLLAMA_USAGE_KEYS)
111
+ ? { inputTokens: data.prompt_eval_count || 0, outputTokens: data.eval_count || 0 }
112
+ : null,
108
113
  ...(temperatureDropped && { temperatureDropped: true }),
109
114
  };
110
115
  }
@@ -82,9 +82,9 @@ export class OpenAIProvider {
82
82
  * remainder (else the cached tokens are double-counted and priced at the full input rate, a ~2x
83
83
  * over-charge on a warm prompt). OpenAI has no separate cache-write tier → cacheCreationTokens 0.
84
84
  * @param {any} u - raw `data.usage`
85
- * @returns {import('../types').Usage}
85
+ * @returns {import('../types').Usage|null}
86
86
  */
87
- _normalizeUsage(u: any): import("../types").Usage;
87
+ _normalizeUsage(u: any): import("../types").Usage | null;
88
88
  /**
89
89
  * @param {string} path
90
90
  * @param {Record<string, any>} body
@@ -6,6 +6,10 @@ const { ProviderError } = require('./errors');
6
6
  const { requestWithTemperatureFallback } = require('./provider-temperature');
7
7
  const { normalizeStopReason } = require('./provider-stop-reason');
8
8
  const { resolveTimeoutMs, applyRequestBounds } = require('./provider-http');
9
+ const { hasUsageSignal } = require('./provider-usage');
10
+
11
+ // BA-24: raw OpenAI usage fields. Any present (even 0) ⇒ a usage signal; none ⇒ null (unpriceable).
12
+ const OPENAI_USAGE_KEYS = ['prompt_tokens', 'completion_tokens', 'prompt_tokens_details'];
9
13
 
10
14
  /** @typedef {import('../types').Message} Message */
11
15
  /** @typedef {import('../types').ToolDef} ToolDef */
@@ -126,9 +130,12 @@ class OpenAIProvider {
126
130
  * remainder (else the cached tokens are double-counted and priced at the full input rate, a ~2x
127
131
  * over-charge on a warm prompt). OpenAI has no separate cache-write tier → cacheCreationTokens 0.
128
132
  * @param {any} u - raw `data.usage`
129
- * @returns {import('../types').Usage}
133
+ * @returns {import('../types').Usage|null}
130
134
  */
131
135
  _normalizeUsage(u) {
136
+ // BA-24: no usage block (or an empty one) ⇒ null, not an all-zeros object (which would launder an
137
+ // unpriceable round into a $0 PRICED one). A present block with an explicit 0 field stays priced.
138
+ if (!hasUsageSignal(u, OPENAI_USAGE_KEYS)) return null;
132
139
  const cacheRead = u?.prompt_tokens_details?.cached_tokens || 0;
133
140
  return {
134
141
  inputTokens: Math.max(0, (u?.prompt_tokens || 0) - cacheRead),
@@ -0,0 +1,24 @@
1
+ /**
2
+ * BA-24: the discriminator between an ABSENT usage block and a PRESENT one whose token fields are
3
+ * legitimately 0.
4
+ *
5
+ * A provider that returns no usage information (the API sent a 200 with no `usage` block, or an empty
6
+ * `{}`) must surface `usage: null` so the round reads as UNPRICEABLE — `resolveRoundCost`'s
7
+ * `if (!usage) return {cost:null, source:null}` branch (the honest-null contract) only fires on a
8
+ * falsy usage. Coalescing an absent block into an all-zeros object (`data.usage?.field || 0` built
9
+ * unconditionally) launders that unknown into a $0 PRICED round — worse, at the confident `'tier'`
10
+ * rate label, so a consumer filtering for `'default'` guesses never sees it. That laundering was the
11
+ * BA-23 fix relocated one layer up: loop.js stopped feeding stale usage to the resolver, but every
12
+ * http provider still MANUFACTURED a truthy zero object, so the null branch stayed unreachable on the
13
+ * paid path.
14
+ *
15
+ * Per-field `|| 0` stays correct (a real round can report an individual tier as 0); it is the coalescing
16
+ * of the WHOLE block's absence into a default object that is the bug. A present block with an explicit
17
+ * zero — e.g. a cache-only round: `input_tokens:0` + `cache_read_input_tokens>0` — carries a signal and
18
+ * stays priced, because that field is present (`!= null`), even at value 0.
19
+ *
20
+ * @param {any} block - raw provider usage object (`data.usage` / `usageMetadata` / the CLI envelope's `usage`)
21
+ * @param {string[]} keys - the recognized raw field names for this provider
22
+ * @returns {boolean} true iff `block` is an object carrying at least one recognized field as a non-null value
23
+ */
24
+ export function hasUsageSignal(block: any, keys: string[]): boolean;
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * BA-24: the discriminator between an ABSENT usage block and a PRESENT one whose token fields are
5
+ * legitimately 0.
6
+ *
7
+ * A provider that returns no usage information (the API sent a 200 with no `usage` block, or an empty
8
+ * `{}`) must surface `usage: null` so the round reads as UNPRICEABLE — `resolveRoundCost`'s
9
+ * `if (!usage) return {cost:null, source:null}` branch (the honest-null contract) only fires on a
10
+ * falsy usage. Coalescing an absent block into an all-zeros object (`data.usage?.field || 0` built
11
+ * unconditionally) launders that unknown into a $0 PRICED round — worse, at the confident `'tier'`
12
+ * rate label, so a consumer filtering for `'default'` guesses never sees it. That laundering was the
13
+ * BA-23 fix relocated one layer up: loop.js stopped feeding stale usage to the resolver, but every
14
+ * http provider still MANUFACTURED a truthy zero object, so the null branch stayed unreachable on the
15
+ * paid path.
16
+ *
17
+ * Per-field `|| 0` stays correct (a real round can report an individual tier as 0); it is the coalescing
18
+ * of the WHOLE block's absence into a default object that is the bug. A present block with an explicit
19
+ * zero — e.g. a cache-only round: `input_tokens:0` + `cache_read_input_tokens>0` — carries a signal and
20
+ * stays priced, because that field is present (`!= null`), even at value 0.
21
+ *
22
+ * @param {any} block - raw provider usage object (`data.usage` / `usageMetadata` / the CLI envelope's `usage`)
23
+ * @param {string[]} keys - the recognized raw field names for this provider
24
+ * @returns {boolean} true iff `block` is an object carrying at least one recognized field as a non-null value
25
+ */
26
+ function hasUsageSignal(block, keys) {
27
+ if (!block || typeof block !== 'object') return false;
28
+ for (const k of keys) {
29
+ if (block[k] != null) return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ module.exports = { hasUsageSignal };
@@ -143,7 +143,7 @@ export function buildScanTool(corpus: Slice[] | (() => Promise<Slice[]>), opts:
143
143
  /**
144
144
  * Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
145
145
  * the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
146
- * docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
146
+ * docs/archive/prd.md). Returns the generic async slice-source recurse's scan reads: a
147
147
  * `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
148
148
  * read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
149
149
  * litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // THE CORPUS FOR SCAN IS A GENERIC ARRAY SLICE-SOURCE (`opts.corpus`), NOT litectx: litectx has no exhaustive,
18
18
  // rank-free enumerate verb today (every read is FTS-gated). The "corpus that already LIVES in litectx" case
19
- // waits on the litectx `enumerate` verb (docs/01-product/prd.md) and drops in behind this
19
+ // waits on the litectx `enumerate` verb (docs/archive/prd.md) and drops in behind this
20
20
  // same slice-source socket with ZERO recurse changes — the same backend-agnostic stance as `remember`'s Store
21
21
  // socket. Composes AROUND a Loop; NEVER imported by loop.js.
22
22
 
@@ -326,7 +326,7 @@ const ENUM_PAGE = 200;
326
326
  /**
327
327
  * Build a SCAN slice-source backed by a litectx-RESIDENT corpus (facts/episodes the agent already accrued) —
328
328
  * the §10-step-7 deferral un-blocked by litectx 0.26's `enumerate` verb (spec:
329
- * docs/01-product/prd.md). Returns the generic async slice-source recurse's scan reads: a
329
+ * docs/archive/prd.md). Returns the generic async slice-source recurse's scan reads: a
330
330
  * `() => Promise<Slice[]>` that pages through EVERY row of `kind` via `enumerate` (exhaustive — the rank-free
331
331
  * read `recall` structurally cannot do) and maps each to `{id: item.path, text: item.body}`. recurse stays
332
332
  * litectx-agnostic: it depends on this source SHAPE, never on litectx (same stance as `remember`'s Store
package/src/recurse.js CHANGED
@@ -1037,7 +1037,7 @@ async function recurseRefineLeaf(task, ctx, opts, state) {
1037
1037
  * never folded into the count as a zero; a governance HaltError mid-scan → clean incomplete.
1038
1038
  *
1039
1039
  * The corpus is the generic array slice-source `opts.corpus`. Absent it, scan has nothing to read — litectx's
1040
- * resident-corpus enumerate path is deferred (docs/01-product/prd.md) — so we return an
1040
+ * resident-corpus enumerate path is deferred (docs/archive/prd.md) — so we return an
1041
1041
  * honest incomplete, never a fabricated zero.
1042
1042
  * @param {string} task
1043
1043
  * @param {RecurseCtx} ctx
package/types/index.d.ts CHANGED
@@ -86,7 +86,9 @@ export interface ToolCall {
86
86
  export interface GenerateResult {
87
87
  text: string;
88
88
  toolCalls: ToolCall[];
89
- usage: Usage;
89
+ /** Normalized token usage, or `null` when the provider reported NO usage block (BA-24) — an
90
+ * unpriceable round, distinct from a present block whose fields are legitimately 0. */
91
+ usage: Usage | null;
90
92
  /** Model id the response was produced by; preferred over Provider.model for cost accounting. */
91
93
  model?: string | null;
92
94
  /**