@vincemakes/kiso-runtime 1.1.0 → 1.2.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/agent.js CHANGED
@@ -55,6 +55,7 @@ export class AgentRuntime {
55
55
  const adapter = await this.#adapterPromise;
56
56
  const config = {
57
57
  model: this.#definition.model,
58
+ ...(this.#definition.provider !== undefined ? { provider: this.#definition.provider } : {}),
58
59
  ...(this.#definition.systemPrompt !== undefined ? { systemPrompt: this.#definition.systemPrompt } : {}),
59
60
  registry: this.#registry,
60
61
  ...(this.#definition.permissionPolicy !== undefined || this.#definition.hooks !== undefined
package/dist/run.js CHANGED
@@ -8,6 +8,8 @@ import { ABORTED, MergedSignal, abortable, openRunId } from "./recovery.js";
8
8
  import { deriveRecoveryPlan, invocationSeqOf } from "./recovery-plan.js";
9
9
  import { composeApprovalChain, composeSystemPrompt, composeToolTable, microcompactFor } from "./compose.js";
10
10
  import { truncationGuard } from "./truncation-guard.js";
11
+ import { RequestTracer, traceGuard } from "./trace/guard.js";
12
+ import { runtimeVersion } from "./trace/writer.js";
11
13
  import { ResumeBlockedError } from "./session.js";
12
14
  /**
13
15
  * A single turn. Async-iterable, so `for await (const ev of session.run(x))`
@@ -47,6 +49,7 @@ export class Run {
47
49
  // The WHOLE body is one try/finally: a consumer that abandons the
48
50
  // run at ANY yield (even the user_input one) must release the
49
51
  // session's single-run slot and its approval resolvers.
52
+ let tracer = null;
50
53
  try {
51
54
  // round 4: health is re-checked when the iterator ACTUALLY starts —
52
55
  // a run constructed before the session was poisoned must fail
@@ -54,6 +57,20 @@ export class Run {
54
57
  this.#session.ensureHealthy();
55
58
  this.#session.beginRun(this);
56
59
  const log = this.#session.log;
60
+ // E1 (1.2.0): the request tracer — the observation ledger. It
61
+ // sits at the adapter boundary; the model-visible byte stream is
62
+ // untouched (I6, trace-bytes.test.ts). Soft-fail: a degraded
63
+ // writer costs one stderr line and the run goes on.
64
+ tracer = new RequestTracer({
65
+ root: this.#store.root,
66
+ sessionId: this.#session.id,
67
+ runId: this.runId,
68
+ provider: this.#config.provider ?? "adapter",
69
+ model: this.#config.model,
70
+ adapterVersion: runtimeVersion(),
71
+ log: log.all,
72
+ });
73
+ tracer.init();
57
74
  const signal = this.#externalSignal ? new MergedSignal(this.#abort.signal, this.#externalSignal) : this.#abort.signal;
58
75
  // E2: the session's own microcompact wins; otherwise the FIRST
59
76
  // extension providing a compaction config supplies it.
@@ -75,7 +92,7 @@ export class Run {
75
92
  const loopConfig = () => ({
76
93
  // 0.1.40 (R-C item 3): the truncation guard gates the model
77
94
  // stream — a truncated turn's tool batch never executes.
78
- adapter: truncationGuard(this.#adapter),
95
+ adapter: traceGuard(tracer, truncationGuard(this.#adapter)), // tracer assigned above, before loopConfig
79
96
  model: this.#config.model,
80
97
  sessionId: this.#session.id, // P3: tools see their session (ToolContext.sessionId)
81
98
  ...(systemPrompt !== undefined ? { systemPrompt } : {}),
@@ -230,6 +247,9 @@ export class Run {
230
247
  for (const executionId of this.#uncertaintyIds) {
231
248
  this.#session.dropUncertaintyResolver(executionId);
232
249
  }
250
+ // E1: the run's ledger story — the run_end lands synchronously
251
+ // (a killed run leaves no run_end, and the next init marks it).
252
+ tracer?.finishRun();
233
253
  this.#session.endRun(this);
234
254
  }
235
255
  }
package/dist/session.d.ts CHANGED
@@ -179,6 +179,9 @@ export declare class AgentSession {
179
179
  }
180
180
  export interface SessionConfig {
181
181
  readonly model: string;
182
+ /** E1: the adapter identity ("anthropic" | "openai-compat") — trace
183
+ * provenance, additive (S1 surface untouched: type-only, optional). */
184
+ readonly provider?: "anthropic" | "openai-compat";
182
185
  readonly systemPrompt?: string;
183
186
  readonly tools?: readonly Tool<any>[];
184
187
  readonly registry: import("@vincemakes/kiso-core").ToolRegistry;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 4, the cache-break derivation (proposal §4, ruling
3
+ * R4b: per-segment hashes + prefix fingerprint; the BREAK COUNT is an
4
+ * analysis-side derivation, never a recorded field).
5
+ *
6
+ * The cacheable prefix is every segment that is NOT the current turn —
7
+ * freshness "fresh" is the boundary (manifest.ts). `cacheableHashes`
8
+ * pairs the manifest segments 1:1 with the per-segment hashes and
9
+ * drops the fresh tail, so the fingerprint and the break derivation
10
+ * share one boundary by construction: a current-turn change alone
11
+ * never moves the fingerprint and never counts a break.
12
+ *
13
+ * `prefixBreak` compares two adjacent requests' cacheable prefixes:
14
+ * the first differing segment is the break, at its (0-based) depth;
15
+ * a prefix that merely GREW (a new turn joined) breaks at the old
16
+ * length. Unchanged prefixes → null (0 breaks). This is what
17
+ * bench/trace-report.mjs and the bench render per-request (slice 5).
18
+ */
19
+ import type { TraceSegment } from "./record.js";
20
+ /** The cacheable-prefix hashes: segments[i] ↔ hashes[i], dropping every
21
+ * freshness "fresh" segment (the current turn). */
22
+ export declare function cacheableHashes(segments: readonly TraceSegment[], hashes: readonly string[]): string[];
23
+ export interface PrefixBreak {
24
+ /** 0-based segment index within the cacheable prefix where the
25
+ * prefix first diverges (depth 0 = the system prompt). */
26
+ readonly depth: number;
27
+ }
28
+ /** R4b: compare two adjacent requests' cacheable prefixes. null = the
29
+ * prefix is unchanged (0 breaks). A prefix that grew breaks at the old
30
+ * length — the new segment is where caching can no longer attach. */
31
+ export declare function prefixBreak(prev: readonly string[], next: readonly string[]): PrefixBreak | null;
32
+ /** Per-request breaks across a run's request sequence: request k's
33
+ * break is relative to request k−1; the first request has no
34
+ * predecessor (null). */
35
+ export declare function deriveBreaks(requests: readonly (readonly string[])[]): (PrefixBreak | null)[];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 4, the cache-break derivation (proposal §4, ruling
3
+ * R4b: per-segment hashes + prefix fingerprint; the BREAK COUNT is an
4
+ * analysis-side derivation, never a recorded field).
5
+ *
6
+ * The cacheable prefix is every segment that is NOT the current turn —
7
+ * freshness "fresh" is the boundary (manifest.ts). `cacheableHashes`
8
+ * pairs the manifest segments 1:1 with the per-segment hashes and
9
+ * drops the fresh tail, so the fingerprint and the break derivation
10
+ * share one boundary by construction: a current-turn change alone
11
+ * never moves the fingerprint and never counts a break.
12
+ *
13
+ * `prefixBreak` compares two adjacent requests' cacheable prefixes:
14
+ * the first differing segment is the break, at its (0-based) depth;
15
+ * a prefix that merely GREW (a new turn joined) breaks at the old
16
+ * length. Unchanged prefixes → null (0 breaks). This is what
17
+ * bench/trace-report.mjs and the bench render per-request (slice 5).
18
+ */
19
+ /** The cacheable-prefix hashes: segments[i] ↔ hashes[i], dropping every
20
+ * freshness "fresh" segment (the current turn). */
21
+ export function cacheableHashes(segments, hashes) {
22
+ const out = [];
23
+ for (let i = 0; i < segments.length; i++) {
24
+ if (segments[i].freshness !== "fresh")
25
+ out.push(hashes[i]);
26
+ }
27
+ return out;
28
+ }
29
+ /** R4b: compare two adjacent requests' cacheable prefixes. null = the
30
+ * prefix is unchanged (0 breaks). A prefix that grew breaks at the old
31
+ * length — the new segment is where caching can no longer attach. */
32
+ export function prefixBreak(prev, next) {
33
+ const shared = Math.min(prev.length, next.length);
34
+ for (let i = 0; i < shared; i++) {
35
+ if (prev[i] !== next[i])
36
+ return { depth: i };
37
+ }
38
+ if (prev.length !== next.length)
39
+ return { depth: shared };
40
+ return null;
41
+ }
42
+ /** Per-request breaks across a run's request sequence: request k's
43
+ * break is relative to request k−1; the first request has no
44
+ * predecessor (null). */
45
+ export function deriveBreaks(requests) {
46
+ const out = [];
47
+ for (let k = 0; k < requests.length; k++) {
48
+ out.push(k === 0 ? null : prefixBreak(requests[k - 1], requests[k]));
49
+ }
50
+ return out;
51
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 3, the request tracer (the guard).
3
+ *
4
+ * One TraceRecord per adapter call, settled when the stream ends. The
5
+ * guard lives AT the adapter boundary (run.ts wires
6
+ * truncationGuard(traceGuard(tracer, adapter))): the kernel, the log,
7
+ * and the model-visible byte stream are untouched (I6 — pinned by
8
+ * trace-bytes.test.ts). retryAttempt counts prior calls in this run
9
+ * with an identical contextHash — the loop re-streams the SAME messages
10
+ * array on retry (loop.ts), so an identical hash is exactly "same
11
+ * request, retried" (§1.4).
12
+ *
13
+ * Soft-fail: any failure in trace assembly (never expected — hashes and
14
+ * the manifest cannot throw today) marks the request ABSENT rather than
15
+ * breaking the stream; the writer's own degradation covers I/O.
16
+ *
17
+ * Usage is provider-raw, never normalized (E2's job): openai-compat
18
+ * reports input TOTAL (fresh = input − cacheRead); anthropic's
19
+ * input_tokens is ALREADY fresh-only (the trace records it as-is).
20
+ * A request that settles with NO usage data records the quartet as
21
+ * zeros with cacheWrite null — "0 = unknown", documented at record.ts;
22
+ * the honest nullable quartet is a schema bump, deferred.
23
+ */
24
+ import type { Adapter, AdapterEvent, Event, StreamOptions } from "@vincemakes/kiso-core";
25
+ export interface RequestTracerDeps {
26
+ root: string;
27
+ sessionId: string;
28
+ runId: string;
29
+ provider: string;
30
+ model: string;
31
+ /** The adapter contract's implementation version (the runtime's own),
32
+ * resolved once at tracer init; null on failure (soft-fail). */
33
+ adapterVersion?: string | null;
34
+ /** The session log — the manifest's seqRange pointers derive from it. */
35
+ log: readonly Event[];
36
+ }
37
+ export declare class RequestTracer {
38
+ #private;
39
+ constructor(deps: RequestTracerDeps);
40
+ init(): void;
41
+ wrap(options: StreamOptions, upstream: AsyncIterable<AdapterEvent>): AsyncIterable<AdapterEvent>;
42
+ /** Clean-settle marking for the whole run. */
43
+ finishRun(): void;
44
+ }
45
+ /** Wrap the adapter so every stream() call settles a trace record. */
46
+ export declare function traceGuard(tracer: RequestTracer, adapter: Adapter): Adapter;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 3, the request tracer (the guard).
3
+ *
4
+ * One TraceRecord per adapter call, settled when the stream ends. The
5
+ * guard lives AT the adapter boundary (run.ts wires
6
+ * truncationGuard(traceGuard(tracer, adapter))): the kernel, the log,
7
+ * and the model-visible byte stream are untouched (I6 — pinned by
8
+ * trace-bytes.test.ts). retryAttempt counts prior calls in this run
9
+ * with an identical contextHash — the loop re-streams the SAME messages
10
+ * array on retry (loop.ts), so an identical hash is exactly "same
11
+ * request, retried" (§1.4).
12
+ *
13
+ * Soft-fail: any failure in trace assembly (never expected — hashes and
14
+ * the manifest cannot throw today) marks the request ABSENT rather than
15
+ * breaking the stream; the writer's own degradation covers I/O.
16
+ *
17
+ * Usage is provider-raw, never normalized (E2's job): openai-compat
18
+ * reports input TOTAL (fresh = input − cacheRead); anthropic's
19
+ * input_tokens is ALREADY fresh-only (the trace records it as-is).
20
+ * A request that settles with NO usage data records the quartet as
21
+ * zeros with cacheWrite null — "0 = unknown", documented at record.ts;
22
+ * the honest nullable quartet is a schema bump, deferred.
23
+ */
24
+ import { randomUUID } from "node:crypto";
25
+ import { buildContextManifest, segmentHashes } from "./manifest.js";
26
+ import { cacheableHashes } from "./analyze.js";
27
+ import { hashContext, hashSystemPrompt, hashToolSpecs, stablePrefixFingerprint } from "./hash.js";
28
+ import { TRACE_SCHEMA_VERSION } from "./record.js";
29
+ import { TraceWriter } from "./writer.js";
30
+ export class RequestTracer {
31
+ #writer;
32
+ #log;
33
+ #provider;
34
+ #runId;
35
+ #adapterVersion;
36
+ #requestIndex = 0;
37
+ #contextHashCounts = new Map();
38
+ constructor(deps) {
39
+ this.#writer = new TraceWriter({ root: deps.root, sessionId: deps.sessionId });
40
+ this.#log = deps.log;
41
+ this.#provider = deps.provider;
42
+ this.#runId = deps.runId;
43
+ this.#adapterVersion = deps.adapterVersion ?? null;
44
+ }
45
+ init() {
46
+ this.#writer.init();
47
+ }
48
+ async *wrap(options, upstream) {
49
+ const t0 = performance.now();
50
+ let record = null;
51
+ try {
52
+ record = this.#startRecord(options);
53
+ }
54
+ catch {
55
+ record = null; // trace absent; the stream is never affected
56
+ }
57
+ // null until the first event; settled to 0 (unknown) only when no
58
+ // event ever came — a stream whose first event lands in the same
59
+ // tick records 0, the resolution limit of the "0 = unknown" marker
60
+ let ttftMs = null;
61
+ let inputTokens = null;
62
+ let cacheRead = null;
63
+ let cacheWrite = null;
64
+ let outputTokens = null;
65
+ let usageKnown = false;
66
+ const toolCalls = [];
67
+ let outcome = "ok";
68
+ try {
69
+ for await (const ev of upstream) {
70
+ // the null check must be the ONLY guard — a 0-initialized
71
+ // number never moves (the slice-3 dead-field finding)
72
+ if (ttftMs === null)
73
+ ttftMs = performance.now() - t0;
74
+ if (ev.type === "tool_call_start")
75
+ toolCalls.push(ev.name);
76
+ if (ev.type === "usage") {
77
+ usageKnown = usageKnown || ev.known;
78
+ if (ev.inputTokens !== null)
79
+ inputTokens = ev.inputTokens;
80
+ if (ev.cacheRead !== null)
81
+ cacheRead = ev.cacheRead;
82
+ if (ev.cacheWrite !== null)
83
+ cacheWrite = ev.cacheWrite;
84
+ if (ev.outputTokens !== null)
85
+ outputTokens = ev.outputTokens;
86
+ }
87
+ yield ev;
88
+ }
89
+ }
90
+ catch (err) {
91
+ outcome = this.#classifyOutcome(err, options);
92
+ throw err;
93
+ }
94
+ finally {
95
+ if (record !== null) {
96
+ this.#settle(record, {
97
+ outcome,
98
+ t0,
99
+ ttftMs,
100
+ toolCalls,
101
+ inputTokens,
102
+ cacheRead,
103
+ cacheWrite,
104
+ outputTokens,
105
+ usageKnown,
106
+ });
107
+ }
108
+ }
109
+ }
110
+ /** Clean-settle marking for the whole run. */
111
+ finishRun() {
112
+ this.#writer.finishRun(this.#runId, this.#requestIndex - 1);
113
+ }
114
+ #startRecord(options) {
115
+ const { systemPrompt, tools, messages } = options;
116
+ const contextHash = hashContext(systemPrompt, tools, messages);
117
+ const retryAttempt = this.#contextHashCounts.get(contextHash) ?? 0;
118
+ this.#contextHashCounts.set(contextHash, retryAttempt + 1);
119
+ const manifest = buildContextManifest({ log: this.#log, systemPrompt, tools, messages });
120
+ const hashes = segmentHashes(systemPrompt, tools, messages);
121
+ return {
122
+ schemaVersion: TRACE_SCHEMA_VERSION,
123
+ kind: "request",
124
+ requestId: randomUUID(),
125
+ runId: this.#runId,
126
+ requestIndex: this.#requestIndex++,
127
+ retryAttempt,
128
+ provider: this.#provider,
129
+ model: options.model,
130
+ adapterVersion: this.#adapterVersion,
131
+ systemPromptHash: hashSystemPrompt(systemPrompt),
132
+ toolSchemaHash: hashToolSpecs(tools),
133
+ contextHash,
134
+ contextManifest: manifest,
135
+ // R4b: the per-segment hash LIST rides the record (the break
136
+ // derivation is analysis-side but needs the list, not the
137
+ // aggregated fingerprint — slice 5's data source); the
138
+ // fingerprint covers the CACHEABLE prefix only — the current
139
+ // turn (freshness fresh) is never part of it (slice 4)
140
+ segmentHashes: hashes,
141
+ stablePrefixFingerprint: stablePrefixFingerprint(cacheableHashes(manifest, hashes)),
142
+ freshInput: 0, // unknown until the usage event — "0 = unknown"
143
+ cacheRead: 0,
144
+ cacheWrite: null,
145
+ output: 0,
146
+ latencyMs: 0,
147
+ ttftMs: 0,
148
+ toolCalls: [],
149
+ outcome: "ok",
150
+ ts: Date.now(),
151
+ };
152
+ }
153
+ #settle(record, p) {
154
+ record.outcome = p.outcome;
155
+ record.latencyMs = performance.now() - p.t0;
156
+ record.ttftMs = p.ttftMs ?? 0; // null = no event ever — the "0 = unknown" marker
157
+ record.toolCalls = p.toolCalls;
158
+ if (p.usageKnown) {
159
+ record.freshInput =
160
+ this.#provider === "anthropic"
161
+ ? p.inputTokens ?? 0
162
+ : p.inputTokens !== null
163
+ ? Math.max(0, p.inputTokens - (p.cacheRead ?? 0))
164
+ : 0;
165
+ record.cacheRead = p.cacheRead ?? 0;
166
+ record.cacheWrite = p.cacheWrite ?? null;
167
+ record.output = p.outputTokens ?? 0;
168
+ }
169
+ this.#writer.enqueue(record);
170
+ }
171
+ #classifyOutcome(err, options) {
172
+ if (options.signal?.aborted === true)
173
+ return "aborted";
174
+ const name = err instanceof Error ? err.name : "";
175
+ if (name === "AbortError" || name === "APIUserAbortError")
176
+ return "aborted";
177
+ return "provider_error";
178
+ }
179
+ }
180
+ /** Wrap the adapter so every stream() call settles a trace record. */
181
+ export function traceGuard(tracer, adapter) {
182
+ return {
183
+ stream: (options) => tracer.wrap(options, adapter.stream(options)),
184
+ };
185
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * E1 (1.2.0) — the request hashes (proposal §4, ruling R4b + R1a).
3
+ *
4
+ * Every hash is pinned to the schemaVersion's HashSpec (sha-256,
5
+ * full hex) — `hashSpecFor(TRACE_SCHEMA_VERSION)` is checked at every
6
+ * call, so a version bump without a re-pin fails loudly instead of
7
+ * silently changing the algorithm. The canonical serialization is
8
+ * JSON.stringify of the request-shaped values; the byte discipline is
9
+ * that the same construction path (the kernel's projection) produces
10
+ * the same serialization, which the trace-bytes gate pins end-to-end.
11
+ */
12
+ import type { Message, ToolSpec } from "@vincemakes/kiso-core";
13
+ export declare function sha256Hex(input: string): string;
14
+ export declare const canonicalJson: (v: unknown) => string;
15
+ export declare function hashSystemPrompt(systemPrompt: string | undefined): string;
16
+ export declare function hashToolSpecs(tools: readonly ToolSpec[] | undefined): string;
17
+ /** The full request projection — the same messages array the loop hands
18
+ * the adapter, so a retry re-serializes to an IDENTICAL hash (§1.4). */
19
+ export declare function hashContext(systemPrompt: string | undefined, tools: readonly ToolSpec[] | undefined, messages: readonly Message[]): string;
20
+ /** sha-256 over the per-segment hashes of the cacheable prefix — the
21
+ * segments up to but NOT including the current turn (R4b). */
22
+ export declare function stablePrefixFingerprint(segmentHashes: readonly string[]): string;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * E1 (1.2.0) — the request hashes (proposal §4, ruling R4b + R1a).
3
+ *
4
+ * Every hash is pinned to the schemaVersion's HashSpec (sha-256,
5
+ * full hex) — `hashSpecFor(TRACE_SCHEMA_VERSION)` is checked at every
6
+ * call, so a version bump without a re-pin fails loudly instead of
7
+ * silently changing the algorithm. The canonical serialization is
8
+ * JSON.stringify of the request-shaped values; the byte discipline is
9
+ * that the same construction path (the kernel's projection) produces
10
+ * the same serialization, which the trace-bytes gate pins end-to-end.
11
+ */
12
+ import { createHash } from "node:crypto";
13
+ import { TRACE_SCHEMA_VERSION, hashSpecFor } from "./record.js";
14
+ export function sha256Hex(input) {
15
+ hashSpecFor(TRACE_SCHEMA_VERSION); // the pinned algorithm, or fail loudly
16
+ return createHash("sha256").update(input, "utf8").digest("hex");
17
+ }
18
+ export const canonicalJson = (v) => JSON.stringify(v);
19
+ export function hashSystemPrompt(systemPrompt) {
20
+ return sha256Hex(systemPrompt ?? "");
21
+ }
22
+ export function hashToolSpecs(tools) {
23
+ return sha256Hex(canonicalJson(tools ?? []));
24
+ }
25
+ /** The full request projection — the same messages array the loop hands
26
+ * the adapter, so a retry re-serializes to an IDENTICAL hash (§1.4). */
27
+ export function hashContext(systemPrompt, tools, messages) {
28
+ return sha256Hex(canonicalJson({ systemPrompt, tools, messages }));
29
+ }
30
+ /** sha-256 over the per-segment hashes of the cacheable prefix — the
31
+ * segments up to but NOT including the current turn (R4b). */
32
+ export function stablePrefixFingerprint(segmentHashes) {
33
+ return sha256Hex(segmentHashes.join(""));
34
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 3, the context manifest (proposal §1.3).
3
+ *
4
+ * buildContextManifest derives the per-request segment list from the
5
+ * session log and the request projection. Segments are the system
6
+ * prompt, the tool schema, then one per user turn. Each turn carries a
7
+ * THIN seqRange pointer into the event log ([firstSeq, lastSeq] of the
8
+ * events that produced it) plus an estTokens estimate — the record
9
+ * never copies payloads (proposal §1.1).
10
+ *
11
+ * Turn boundaries are the log's visible user_input events: a VETOED
12
+ * input (user_input_replaced with null content) is not a boundary — the
13
+ * model never saw it; a REWRITE keeps the original boundary position
14
+ * (the projection renders the replacement in place, events.ts:339).
15
+ * The last turn is current_turn/fresh, earlier turns cache_read; the
16
+ * system/tools head is cache_read (a stable prefix, R4b).
17
+ *
18
+ * When the projection's user-message count and the log's visible
19
+ * boundary count diverge (a summary replaced whole turns, an alignment
20
+ * surprise), every turn range degrades to null — honest thin pointers
21
+ * rather than wrong ones.
22
+ */
23
+ import type { Event, Message, ToolSpec } from "@vincemakes/kiso-core";
24
+ import type { TraceSegment } from "./record.js";
25
+ export interface ManifestInput {
26
+ log: readonly Event[];
27
+ systemPrompt: string | undefined;
28
+ tools: readonly ToolSpec[] | undefined;
29
+ messages: readonly Message[];
30
+ }
31
+ export declare function buildContextManifest(input: ManifestInput): TraceSegment[];
32
+ /** Per-segment content hashes for the fingerprint (R4b): the system
33
+ * prompt, the tool schema, and each turn's messages, in manifest
34
+ * order. The CURRENT turn's hash is the last element — the cacheable
35
+ * prefix is everything before it. */
36
+ export declare function segmentHashes(systemPrompt: string | undefined, tools: readonly ToolSpec[] | undefined, messages: readonly Message[]): string[];
@@ -0,0 +1,100 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 3, the context manifest (proposal §1.3).
3
+ *
4
+ * buildContextManifest derives the per-request segment list from the
5
+ * session log and the request projection. Segments are the system
6
+ * prompt, the tool schema, then one per user turn. Each turn carries a
7
+ * THIN seqRange pointer into the event log ([firstSeq, lastSeq] of the
8
+ * events that produced it) plus an estTokens estimate — the record
9
+ * never copies payloads (proposal §1.1).
10
+ *
11
+ * Turn boundaries are the log's visible user_input events: a VETOED
12
+ * input (user_input_replaced with null content) is not a boundary — the
13
+ * model never saw it; a REWRITE keeps the original boundary position
14
+ * (the projection renders the replacement in place, events.ts:339).
15
+ * The last turn is current_turn/fresh, earlier turns cache_read; the
16
+ * system/tools head is cache_read (a stable prefix, R4b).
17
+ *
18
+ * When the projection's user-message count and the log's visible
19
+ * boundary count diverge (a summary replaced whole turns, an alignment
20
+ * surprise), every turn range degrades to null — honest thin pointers
21
+ * rather than wrong ones.
22
+ */
23
+ import { estimateTokens } from "@vincemakes/kiso-core";
24
+ import { canonicalJson, hashSystemPrompt, hashToolSpecs, sha256Hex } from "./hash.js";
25
+ /** The log's visible user boundaries, in log order: every user_input
26
+ * whose replacement (if any) is not a veto. */
27
+ function visibleBoundaries(log) {
28
+ const vetoed = new Set();
29
+ for (const ev of log) {
30
+ if (ev.type === "user_input_replaced" && ev.content === null)
31
+ vetoed.add(ev.replaces);
32
+ }
33
+ const result = [];
34
+ for (const ev of log) {
35
+ if (ev.type === "user_input" && !vetoed.has(ev.seq))
36
+ result.push(ev.seq);
37
+ }
38
+ return result;
39
+ }
40
+ const systemSegment = (systemPrompt) => ({
41
+ role: "system",
42
+ seqRange: null,
43
+ estTokens: estimateTokens([{ role: "user", content: systemPrompt ?? "" }]),
44
+ freshness: "cache_read",
45
+ });
46
+ const toolsSegment = (tools) => ({
47
+ role: "tools",
48
+ seqRange: null,
49
+ estTokens: estimateTokens([{ role: "tool", callId: "", content: JSON.stringify(tools ?? []), isError: false }]),
50
+ freshness: "cache_read",
51
+ });
52
+ export function buildContextManifest(input) {
53
+ const { log, systemPrompt, tools, messages } = input;
54
+ const boundaries = visibleBoundaries(log);
55
+ const userCount = messages.filter((m) => m.role === "user").length;
56
+ const aligned = userCount === boundaries.length;
57
+ const lastSeq = log.length > 0 ? log[log.length - 1].seq : -1;
58
+ const segments = [systemSegment(systemPrompt), toolsSegment(tools)];
59
+ // Partition the projection at user boundaries: partition k runs from
60
+ // user_k (inclusive) to user_{k+1} (exclusive); messages before the
61
+ // first user (not produced by the projection today) join partition 0.
62
+ const userPositions = [];
63
+ messages.forEach((m, i) => {
64
+ if (m.role === "user")
65
+ userPositions.push(i);
66
+ });
67
+ for (let k = 0; k < userPositions.length; k++) {
68
+ const start = k === 0 ? 0 : userPositions[k];
69
+ const end = k + 1 < userPositions.length ? userPositions[k + 1] : messages.length;
70
+ const isCurrent = k === userPositions.length - 1;
71
+ const range = aligned && boundaries[k] !== undefined
72
+ ? [boundaries[k], k + 1 < boundaries.length ? boundaries[k + 1] - 1 : lastSeq]
73
+ : null;
74
+ segments.push({
75
+ role: isCurrent ? "current_turn" : "turn",
76
+ seqRange: range,
77
+ estTokens: estimateTokens(messages.slice(start, end)),
78
+ freshness: isCurrent ? "fresh" : "cache_read",
79
+ });
80
+ }
81
+ return segments;
82
+ }
83
+ /** Per-segment content hashes for the fingerprint (R4b): the system
84
+ * prompt, the tool schema, and each turn's messages, in manifest
85
+ * order. The CURRENT turn's hash is the last element — the cacheable
86
+ * prefix is everything before it. */
87
+ export function segmentHashes(systemPrompt, tools, messages) {
88
+ const hashes = [hashSystemPrompt(systemPrompt), hashToolSpecs(tools)];
89
+ const userPositions = [];
90
+ messages.forEach((m, i) => {
91
+ if (m.role === "user")
92
+ userPositions.push(i);
93
+ });
94
+ for (let k = 0; k < userPositions.length; k++) {
95
+ const start = k === 0 ? 0 : userPositions[k];
96
+ const end = k + 1 < userPositions.length ? userPositions[k + 1] : messages.length;
97
+ hashes.push(sha256Hex(canonicalJson(messages.slice(start, end))));
98
+ }
99
+ return hashes;
100
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * E1 (1.2.0) — the Request Trace record schema (proposal §1.1 as adopted
3
+ * by the R1 ruling: "adopt as-is, keep ts, per-round additive"). This is
4
+ * the 1.2.0 field-set lock: every field below is spec'd, and the
5
+ * closed-field-set gate (ruling R1a) pins the shape bidirectionally —
6
+ * `TRACE_RECORD_FIELDS`/`TRACE_SEGMENT_FIELDS` must stay in exact
7
+ * agreement with the interfaces, or trace-schema.test.ts goes red.
8
+ *
9
+ * R1a: `schemaVersion` pins BOTH the record shape AND the hash /
10
+ * fingerprint algorithms (`HASH_SPEC_BY_VERSION`). A version bump must
11
+ * pin a spec for the new version before any writer can use it
12
+ * (`hashSpecFor` throws otherwise).
13
+ *
14
+ * The ledger is OUT-side (ADR-0051 §6): versionable, never part of the
15
+ * correctness ABI. These types are runtime-internal (Case B of R2 — the
16
+ * export surface stays untouched pending the product-line countersign).
17
+ */
18
+ /** schemaVersion: 1 for 1.2.0. Algorithm and shape changes bump it
19
+ * (ADR-0051 §6 OUT-side versioning). */
20
+ export declare const TRACE_SCHEMA_VERSION = 1;
21
+ export type Freshness = "fresh" | "cache_read" | "cache_write";
22
+ /** That is the complete set for 1.2.0. */
23
+ export type Outcome = "ok" | "provider_error" | "aborted";
24
+ /** That is the complete set for 1.2.0. (Truncation surfaces as "aborted"
25
+ * when the stream ends before settle; refined in E5 if needed.) */
26
+ export interface TraceSegment {
27
+ role: "system" | "tools" | "turn" | "current_turn";
28
+ /** Thin pointer into the event log: [firstSeq, lastSeq] inclusive of the
29
+ * events that produced this segment. null for system/tools (not events). */
30
+ seqRange: [number, number] | null;
31
+ estTokens: number;
32
+ freshness: Freshness;
33
+ }
34
+ /** That is the complete set for 1.2.0. */
35
+ export interface TraceRecord {
36
+ schemaVersion: 1;
37
+ kind: "request";
38
+ requestId: string;
39
+ runId: string;
40
+ requestIndex: number;
41
+ retryAttempt: number;
42
+ provider: string;
43
+ model: string;
44
+ adapterVersion: string | null;
45
+ systemPromptHash: string;
46
+ toolSchemaHash: string;
47
+ contextHash: string;
48
+ contextManifest: TraceSegment[];
49
+ /** Per-segment content hashes, indexed 1:1 with contextManifest
50
+ * (segment i's canonical serialization — R4b's analysis-side data
51
+ * source: the cache-break derivation needs the LIST, not the
52
+ * aggregated fingerprint). Sizing note: 64 hex chars × segments
53
+ * per request. */
54
+ segmentHashes: string[];
55
+ stablePrefixFingerprint: string;
56
+ /** The usage quartet is PROVIDER-RAW — never a billing surface.
57
+ * Canonical/billing usage lives in E2; these fields are observation
58
+ * only (a provider may count a token a dozen ways; billing must not). */
59
+ freshInput: number;
60
+ cacheRead: number;
61
+ cacheWrite: number | null;
62
+ output: number;
63
+ latencyMs: number;
64
+ ttftMs: number;
65
+ toolCalls: string[];
66
+ outcome: Outcome;
67
+ lineageLink?: {
68
+ parentSessionId: string;
69
+ parentRunId: string;
70
+ parentInvocationSeq: number;
71
+ role: string;
72
+ };
73
+ ts: number;
74
+ }
75
+ /** That is the complete set for 1.2.0. */
76
+ export interface HeaderLine {
77
+ schemaVersion: 1;
78
+ kind: "header";
79
+ sessionId: string;
80
+ kisoVersion: string;
81
+ createdAt: number;
82
+ }
83
+ export interface RunEndLine {
84
+ schemaVersion: 1;
85
+ kind: "run_end";
86
+ runId: string;
87
+ ts: number;
88
+ lastRequestIndex: number;
89
+ }
90
+ export interface CrashLine {
91
+ schemaVersion: 1;
92
+ kind: "crash";
93
+ ts: number;
94
+ note: string;
95
+ }
96
+ export type TraceLine = HeaderLine | TraceRecord | RunEndLine | CrashLine;
97
+ export interface HashSpec {
98
+ readonly algorithm: "sha-256";
99
+ readonly output: "full-hex";
100
+ }
101
+ export declare const HASH_SPEC_BY_VERSION: Readonly<Record<number, HashSpec>>;
102
+ export declare function hashSpecFor(version: number): HashSpec;
103
+ 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"];
104
+ export declare const TRACE_SEGMENT_FIELDS: readonly ["role", "seqRange", "estTokens", "freshness"];
105
+ export declare function validateTraceSegment(v: unknown): v is TraceSegment;
106
+ export declare function validateTraceRecord(v: unknown): v is TraceRecord;
107
+ export declare function validateTraceLine(v: unknown): v is TraceLine;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * E1 (1.2.0) — the Request Trace record schema (proposal §1.1 as adopted
3
+ * by the R1 ruling: "adopt as-is, keep ts, per-round additive"). This is
4
+ * the 1.2.0 field-set lock: every field below is spec'd, and the
5
+ * closed-field-set gate (ruling R1a) pins the shape bidirectionally —
6
+ * `TRACE_RECORD_FIELDS`/`TRACE_SEGMENT_FIELDS` must stay in exact
7
+ * agreement with the interfaces, or trace-schema.test.ts goes red.
8
+ *
9
+ * R1a: `schemaVersion` pins BOTH the record shape AND the hash /
10
+ * fingerprint algorithms (`HASH_SPEC_BY_VERSION`). A version bump must
11
+ * pin a spec for the new version before any writer can use it
12
+ * (`hashSpecFor` throws otherwise).
13
+ *
14
+ * The ledger is OUT-side (ADR-0051 §6): versionable, never part of the
15
+ * correctness ABI. These types are runtime-internal (Case B of R2 — the
16
+ * export surface stays untouched pending the product-line countersign).
17
+ */
18
+ /** schemaVersion: 1 for 1.2.0. Algorithm and shape changes bump it
19
+ * (ADR-0051 §6 OUT-side versioning). */
20
+ export const TRACE_SCHEMA_VERSION = 1;
21
+ export const HASH_SPEC_BY_VERSION = {
22
+ 1: { algorithm: "sha-256", output: "full-hex" },
23
+ };
24
+ export function hashSpecFor(version) {
25
+ const spec = HASH_SPEC_BY_VERSION[version];
26
+ if (spec === undefined)
27
+ throw new Error(`no hash spec pinned for trace schemaVersion ${version}`);
28
+ return spec;
29
+ }
30
+ // ── The closed-field-set gate (R1a) ───────────────────────────────────────
31
+ // Trace-schema.test.ts asserts Object.keys of a fully populated record is
32
+ // EXACTLY this set (both directions).
33
+ export const TRACE_RECORD_FIELDS = [
34
+ "schemaVersion",
35
+ "kind",
36
+ "requestId",
37
+ "runId",
38
+ "requestIndex",
39
+ "retryAttempt",
40
+ "provider",
41
+ "model",
42
+ "adapterVersion",
43
+ "systemPromptHash",
44
+ "toolSchemaHash",
45
+ "contextHash",
46
+ "contextManifest",
47
+ "segmentHashes",
48
+ "stablePrefixFingerprint",
49
+ "freshInput",
50
+ "cacheRead",
51
+ "cacheWrite",
52
+ "output",
53
+ "latencyMs",
54
+ "ttftMs",
55
+ "toolCalls",
56
+ "outcome",
57
+ "lineageLink",
58
+ "ts",
59
+ ];
60
+ export const TRACE_SEGMENT_FIELDS = ["role", "seqRange", "estTokens", "freshness"];
61
+ // ── Validators ────────────────────────────────────────────────────────────
62
+ // Strict by design: extra keys are rejected (the closed set), so a
63
+ // misspelled field can never silently enter the ledger.
64
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
65
+ const keysOf = (v) => (isRecord(v) ? Object.keys(v) : []);
66
+ const isNonNegInt = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
67
+ const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
68
+ const isHex64 = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
69
+ /** The closed set, both directions: no key outside the spec, and every
70
+ * spec'd key present except the explicit optional ones. */
71
+ const hasClosedKeys = (v, spec, optional = []) => {
72
+ const keys = keysOf(v);
73
+ if (keys.some((k) => !spec.includes(k)))
74
+ return false;
75
+ const optionalSet = new Set(optional);
76
+ return spec.every((k) => optionalSet.has(k) || keys.includes(k));
77
+ };
78
+ const VALID_SEGMENT_ROLES = new Set(["system", "tools", "turn", "current_turn"]);
79
+ const VALID_FRESHNESS = new Set(["fresh", "cache_read", "cache_write"]);
80
+ const VALID_OUTCOMES = new Set(["ok", "provider_error", "aborted"]);
81
+ function isValidSeqRange(v) {
82
+ if (v === null)
83
+ return true;
84
+ if (!Array.isArray(v) || v.length !== 2)
85
+ return false;
86
+ const [a, b] = v;
87
+ return isNonNegInt(a) && isNonNegInt(b) && a <= b;
88
+ }
89
+ export function validateTraceSegment(v) {
90
+ if (!isRecord(v) || !hasClosedKeys(v, TRACE_SEGMENT_FIELDS))
91
+ return false;
92
+ if (typeof v.role !== "string" || !VALID_SEGMENT_ROLES.has(v.role))
93
+ return false;
94
+ if (!isValidSeqRange(v.seqRange))
95
+ return false;
96
+ if (!isNonNegInt(v.estTokens))
97
+ return false;
98
+ if (!VALID_FRESHNESS.has(v.freshness))
99
+ return false;
100
+ return true;
101
+ }
102
+ export function validateTraceRecord(v) {
103
+ if (!isRecord(v) || !hasClosedKeys(v, TRACE_RECORD_FIELDS, ["lineageLink"]))
104
+ return false;
105
+ if (v.schemaVersion !== TRACE_SCHEMA_VERSION)
106
+ return false;
107
+ if (v.kind !== "request")
108
+ return false;
109
+ if (typeof v.requestId !== "string" || typeof v.runId !== "string")
110
+ return false;
111
+ if (!isNonNegInt(v.requestIndex) || !isNonNegInt(v.retryAttempt))
112
+ return false;
113
+ if (typeof v.provider !== "string" || typeof v.model !== "string")
114
+ return false;
115
+ if (v.adapterVersion !== null && typeof v.adapterVersion !== "string")
116
+ return false;
117
+ if (!isHex64(v.systemPromptHash) || !isHex64(v.toolSchemaHash) || !isHex64(v.contextHash))
118
+ return false;
119
+ if (!isHex64(v.stablePrefixFingerprint))
120
+ return false;
121
+ if (!Array.isArray(v.contextManifest) || !v.contextManifest.every(validateTraceSegment))
122
+ return false;
123
+ // segmentHashes must mirror the manifest 1:1 — a misaligned list
124
+ // would silently corrupt the break derivation (R4b)
125
+ if (!Array.isArray(v.segmentHashes) ||
126
+ v.segmentHashes.length !== v.contextManifest.length ||
127
+ !v.segmentHashes.every(isHex64))
128
+ return false;
129
+ if (!isNumber(v.freshInput) || !isNumber(v.cacheRead))
130
+ return false;
131
+ if (v.cacheWrite !== null && !isNumber(v.cacheWrite))
132
+ return false;
133
+ if (!isNumber(v.output) || !isNumber(v.latencyMs))
134
+ return false;
135
+ if (!isNumber(v.ttftMs))
136
+ return false; // 0 = unknown, never null (locked set)
137
+ if (!Array.isArray(v.toolCalls) || !v.toolCalls.every((t) => typeof t === "string"))
138
+ return false;
139
+ if (typeof v.outcome !== "string" || !VALID_OUTCOMES.has(v.outcome))
140
+ return false;
141
+ if (v.lineageLink !== undefined) {
142
+ const l = v.lineageLink;
143
+ if (!isRecord(l))
144
+ return false;
145
+ if (typeof l.parentSessionId !== "string" || typeof l.parentRunId !== "string")
146
+ return false;
147
+ if (!isNonNegInt(l.parentInvocationSeq))
148
+ return false;
149
+ if (typeof l.role !== "string")
150
+ return false;
151
+ }
152
+ if (!isNumber(v.ts))
153
+ return false;
154
+ return true;
155
+ }
156
+ export function validateTraceLine(v) {
157
+ if (!isRecord(v))
158
+ return false;
159
+ if (v.schemaVersion !== TRACE_SCHEMA_VERSION)
160
+ return false;
161
+ switch (v.kind) {
162
+ case "header":
163
+ return (hasClosedKeys(v, ["schemaVersion", "kind", "sessionId", "kisoVersion", "createdAt"]) &&
164
+ typeof v.sessionId === "string" &&
165
+ typeof v.kisoVersion === "string" &&
166
+ isNumber(v.createdAt));
167
+ case "request":
168
+ return validateTraceRecord(v);
169
+ case "run_end":
170
+ return (hasClosedKeys(v, ["schemaVersion", "kind", "runId", "ts", "lastRequestIndex"]) &&
171
+ typeof v.runId === "string" &&
172
+ isNumber(v.ts) &&
173
+ // -1 = the run made no adapter calls (an empty run still
174
+ // settles cleanly); anything below that is not a request index
175
+ typeof v.lastRequestIndex === "number" &&
176
+ Number.isInteger(v.lastRequestIndex) &&
177
+ v.lastRequestIndex >= -1);
178
+ case "crash":
179
+ return (hasClosedKeys(v, ["schemaVersion", "kind", "ts", "note"]) &&
180
+ isNumber(v.ts) &&
181
+ typeof v.note === "string");
182
+ default:
183
+ return false;
184
+ }
185
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 2, the trace writer (proposal §1.2 + §6).
3
+ *
4
+ * The ledger is OUT-side (ADR-0051 §6): a versionable observation
5
+ * artifact, never part of the correctness ABI. The soft-fail law
6
+ * (work-order): trace loss costs ONE stderr line, then the session runs
7
+ * on — the writer degrades to a no-op on its first I/O failure and
8
+ * never throws into the caller.
9
+ *
10
+ * Async discipline (work-order §9): request lines are ENQUEUED (a sync
11
+ * push, sub-microsecond) and flushed on setImmediate — the model hot
12
+ * path never blocks on the ledger. The run_end line is flushed
13
+ * SYNCHRONOUSLY from the run's finally block: clean-settle marking is
14
+ * where the ledger's story (every hole reconciles as a crash, never as
15
+ * a writer bug) needs the write to have happened before the run ends.
16
+ */
17
+ import { type TraceLine } from "./record.js";
18
+ /** The runtime's own version, read from the package.json next to the
19
+ * build; null on failure (soft-fail — the header still ships). */
20
+ export declare function runtimeVersion(): string | null;
21
+ export interface TraceWriterDeps {
22
+ /** The sessions dir (store.root). The ledger lives in <root>/traces/ —
23
+ * NOT flat in root: a flat `<sid>.trace.jsonl` would be enumerated as
24
+ * a phantom session by SessionStore.list() and corrupt the bench
25
+ * sessions glob (proposal §3.2; the R3a gate pins it). */
26
+ root: string;
27
+ sessionId: string;
28
+ }
29
+ export declare class TraceWriter {
30
+ #private;
31
+ constructor(deps: TraceWriterDeps);
32
+ /** One-time setup: mkdir the ledger dir, mark a previous run that left
33
+ * no run_end (crash), write the header — the header once per ledger
34
+ * file (session-level, §1.2), so a resume appends markers, never a
35
+ * second header. Never throws. */
36
+ init(): void;
37
+ /** Enqueue a line for the next flush — the hot path's only cost.
38
+ * Never throws; after a degradation the line is dropped. */
39
+ enqueue(line: TraceLine): void;
40
+ /** Clean-settle marking: the run_end line lands NOW (synchronous),
41
+ * together with any still-pending request lines. */
42
+ finishRun(runId: string, lastRequestIndex: number): void;
43
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * E1 (1.2.0) — slice 2, the trace writer (proposal §1.2 + §6).
3
+ *
4
+ * The ledger is OUT-side (ADR-0051 §6): a versionable observation
5
+ * artifact, never part of the correctness ABI. The soft-fail law
6
+ * (work-order): trace loss costs ONE stderr line, then the session runs
7
+ * on — the writer degrades to a no-op on its first I/O failure and
8
+ * never throws into the caller.
9
+ *
10
+ * Async discipline (work-order §9): request lines are ENQUEUED (a sync
11
+ * push, sub-microsecond) and flushed on setImmediate — the model hot
12
+ * path never blocks on the ledger. The run_end line is flushed
13
+ * SYNCHRONOUSLY from the run's finally block: clean-settle marking is
14
+ * where the ledger's story (every hole reconciles as a crash, never as
15
+ * a writer bug) needs the write to have happened before the run ends.
16
+ */
17
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { TRACE_SCHEMA_VERSION } from "./record.js";
21
+ let cachedRuntimeVersion;
22
+ /** The runtime's own version, read from the package.json next to the
23
+ * build; null on failure (soft-fail — the header still ships). */
24
+ export function runtimeVersion() {
25
+ if (cachedRuntimeVersion === undefined) {
26
+ cachedRuntimeVersion = (() => {
27
+ try {
28
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"));
29
+ return pkg.version ?? null;
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ })();
35
+ }
36
+ return cachedRuntimeVersion;
37
+ }
38
+ export class TraceWriter {
39
+ #root;
40
+ #sessionId;
41
+ #path;
42
+ #pending = [];
43
+ #flushScheduled = false;
44
+ #degraded = false;
45
+ constructor(deps) {
46
+ this.#root = deps.root;
47
+ this.#sessionId = deps.sessionId;
48
+ this.#path = join(deps.root, "traces", `${deps.sessionId}.jsonl`);
49
+ }
50
+ /** One-time setup: mkdir the ledger dir, mark a previous run that left
51
+ * no run_end (crash), write the header — the header once per ledger
52
+ * file (session-level, §1.2), so a resume appends markers, never a
53
+ * second header. Never throws. */
54
+ init() {
55
+ if (this.#degraded)
56
+ return;
57
+ try {
58
+ mkdirSync(join(this.#root, "traces"), { recursive: true });
59
+ const fresh = !existsSync(this.#path);
60
+ if (this.#crashDetect()) {
61
+ this.#writeSync([
62
+ { schemaVersion: TRACE_SCHEMA_VERSION, kind: "crash", ts: Date.now(), note: "previous run left no run_end" },
63
+ ]);
64
+ }
65
+ if (fresh) {
66
+ this.#writeSync([
67
+ {
68
+ schemaVersion: TRACE_SCHEMA_VERSION,
69
+ kind: "header",
70
+ sessionId: this.#sessionId,
71
+ kisoVersion: runtimeVersion() ?? "?",
72
+ createdAt: Date.now(),
73
+ },
74
+ ]);
75
+ }
76
+ }
77
+ catch (err) {
78
+ this.#degrade(err);
79
+ }
80
+ }
81
+ /** Enqueue a line for the next flush — the hot path's only cost.
82
+ * Never throws; after a degradation the line is dropped. */
83
+ enqueue(line) {
84
+ if (this.#degraded)
85
+ return;
86
+ this.#pending.push(line);
87
+ if (!this.#flushScheduled) {
88
+ this.#flushScheduled = true;
89
+ setImmediate(() => {
90
+ this.#flushScheduled = false;
91
+ this.#flushSync();
92
+ });
93
+ }
94
+ }
95
+ /** Clean-settle marking: the run_end line lands NOW (synchronous),
96
+ * together with any still-pending request lines. */
97
+ finishRun(runId, lastRequestIndex) {
98
+ this.enqueue({
99
+ schemaVersion: TRACE_SCHEMA_VERSION,
100
+ kind: "run_end",
101
+ runId,
102
+ ts: Date.now(),
103
+ lastRequestIndex,
104
+ });
105
+ this.#flushSync();
106
+ }
107
+ /** True when the ledger already exists and its LAST line is neither
108
+ * run_end nor crash — an un-terminated run (killed mid-run), to be
109
+ * marked. A crash-marked ledger is not re-marked. */
110
+ #crashDetect() {
111
+ if (!existsSync(this.#path))
112
+ return false;
113
+ let last;
114
+ try {
115
+ for (const line of readFileSync(this.#path, "utf8").split("\n")) {
116
+ if (line.trim() === "")
117
+ continue;
118
+ last = JSON.parse(line);
119
+ }
120
+ }
121
+ catch {
122
+ return false; // unreadable ledger — do not compound the failure
123
+ }
124
+ if (last === undefined)
125
+ return false;
126
+ return last.kind !== "run_end" && last.kind !== "crash";
127
+ }
128
+ #writeSync(lines) {
129
+ if (lines.length === 0)
130
+ return;
131
+ try {
132
+ appendFileSync(this.#path, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
133
+ }
134
+ catch (err) {
135
+ this.#degrade(err);
136
+ }
137
+ }
138
+ #flushSync() {
139
+ if (this.#pending.length === 0)
140
+ return;
141
+ const lines = this.#pending;
142
+ this.#pending = [];
143
+ this.#writeSync(lines);
144
+ }
145
+ /** The soft-fail law: one stderr line, then the ledger is dropped and
146
+ * the session runs on. Idempotent. */
147
+ #degrade(err) {
148
+ if (this.#degraded)
149
+ return;
150
+ this.#degraded = true;
151
+ this.#pending = [];
152
+ console.error(`[kiso] trace writer degraded (${err instanceof Error ? err.message : String(err)}); request traces dropped`);
153
+ }
154
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "1.1.0",
4
- "description": "kiso runtime durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
3
+ "version": "1.2.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": {