@vincemakes/kiso-runtime 0.1.38 → 0.2.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 +1 -0
- package/dist/agent.js +2 -0
- package/dist/index.d.ts +32 -11
- package/dist/index.js +41 -11
- package/dist/internal.d.ts +25 -0
- package/dist/internal.js +25 -0
- package/dist/lock-adapter.js +46 -1
- package/dist/run.js +40 -5
- package/dist/session.d.ts +9 -0
- package/dist/session.js +8 -0
- package/dist/trace/analyze.d.ts +35 -0
- package/dist/trace/analyze.js +51 -0
- package/dist/trace/guard.d.ts +52 -0
- package/dist/trace/guard.js +224 -0
- package/dist/trace/hash.d.ts +22 -0
- package/dist/trace/hash.js +34 -0
- package/dist/trace/manifest.d.ts +36 -0
- package/dist/trace/manifest.js +100 -0
- package/dist/trace/record.d.ts +145 -0
- package/dist/trace/record.js +257 -0
- package/dist/trace/rent.d.ts +81 -0
- package/dist/trace/rent.js +96 -0
- package/dist/trace/writer.d.ts +43 -0
- package/dist/trace/writer.js +154 -0
- package/dist/usage/canonical.d.ts +123 -0
- package/dist/usage/canonical.js +152 -0
- package/package.json +10 -6
|
@@ -0,0 +1,257 @@
|
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* E2 (1.3.0) — schemaVersion 2: the record gains the `canonical` block
|
|
18
|
+
* (E2 proposal §1.5 Case A — the nested block, ruled 2026-08-13; raw
|
|
19
|
+
* quartet stays as provider observation above it). The validators accept
|
|
20
|
+
* BOTH generations (R1d-1): a v1 sidecar has no canonical block and reads
|
|
21
|
+
* as defaults at every consumer — never a crash.
|
|
22
|
+
*
|
|
23
|
+
* E3 (0.2.1) — schemaVersion 3: the record gains the `rent` block — the
|
|
24
|
+
* static rent ledger, one line per surface (trace/rent.ts). The v3
|
|
25
|
+
* writers record it; v1/v2 sidecars keep reading as defaults (R1d-1,
|
|
26
|
+
* R2-1): no rent block = no rent lines = the zero-rent reading, never a
|
|
27
|
+
* crash.
|
|
28
|
+
*/
|
|
29
|
+
/** schemaVersion: 3 for 0.2.1 (the rent block). Version 1 = the 1.2.0
|
|
30
|
+
* shape, version 2 = the 1.3.0 shape; both kept for generation-compat
|
|
31
|
+
* reads (R1d-1, R2-1). Algorithm and shape changes bump it (ADR-0051 §6
|
|
32
|
+
* OUT-side versioning). */
|
|
33
|
+
export const TRACE_SCHEMA_VERSION = 3;
|
|
34
|
+
/** The versions a reader may meet in a ledger. v1 and v2 records are
|
|
35
|
+
* accepted (generation-compat) and read as defaults — no canonical
|
|
36
|
+
* block (v1), no rent block (v1, v2). */
|
|
37
|
+
export const TRACE_SCHEMA_VERSIONS = new Set([1, 2, TRACE_SCHEMA_VERSION]);
|
|
38
|
+
import { PRICING_TABLE_V1, priceFor, pricingTableFor, validateCanonicalUsage } from "../usage/canonical.js";
|
|
39
|
+
import { validateRentLine } from "./rent.js";
|
|
40
|
+
export const HASH_SPEC_BY_VERSION = {
|
|
41
|
+
1: { algorithm: "sha-256", output: "full-hex" },
|
|
42
|
+
2: { algorithm: "sha-256", output: "full-hex" }, // E2 — the algorithms do not change
|
|
43
|
+
3: { algorithm: "sha-256", output: "full-hex" }, // E3 — same algorithms, re-pinned (the E2 ritual)
|
|
44
|
+
};
|
|
45
|
+
export function hashSpecFor(version) {
|
|
46
|
+
const spec = HASH_SPEC_BY_VERSION[version];
|
|
47
|
+
if (spec === undefined)
|
|
48
|
+
throw new Error(`no hash spec pinned for trace schemaVersion ${version}`);
|
|
49
|
+
return spec;
|
|
50
|
+
}
|
|
51
|
+
// ── The closed-field-set gate (R1a) ───────────────────────────────────────
|
|
52
|
+
// Trace-schema.test.ts asserts Object.keys of a fully populated record is
|
|
53
|
+
// EXACTLY this set (both directions).
|
|
54
|
+
/** The 1.2.0 field set (schemaVersion 1) — kept verbatim for
|
|
55
|
+
* generation-compat reads of old sidecars (R1d-1): a v1 record has no
|
|
56
|
+
* canonical block and reads as defaults at every consumer. */
|
|
57
|
+
export const TRACE_RECORD_FIELDS_V1 = [
|
|
58
|
+
"schemaVersion",
|
|
59
|
+
"kind",
|
|
60
|
+
"requestId",
|
|
61
|
+
"runId",
|
|
62
|
+
"requestIndex",
|
|
63
|
+
"retryAttempt",
|
|
64
|
+
"provider",
|
|
65
|
+
"model",
|
|
66
|
+
"adapterVersion",
|
|
67
|
+
"systemPromptHash",
|
|
68
|
+
"toolSchemaHash",
|
|
69
|
+
"contextHash",
|
|
70
|
+
"contextManifest",
|
|
71
|
+
"segmentHashes",
|
|
72
|
+
"stablePrefixFingerprint",
|
|
73
|
+
"freshInput",
|
|
74
|
+
"cacheRead",
|
|
75
|
+
"cacheWrite",
|
|
76
|
+
"output",
|
|
77
|
+
"latencyMs",
|
|
78
|
+
"ttftMs",
|
|
79
|
+
"toolCalls",
|
|
80
|
+
"outcome",
|
|
81
|
+
"lineageLink",
|
|
82
|
+
"ts",
|
|
83
|
+
];
|
|
84
|
+
/** The 1.3.0 field set (schemaVersion 2) = the v1 set + `canonical`. */
|
|
85
|
+
export const TRACE_RECORD_FIELDS_V2 = [...TRACE_RECORD_FIELDS_V1, "canonical"];
|
|
86
|
+
/** The 0.2.1 field set (schemaVersion 3) = the v2 set + `rent`. */
|
|
87
|
+
export const TRACE_RECORD_FIELDS = [...TRACE_RECORD_FIELDS_V2, "rent"];
|
|
88
|
+
export const TRACE_SEGMENT_FIELDS = ["role", "seqRange", "estTokens", "freshness"];
|
|
89
|
+
// ── Validators ────────────────────────────────────────────────────────────
|
|
90
|
+
// Strict by design: extra keys are rejected (the closed set), so a
|
|
91
|
+
// misspelled field can never silently enter the ledger.
|
|
92
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
93
|
+
const keysOf = (v) => (isRecord(v) ? Object.keys(v) : []);
|
|
94
|
+
const isNonNegInt = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
95
|
+
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
96
|
+
const isHex64 = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
|
|
97
|
+
/** The closed set, both directions: no key outside the spec, and every
|
|
98
|
+
* spec'd key present except the explicit optional ones. */
|
|
99
|
+
const hasClosedKeys = (v, spec, optional = []) => {
|
|
100
|
+
const keys = keysOf(v);
|
|
101
|
+
if (keys.some((k) => !spec.includes(k)))
|
|
102
|
+
return false;
|
|
103
|
+
const optionalSet = new Set(optional);
|
|
104
|
+
return spec.every((k) => optionalSet.has(k) || keys.includes(k));
|
|
105
|
+
};
|
|
106
|
+
const VALID_SEGMENT_ROLES = new Set(["system", "tools", "turn", "current_turn"]);
|
|
107
|
+
const VALID_FRESHNESS = new Set(["fresh", "cache_read", "cache_write"]);
|
|
108
|
+
const VALID_OUTCOMES = new Set(["ok", "provider_error", "aborted"]);
|
|
109
|
+
function isValidSeqRange(v) {
|
|
110
|
+
if (v === null)
|
|
111
|
+
return true;
|
|
112
|
+
if (!Array.isArray(v) || v.length !== 2)
|
|
113
|
+
return false;
|
|
114
|
+
const [a, b] = v;
|
|
115
|
+
return isNonNegInt(a) && isNonNegInt(b) && a <= b;
|
|
116
|
+
}
|
|
117
|
+
export function validateTraceSegment(v) {
|
|
118
|
+
if (!isRecord(v) || !hasClosedKeys(v, TRACE_SEGMENT_FIELDS))
|
|
119
|
+
return false;
|
|
120
|
+
if (typeof v.role !== "string" || !VALID_SEGMENT_ROLES.has(v.role))
|
|
121
|
+
return false;
|
|
122
|
+
if (!isValidSeqRange(v.seqRange))
|
|
123
|
+
return false;
|
|
124
|
+
if (!isNonNegInt(v.estTokens))
|
|
125
|
+
return false;
|
|
126
|
+
if (!VALID_FRESHNESS.has(v.freshness))
|
|
127
|
+
return false;
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
export function validateTraceRecord(v) {
|
|
131
|
+
if (!isRecord(v))
|
|
132
|
+
return false;
|
|
133
|
+
const version = v.schemaVersion;
|
|
134
|
+
// generation-compat (R1d-1, R2-1): a v1 sidecar has no canonical
|
|
135
|
+
// block, a v2 sidecar has no rent block — both read as defaults,
|
|
136
|
+
// accepted, never a crash; the current version is fully checked
|
|
137
|
+
// (shape + the canonical block + the rent ledger).
|
|
138
|
+
if (version !== 1 && version !== 2 && version !== TRACE_SCHEMA_VERSION)
|
|
139
|
+
return false;
|
|
140
|
+
const fields = version === 1 ? TRACE_RECORD_FIELDS_V1 : version === 2 ? TRACE_RECORD_FIELDS_V2 : TRACE_RECORD_FIELDS;
|
|
141
|
+
if (!hasClosedKeys(v, fields, ["lineageLink"]))
|
|
142
|
+
return false;
|
|
143
|
+
if (v.kind !== "request")
|
|
144
|
+
return false;
|
|
145
|
+
if (typeof v.requestId !== "string" || typeof v.runId !== "string")
|
|
146
|
+
return false;
|
|
147
|
+
if (!isNonNegInt(v.requestIndex) || !isNonNegInt(v.retryAttempt))
|
|
148
|
+
return false;
|
|
149
|
+
if (typeof v.provider !== "string" || typeof v.model !== "string")
|
|
150
|
+
return false;
|
|
151
|
+
if (v.adapterVersion !== null && typeof v.adapterVersion !== "string")
|
|
152
|
+
return false;
|
|
153
|
+
if (!isHex64(v.systemPromptHash) || !isHex64(v.toolSchemaHash) || !isHex64(v.contextHash))
|
|
154
|
+
return false;
|
|
155
|
+
if (!isHex64(v.stablePrefixFingerprint))
|
|
156
|
+
return false;
|
|
157
|
+
if (!Array.isArray(v.contextManifest) || !v.contextManifest.every(validateTraceSegment))
|
|
158
|
+
return false;
|
|
159
|
+
// segmentHashes must mirror the manifest 1:1 — a misaligned list
|
|
160
|
+
// would silently corrupt the break derivation (R4b)
|
|
161
|
+
if (!Array.isArray(v.segmentHashes) ||
|
|
162
|
+
v.segmentHashes.length !== v.contextManifest.length ||
|
|
163
|
+
!v.segmentHashes.every(isHex64))
|
|
164
|
+
return false;
|
|
165
|
+
if (!isNumber(v.freshInput) || !isNumber(v.cacheRead))
|
|
166
|
+
return false;
|
|
167
|
+
if (v.cacheWrite !== null && !isNumber(v.cacheWrite))
|
|
168
|
+
return false;
|
|
169
|
+
if (!isNumber(v.output) || !isNumber(v.latencyMs))
|
|
170
|
+
return false;
|
|
171
|
+
if (!isNumber(v.ttftMs))
|
|
172
|
+
return false; // 0 = unknown, never null (locked set)
|
|
173
|
+
if (!Array.isArray(v.toolCalls) || !v.toolCalls.every((t) => typeof t === "string"))
|
|
174
|
+
return false;
|
|
175
|
+
if (typeof v.outcome !== "string" || !VALID_OUTCOMES.has(v.outcome))
|
|
176
|
+
return false;
|
|
177
|
+
if (v.lineageLink !== undefined) {
|
|
178
|
+
const l = v.lineageLink;
|
|
179
|
+
if (!isRecord(l))
|
|
180
|
+
return false;
|
|
181
|
+
if (typeof l.parentSessionId !== "string" || typeof l.parentRunId !== "string")
|
|
182
|
+
return false;
|
|
183
|
+
if (!isNonNegInt(l.parentInvocationSeq))
|
|
184
|
+
return false;
|
|
185
|
+
if (typeof l.role !== "string")
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if (version !== 1) {
|
|
189
|
+
// the canonical block: the schema's invariants machine-checked
|
|
190
|
+
if (!validateCanonicalUsage(v.canonical))
|
|
191
|
+
return false;
|
|
192
|
+
const c = v.canonical;
|
|
193
|
+
// the block formalizes the raw quartet — a divergence means the
|
|
194
|
+
// derivation drifted (a future bug, caught at the ledger)
|
|
195
|
+
if (c.input !== v.freshInput || c.cacheRead !== v.cacheRead || c.output !== v.output || c.cacheWrite !== v.cacheWrite)
|
|
196
|
+
return false;
|
|
197
|
+
// cost consistency: recomputed from the components × the version's
|
|
198
|
+
// pinned table, at the record's own route (the route context lives
|
|
199
|
+
// in the record; the standalone schema cannot check this). A null
|
|
200
|
+
// costUsd is the R5b-④c absent stamp (the table has no rate for
|
|
201
|
+
// this route) — nothing to recompute against, accepted. The
|
|
202
|
+
// cross-check runs only for builtin-id records: a foreign table's
|
|
203
|
+
// consistency is the billing layer's accounting, not the ledger's.
|
|
204
|
+
if (c.costUsd !== null && c.pricingTableId === PRICING_TABLE_V1.id) {
|
|
205
|
+
const expected = priceFor(v.provider, { input: c.input, output: c.output, cacheRead: c.cacheRead, cacheWrite: c.cacheWrite }, pricingTableFor(c.pricingTableVersion));
|
|
206
|
+
// non-null for the builtin table by construction (both real
|
|
207
|
+
// routes + the mirror fallback) — a null here is a code bug,
|
|
208
|
+
// and an epsilon check has nothing to compare
|
|
209
|
+
if (expected !== null && Math.abs(c.costUsd - expected) > 1e-6)
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (version === TRACE_SCHEMA_VERSION) {
|
|
214
|
+
// the rent block: every line validates (closed fields, non-empty
|
|
215
|
+
// surface, non-negative integer chars, the estTokens == ceil(chars/4)
|
|
216
|
+
// cross-check — R6). v1/v2 sidecars have no block (R2-1).
|
|
217
|
+
if (!Array.isArray(v.rent) || !v.rent.every(validateRentLine))
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
if (!isNumber(v.ts))
|
|
221
|
+
return false;
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
export function validateTraceLine(v) {
|
|
225
|
+
if (!isRecord(v))
|
|
226
|
+
return false;
|
|
227
|
+
// both ledger generations are readable (R1d-1); the per-kind shapes are
|
|
228
|
+
// identical across 1 → 2 — only the request line's field set differs
|
|
229
|
+
// (v1 lacks the canonical block), handled by validateTraceRecord's
|
|
230
|
+
// version dispatch
|
|
231
|
+
if (!TRACE_SCHEMA_VERSIONS.has(v.schemaVersion))
|
|
232
|
+
return false;
|
|
233
|
+
switch (v.kind) {
|
|
234
|
+
case "header":
|
|
235
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "sessionId", "kisoVersion", "createdAt"]) &&
|
|
236
|
+
typeof v.sessionId === "string" &&
|
|
237
|
+
typeof v.kisoVersion === "string" &&
|
|
238
|
+
isNumber(v.createdAt));
|
|
239
|
+
case "request":
|
|
240
|
+
return validateTraceRecord(v);
|
|
241
|
+
case "run_end":
|
|
242
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "runId", "ts", "lastRequestIndex"]) &&
|
|
243
|
+
typeof v.runId === "string" &&
|
|
244
|
+
isNumber(v.ts) &&
|
|
245
|
+
// -1 = the run made no adapter calls (an empty run still
|
|
246
|
+
// settles cleanly); anything below that is not a request index
|
|
247
|
+
typeof v.lastRequestIndex === "number" &&
|
|
248
|
+
Number.isInteger(v.lastRequestIndex) &&
|
|
249
|
+
v.lastRequestIndex >= -1);
|
|
250
|
+
case "crash":
|
|
251
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "ts", "note"]) &&
|
|
252
|
+
isNumber(v.ts) &&
|
|
253
|
+
typeof v.note === "string");
|
|
254
|
+
default:
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E3 (0.2.1) — the rent ledger: every request's static model-side rent,
|
|
3
|
+
* one line per surface.
|
|
4
|
+
*
|
|
5
|
+
* The static rent is the surface the model pays REGARDLESS of the turn's
|
|
6
|
+
* content: the base system prompt, each extension's append, every tool's
|
|
7
|
+
* serialized spec, and the per-request envelope. One line per surface,
|
|
8
|
+
* four classes, measured in chars + the rounds' est-token convention
|
|
9
|
+
* (chars/4, ceil — the E1 script's formula, the work order's own words,
|
|
10
|
+
* R6):
|
|
11
|
+
*
|
|
12
|
+
* - system:base the session's own prompt as the CLI handed it
|
|
13
|
+
* (built-in constant + project instructions — the
|
|
14
|
+
* runtime cannot split those, R3). The runtime's
|
|
15
|
+
* generated tool table is machinery BETWEEN base and
|
|
16
|
+
* appends; it stays out of the ledger (counts live
|
|
17
|
+
* in tool:<name> lines and the base's own line).
|
|
18
|
+
* - system:ext:<name> one line per extension with a non-empty
|
|
19
|
+
* systemPrompt.append, measured on the append
|
|
20
|
+
* string, in load order.
|
|
21
|
+
* - tool:<name> one line per tool, measured on the exact
|
|
22
|
+
* serialized ToolSpec projection the adapters
|
|
23
|
+
* receive ({name, description, inputSchema} — the
|
|
24
|
+
* registry.toSpecs() shape, protocol/messages.ts).
|
|
25
|
+
* - envelope the per-request fixed overhead OUTSIDE the
|
|
26
|
+
* conversation payloads: the R5 skeleton
|
|
27
|
+
* JSON.stringify({model, messages: [], tools: []})
|
|
28
|
+
* — a function of the model string only, never of
|
|
29
|
+
* the payloads (the skeleton is the definition).
|
|
30
|
+
*
|
|
31
|
+
* R9: an absent surface is an absent line — an unconfigured mcp, a
|
|
32
|
+
* session with no project instructions: no line. The absence IS the
|
|
33
|
+
* ledger statement (the 0.1.45 diet-A precedent: not paid = no rent).
|
|
34
|
+
*
|
|
35
|
+
* Determinism (the reviewer-facing property): every line is derivable
|
|
36
|
+
* from components the reviewer can recompute — the exported prompt
|
|
37
|
+
* constant, the appends, the ToolSpec array, the request skeleton. The
|
|
38
|
+
* ledger stores COUNTS, never payloads (the seqRange thin-pointer
|
|
39
|
+
* discipline applied to rent).
|
|
40
|
+
*
|
|
41
|
+
* buildRentLedger is the SINGLE source: the R7 star gate drives a real
|
|
42
|
+
* session with the script's predicted composition and asserts the
|
|
43
|
+
* recorded ledger equals the prediction line for line.
|
|
44
|
+
*/
|
|
45
|
+
import type { ToolSpec } from "@vincemakes/kiso-core";
|
|
46
|
+
export interface RentLine {
|
|
47
|
+
/** "system:base" | "system:ext:<name>" | "tool:<name>" | "envelope" */
|
|
48
|
+
surface: string;
|
|
49
|
+
/** length of the serialized surface, measured, never a copy */
|
|
50
|
+
chars: number;
|
|
51
|
+
/** Math.ceil(chars / 4) — the E1 script convention, pinned (R6) */
|
|
52
|
+
estTokens: number;
|
|
53
|
+
}
|
|
54
|
+
/** The closed line set (the same gate discipline as the record fields). */
|
|
55
|
+
export declare const RENT_LINE_FIELDS: readonly ["surface", "chars", "estTokens"];
|
|
56
|
+
/** What the runtime knows about the static surface beyond what the
|
|
57
|
+
* adapter call itself carries: the base prompt as configured, and the
|
|
58
|
+
* per-extension appends in load order (the adapter's composed
|
|
59
|
+
* systemPrompt is the result — the parts are the ledger's inputs). */
|
|
60
|
+
export interface RentParts {
|
|
61
|
+
base?: string;
|
|
62
|
+
appends?: readonly {
|
|
63
|
+
name: string;
|
|
64
|
+
text: string;
|
|
65
|
+
}[];
|
|
66
|
+
}
|
|
67
|
+
export interface RentInput extends RentParts {
|
|
68
|
+
model: string;
|
|
69
|
+
tools?: readonly ToolSpec[];
|
|
70
|
+
}
|
|
71
|
+
/** The one ledger: system:base, then system:ext:* (load order), then
|
|
72
|
+
* tool:* (the array's order — the registry's toSpecs() order), then the
|
|
73
|
+
* envelope. Absent surfaces contribute no lines (R9). */
|
|
74
|
+
export declare function buildRentLedger(input: RentInput): RentLine[];
|
|
75
|
+
/** The line validator — the same strict discipline as the record's other
|
|
76
|
+
* closed sets: no key outside the spec, every spec'd key present,
|
|
77
|
+
* non-empty surface (R9: a line exists only for a surface that exists),
|
|
78
|
+
* non-negative integer chars, and the estTokens cross-check (R6). The
|
|
79
|
+
* schema pins the LINE, not the multiset — duplicate surfaces are the
|
|
80
|
+
* writer's business, never a schema crash. */
|
|
81
|
+
export declare function validateRentLine(v: unknown): v is RentLine;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E3 (0.2.1) — the rent ledger: every request's static model-side rent,
|
|
3
|
+
* one line per surface.
|
|
4
|
+
*
|
|
5
|
+
* The static rent is the surface the model pays REGARDLESS of the turn's
|
|
6
|
+
* content: the base system prompt, each extension's append, every tool's
|
|
7
|
+
* serialized spec, and the per-request envelope. One line per surface,
|
|
8
|
+
* four classes, measured in chars + the rounds' est-token convention
|
|
9
|
+
* (chars/4, ceil — the E1 script's formula, the work order's own words,
|
|
10
|
+
* R6):
|
|
11
|
+
*
|
|
12
|
+
* - system:base the session's own prompt as the CLI handed it
|
|
13
|
+
* (built-in constant + project instructions — the
|
|
14
|
+
* runtime cannot split those, R3). The runtime's
|
|
15
|
+
* generated tool table is machinery BETWEEN base and
|
|
16
|
+
* appends; it stays out of the ledger (counts live
|
|
17
|
+
* in tool:<name> lines and the base's own line).
|
|
18
|
+
* - system:ext:<name> one line per extension with a non-empty
|
|
19
|
+
* systemPrompt.append, measured on the append
|
|
20
|
+
* string, in load order.
|
|
21
|
+
* - tool:<name> one line per tool, measured on the exact
|
|
22
|
+
* serialized ToolSpec projection the adapters
|
|
23
|
+
* receive ({name, description, inputSchema} — the
|
|
24
|
+
* registry.toSpecs() shape, protocol/messages.ts).
|
|
25
|
+
* - envelope the per-request fixed overhead OUTSIDE the
|
|
26
|
+
* conversation payloads: the R5 skeleton
|
|
27
|
+
* JSON.stringify({model, messages: [], tools: []})
|
|
28
|
+
* — a function of the model string only, never of
|
|
29
|
+
* the payloads (the skeleton is the definition).
|
|
30
|
+
*
|
|
31
|
+
* R9: an absent surface is an absent line — an unconfigured mcp, a
|
|
32
|
+
* session with no project instructions: no line. The absence IS the
|
|
33
|
+
* ledger statement (the 0.1.45 diet-A precedent: not paid = no rent).
|
|
34
|
+
*
|
|
35
|
+
* Determinism (the reviewer-facing property): every line is derivable
|
|
36
|
+
* from components the reviewer can recompute — the exported prompt
|
|
37
|
+
* constant, the appends, the ToolSpec array, the request skeleton. The
|
|
38
|
+
* ledger stores COUNTS, never payloads (the seqRange thin-pointer
|
|
39
|
+
* discipline applied to rent).
|
|
40
|
+
*
|
|
41
|
+
* buildRentLedger is the SINGLE source: the R7 star gate drives a real
|
|
42
|
+
* session with the script's predicted composition and asserts the
|
|
43
|
+
* recorded ledger equals the prediction line for line.
|
|
44
|
+
*/
|
|
45
|
+
/** The closed line set (the same gate discipline as the record fields). */
|
|
46
|
+
export const RENT_LINE_FIELDS = ["surface", "chars", "estTokens"];
|
|
47
|
+
const line = (surface, text) => ({
|
|
48
|
+
surface,
|
|
49
|
+
chars: text.length,
|
|
50
|
+
estTokens: Math.ceil(text.length / 4),
|
|
51
|
+
});
|
|
52
|
+
/** The one ledger: system:base, then system:ext:* (load order), then
|
|
53
|
+
* tool:* (the array's order — the registry's toSpecs() order), then the
|
|
54
|
+
* envelope. Absent surfaces contribute no lines (R9). */
|
|
55
|
+
export function buildRentLedger(input) {
|
|
56
|
+
const lines = [];
|
|
57
|
+
if (input.base !== undefined)
|
|
58
|
+
lines.push(line("system:base", input.base));
|
|
59
|
+
for (const append of input.appends ?? [])
|
|
60
|
+
lines.push(line(`system:ext:${append.name}`, append.text));
|
|
61
|
+
for (const tool of input.tools ?? []) {
|
|
62
|
+
lines.push(line(`tool:${tool.name}`, JSON.stringify({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })));
|
|
63
|
+
}
|
|
64
|
+
// R5: the envelope is the request skeleton — a model-string function,
|
|
65
|
+
// never the payloads; the empty arrays are literal (the definition).
|
|
66
|
+
lines.push(line("envelope", JSON.stringify({ model: input.model, messages: [], tools: [] })));
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
/** The line validator — the same strict discipline as the record's other
|
|
70
|
+
* closed sets: no key outside the spec, every spec'd key present,
|
|
71
|
+
* non-empty surface (R9: a line exists only for a surface that exists),
|
|
72
|
+
* non-negative integer chars, and the estTokens cross-check (R6). The
|
|
73
|
+
* schema pins the LINE, not the multiset — duplicate surfaces are the
|
|
74
|
+
* writer's business, never a schema crash. */
|
|
75
|
+
export function validateRentLine(v) {
|
|
76
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
77
|
+
return false;
|
|
78
|
+
const o = v;
|
|
79
|
+
const keys = Object.keys(o);
|
|
80
|
+
const fields = RENT_LINE_FIELDS;
|
|
81
|
+
if (keys.some((k) => !fields.includes(k)))
|
|
82
|
+
return false;
|
|
83
|
+
if (!fields.every((k) => k in o))
|
|
84
|
+
return false;
|
|
85
|
+
if (typeof o.surface !== "string" || o.surface.length === 0)
|
|
86
|
+
return false;
|
|
87
|
+
const chars = o.chars;
|
|
88
|
+
const estTokens = o.estTokens;
|
|
89
|
+
if (typeof chars !== "number" || !Number.isInteger(chars) || chars < 0)
|
|
90
|
+
return false;
|
|
91
|
+
if (typeof estTokens !== "number" || !Number.isInteger(estTokens) || estTokens < 0)
|
|
92
|
+
return false;
|
|
93
|
+
if (estTokens !== Math.ceil(chars / 4))
|
|
94
|
+
return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
@@ -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
|
+
}
|