@vincemakes/kiso-runtime 0.34.0 → 0.37.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/dist/compose.js CHANGED
@@ -16,7 +16,7 @@ const TOOL_RULES = [
16
16
  { tool: "read_file", line: "read files with read_file, never shell cat/head/tail" },
17
17
  { tool: "search_text", line: "search with search_text, never shell grep/rg" },
18
18
  { tool: "list_dir", line: "list with list_dir, never ls" },
19
- { tool: "shell", line: "reserve shell for real system commands" },
19
+ { tool: "shell", line: "shell for what the file tools cannot do: commands, git, the network, the system" },
20
20
  ];
21
21
  /** The table, or "" when the registry is empty (no vocabulary, no tools). */
22
22
  export function composeToolTable(registry) {
@@ -189,7 +189,28 @@ const ENTRIES = [
189
189
  capabilities: { contextWindow: null, maxOutputTokens: null, promptCaching: "automatic", reasoning: {
190
190
  emitsThinkingStream: true,
191
191
  thinking: { modes: ["enabled", "disabled"], default: "enabled" },
192
- effort: { levels: ["low", "high", "max"], default: "high", wire: "reasoning_effort" },
192
+ // `none` added 2026-09-14 from a live probe. The three natives were
193
+ // RIGHT and stay: the endpoint's enum accepts seven values, but
194
+ // the vendor maps minimal->low, medium->high and xhigh->high, so
195
+ // those three are the provider's own aliases and this row's rule
196
+ // is that an alias is never shown as native. Offering `medium`
197
+ // would promise a level the model does not have.
198
+ //
199
+ // `none` is not an alias. It turns thinking OFF: the response
200
+ // carries no reasoning_content and completion_tokens_details is
201
+ // null outright. A distinct behaviour the row simply lacked, and
202
+ // the only reachable way to buy no reasoning at all.
203
+ //
204
+ // Measured, 12 runs per level on one task (median reasoning
205
+ // tokens): minimal 191, low 186 | medium 212, high 199, xhigh 195
206
+ // | max 229. The grouping and the ordering match the vendor's
207
+ // mapping. The unset default sits at 219, which this sample
208
+ // cannot separate from high or from max, so `default` stays what
209
+ // the vendor documents rather than what one task suggests.
210
+ //
211
+ // bench/probe-effort-levels.mjs reads the accepted set from the
212
+ // endpoint's own refusal and costs nothing.
213
+ effort: { levels: ["none", "low", "high", "max"], default: "high", wire: "reasoning_effort" },
193
214
  asOf: "2026-08-26",
194
215
  source: "https://api-docs.deepseek.com/guides/thinking_mode",
195
216
  }, inputModalities: null },
@@ -197,6 +218,61 @@ const ENTRIES = [
197
218
  capabilitiesSource: "https://api-docs.deepseek.com/guides/thinking_mode",
198
219
  pricing: null,
199
220
  },
221
+ {
222
+ // Astra F5: THE VENDOR'S CURRENT RECOMMENDED NAME, registered so that
223
+ // copying it out of the vendor's own docs does not silently cost the
224
+ // effort controls. `deepseek-flash` had no row, and an unregistered
225
+ // model resolves default/default to an empty wire setting while
226
+ // REFUSING any explicit level by name — correct behaviour (unknown
227
+ // stays unknown) reached for the wrong reason: the model is known,
228
+ // the registry just had not been told its current name.
229
+ //
230
+ // The legacy alias is RETAINED above, not replaced: the vendor still
231
+ // accepts it and routes both to the same weights, so removing it
232
+ // would break configs that work today.
233
+ //
234
+ // SAME capabilities as the legacy row, deliberately. The vendor's
235
+ // page lists a 1M context window, and a window read off a page is not
236
+ // a measurement — it would make the meter claim precision the product
237
+ // has not earned, on the row that decides when context relief fires.
238
+ // contextWindow stays null, the CLI's conservative fallback applies,
239
+ // and the override is documented. Pricing likewise stays null: no
240
+ // price is invented for a name.
241
+ model: "deepseek-flash",
242
+ providerId: "deepseek",
243
+ endpoint: "https://api.deepseek.com",
244
+ capabilities: { contextWindow: null, maxOutputTokens: null, promptCaching: "automatic", reasoning: {
245
+ emitsThinkingStream: true,
246
+ thinking: { modes: ["enabled", "disabled"], default: "enabled" },
247
+ // `none` added 2026-09-14 from a live probe. The three natives were
248
+ // RIGHT and stay: the endpoint's enum accepts seven values, but
249
+ // the vendor maps minimal->low, medium->high and xhigh->high, so
250
+ // those three are the provider's own aliases and this row's rule
251
+ // is that an alias is never shown as native. Offering `medium`
252
+ // would promise a level the model does not have.
253
+ //
254
+ // `none` is not an alias. It turns thinking OFF: the response
255
+ // carries no reasoning_content and completion_tokens_details is
256
+ // null outright. A distinct behaviour the row simply lacked, and
257
+ // the only reachable way to buy no reasoning at all.
258
+ //
259
+ // Measured, 12 runs per level on one task (median reasoning
260
+ // tokens): minimal 191, low 186 | medium 212, high 199, xhigh 195
261
+ // | max 229. The grouping and the ordering match the vendor's
262
+ // mapping. The unset default sits at 219, which this sample
263
+ // cannot separate from high or from max, so `default` stays what
264
+ // the vendor documents rather than what one task suggests.
265
+ //
266
+ // bench/probe-effort-levels.mjs reads the accepted set from the
267
+ // endpoint's own refusal and costs nothing.
268
+ effort: { levels: ["none", "low", "high", "max"], default: "high", wire: "reasoning_effort" },
269
+ asOf: "2026-09-12",
270
+ source: "https://api-docs.deepseek.com/quick_start/pricing/",
271
+ }, inputModalities: null },
272
+ capabilitiesAsOf: "2026-09-12",
273
+ capabilitiesSource: "https://api-docs.deepseek.com/quick_start/pricing/",
274
+ pricing: null,
275
+ },
200
276
  {
201
277
  model: "deepseek-v4-pro",
202
278
  providerId: "deepseek",
@@ -204,7 +280,28 @@ const ENTRIES = [
204
280
  capabilities: { contextWindow: null, maxOutputTokens: null, promptCaching: "automatic", reasoning: {
205
281
  emitsThinkingStream: true,
206
282
  thinking: { modes: ["enabled", "disabled"], default: "enabled" },
207
- effort: { levels: ["low", "high", "max"], default: "high", wire: "reasoning_effort" },
283
+ // `none` added 2026-09-14 from a live probe. The three natives were
284
+ // RIGHT and stay: the endpoint's enum accepts seven values, but
285
+ // the vendor maps minimal->low, medium->high and xhigh->high, so
286
+ // those three are the provider's own aliases and this row's rule
287
+ // is that an alias is never shown as native. Offering `medium`
288
+ // would promise a level the model does not have.
289
+ //
290
+ // `none` is not an alias. It turns thinking OFF: the response
291
+ // carries no reasoning_content and completion_tokens_details is
292
+ // null outright. A distinct behaviour the row simply lacked, and
293
+ // the only reachable way to buy no reasoning at all.
294
+ //
295
+ // Measured, 12 runs per level on one task (median reasoning
296
+ // tokens): minimal 191, low 186 | medium 212, high 199, xhigh 195
297
+ // | max 229. The grouping and the ordering match the vendor's
298
+ // mapping. The unset default sits at 219, which this sample
299
+ // cannot separate from high or from max, so `default` stays what
300
+ // the vendor documents rather than what one task suggests.
301
+ //
302
+ // bench/probe-effort-levels.mjs reads the accepted set from the
303
+ // endpoint's own refusal and costs nothing.
304
+ effort: { levels: ["none", "low", "high", "max"], default: "high", wire: "reasoning_effort" },
208
305
  asOf: "2026-08-26",
209
306
  source: "https://api-docs.deepseek.com/guides/thinking_mode",
210
307
  }, inputModalities: null },
package/dist/session.d.ts CHANGED
@@ -141,7 +141,30 @@ export declare class AgentSession {
141
141
  /** XP-1: the reasoning axes travel with the binding too; absent =
142
142
  * fresh defaults (a new binding never inherits stale effort). */
143
143
  readonly reasoning?: ReasoningSetting;
144
+ /** CTX-1: the compaction threshold follows the live model's window.
145
+ * Absent KEEPS the current one — a caller that does not know the new
146
+ * model's window must not silently reset the policy to nothing. */
147
+ readonly microcompact?: {
148
+ readonly thresholdTokens: number;
149
+ };
144
150
  }): void;
151
+ /**
152
+ * CTX-1: the compaction threshold alone, with NO profile revision.
153
+ *
154
+ * `setModelBinding` is the atomic switch — the adapter and everything
155
+ * that must travel with it, recorded durably. This is the other door:
156
+ * a session OPENED onto a model the process was not configured for.
157
+ * `/resume` restores the recorded model, which can be any model the
158
+ * session ever used, while the threshold came from whatever model this
159
+ * process started on. Resuming a 1M session from a 200k start left it
160
+ * clearing tool results at 100,000.
161
+ *
162
+ * It writes no revision because nothing was selected: the caller is
163
+ * bringing the policy into line with a model already in force, not
164
+ * choosing one. Recording a revision here would write a new profile on
165
+ * every open.
166
+ */
167
+ setMicrocompactThreshold(thresholdTokens: number): void;
145
168
  /** E2: the adapter identity (anthropic / openai-compat / openai-responses) — the route
146
169
  * key the canonical consumer (CLI usage, the trace block) keys on. The
147
170
  * per-run tracer reads the SAME live binding; one source, one
package/dist/session.js CHANGED
@@ -92,6 +92,12 @@ export class AgentSession {
92
92
  #continuationScope;
93
93
  // XP-1: the selected axes; resolved per request (next-turn semantics).
94
94
  #reasoning;
95
+ // CTX-1: the compaction threshold is derived from the LIVE model's window,
96
+ // so it is a passenger on the binding like the four above. PH-F8 fixed
97
+ // exactly this class — a frozen startup value still in use after a switch
98
+ // — for the model id, the provider, the endpoint and the scope. The
99
+ // threshold was one field over and was never asked.
100
+ #microcompact;
95
101
  // XP-1: a legacy session records revision 1 at the next explicit
96
102
  // selection or first request — never eagerly at open.
97
103
  #profilePending;
@@ -146,6 +152,8 @@ export class AgentSession {
146
152
  this.#baseUrl = config.baseUrl;
147
153
  this.#continuationScope = config.continuationScope;
148
154
  this.#reasoning = config.reasoning ?? { thinking: "default", effort: "default" };
155
+ // CTX-1: starts as the startup policy, then follows the binding.
156
+ this.#microcompact = config.microcompact;
149
157
  this.#profilePending = config.profilePending === true;
150
158
  }
151
159
  /** The config a NEW run/resume/summary sees: the frozen startup config
@@ -153,9 +161,10 @@ export class AgentSession {
153
161
  * fresh per call so an in-flight run keeps the config it started with
154
162
  * — the same boundary setAdapter has always drawn. */
155
163
  #effectiveConfig() {
156
- const { provider: _startup, baseUrl: _startupUrl, continuationScope: _startupScope, ...rest } = this.#config;
164
+ const { provider: _startup, baseUrl: _startupUrl, continuationScope: _startupScope, microcompact: _startupMicro, ...rest } = this.#config;
157
165
  return {
158
166
  ...rest,
167
+ ...(this.#microcompact !== undefined ? { microcompact: this.#microcompact } : {}),
159
168
  model: this.#model,
160
169
  ...(this.#provider !== undefined ? { provider: this.#provider } : {}),
161
170
  ...(this.#baseUrl !== undefined ? { baseUrl: this.#baseUrl } : {}),
@@ -224,10 +233,31 @@ export class AgentSession {
224
233
  this.#baseUrl = binding.baseUrl;
225
234
  this.#continuationScope = binding.scope;
226
235
  this.#reasoning = binding.reasoning ?? { thinking: "default", effort: "default" };
236
+ if (binding.microcompact !== undefined)
237
+ this.#microcompact = binding.microcompact;
227
238
  // XP-1: an explicit selection is DURABLE — the setting survives
228
239
  // /resume because a revision records it now, not at some later flush.
229
240
  this.#recordProfile();
230
241
  }
242
+ /**
243
+ * CTX-1: the compaction threshold alone, with NO profile revision.
244
+ *
245
+ * `setModelBinding` is the atomic switch — the adapter and everything
246
+ * that must travel with it, recorded durably. This is the other door:
247
+ * a session OPENED onto a model the process was not configured for.
248
+ * `/resume` restores the recorded model, which can be any model the
249
+ * session ever used, while the threshold came from whatever model this
250
+ * process started on. Resuming a 1M session from a 200k start left it
251
+ * clearing tool results at 100,000.
252
+ *
253
+ * It writes no revision because nothing was selected: the caller is
254
+ * bringing the policy into line with a model already in force, not
255
+ * choosing one. Recording a revision here would write a new profile on
256
+ * every open.
257
+ */
258
+ setMicrocompactThreshold(thresholdTokens) {
259
+ this.#microcompact = { thresholdTokens };
260
+ }
231
261
  /** E2: the adapter identity (anthropic / openai-compat / openai-responses) — the route
232
262
  * key the canonical consumer (CLI usage, the trace block) keys on. The
233
263
  * per-run tracer reads the SAME live binding; one source, one
package/dist/summarize.js CHANGED
@@ -226,7 +226,19 @@ export async function summarizeConversation(options) {
226
226
  }
227
227
  // The LAST usage event is the call's (a turn reports usage once).
228
228
  if (ev.type === "usage" && ev.known) {
229
- usage = { inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, cacheRead: ev.cacheRead, cacheWrite: ev.cacheWrite };
229
+ // RSN-1, and the summary path was missed on the first wiring:
230
+ // the quartet was copied field by field, so a fifth field added
231
+ // later is silently dropped. The summary IS a billed call whose
232
+ // cost the ledger records; leaving its split behind made that
233
+ // one call's thinking unknown while every other call's was
234
+ // measured. The output total was never wrong — the split was.
235
+ usage = {
236
+ inputTokens: ev.inputTokens,
237
+ outputTokens: ev.outputTokens,
238
+ cacheRead: ev.cacheRead,
239
+ cacheWrite: ev.cacheWrite,
240
+ ...(ev.reasoningTokens !== undefined ? { reasoningTokens: ev.reasoningTokens } : {}),
241
+ };
230
242
  }
231
243
  }
232
244
  if (stops === 0)
@@ -71,6 +71,14 @@ export class RequestTracer {
71
71
  let cacheWrite = null;
72
72
  let outputTokens = null;
73
73
  let usageKnown = false;
74
+ // TRACE-F1: the server's own statement of what it ran. Null until a
75
+ // usage event carries one; it stays null when the provider says
76
+ // nothing, which is a different fact from "it matched".
77
+ let servedModel = null;
78
+ // RSN-1: the reported thinking split. Undefined until a usage event
79
+ // carries one; it stays undefined when the provider reports none,
80
+ // which is a different fact from a measured zero.
81
+ let reasoningTokens;
74
82
  const toolCalls = [];
75
83
  let outcome = "ok";
76
84
  try {
@@ -91,6 +99,10 @@ export class RequestTracer {
91
99
  cacheWrite = ev.cacheWrite;
92
100
  if (ev.outputTokens !== null)
93
101
  outputTokens = ev.outputTokens;
102
+ if (ev.servedModel !== undefined)
103
+ servedModel = ev.servedModel;
104
+ if (ev.reasoningTokens !== undefined)
105
+ reasoningTokens = ev.reasoningTokens;
94
106
  }
95
107
  yield ev;
96
108
  }
@@ -111,6 +123,8 @@ export class RequestTracer {
111
123
  cacheWrite,
112
124
  outputTokens,
113
125
  usageKnown,
126
+ servedModel,
127
+ reasoningTokens,
114
128
  });
115
129
  }
116
130
  }
@@ -163,6 +177,10 @@ export class RequestTracer {
163
177
  // turn (freshness fresh) is never part of it (slice 4)
164
178
  segmentHashes: hashes,
165
179
  stablePrefixFingerprint: stablePrefixFingerprint(cacheableHashes(manifest, hashes)),
180
+ // F33-1: the convention below writes unknown as zero, so the
181
+ // record says out loud which of the two it is. False until a
182
+ // usage event settles it.
183
+ usageKnown: false,
166
184
  freshInput: 0, // unknown until the usage event — "0 = unknown"
167
185
  cacheRead: 0,
168
186
  cacheWrite: null,
@@ -192,6 +210,35 @@ export class RequestTracer {
192
210
  record.latencyMs = performance.now() - p.t0;
193
211
  record.ttftMs = p.ttftMs ?? 0; // null = no event ever — the "0 = unknown" marker
194
212
  record.toolCalls = p.toolCalls;
213
+ // F33-1: the settle path has always been TOLD whether the provider
214
+ // reported usage; it just never wrote it down, and every consumer
215
+ // downstream then had to guess from four zeros.
216
+ //
217
+ // F33-R3: but `known` is NOT completeness. Core states the contract
218
+ // exactly — `known: true` means AT LEAST ONE field was reported and
219
+ // the others may still be null (protocol/events.ts, Area 6 round 9)
220
+ // — and canonicalization then fills the unreported ones with zero.
221
+ // Copying the boolean therefore re-lost the distinction one level
222
+ // down: a provider that reported input and cache but no output wrote
223
+ // `usageKnown: true, canonical.output: 0`, and every consumer read a
224
+ // measured zero.
225
+ //
226
+ // What a consumer needs is whether THE FIELDS THE COST IS MADE OF
227
+ // were each reported. That is a property of the raw quartet, so it
228
+ // is computed from the raw quartet rather than inherited from a flag
229
+ // that answers a weaker question. A genuine reported zero stays
230
+ // known: null is unreported, 0 is measured, and only null makes this
231
+ // false.
232
+ record.usageKnown =
233
+ p.usageKnown &&
234
+ p.inputTokens !== null &&
235
+ p.cacheRead !== null &&
236
+ p.outputTokens !== null;
237
+ // TRACE-F1: written ONLY when the server stated one. The field is
238
+ // absent otherwise — writing the requested id here would manufacture
239
+ // the agreement the reconciliation exists to test.
240
+ if (p.servedModel !== null)
241
+ record.servedModel = p.servedModel;
195
242
  if (p.usageKnown) {
196
243
  record.freshInput =
197
244
  this.#provider === "anthropic"
@@ -218,6 +265,7 @@ export class RequestTracer {
218
265
  outputTokens: p.outputTokens,
219
266
  cacheRead: p.cacheRead,
220
267
  cacheWrite: p.cacheWrite,
268
+ ...(p.reasoningTokens !== undefined ? { reasoningTokens: p.reasoningTokens } : {}),
221
269
  });
222
270
  this.#writer.enqueue(record);
223
271
  }
@@ -35,15 +35,64 @@
35
35
  * FOR ("safer-options"). Optional by construction: adding a required
36
36
  * field would have invalidated every v3 record ever written, and the
37
37
  * whole generation-compat discipline exists to prevent exactly that.
38
+ *
39
+ * Astra F33-1 (0.36.x) — schemaVersion 5: the record gains `usageKnown`.
40
+ * The writer has always initialised the quartet to zero under an explicit
41
+ * "0 = unknown" convention, and settled it only when the provider actually
42
+ * reported usage — so a request nobody measured was written as a request
43
+ * that cost nothing, indistinguishable from a genuine zero. Every consumer
44
+ * reading the sidecar inherited that: the bench extractors' "unknown is not
45
+ * zero" fix ran only on the plain-log branch, and adding a conforming
46
+ * sidecar to a session with one unmeasured request flipped
47
+ * usage_incomplete from true to false.
48
+ *
49
+ * The settle path already receives `usageKnown`; it simply never wrote it
50
+ * down. Recording it makes the ledger self-describing, which is the only
51
+ * form in which a later reader can tell the two zeros apart. It is listed
52
+ * as OPTIONAL so that v1-v4 sidecars stay readable — their absence means
53
+ * "this generation could not say", which a consumer must treat as unknown
54
+ * rather than as complete.
55
+ *
56
+ * TRACE-F1 (0.36.x) — schemaVersion 6: the record gains `servedModel`, the
57
+ * id the SERVER says it served. Every generation before this one recorded
58
+ * only `model`, the id we asked for, and the two were assumed identical
59
+ * because nothing ever checked. A vendor is free to disagree: a retired
60
+ * name becomes an alias, a migration id resolves elsewhere, a tier is
61
+ * silently upgraded. The bench asked for `deepseek-v4-flash` on every leg
62
+ * for four days while the server answered as `deepseek-flash`, and the
63
+ * specified-vs-observed reconciliation built to catch precisely that
64
+ * reported nothing — it had no observed half to compare, so it compared
65
+ * the specification against itself.
66
+ *
67
+ * OPTIONAL, and absent means "the server stated nothing" — NEVER "the same
68
+ * as requested". That default is the whole defect: a field filled in with
69
+ * the request manufactures the agreement the reconciliation exists to
70
+ * test.
38
71
  */
39
- /** schemaVersion: 4 for 0.12.0 (the optional `purpose` marker). Version
72
+ /** schemaVersion: 6 for the `servedModel` statement (TRACE-F1);
73
+ * 5 was the `usageKnown` marker (F33-1). Version
40
74
  * 1 = the 1.2.0 shape, 2 = the 1.3.0 shape, 3 = the 0.2.1 shape; all
41
75
  * kept for generation-compat reads (R1d-1, R2-1). Algorithm and shape
42
76
  * changes bump it (ADR-0051 §6 OUT-side versioning). */
43
- export declare const TRACE_SCHEMA_VERSION = 4;
77
+ export declare const TRACE_SCHEMA_VERSION = 6;
44
78
  /** The versions a reader may meet in a ledger. v1 and v2 records are
45
79
  * accepted (generation-compat) and read as defaults — no canonical
46
80
  * block (v1), no rent block (v1, v2). */
81
+ /**
82
+ * EVERY generation ever written, named explicitly.
83
+ *
84
+ * F33-R1: this was `[1, 2, 3, TRACE_SCHEMA_VERSION]`, and a set spelled
85
+ * with a MOVING member drops the generation it was standing on every time
86
+ * the version rises. Raising 4 to 5 silently removed 4 — the version every
87
+ * trace the deployed 0.36.0 has ever written carries — so the reader
88
+ * stopped accepting the product's own live output while a v5 record went
89
+ * through. The whole generation-compat discipline exists to prevent that,
90
+ * and its own constant undid it.
91
+ *
92
+ * A new version is added here BY HAND, next to its field set and its hash
93
+ * spec. Three lists to extend is the point: a bump that forgets one is a
94
+ * bump that fails loudly rather than a reader that quietly narrows.
95
+ */
47
96
  export declare const TRACE_SCHEMA_VERSIONS: Readonly<Set<number>>;
48
97
  import type { CanonicalUsage } from "../usage/canonical.js";
49
98
  import { type RentLine } from "./rent.js";
@@ -62,7 +111,7 @@ export interface TraceSegment {
62
111
  }
63
112
  /** That is the complete set for 1.2.0. */
64
113
  export interface TraceRecord {
65
- schemaVersion: 4;
114
+ schemaVersion: 6;
66
115
  kind: "request";
67
116
  requestId: string;
68
117
  runId: string;
@@ -86,6 +135,12 @@ export interface TraceRecord {
86
135
  * Canonical/billing usage lives in the `canonical` block (E2); these
87
136
  * fields are observation only (a provider may count a token a dozen
88
137
  * ways; billing must not). */
138
+ /** F33-1 (v5): did the provider actually REPORT usage for this request?
139
+ * The quartet below is written under a "0 = unknown" convention, so
140
+ * without this a request nobody measured and a request that genuinely
141
+ * cost nothing are the same four zeros. Optional only for reading v1-v4
142
+ * sidecars; every v5 writer records it. */
143
+ usageKnown?: boolean;
89
144
  freshInput: number;
90
145
  cacheRead: number;
91
146
  cacheWrite: number | null;
@@ -117,25 +172,31 @@ export interface TraceRecord {
117
172
  * its purpose: "safer-options". A consumer separating on-demand rent
118
173
  * from run rent reads this field and needs no heuristic. */
119
174
  purpose?: string;
175
+ /** TRACE-F1 (v6) — the model id the SERVER said it served, when it said
176
+ * one. ABSENT means the server stated nothing, NEVER "the same as
177
+ * requested": that default is what let a retired id read as live for
178
+ * four days. The requested id stays in `model`; these are two facts,
179
+ * never merged. */
180
+ servedModel?: string;
120
181
  ts: number;
121
182
  }
122
183
  /** That is the complete set for 1.2.0. */
123
184
  export interface HeaderLine {
124
- schemaVersion: 4;
185
+ schemaVersion: 6;
125
186
  kind: "header";
126
187
  sessionId: string;
127
188
  kisoVersion: string;
128
189
  createdAt: number;
129
190
  }
130
191
  export interface RunEndLine {
131
- schemaVersion: 4;
192
+ schemaVersion: 6;
132
193
  kind: "run_end";
133
194
  runId: string;
134
195
  ts: number;
135
196
  lastRequestIndex: number;
136
197
  }
137
198
  export interface CrashLine {
138
- schemaVersion: 4;
199
+ schemaVersion: 6;
139
200
  kind: "crash";
140
201
  ts: number;
141
202
  note: string;
@@ -159,9 +220,18 @@ export declare const TRACE_RECORD_FIELDS_V3: readonly ["schemaVersion", "kind",
159
220
  * TUI2-R3v2 ③: `purpose` is OPTIONAL — a run request omits it entirely,
160
221
  * a side query names what it was for. It is listed in TRACE_RECORD_OPTIONAL
161
222
  * below so the closed-set gate accepts a record without it. */
162
- export declare const TRACE_RECORD_FIELDS: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical", "rent", "purpose"];
223
+ export declare const TRACE_RECORD_FIELDS_V4: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical", "rent", "purpose"];
224
+ /** The field set (schemaVersion 5) = the v4 set + `usageKnown` (F33-1).
225
+ * Optional for the same reason `purpose` is: requiring it would invalidate
226
+ * every v4 record ever written, and reading old generations is the whole
227
+ * point of the discipline. Absent means the generation could not say. */
228
+ export declare const TRACE_RECORD_FIELDS_V5: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical", "rent", "purpose", "usageKnown"];
229
+ /** TRACE-F1 (v6): the model the SERVER said it served. A v5 ledger has none
230
+ * on any record, which reads as "nobody asked the server" — the true
231
+ * statement about a ledger written before the adapters read the field. */
232
+ export declare const TRACE_RECORD_FIELDS: readonly ["schemaVersion", "kind", "requestId", "runId", "requestIndex", "retryAttempt", "provider", "model", "adapterVersion", "systemPromptHash", "toolSchemaHash", "contextHash", "contextManifest", "segmentHashes", "stablePrefixFingerprint", "freshInput", "cacheRead", "cacheWrite", "output", "latencyMs", "ttftMs", "toolCalls", "outcome", "lineageLink", "ts", "canonical", "rent", "purpose", "usageKnown", "servedModel"];
163
233
  /** The fields the closed-set check does not require to be present. */
164
- export declare const TRACE_RECORD_OPTIONAL: readonly ["purpose"];
234
+ export declare const TRACE_RECORD_OPTIONAL: readonly ["purpose", "usageKnown", "servedModel"];
165
235
  export declare const TRACE_SEGMENT_FIELDS: readonly ["role", "seqRange", "estTokens", "freshness"];
166
236
  export declare function validateTraceSegment(v: unknown): v is TraceSegment;
167
237
  export declare function validateTraceRecord(v: unknown): v is TraceRecord;
@@ -35,16 +35,65 @@
35
35
  * FOR ("safer-options"). Optional by construction: adding a required
36
36
  * field would have invalidated every v3 record ever written, and the
37
37
  * whole generation-compat discipline exists to prevent exactly that.
38
+ *
39
+ * Astra F33-1 (0.36.x) — schemaVersion 5: the record gains `usageKnown`.
40
+ * The writer has always initialised the quartet to zero under an explicit
41
+ * "0 = unknown" convention, and settled it only when the provider actually
42
+ * reported usage — so a request nobody measured was written as a request
43
+ * that cost nothing, indistinguishable from a genuine zero. Every consumer
44
+ * reading the sidecar inherited that: the bench extractors' "unknown is not
45
+ * zero" fix ran only on the plain-log branch, and adding a conforming
46
+ * sidecar to a session with one unmeasured request flipped
47
+ * usage_incomplete from true to false.
48
+ *
49
+ * The settle path already receives `usageKnown`; it simply never wrote it
50
+ * down. Recording it makes the ledger self-describing, which is the only
51
+ * form in which a later reader can tell the two zeros apart. It is listed
52
+ * as OPTIONAL so that v1-v4 sidecars stay readable — their absence means
53
+ * "this generation could not say", which a consumer must treat as unknown
54
+ * rather than as complete.
55
+ *
56
+ * TRACE-F1 (0.36.x) — schemaVersion 6: the record gains `servedModel`, the
57
+ * id the SERVER says it served. Every generation before this one recorded
58
+ * only `model`, the id we asked for, and the two were assumed identical
59
+ * because nothing ever checked. A vendor is free to disagree: a retired
60
+ * name becomes an alias, a migration id resolves elsewhere, a tier is
61
+ * silently upgraded. The bench asked for `deepseek-v4-flash` on every leg
62
+ * for four days while the server answered as `deepseek-flash`, and the
63
+ * specified-vs-observed reconciliation built to catch precisely that
64
+ * reported nothing — it had no observed half to compare, so it compared
65
+ * the specification against itself.
66
+ *
67
+ * OPTIONAL, and absent means "the server stated nothing" — NEVER "the same
68
+ * as requested". That default is the whole defect: a field filled in with
69
+ * the request manufactures the agreement the reconciliation exists to
70
+ * test.
38
71
  */
39
- /** schemaVersion: 4 for 0.12.0 (the optional `purpose` marker). Version
72
+ /** schemaVersion: 6 for the `servedModel` statement (TRACE-F1);
73
+ * 5 was the `usageKnown` marker (F33-1). Version
40
74
  * 1 = the 1.2.0 shape, 2 = the 1.3.0 shape, 3 = the 0.2.1 shape; all
41
75
  * kept for generation-compat reads (R1d-1, R2-1). Algorithm and shape
42
76
  * changes bump it (ADR-0051 §6 OUT-side versioning). */
43
- export const TRACE_SCHEMA_VERSION = 4;
77
+ export const TRACE_SCHEMA_VERSION = 6;
44
78
  /** The versions a reader may meet in a ledger. v1 and v2 records are
45
79
  * accepted (generation-compat) and read as defaults — no canonical
46
80
  * block (v1), no rent block (v1, v2). */
47
- export const TRACE_SCHEMA_VERSIONS = new Set([1, 2, 3, TRACE_SCHEMA_VERSION]);
81
+ /**
82
+ * EVERY generation ever written, named explicitly.
83
+ *
84
+ * F33-R1: this was `[1, 2, 3, TRACE_SCHEMA_VERSION]`, and a set spelled
85
+ * with a MOVING member drops the generation it was standing on every time
86
+ * the version rises. Raising 4 to 5 silently removed 4 — the version every
87
+ * trace the deployed 0.36.0 has ever written carries — so the reader
88
+ * stopped accepting the product's own live output while a v5 record went
89
+ * through. The whole generation-compat discipline exists to prevent that,
90
+ * and its own constant undid it.
91
+ *
92
+ * A new version is added here BY HAND, next to its field set and its hash
93
+ * spec. Three lists to extend is the point: a bump that forgets one is a
94
+ * bump that fails loudly rather than a reader that quietly narrows.
95
+ */
96
+ export const TRACE_SCHEMA_VERSIONS = new Set([1, 2, 3, 4, 5, 6]);
48
97
  import { PRICING_TABLE_V1, priceFor, pricingTableFor, validateCanonicalUsage } from "../usage/canonical.js";
49
98
  import { validateRentLine } from "./rent.js";
50
99
  export const HASH_SPEC_BY_VERSION = {
@@ -52,6 +101,8 @@ export const HASH_SPEC_BY_VERSION = {
52
101
  2: { algorithm: "sha-256", output: "full-hex" }, // E2 — the algorithms do not change
53
102
  3: { algorithm: "sha-256", output: "full-hex" }, // E3 — same algorithms, re-pinned (the E2 ritual)
54
103
  4: { algorithm: "sha-256", output: "full-hex" }, // TUI2-R3v2 — `purpose` is a marker, not an input to any hash; re-pinned by the same ritual
104
+ 5: { algorithm: "sha-256", output: "full-hex" }, // F33-1 — `usageKnown` is a marker, not an input to any hash; re-pinned by the same ritual
105
+ 6: { algorithm: "sha-256", output: "full-hex" }, // TRACE-F1 — `servedModel` is the server's statement, not an input to any hash; re-pinned by the same ritual
55
106
  };
56
107
  export function hashSpecFor(version) {
57
108
  const spec = HASH_SPEC_BY_VERSION[version];
@@ -100,9 +151,18 @@ export const TRACE_RECORD_FIELDS_V3 = [...TRACE_RECORD_FIELDS_V2, "rent"];
100
151
  * TUI2-R3v2 ③: `purpose` is OPTIONAL — a run request omits it entirely,
101
152
  * a side query names what it was for. It is listed in TRACE_RECORD_OPTIONAL
102
153
  * below so the closed-set gate accepts a record without it. */
103
- export const TRACE_RECORD_FIELDS = [...TRACE_RECORD_FIELDS_V3, "purpose"];
154
+ export const TRACE_RECORD_FIELDS_V4 = [...TRACE_RECORD_FIELDS_V3, "purpose"];
155
+ /** The field set (schemaVersion 5) = the v4 set + `usageKnown` (F33-1).
156
+ * Optional for the same reason `purpose` is: requiring it would invalidate
157
+ * every v4 record ever written, and reading old generations is the whole
158
+ * point of the discipline. Absent means the generation could not say. */
159
+ export const TRACE_RECORD_FIELDS_V5 = [...TRACE_RECORD_FIELDS_V4, "usageKnown"];
160
+ /** TRACE-F1 (v6): the model the SERVER said it served. A v5 ledger has none
161
+ * on any record, which reads as "nobody asked the server" — the true
162
+ * statement about a ledger written before the adapters read the field. */
163
+ export const TRACE_RECORD_FIELDS = [...TRACE_RECORD_FIELDS_V5, "servedModel"];
104
164
  /** The fields the closed-set check does not require to be present. */
105
- export const TRACE_RECORD_OPTIONAL = ["purpose"];
165
+ export const TRACE_RECORD_OPTIONAL = ["purpose", "usageKnown", "servedModel"];
106
166
  export const TRACE_SEGMENT_FIELDS = ["role", "seqRange", "estTokens", "freshness"];
107
167
  // ── Validators ────────────────────────────────────────────────────────────
108
168
  // Strict by design: extra keys are rejected (the closed set), so a
@@ -157,13 +217,37 @@ export function validateTraceRecord(v) {
157
217
  // no `purpose` on any record, which reads as "every request was a run
158
218
  // request", the true statement about a ledger written before side
159
219
  // queries existed.
160
- if (version !== 1 && version !== 2 && version !== 3 && version !== TRACE_SCHEMA_VERSION)
220
+ // F33-R1: the same moving-member defect lived here too. Every generation
221
+ // dispatches to ITS OWN field set; v4 is not "whatever the current one
222
+ // is minus a field", it is the set a v4 writer actually emitted.
223
+ if (typeof version !== "number" || !TRACE_SCHEMA_VERSIONS.has(version))
161
224
  return false;
162
- const fields = version === 1 ? TRACE_RECORD_FIELDS_V1 : version === 2 ? TRACE_RECORD_FIELDS_V2 : version === 3 ? TRACE_RECORD_FIELDS_V3 : TRACE_RECORD_FIELDS;
225
+ const fields = version === 1
226
+ ? TRACE_RECORD_FIELDS_V1
227
+ : version === 2
228
+ ? TRACE_RECORD_FIELDS_V2
229
+ : version === 3
230
+ ? TRACE_RECORD_FIELDS_V3
231
+ : version === 4
232
+ ? TRACE_RECORD_FIELDS_V4
233
+ : version === 5
234
+ ? TRACE_RECORD_FIELDS_V5
235
+ : TRACE_RECORD_FIELDS;
163
236
  if (!hasClosedKeys(v, fields, ["lineageLink", ...TRACE_RECORD_OPTIONAL]))
164
237
  return false;
165
238
  if (v.purpose !== undefined && (typeof v.purpose !== "string" || v.purpose === ""))
166
239
  return false;
240
+ // F33-R8: the completeness marker is a BOOLEAN. It was accepted as
241
+ // anything at all, so a record carrying `usageKnown: "yes"` validated and
242
+ // every consumer that branches on it read truthy — a marker a reader acts
243
+ // on has to be the type it claims to be.
244
+ if (v.usageKnown !== undefined && typeof v.usageKnown !== "boolean")
245
+ return false;
246
+ // TRACE-F1: the served id is a NON-EMPTY string when stated. Empty would
247
+ // reconcile as a contradiction against every requested id; absent is the
248
+ // honest "the server said nothing".
249
+ if (v.servedModel !== undefined && (typeof v.servedModel !== "string" || v.servedModel === ""))
250
+ return false;
167
251
  if (v.kind !== "request")
168
252
  return false;
169
253
  if (typeof v.requestId !== "string" || typeof v.runId !== "string")
@@ -33,6 +33,10 @@ export interface RawUsage {
33
33
  readonly outputTokens: number | null;
34
34
  readonly cacheRead: number | null;
35
35
  readonly cacheWrite: number | null;
36
+ /** RSN-1: absent when the provider reported no split. Optional rather
37
+ * than `number | null` so every existing caller stays valid and an
38
+ * omission cannot be mistaken for a measured zero. */
39
+ readonly reasoningTokens?: number;
36
40
  }
37
41
  /** The ONE canonical usage record. Every field has exactly one meaning
38
42
  * (proposal §1.2). wall/ttft live in the trace record (latencyMs/ttftMs)
@@ -45,7 +49,17 @@ export interface CanonicalUsage {
45
49
  readonly cacheRead: number;
46
50
  /** null = the provider reports none (openai-compat honestly does). */
47
51
  readonly cacheWrite: number | null;
48
- /** null = no reasoning split reported (reserved — every provider today). */
52
+ /** RSN-1: how much of `output` was THINKING, when the provider says.
53
+ * A SPLIT of the output, never a fifth quantity — completion tokens
54
+ * have always included reasoning ones, so the bill was never short;
55
+ * what was missing is which half the money went to, and that is the
56
+ * half the effort knob moves.
57
+ *
58
+ * null = not reported. The comment here used to say "reserved — every
59
+ * provider today", which was true when it was written and had stopped
60
+ * being true: the field sat hardcoded null while the vendor's own
61
+ * response carried `completion_tokens_details.reasoning_tokens`, read
62
+ * from the SAME object we already read the cache figure out of. */
49
63
  readonly reasoning: number | null;
50
64
  /** USD from the pricing table below — null is the R5b-④c ABSENT stamp:
51
65
  * the table has no rate for this route (an injected table's hole is
@@ -101,7 +101,11 @@ export function canonicalizeUsage(route, raw, table = PRICING_TABLE_V1) {
101
101
  output,
102
102
  cacheRead,
103
103
  cacheWrite,
104
- reasoning: null, // reserved — no provider reports a split today
104
+ // RSN-1: the reported split, or null when none was reported. NEVER 0
105
+ // for an absent one — a provider that measured no thinking and a
106
+ // provider that measured nothing are different facts, and this file
107
+ // spent a release conflating them under a comment that had aged out.
108
+ reasoning: raw.reasoningTokens ?? null,
105
109
  costUsd: priceFor(route, { input, output, cacheRead, cacheWrite }, table),
106
110
  pricingTableId: table.id,
107
111
  pricingTableVersion: table.version,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.34.0",
4
- "description": "kiso runtime — durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
3
+ "version": "0.37.0",
4
+ "description": "kiso runtime \u2014 durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "exports": {
@@ -25,12 +25,12 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.34.0"
28
+ "@vincemakes/kiso-core": "0.37.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.34.0",
32
- "@vincemakes/kiso-provider-openai": "0.34.0",
33
- "@vincemakes/kiso-provider-openai-responses": "0.34.0"
31
+ "@vincemakes/kiso-provider-anthropic": "0.37.0",
32
+ "@vincemakes/kiso-provider-openai": "0.37.0",
33
+ "@vincemakes/kiso-provider-openai-responses": "0.37.0"
34
34
  },
35
35
  "peerDependenciesMeta": {
36
36
  "@vincemakes/kiso-provider-anthropic": {
@@ -44,7 +44,7 @@
44
44
  }
45
45
  },
46
46
  "devDependencies": {
47
- "@vincemakes/kiso-evals": "0.34.0",
47
+ "@vincemakes/kiso-evals": "0.37.0",
48
48
  "@types/node": "^26.1.2",
49
49
  "typescript": "^5.7.2",
50
50
  "vitest": "^3.0.0"