@vincemakes/kiso-runtime 0.14.0 → 0.15.1

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/agent.d.ts CHANGED
@@ -39,6 +39,10 @@ export interface AgentDefinition {
39
39
  readonly provider?: "anthropic" | "openai-compat";
40
40
  readonly apiKey?: string;
41
41
  readonly baseUrl?: string;
42
+ /** PH-1c.1: opt-in Anthropic prompt caching (cache_control
43
+ * breakpoints) — OFF by default; the openai-compat path ignores it
44
+ * (that dialect's caching is server-automatic). Type-only additive. */
45
+ readonly promptCaching?: boolean;
42
46
  readonly maxTurns?: number;
43
47
  readonly maxTokens?: number;
44
48
  readonly temperature?: number;
@@ -90,4 +94,5 @@ export declare function createAgent(definition: AgentDefinition): AgentRuntime;
90
94
  export declare function buildAdapter(provider: "anthropic" | "openai-compat", opts?: {
91
95
  readonly apiKey?: string;
92
96
  readonly baseUrl?: string;
97
+ readonly promptCaching?: boolean;
93
98
  }): Promise<Adapter>;
package/dist/agent.js CHANGED
@@ -132,6 +132,7 @@ async function resolveAdapter(definition) {
132
132
  return createAnthropicProvider({
133
133
  ...(definition.apiKey !== undefined ? { apiKey: definition.apiKey } : {}),
134
134
  ...(definition.baseUrl !== undefined ? { baseUrl: definition.baseUrl } : {}),
135
+ ...(definition.promptCaching !== undefined ? { promptCaching: definition.promptCaching } : {}),
135
136
  });
136
137
  }
137
138
  case "openai-compat": {
package/dist/compose.js CHANGED
@@ -201,8 +201,13 @@ export function composeApprovalChain(extensions) {
201
201
  try {
202
202
  v = await Promise.resolve(policy.decide(payload, ctx));
203
203
  }
204
- catch {
204
+ catch (err) {
205
205
  v = { action: "ask" }; // a throwing policy counts as ask — it speaks, never silently
206
+ // PH-1a (finding PH-F17): the degradation itself must speak
207
+ // too — a permanently-throwing policy used to manifest only
208
+ // as "why is it asking me about everything?". One stderr
209
+ // line, naming the extension; the verdict is unchanged.
210
+ console.error(`[kiso] approval policy from extension "${extension}" threw (${err instanceof Error ? err.message : String(err)}) — degraded to ask`);
206
211
  }
207
212
  if (v.action === "abstain")
208
213
  continue; // no opinion — not a verdict
@@ -23,3 +23,5 @@ export * from "./lock-adapter.js";
23
23
  export * from "./ledger.js";
24
24
  export * from "./extensions.js";
25
25
  export * from "./trust.js";
26
+ export * from "./provider/metadata.js";
27
+ export { canonicalizeUsageForModel } from "./usage/canonical.js";
package/dist/internal.js CHANGED
@@ -23,3 +23,10 @@ export * from "./lock-adapter.js";
23
23
  export * from "./ledger.js";
24
24
  export * from "./extensions.js";
25
25
  export * from "./trust.js";
26
+ // PH-1c: the model metadata registry (capabilities + dated pricing) —
27
+ // first-party consumers (the CLI window derivation) read it here; the
28
+ // curated root surface does not move.
29
+ export * from "./provider/metadata.js";
30
+ // PH-1c: the model-keyed cost derivation rides the same door (the root
31
+ // surface keeps only the frozen canonicalizeUsage).
32
+ export { canonicalizeUsageForModel } from "./usage/canonical.js";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * PH-1c — the model metadata table (findings PH-F14/PH-F15/PH-F16).
3
+ *
4
+ * Capabilities and pricing keyed by MODEL (+ optional endpoint), never
5
+ * by route: `anthropic`/`openai-compat` are protocol conventions — the
6
+ * same route serves models whose windows and rates have nothing in
7
+ * common, which is how the old route-keyed table priced an Anthropic
8
+ * run at DeepSeek's rates. The registry's one discipline: **unknown is
9
+ * null, everywhere** — an absent entry, an absent field, an unverified
10
+ * price all surface as null and no layer downstream may guess.
11
+ *
12
+ * Pricing is SEPARATE from capabilities (the review boundary): a price
13
+ * is a dated claim about someone else's billing page, so every entry
14
+ * carries `asOf` (the freeze date) and `source` (the page). A model we
15
+ * can name but not price stays `pricing: null` — the honest stamp the
16
+ * E2 nullable convention already defined.
17
+ *
18
+ * Lives under runtime/internal — the curated root surface (44 names)
19
+ * does not move; core is untouched.
20
+ */
21
+ export interface ModelCapabilities {
22
+ /** tokens of context the model accepts; null = unknown. */
23
+ readonly contextWindow: number | null;
24
+ readonly maxOutputTokens: number | null;
25
+ /** "automatic" — the provider caches without request markup (DeepSeek,
26
+ * OpenAI); "explicit" — the request must place cache_control
27
+ * breakpoints (Anthropic); "none" — no caching; null = unknown. */
28
+ readonly promptCaching: "none" | "automatic" | "explicit" | null;
29
+ /** the model emits a reasoning stream (thinking); null = unknown. */
30
+ readonly reasoning: boolean | null;
31
+ }
32
+ export interface ModelPricing {
33
+ readonly inputPerM: number;
34
+ readonly outputPerM: number;
35
+ readonly cacheReadPerM: number;
36
+ readonly cacheWritePerM: number;
37
+ /** the date the rates were read — a price is a dated claim. */
38
+ readonly asOf: string;
39
+ /** the billing page the rates came from. */
40
+ readonly source: string;
41
+ }
42
+ export interface ModelMetadataEntry {
43
+ /** EXACT model id — v1 does no pattern matching. */
44
+ readonly model: string;
45
+ /** origin qualifier (e.g. "https://api.deepseek.com"): when present,
46
+ * the entry matches only requests aimed at that endpoint. */
47
+ readonly endpoint?: string;
48
+ readonly capabilities: ModelCapabilities;
49
+ readonly pricing: ModelPricing | null;
50
+ }
51
+ /**
52
+ * Look a model up. `endpoint` narrows: an entry WITH an endpoint only
53
+ * matches when the caller's endpoint origin equals it; an entry without
54
+ * one matches any endpoint. Unknown model → null, never a default.
55
+ */
56
+ export declare function lookupModelMetadata(model: string, endpoint?: string): ModelMetadataEntry | null;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * PH-1c — the model metadata table (findings PH-F14/PH-F15/PH-F16).
3
+ *
4
+ * Capabilities and pricing keyed by MODEL (+ optional endpoint), never
5
+ * by route: `anthropic`/`openai-compat` are protocol conventions — the
6
+ * same route serves models whose windows and rates have nothing in
7
+ * common, which is how the old route-keyed table priced an Anthropic
8
+ * run at DeepSeek's rates. The registry's one discipline: **unknown is
9
+ * null, everywhere** — an absent entry, an absent field, an unverified
10
+ * price all surface as null and no layer downstream may guess.
11
+ *
12
+ * Pricing is SEPARATE from capabilities (the review boundary): a price
13
+ * is a dated claim about someone else's billing page, so every entry
14
+ * carries `asOf` (the freeze date) and `source` (the page). A model we
15
+ * can name but not price stays `pricing: null` — the honest stamp the
16
+ * E2 nullable convention already defined.
17
+ *
18
+ * Lives under runtime/internal — the curated root surface (44 names)
19
+ * does not move; core is untouched.
20
+ */
21
+ const DEEPSEEK_PRICING = {
22
+ // The E2-frozen rates (pricing table v1, freeze date 2026-08-13),
23
+ // re-homed here with their provenance made explicit. The caveat
24
+ // carries forward verbatim: an approximation, not a bill.
25
+ inputPerM: 0.27,
26
+ outputPerM: 1.1,
27
+ cacheReadPerM: 0.027,
28
+ cacheWritePerM: 0,
29
+ asOf: "2026-08-13",
30
+ source: "https://api-docs.deepseek.com/quick_start/pricing",
31
+ };
32
+ /** The v1 table. Nulls outnumber numbers ON PURPOSE: only values with a
33
+ * named source enter; everything else waits for one. */
34
+ const ENTRIES = [
35
+ {
36
+ model: "deepseek-chat",
37
+ endpoint: "https://api.deepseek.com",
38
+ capabilities: { contextWindow: null, maxOutputTokens: null, promptCaching: "automatic", reasoning: false },
39
+ pricing: DEEPSEEK_PRICING,
40
+ },
41
+ {
42
+ model: "deepseek-reasoner",
43
+ endpoint: "https://api.deepseek.com",
44
+ capabilities: { contextWindow: null, maxOutputTokens: null, promptCaching: "automatic", reasoning: true },
45
+ pricing: DEEPSEEK_PRICING,
46
+ },
47
+ {
48
+ model: "claude-sonnet-5",
49
+ capabilities: { contextWindow: 200_000, maxOutputTokens: null, promptCaching: "explicit", reasoning: true },
50
+ // Priced only when the rates are read from the live billing page
51
+ // and dated — never copied from memory (the review's boundary ②).
52
+ pricing: null,
53
+ },
54
+ {
55
+ model: "gpt-4o",
56
+ capabilities: { contextWindow: 128_000, maxOutputTokens: null, promptCaching: "automatic", reasoning: false },
57
+ pricing: null,
58
+ },
59
+ ];
60
+ /**
61
+ * Look a model up. `endpoint` narrows: an entry WITH an endpoint only
62
+ * matches when the caller's endpoint origin equals it; an entry without
63
+ * one matches any endpoint. Unknown model → null, never a default.
64
+ */
65
+ export function lookupModelMetadata(model, endpoint) {
66
+ for (const entry of ENTRIES) {
67
+ if (entry.model !== model)
68
+ continue;
69
+ if (entry.endpoint !== undefined && endpoint !== undefined && entry.endpoint !== originOf(endpoint))
70
+ continue;
71
+ return entry;
72
+ }
73
+ return null;
74
+ }
75
+ function originOf(endpoint) {
76
+ try {
77
+ return new URL(endpoint).origin;
78
+ }
79
+ catch {
80
+ return endpoint;
81
+ }
82
+ }
package/dist/run.js CHANGED
@@ -436,8 +436,12 @@ export class Run {
436
436
  verdict = chainVerdict;
437
437
  }
438
438
  }
439
- catch {
439
+ catch (err) {
440
440
  verdict = { action: "ask" };
441
+ // PH-1a (finding PH-F17): same speaking degradation as the chain's
442
+ // per-policy catch — the recovery path has no extension name, so
443
+ // the chain is named as a whole.
444
+ console.error(`[kiso] approval chain threw during recovery (${err instanceof Error ? err.message : String(err)}) — degraded to ask`);
441
445
  }
442
446
  if (verdict === undefined && hooks?.onPreTool !== undefined) {
443
447
  const decision = await abortable(Promise.resolve(hooks.onPreTool(payload, { sessionId: this.#session.id })), signal);
package/dist/session.d.ts CHANGED
@@ -114,9 +114,25 @@ export declare class AgentSession {
114
114
  * turns (dispatch's /model), never mid-run.
115
115
  */
116
116
  setAdapter(adapter: Adapter): void;
117
+ /**
118
+ * PH-1a (finding PH-F8, P0): the ATOMIC model switch — adapter, model
119
+ * id, and provider route replace together, effective at the next run.
120
+ * setAdapter alone is for a same-binding adapter swap (the faux
121
+ * re-arm); a switch that changes WHAT model answers must come through
122
+ * here, or the UI claims one model while requests carry another.
123
+ * Omitting `provider` clears the route — an unknown binding is
124
+ * canonicalized under the honest "adapter" route (null-priced), never
125
+ * the stale one. (The context window joins the binding when per-model
126
+ * metadata exists — the PH-1c registry.)
127
+ */
128
+ setModelBinding(binding: {
129
+ readonly adapter: Adapter;
130
+ readonly model: string;
131
+ readonly provider?: "anthropic" | "openai-compat";
132
+ }): void;
117
133
  /** E2: the adapter identity ("anthropic" | "openai-compat") — the route
118
134
  * key the canonical consumer (CLI usage, the trace block) keys on. The
119
- * per-run tracer reads the SAME #config.provider; one source, one
135
+ * per-run tracer reads the SAME live binding; one source, one
120
136
  * route — the CLI and the trace can never disagree. */
121
137
  get provider(): "anthropic" | "openai-compat" | undefined;
122
138
  /**
@@ -185,6 +201,7 @@ export declare class AgentSession {
185
201
  signal?: AbortSignalLike;
186
202
  onStart?: (info: CompactInfo) => void;
187
203
  drop?: boolean;
204
+ focus?: string;
188
205
  }): Promise<SummarizeResult | null>;
189
206
  /**
190
207
  * E6 — the run-start context policy (candidate A + the crux drop arm).
@@ -224,9 +241,12 @@ export declare class AgentSession {
224
241
  uncertainExecutions(): import("./ledger.js").ExecutionRecord[];
225
242
  /**
226
243
  * TV-1A — assess the task claims and their evidence freshness over THIS
227
- * session's durable log. The shared-tool set comes from the live tools'
228
- * own `effects.concurrency: "shared"` certificates (one direction of
229
- * truth, never a second declaration); the evidence policy defaults to
244
+ * session's durable log. The non-mutating set comes from the live tools'
245
+ * own `effects.precommitSafe` certificates (one direction of truth,
246
+ * never a second declaration) the read-only+free+local contract, the
247
+ * only certificate that proves the world untouched. `concurrency:
248
+ * "shared"` is a SCHEDULING promise and never feeds this set (TV-1C —
249
+ * slow_touch is shared and writes). The evidence policy defaults to
230
250
  * {"shell"} — the convention the task extension's own "make the LAST
231
251
  * item a verification step" guidance produces.
232
252
  */
package/dist/session.js CHANGED
@@ -36,7 +36,7 @@ import { assessTasks } from "./task-assessment.js";
36
36
  const DEFAULT_EVIDENCE_TOOLS = new Set(["shell"]);
37
37
  import { denialResult } from "@vincemakes/kiso-core";
38
38
  import { DROP_PLACEHOLDER, estimateSummarySavings, KEEP_RECENT_ROUNDS, KEEP_TOKENS_DEFAULT, lastSummaryPoint, MAX_SUMMARY_FAILURES, policyTriggerFromWindow, serializeCovered, SUMMARY_MAX_OUTPUT, summarizeConversation, summaryBoundarySeq, } from "./summarize.js";
39
- import { canonicalizeUsage } from "./usage/canonical.js";
39
+ import { canonicalizeUsageForModel } from "./usage/canonical.js";
40
40
  import { appendFileSync, mkdirSync } from "node:fs";
41
41
  import { join } from "node:path";
42
42
  import { estimateTokens } from "@vincemakes/kiso-core";
@@ -72,9 +72,18 @@ export class AgentSession {
72
72
  log;
73
73
  #store;
74
74
  // NOT readonly since 0.1.23: /model replaces it between runs (the
75
- // constructor and setAdapter are the only writers).
75
+ // constructor, setAdapter, and setModelBinding are the only writers).
76
76
  #adapter;
77
77
  #config;
78
+ // PH-1a (finding PH-F8, P0): the LIVE model binding. #config froze the
79
+ // startup model/provider, so a /model switch replaced the adapter while
80
+ // every later run kept sending the OLD model id and canonicalizing
81
+ // usage under the OLD route. These two travel WITH the adapter now —
82
+ // setModelBinding writes all three in one call; runs read them through
83
+ // #effectiveConfig at run construction (next-turn semantics, same as
84
+ // setAdapter always had).
85
+ #model;
86
+ #provider;
78
87
  #pendingResolvers = new Map();
79
88
  #uncertaintyResolvers = new Map();
80
89
  #answered = new Set();
@@ -126,6 +135,16 @@ export class AgentSession {
126
135
  }
127
136
  const composedHooks = composeHooks(config.hooks, config.extensions ?? []);
128
137
  this.#config = composedHooks === undefined ? config : { ...config, hooks: composedHooks };
138
+ this.#model = config.model;
139
+ this.#provider = config.provider;
140
+ }
141
+ /** The config a NEW run/resume/summary sees: the frozen startup config
142
+ * with the LIVE binding fields (model, provider) substituted. Built
143
+ * fresh per call so an in-flight run keeps the config it started with
144
+ * — the same boundary setAdapter has always drawn. */
145
+ #effectiveConfig() {
146
+ const { provider: _startup, ...rest } = this.#config;
147
+ return { ...rest, model: this.#model, ...(this.#provider !== undefined ? { provider: this.#provider } : {}) };
129
148
  }
130
149
  /** Write-ahead through the store; a rejected write POISONS the session
131
150
  * (round 1/round 4): the in-memory log no longer matches the disk — whatever
@@ -170,12 +189,28 @@ export class AgentSession {
170
189
  setAdapter(adapter) {
171
190
  this.#adapter = adapter;
172
191
  }
192
+ /**
193
+ * PH-1a (finding PH-F8, P0): the ATOMIC model switch — adapter, model
194
+ * id, and provider route replace together, effective at the next run.
195
+ * setAdapter alone is for a same-binding adapter swap (the faux
196
+ * re-arm); a switch that changes WHAT model answers must come through
197
+ * here, or the UI claims one model while requests carry another.
198
+ * Omitting `provider` clears the route — an unknown binding is
199
+ * canonicalized under the honest "adapter" route (null-priced), never
200
+ * the stale one. (The context window joins the binding when per-model
201
+ * metadata exists — the PH-1c registry.)
202
+ */
203
+ setModelBinding(binding) {
204
+ this.#adapter = binding.adapter;
205
+ this.#model = binding.model;
206
+ this.#provider = binding.provider;
207
+ }
173
208
  /** E2: the adapter identity ("anthropic" | "openai-compat") — the route
174
209
  * key the canonical consumer (CLI usage, the trace block) keys on. The
175
- * per-run tracer reads the SAME #config.provider; one source, one
210
+ * per-run tracer reads the SAME live binding; one source, one
176
211
  * route — the CLI and the trace can never disagree. */
177
212
  get provider() {
178
- return this.#config.provider;
213
+ return this.#provider;
179
214
  }
180
215
  /**
181
216
  * TUI2-R3v2 ③ — ONE model request that belongs to no run (the
@@ -222,8 +257,8 @@ export class AgentSession {
222
257
  root: this.#store.root,
223
258
  sessionId: this.id,
224
259
  runId,
225
- provider: this.#config.provider ?? "adapter",
226
- model: this.#config.model,
260
+ provider: this.#provider ?? "adapter",
261
+ model: this.#model,
227
262
  adapterVersion: runtimeVersion(),
228
263
  purpose: options.purpose,
229
264
  // the manifest's seqRange pointers derive from the log, and a side
@@ -239,7 +274,7 @@ export class AgentSession {
239
274
  let text = "";
240
275
  try {
241
276
  for await (const ev of guarded.stream({
242
- model: this.#config.model,
277
+ model: this.#model,
243
278
  messages: [{ role: "user", content: options.prompt }],
244
279
  systemPrompt: options.systemPrompt,
245
280
  ...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
@@ -257,7 +292,7 @@ export class AgentSession {
257
292
  /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
258
293
  run(input, options) {
259
294
  this.ensureHealthy();
260
- return new Run(this.#store, this.#adapter, this.#config, this, input, options?.signal, false, options?.source);
295
+ return new Run(this.#store, this.#adapter, this.#effectiveConfig(), this, input, options?.signal, false, options?.source);
261
296
  }
262
297
  /**
263
298
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -267,7 +302,7 @@ export class AgentSession {
267
302
  */
268
303
  resume() {
269
304
  this.ensureHealthy();
270
- return new Run(this.#store, this.#adapter, this.#config, this, undefined, undefined, true);
305
+ return new Run(this.#store, this.#adapter, this.#effectiveConfig(), this, undefined, undefined, true);
271
306
  }
272
307
  /**
273
308
  * /compact (ADR-0044): compress the older conversation with a model
@@ -280,7 +315,11 @@ export class AgentSession {
280
315
  * worth covering yet). Crash semantics: a crash BEFORE the persist is
281
316
  * "nothing happened"; after it, a resume projects the compressed view.
282
317
  */
283
- async summarize(options = {}) {
318
+ async summarize(
319
+ // R3a: `focus` — an optional steer for the summary call ("keep the
320
+ // auth details"). Rides the serialized input as ONE instruction
321
+ // line; absent = byte-identical to the pre-round call.
322
+ options = {}) {
284
323
  this.ensureHealthy();
285
324
  const keepRounds = options.keepRounds ?? KEEP_RECENT_ROUNDS;
286
325
  // W18: the signal is observed at EVERY phase boundary — the cancel
@@ -301,7 +340,8 @@ export class AgentSession {
301
340
  // messages still feed the pre-call token estimate and the savings
302
341
  // figure (estimate-only, never the model input).
303
342
  const covered = projectMessages(events.filter((e) => e.seq > prevPoint && e.seq <= boundary && e.type !== "summarized"));
304
- const serializedInput = serializeCovered({ events, prevPoint, boundary });
343
+ const serialized0 = serializeCovered({ events, prevPoint, boundary });
344
+ const serializedInput = options.focus === undefined ? serialized0 : `Focus the summary on: ${options.focus}\n\n${serialized0}`;
305
345
  // W18: the indicator's pre-call data — rounds + the token estimate
306
346
  // are knowable BEFORE the adapter call; the summary itself is ONE
307
347
  // call with no fraction (kiso never invents a percentage here).
@@ -322,7 +362,7 @@ export class AgentSession {
322
362
  else {
323
363
  const call = await summarizeConversation({
324
364
  adapter: this.#adapter,
325
- model: this.#config.model,
365
+ model: this.#model,
326
366
  // E6 (a): ONE serialized user message — the DSML bug's
327
367
  // raw-message array is structurally dead on this path.
328
368
  messages: [{ role: "user", content: serializedInput }],
@@ -356,7 +396,7 @@ export class AgentSession {
356
396
  // a degraded ledger costs one stderr line, never the summary.
357
397
  if (usage !== null) {
358
398
  try {
359
- const canonical = canonicalizeUsage(this.#config.provider ?? "adapter", usage);
399
+ const canonical = canonicalizeUsageForModel(this.#model, undefined, this.#provider ?? "adapter", usage);
360
400
  const line = JSON.stringify({ kind: "summary", canonical }) + "\n";
361
401
  mkdirSync(join(this.#store.root, "traces"), { recursive: true });
362
402
  appendFileSync(join(this.#store.root, "traces", `${this.id}.jsonl`), line);
@@ -535,20 +575,23 @@ export class AgentSession {
535
575
  }
536
576
  /**
537
577
  * TV-1A — assess the task claims and their evidence freshness over THIS
538
- * session's durable log. The shared-tool set comes from the live tools'
539
- * own `effects.concurrency: "shared"` certificates (one direction of
540
- * truth, never a second declaration); the evidence policy defaults to
578
+ * session's durable log. The non-mutating set comes from the live tools'
579
+ * own `effects.precommitSafe` certificates (one direction of truth,
580
+ * never a second declaration) the read-only+free+local contract, the
581
+ * only certificate that proves the world untouched. `concurrency:
582
+ * "shared"` is a SCHEDULING promise and never feeds this set (TV-1C —
583
+ * slow_touch is shared and writes). The evidence policy defaults to
541
584
  * {"shell"} — the convention the task extension's own "make the LAST
542
585
  * item a verification step" guidance produces.
543
586
  */
544
587
  assessTasks(opts) {
545
- const sharedTools = new Set();
588
+ const nonMutatingTools = new Set();
546
589
  for (const tool of this.#config.registry.list()) {
547
- if (tool.effects?.concurrency === "shared")
548
- sharedTools.add(tool.name);
590
+ if (tool.effects?.precommitSafe === true)
591
+ nonMutatingTools.add(tool.name);
549
592
  }
550
593
  return assessTasks(this.log.all, {
551
- sharedTools,
594
+ nonMutatingTools,
552
595
  evidenceTools: opts?.evidenceTools ?? DEFAULT_EVIDENCE_TOOLS,
553
596
  });
554
597
  }
@@ -9,13 +9,18 @@
9
9
  *
10
10
  * No new event kind, no core diff — the projection reads the existing
11
11
  * durable vocabulary (`deriveRecoveryPlan` is the shape precedent). The
12
- * classifiers are maximally conservative and reuse EC-1's certificate
13
- * direction verbatim:
12
+ * classifiers are maximally conservative; the exemptions are exactly
13
+ * three (TV-1C — no wider taxonomy exists or is wanted):
14
14
  *
15
15
  * - MUTATION marker: `tool_execution_started` (intent-to-effect) of any
16
- * name NOT in `sharedTools`. Started — not the receipt — so failed and
17
- * crash-window executions invalidate too, and pre-EC-1 overlap eras are
18
- * covered by the same rule. Absence of a certificate is a mutation.
16
+ * name NOT in `nonMutatingTools`. Started — not the receipt — so failed
17
+ * and crash-window executions invalidate too, and pre-EC-1 overlap eras
18
+ * are covered by the same rule. Absence of a certificate is a mutation.
19
+ * The exemption set is fed by `effects.precommitSafe` — the frozen
20
+ * read-only+free+local contract, the only certificate that PROVES the
21
+ * world untouched. `concurrency: "shared"` proves overlap-safety and
22
+ * nothing else (the TV-1C finding: slow_touch is shared AND writes) —
23
+ * it never feeds this set.
19
24
  * ONE proven exception (TV-1B, grounded in WR-1A): an execution whose
20
25
  * terminal receipt is failed(errorKind:"precondition") never counts —
21
26
  * that kind's frozen contract is "work refused BEFORE it starts".
@@ -27,6 +32,19 @@
27
32
  * - task_set itself never invalidates: recording the claim after the check
28
33
  * is the natural arc (tests → mark done), and the claim-recording act
29
34
  * mutates nothing the evidence observed.
35
+ * - VOIDED RANGES (TV-1C): a `model_output_abandoned` marker voids
36
+ * (voidFromSeq, seq] — the durable vocabulary's own "never happened",
37
+ * the same ranges the kernel projection skips. Every consumer here
38
+ * skips them uniformly: a voided echo is not a claim, a voided receipt
39
+ * is not evidence, a voided start is not a mutation. task_set waits
40
+ * for turn commit today (TT-1), so the scheduler already keeps claims
41
+ * out of voided turns — the projection encodes the admissibility rule
42
+ * itself instead of relying on that forever.
43
+ *
44
+ * `verified` means FRESH EVIDENCE UNDER THE CONFIGURED POLICY — never
45
+ * "objectively proven correct". Goal Truth is external (PE-1's
46
+ * evaluator); this projection only ever ranks the model's own claim
47
+ * against the trajectory's own receipts.
30
48
  */
31
49
  import type { Event } from "@vincemakes/kiso-core";
32
50
  /** One item of the model's plan, exactly as last claimed. */
@@ -64,9 +82,11 @@ export interface TaskAssessment {
64
82
  * trajectory. Pure: same events, same options, same assessment.
65
83
  */
66
84
  export declare function assessTasks(events: readonly Event[], opts?: {
67
- /** Names whose executions never mutate (from `effects.concurrency:
68
- * "shared"` certificates). Default ∅ everything mutates. */
69
- readonly sharedTools?: ReadonlySet<string>;
85
+ /** Names whose executions PROVABLY never mutate the observable
86
+ * world (from `effects.precommitSafe` certificates — read-only +
87
+ * free + local). `concurrency: "shared"` is NOT such a proof
88
+ * (TV-1C). Default ∅ — everything mutates. */
89
+ readonly nonMutatingTools?: ReadonlySet<string>;
70
90
  /** Names whose successful receipts count as evidence. Default ∅ —
71
91
  * nothing does. */
72
92
  readonly evidenceTools?: ReadonlySet<string>;
@@ -9,13 +9,18 @@
9
9
  *
10
10
  * No new event kind, no core diff — the projection reads the existing
11
11
  * durable vocabulary (`deriveRecoveryPlan` is the shape precedent). The
12
- * classifiers are maximally conservative and reuse EC-1's certificate
13
- * direction verbatim:
12
+ * classifiers are maximally conservative; the exemptions are exactly
13
+ * three (TV-1C — no wider taxonomy exists or is wanted):
14
14
  *
15
15
  * - MUTATION marker: `tool_execution_started` (intent-to-effect) of any
16
- * name NOT in `sharedTools`. Started — not the receipt — so failed and
17
- * crash-window executions invalidate too, and pre-EC-1 overlap eras are
18
- * covered by the same rule. Absence of a certificate is a mutation.
16
+ * name NOT in `nonMutatingTools`. Started — not the receipt — so failed
17
+ * and crash-window executions invalidate too, and pre-EC-1 overlap eras
18
+ * are covered by the same rule. Absence of a certificate is a mutation.
19
+ * The exemption set is fed by `effects.precommitSafe` — the frozen
20
+ * read-only+free+local contract, the only certificate that PROVES the
21
+ * world untouched. `concurrency: "shared"` proves overlap-safety and
22
+ * nothing else (the TV-1C finding: slow_touch is shared AND writes) —
23
+ * it never feeds this set.
19
24
  * ONE proven exception (TV-1B, grounded in WR-1A): an execution whose
20
25
  * terminal receipt is failed(errorKind:"precondition") never counts —
21
26
  * that kind's frozen contract is "work refused BEFORE it starts".
@@ -27,6 +32,19 @@
27
32
  * - task_set itself never invalidates: recording the claim after the check
28
33
  * is the natural arc (tests → mark done), and the claim-recording act
29
34
  * mutates nothing the evidence observed.
35
+ * - VOIDED RANGES (TV-1C): a `model_output_abandoned` marker voids
36
+ * (voidFromSeq, seq] — the durable vocabulary's own "never happened",
37
+ * the same ranges the kernel projection skips. Every consumer here
38
+ * skips them uniformly: a voided echo is not a claim, a voided receipt
39
+ * is not evidence, a voided start is not a mutation. task_set waits
40
+ * for turn commit today (TT-1), so the scheduler already keeps claims
41
+ * out of voided turns — the projection encodes the admissibility rule
42
+ * itself instead of relying on that forever.
43
+ *
44
+ * `verified` means FRESH EVIDENCE UNDER THE CONFIGURED POLICY — never
45
+ * "objectively proven correct". Goal Truth is external (PE-1's
46
+ * evaluator); this projection only ever ranks the model's own claim
47
+ * against the trajectory's own receipts.
30
48
  */
31
49
  const TASK_TOOL = "task_set";
32
50
  const STATUSES = new Set(["pending", "active", "done"]);
@@ -62,8 +80,18 @@ function parseEcho(content) {
62
80
  * trajectory. Pure: same events, same options, same assessment.
63
81
  */
64
82
  export function assessTasks(events, opts) {
65
- const shared = opts?.sharedTools ?? new Set();
83
+ const nonMutating = opts?.nonMutatingTools ?? new Set();
66
84
  const evidenceNames = opts?.evidenceTools ?? new Set();
85
+ // TV-1C: PASS 0 — the voided ranges. (voidFromSeq, seq] of every
86
+ // `model_output_abandoned` marker is "never happened" for this whole
87
+ // projection, exactly as the kernel's own context projection treats
88
+ // it. Markers are rare; a linear range check is honest and enough.
89
+ const voidRanges = [];
90
+ for (const ev of events) {
91
+ if (ev.type === "model_output_abandoned")
92
+ voidRanges.push({ from: ev.voidFromSeq, to: ev.seq });
93
+ }
94
+ const voided = (seq) => voidRanges.some((r) => seq > r.from && seq <= r.to);
67
95
  // TV-1B: PASS 1 — executions whose terminal receipt is a
68
96
  // PRECONDITION failure are PROVEN no-mutation (WR-1A froze that
69
97
  // contract: work refused before it starts). Everything else stays
@@ -72,6 +100,8 @@ export function assessTasks(events, opts) {
72
100
  // Two passes, no temporal rollback state — same events, same verdict.
73
101
  const provenNoMutation = new Set();
74
102
  for (const ev of events) {
103
+ if (voided(ev.seq))
104
+ continue;
75
105
  if (ev.type === "tool_execution_failed" && ev.errorKind === "precondition") {
76
106
  provenNoMutation.add(ev.executionId);
77
107
  }
@@ -84,9 +114,11 @@ export function assessTasks(events, opts) {
84
114
  let lastEvidenceSeq = null;
85
115
  let staleBySeq = null;
86
116
  for (const ev of events) {
117
+ if (voided(ev.seq))
118
+ continue;
87
119
  if (ev.type === "tool_execution_started") {
88
120
  nameByExecution.set(ev.executionId, ev.name);
89
- if (ev.name !== TASK_TOOL && !shared.has(ev.name) && !provenNoMutation.has(ev.executionId)) {
121
+ if (ev.name !== TASK_TOOL && !nonMutating.has(ev.name) && !provenNoMutation.has(ev.executionId)) {
90
122
  lastMutationSeq = ev.seq;
91
123
  if (lastEvidenceSeq !== null && staleBySeq === null)
92
124
  staleBySeq = ev.seq;
@@ -25,7 +25,7 @@ import { randomUUID } from "node:crypto";
25
25
  import { buildContextManifest, segmentHashes } from "./manifest.js";
26
26
  import { cacheableHashes } from "./analyze.js";
27
27
  import { hashContext, hashSystemPrompt, hashToolSpecs, stablePrefixFingerprint } from "./hash.js";
28
- import { PRICING_TABLE_V1, canonicalizeUsage } from "../usage/canonical.js";
28
+ import { PRICING_TABLE_V1, canonicalizeUsageForModel } from "../usage/canonical.js";
29
29
  import { buildRentLedger } from "./rent.js";
30
30
  import { TRACE_SCHEMA_VERSION } from "./record.js";
31
31
  import { TraceWriter } from "./writer.js";
@@ -201,11 +201,14 @@ export class RequestTracer {
201
201
  record.cacheWrite = p.cacheWrite ?? null;
202
202
  record.output = p.outputTokens ?? 0;
203
203
  }
204
- // E2 — the canonical block formalizes the same raw (the route-keyed
205
- // mapping; the validator pins block == quartet, so the two can never
206
- // drift apart). Unknown usage canonicalizes to the "0 = unknown"
207
- // convention, consistent with the quartet above.
208
- record.canonical = canonicalizeUsage(this.#provider, {
204
+ // E2 — the canonical block formalizes the same raw (the validator
205
+ // pins block == quartet, so the two can never drift apart). Unknown
206
+ // usage canonicalizes to the "0 = unknown" convention, consistent
207
+ // with the quartet above. PH-1c: the PRICE keys on the record's own
208
+ // MODEL via the metadata registry (route-keyed pricing priced an
209
+ // Anthropic run at DeepSeek's rates); the convention still keys on
210
+ // the route. An unpriced model costs null — never a fallback rate.
211
+ record.canonical = canonicalizeUsageForModel(record.model, undefined, this.#provider, {
209
212
  inputTokens: p.inputTokens,
210
213
  outputTokens: p.outputTokens,
211
214
  cacheRead: p.cacheRead,
@@ -111,6 +111,21 @@ export declare function priceFor(route: string, u: {
111
111
  * builtin v1 table so the export's default behavior is the pinned one
112
112
  * and a future injection never widens this frozen signature. */
113
113
  export declare function canonicalizeUsage(route: string, raw: RawUsage, table?: PricingTable): CanonicalUsage;
114
+ /**
115
+ * PH-1c — the MODEL-KEYED cost path (findings PH-F14/PH-F16). The
116
+ * convention still keys on the ROUTE (a protocol property: fresh vs
117
+ * total input); the PRICE keys on the model+endpoint metadata entry —
118
+ * the route-keyed table is how an Anthropic run got priced at
119
+ * DeepSeek's rates. No metadata entry, or an entry with `pricing:
120
+ * null`, costs null — the honest stamp, never a fallback rate.
121
+ *
122
+ * A NEW function beside canonicalizeUsage, not a widened signature —
123
+ * the E2 ruling froze that one ("a future injection never widens this
124
+ * frozen signature"), and this is the future it reserved room for.
125
+ * Metadata-priced records stamp pricingTableId "metadata" (version 1);
126
+ * the (id, version) tuple ritual carries forward unchanged.
127
+ */
128
+ export declare function canonicalizeUsageForModel(model: string, endpoint: string | undefined, route: string, raw: RawUsage): CanonicalUsage;
114
129
  /** The canonical schema's invariants, machine-checked: closed key set;
115
130
  * non-negative numbers; cacheWrite/reasoning null-or-number; the pinned
116
131
  * sentence — cacheRead can never exceed total (= input + cacheRead +
@@ -27,6 +27,7 @@
27
27
  * accounting boundary (R4 Case B, pure derivation; the union does not
28
28
  * move). Raw is provider observation; canonical is the accounting truth.
29
29
  */
30
+ import { lookupModelMetadata } from "../provider/metadata.js";
30
31
  /** The route → input-convention table (R2a). `input` semantics differ
31
32
  * between the two routes; the convention is a property of the ROUTE, not
32
33
  * the provider. Unknown routes fall back to "total" — exactly the
@@ -61,15 +62,14 @@ export function pricingTableFor(version) {
61
62
  return table;
62
63
  }
63
64
  export function priceFor(route, u, table) {
64
- // The R5b-④c semantics: a REAL route (an INPUT_CONVENTIONS key — the
65
- // routes the table is expected to price) missing from the table is a
66
- // HOLE null, the explicit-absent stamp. The builtin table never
67
- // backfills an injected table's hole. Unknown routes keep the legacy
68
- // within-table mirror fallback (the total-convention entry, matching
69
- // the convention fallback above one table, one fallback, no drift);
70
- // a table without even the mirror entry yields null, never a crash.
71
- const entry = table.entries[route] ??
72
- (route in INPUT_CONVENTIONS ? undefined : table.entries["openai-compat"]);
65
+ // PH-1a (supersedes R5b-④c's mirror fallback): a route missing from
66
+ // the table is a HOLE null, the explicit-absent stamp — REAL routes
67
+ // and unknown routes alike. The retired mirror fallback priced the
68
+ // "adapter" route (a directly-injected adapter, finding PH-F7) at the
69
+ // openai-compat entry: a fabricated cost on exactly the runs whose
70
+ // rates are unknown. null is the honest answer; the builtin table
71
+ // never backfills an injected table's hole either.
72
+ const entry = table.entries[route];
73
73
  if (entry === undefined)
74
74
  return null;
75
75
  return ((u.input * entry.inputPerM +
@@ -107,6 +107,37 @@ export function canonicalizeUsage(route, raw, table = PRICING_TABLE_V1) {
107
107
  pricingTableVersion: table.version,
108
108
  };
109
109
  }
110
+ /**
111
+ * PH-1c — the MODEL-KEYED cost path (findings PH-F14/PH-F16). The
112
+ * convention still keys on the ROUTE (a protocol property: fresh vs
113
+ * total input); the PRICE keys on the model+endpoint metadata entry —
114
+ * the route-keyed table is how an Anthropic run got priced at
115
+ * DeepSeek's rates. No metadata entry, or an entry with `pricing:
116
+ * null`, costs null — the honest stamp, never a fallback rate.
117
+ *
118
+ * A NEW function beside canonicalizeUsage, not a widened signature —
119
+ * the E2 ruling froze that one ("a future injection never widens this
120
+ * frozen signature"), and this is the future it reserved room for.
121
+ * Metadata-priced records stamp pricingTableId "metadata" (version 1);
122
+ * the (id, version) tuple ritual carries forward unchanged.
123
+ */
124
+ export function canonicalizeUsageForModel(model, endpoint, route, raw) {
125
+ const base = canonicalizeUsage(route, raw, EMPTY_TABLE); // convention math only — EMPTY_TABLE prices nothing
126
+ const entry = lookupModelMetadata(model, endpoint);
127
+ const pricing = entry?.pricing ?? null;
128
+ if (pricing === null) {
129
+ return { ...base, costUsd: null, pricingTableId: "metadata", pricingTableVersion: 1 };
130
+ }
131
+ const costUsd = (base.input * pricing.inputPerM +
132
+ base.output * pricing.outputPerM +
133
+ base.cacheRead * pricing.cacheReadPerM +
134
+ (base.cacheWrite ?? 0) * pricing.cacheWritePerM) /
135
+ 1e6;
136
+ return { ...base, costUsd, pricingTableId: "metadata", pricingTableVersion: 1 };
137
+ }
138
+ /** No entries at all — canonicalizeUsageForModel's convention-only pass
139
+ * through canonicalizeUsage; every price it could produce is null. */
140
+ const EMPTY_TABLE = { id: "metadata", version: 1, entries: {} };
110
141
  // ── The validator ──────────────────────────────────────────────────────────
111
142
  // Strict by design: closed keys (a misspelled field can never enter a
112
143
  // ledger), non-negative numbers, the pinned sentence machine-checked, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "kiso runtime — durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,11 +25,11 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.14.0"
28
+ "@vincemakes/kiso-core": "0.15.1"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.14.0",
32
- "@vincemakes/kiso-provider-openai": "0.14.0"
31
+ "@vincemakes/kiso-provider-anthropic": "0.15.1",
32
+ "@vincemakes/kiso-provider-openai": "0.15.1"
33
33
  },
34
34
  "peerDependenciesMeta": {
35
35
  "@vincemakes/kiso-provider-anthropic": {
@@ -40,7 +40,7 @@
40
40
  }
41
41
  },
42
42
  "devDependencies": {
43
- "@vincemakes/kiso-evals": "0.14.0",
43
+ "@vincemakes/kiso-evals": "0.15.1",
44
44
  "@types/node": "^26.1.2",
45
45
  "typescript": "^5.7.2",
46
46
  "vitest": "^3.0.0"