dsh-context 0.22.0 → 0.22.2

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/lib/index.js CHANGED
@@ -1,697 +1,807 @@
1
- // src/host/config.ts
2
1
  import { z } from "zod";
3
- var DEFAULT_BOUNDS = {
4
- maxRequestSteps: 1500,
5
- maxKeptTurns: 300,
6
- maxEvents: 400,
7
- maxNodes: 2e3,
8
- maxArchiveNodes: 400
2
+ import { deriveEventMessage } from "@deepseek-ai/dsh-session";
3
+ //#region src/host/config.ts
4
+ /**
5
+ * dsh-context host configuration — the `config:` block of the `dsh-context`
6
+ * loader row in cordis.yml.
7
+ *
8
+ * Cordis validates the entry config against this exported `Config` schema
9
+ * (any Standard Schema v1 validator — zod is ours) before `apply` runs, fills
10
+ * per-field defaults, and fails the load loudly on invalid or unknown keys
11
+ * (`.strict()`). The official plugin-config principle this answers: "anything
12
+ * that two deployments may want to set differently is a configuration field".
13
+ *
14
+ * The persisted projection state shape is independent of these bounds — they
15
+ * only tune the fold's retention / presentation slice, so changing them never
16
+ * requires a projection `stateVersion` bump.
17
+ */
18
+ /** Defaults — the exact bounds the fold used before they became configurable. */
19
+ const DEFAULT_BOUNDS = {
20
+ maxRequestSteps: 1500,
21
+ maxKeptTurns: 300,
22
+ maxEvents: 400,
23
+ maxNodes: 2e3,
24
+ maxArchiveNodes: 400
9
25
  };
10
- var Config = z.preprocess(
11
- (v) => v ?? {},
12
- z.object({
13
- maxRequestSteps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxRequestSteps),
14
- maxKeptTurns: z.number().int().min(1).default(DEFAULT_BOUNDS.maxKeptTurns),
15
- maxEvents: z.number().int().min(1).default(DEFAULT_BOUNDS.maxEvents),
16
- maxNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxNodes),
17
- maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes)
18
- }).strict()
19
- );
26
+ /**
27
+ * The cordis `Config` validator: strict on keys, defaults on the schema fields.
28
+ * Tolerates `undefined` (a patch row without a `config:` block — defaults win).
29
+ */
30
+ const Config = z.preprocess((v) => v ?? {}, z.object({
31
+ maxRequestSteps: z.number().int().min(1).default(DEFAULT_BOUNDS.maxRequestSteps),
32
+ maxKeptTurns: z.number().int().min(1).default(DEFAULT_BOUNDS.maxKeptTurns),
33
+ maxEvents: z.number().int().min(1).default(DEFAULT_BOUNDS.maxEvents),
34
+ maxNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxNodes),
35
+ maxArchiveNodes: z.number().int().min(1).default(DEFAULT_BOUNDS.maxArchiveNodes)
36
+ }).strict());
37
+ /** Validate (and default) the entry config into concrete fold bounds. */
20
38
  function resolveBounds(config) {
21
- return Config.parse(config ?? {});
39
+ return Config.parse(config ?? {});
22
40
  }
23
-
24
- // src/host/headers.ts
25
- import { z as z2 } from "zod";
26
-
27
- // src/host/pricing.ts
28
- var CHARS_PER_TOKEN = 4;
29
- var BLOCK_OVERHEAD = 4;
30
- var ROLE_OVERHEAD = 4;
41
+ //#endregion
42
+ //#region src/host/pricing.ts
43
+ /**
44
+ * Token pricing — the same fixed-density heuristic as the harness's own
45
+ * token-meter (`dsh-token-meter/estimate.ts`): ~4 chars ≈ 1 token, +4 per
46
+ * content block, +4 role framing. Pure functions over message payloads.
47
+ */
48
+ const CHARS_PER_TOKEN = 4;
49
+ const BLOCK_OVERHEAD = 4;
50
+ const ROLE_OVERHEAD = 4;
51
+ /** Whole-array tool-schema price (the header's tools total). */
31
52
  function estimateToolsTotal(tools) {
32
- return tools.length > 0 ? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD : 0;
53
+ return tools.length > 0 ? Math.ceil(JSON.stringify(tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD : 0;
33
54
  }
34
55
  function estimateBlocks(blocks) {
35
- let tokens = 0;
36
- if (!Array.isArray(blocks)) return 0;
37
- for (const block of blocks) {
38
- if (block === null || typeof block !== "object") continue;
39
- switch (block.type) {
40
- case "text":
41
- case "reasoning":
42
- tokens += Math.ceil(String(block.text || "").length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
43
- break;
44
- case "tool-call":
45
- tokens += Math.ceil(String(block.name || "").length / CHARS_PER_TOKEN) + Math.ceil(String(block.arguments || "").length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
46
- break;
47
- case "tool-result":
48
- tokens += estimateBlocks(block.content) + BLOCK_OVERHEAD;
49
- break;
50
- default:
51
- tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN);
52
- }
53
- }
54
- return tokens;
56
+ let tokens = 0;
57
+ if (!Array.isArray(blocks)) return 0;
58
+ for (const block of blocks) switch (block.type) {
59
+ case "text":
60
+ case "reasoning":
61
+ tokens += Math.ceil((block.text || "").length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
62
+ break;
63
+ case "tool-call":
64
+ tokens += Math.ceil((block.name || "").length / CHARS_PER_TOKEN) + Math.ceil((block.arguments || "").length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
65
+ break;
66
+ case "tool-result":
67
+ tokens += estimateBlocks(block.content) + BLOCK_OVERHEAD;
68
+ break;
69
+ default: tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN);
70
+ }
71
+ return tokens;
55
72
  }
73
+ /**
74
+ * Price one surface message exactly like dsh's token-meter estimate:
75
+ * an empty-content assistant/message projects to NO message (it only hosts
76
+ * usage), so it prices 0; every other message pays content + role framing.
77
+ */
56
78
  function estimateMessage(message, emptyIsZero = false) {
57
- if (emptyIsZero && (message === null || message === void 0 || !Array.isArray(message.content) || message.content.length === 0)) {
58
- return 0;
59
- }
60
- return estimateBlocks(message?.content) + ROLE_OVERHEAD;
79
+ if (emptyIsZero && (message === null || message === void 0 || !Array.isArray(message.content) || message.content.length === 0)) return 0;
80
+ return estimateBlocks(message?.content) + ROLE_OVERHEAD;
61
81
  }
62
82
  function estimateSystem(text) {
63
- if (typeof text !== "string" || text.length === 0) return 0;
64
- return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD;
83
+ if (typeof text !== "string" || text.length === 0) return 0;
84
+ return Math.ceil(text.length / CHARS_PER_TOKEN) + ROLE_OVERHEAD;
65
85
  }
86
+ /** Per-tool price for the top-tools display (the total uses dsh's whole-array price). */
66
87
  function estimateToolSchema(tool) {
67
- return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
88
+ return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
68
89
  }
69
90
  function firstText(blocks) {
70
- if (!Array.isArray(blocks)) return "";
71
- for (const b of blocks) {
72
- if (b && b.type === "text" && typeof b.text === "string" && b.text.trim() !== "") {
73
- return b.text.replace(/\s+/g, " ").trim().slice(0, 80);
74
- }
75
- }
76
- return "";
91
+ if (!Array.isArray(blocks)) return "";
92
+ for (const b of blocks) if (b.type === "text" && typeof b.text === "string" && b.text.trim() !== "") return b.text.replace(/\s+/g, " ").trim().slice(0, 80);
93
+ return "";
77
94
  }
78
95
  function toolCallNames(blocks) {
79
- const names = [];
80
- if (!Array.isArray(blocks)) return names;
81
- for (const b of blocks) {
82
- if (b && b.type === "tool-call" && typeof b.name === "string") names.push(b.name);
83
- }
84
- return names;
96
+ const names = [];
97
+ if (!Array.isArray(blocks)) return names;
98
+ for (const b of blocks) if (b.type === "tool-call" && typeof b.name === "string") names.push(b.name);
99
+ return names;
85
100
  }
86
101
  function isInjection(source) {
87
- return source !== null && typeof source === "object" && (source.kind === "plugin" || source.kind === "skill-invocation" || typeof source.form === "string");
102
+ return source !== null && source !== void 0 && (source.kind === "plugin" || source.kind === "skill-invocation" || typeof source.form === "string");
88
103
  }
89
-
90
- // src/host/headers.ts
91
- var HEADERS_MAX = 50;
92
- var headerToolSchema = z2.object({
93
- name: z2.string(),
94
- tokens: z2.number().int().nonnegative(),
95
- description: z2.string().optional(),
96
- schema: z2.unknown().optional()
97
- }).strict();
98
- var contextHeadersSchema = z2.object({
99
- headers: z2.array(z2.object({
100
- seq: z2.number(),
101
- time: z2.number(),
102
- system: z2.string().optional(),
103
- tools: z2.array(headerToolSchema)
104
- }).strict())
104
+ //#endregion
105
+ //#region src/host/headers.ts
106
+ /**
107
+ * The `contextHeaders` session projection unit — the request-header CONTENT
108
+ * epochs behind the timeline's envelope figures.
109
+ *
110
+ * The hot `contextTimeline` unit carries only token prices of the system
111
+ * prompt and tool schemas; this companion unit keeps the CONTENT (full
112
+ * system prompt text, full tool JSON schemas) so the Context browser card
113
+ * can show what a picked step's request was actually assembled from. It is
114
+ * a separate unit on purpose: the agent loop logs `request/header` only
115
+ * when the header changes, so this state (and its pushes to the browser)
116
+ * moves rarely — carrying full content costs nothing on the per-event hot
117
+ * path.
118
+ *
119
+ * Same projection contract as the timeline unit: pure init/apply/view,
120
+ * `Object.is` reference stability for uninteresting events, plain-JSON
121
+ * bounded state (epoch list capped — see HEADERS_MAX).
122
+ */
123
+ /** Retention cap on header epochs (changes are rare; 50 is generous). */
124
+ const HEADERS_MAX = 50;
125
+ const headerToolSchema = z.object({
126
+ name: z.string(),
127
+ tokens: z.number().int().nonnegative(),
128
+ description: z.string().optional(),
129
+ schema: z.unknown().optional()
105
130
  }).strict();
106
- var contextHeadersStateSchema = contextHeadersSchema;
131
+ const contextHeadersSchema = z.object({ headers: z.array(z.object({
132
+ seq: z.number(),
133
+ time: z.number(),
134
+ system: z.string().optional(),
135
+ tools: z.array(headerToolSchema)
136
+ }).strict()) }).strict();
137
+ /**
138
+ * The persisted state and the wire view are the same shape here (the view
139
+ * only shallow-copies each record), so one schema validates both under the
140
+ * dsh 0.1.1-rc.1+ `stateSchema`/`wire` contract.
141
+ */
142
+ const contextHeadersStateSchema = contextHeadersSchema;
143
+ /** Fold one `request/header` payload into an epoch record (display-priced). */
107
144
  function recordOf(event) {
108
- if (event.type !== "request/header") return null;
109
- const header = event.data.header;
110
- if (header === null || typeof header !== "object") return null;
111
- const tools = Array.isArray(header.tools) ? header.tools : [];
112
- const record = {
113
- seq: event.seq,
114
- time: event.time,
115
- tools: tools.map((t) => {
116
- const tool = t;
117
- const entry = {
118
- name: typeof tool.name === "string" ? tool.name : "?",
119
- tokens: estimateToolSchema(t),
120
- schema: t
121
- };
122
- if (typeof tool.description === "string" && tool.description !== "") {
123
- entry.description = tool.description;
124
- }
125
- return entry;
126
- })
127
- };
128
- if (typeof header.system === "string" && header.system.length > 0) {
129
- record.system = header.system;
130
- }
131
- return record;
145
+ if (event.type !== "request/header") return null;
146
+ const rawHeader = event.data.header;
147
+ if (rawHeader === null || rawHeader === void 0 || typeof rawHeader !== "object") return null;
148
+ const header = rawHeader;
149
+ const tools = Array.isArray(header.tools) ? header.tools : [];
150
+ const record = {
151
+ seq: event.seq,
152
+ time: event.time,
153
+ tools: tools.map((t) => {
154
+ const tool = t;
155
+ const entry = {
156
+ name: typeof tool.name === "string" ? tool.name : "?",
157
+ tokens: estimateToolSchema(t),
158
+ schema: t
159
+ };
160
+ if (typeof tool.description === "string" && tool.description !== "") entry.description = tool.description;
161
+ return entry;
162
+ })
163
+ };
164
+ if (typeof header.system === "string" && header.system.length > 0) record.system = header.system;
165
+ return record;
132
166
  }
167
+ /**
168
+ * The context-headers projection unit. Registered alongside the timeline
169
+ * unit (host/index.ts); clients read it through `useProjection('contextHeaders')`
170
+ * and degrade to tokens-only header sections when the key is absent.
171
+ *
172
+ * Dual-contract definition (see compat.ts): `schema`/`view` for
173
+ * dsh <= 0.1.0-rc.8, `stateSchema`/`wire` for dsh >= 0.1.1-rc.1. Without
174
+ * `wire`, the 0.1.1-rc.1+ registry treats the unit as host-only and the
175
+ * Context browser's system/tools sections would degrade.
176
+ */
133
177
  function createContextHeadersDefinition() {
134
- const view = (state) => ({
135
- headers: state.headers.map((h) => ({ ...h, tools: h.tools.map((t) => ({ ...t })) }))
136
- });
137
- const definition = {
138
- key: "contextHeaders",
139
- // dsh <= 0.1.0-rc.8 contract: one schema validates the wire payload, `view` is top-level.
140
- schema: contextHeadersSchema,
141
- view,
142
- // dsh >= 0.1.1-rc.1 contract: `stateSchema` validates persisted state, the client view lives in `wire`.
143
- stateSchema: contextHeadersStateSchema,
144
- wire: { viewSchema: contextHeadersSchema, view },
145
- init: () => ({ headers: [] }),
146
- apply: (state, event) => {
147
- const record = recordOf(event);
148
- if (record === null) return state;
149
- const last = state.headers[state.headers.length - 1];
150
- if (last !== void 0 && last.seq === record.seq) return state;
151
- const headers = [...state.headers, record];
152
- return { headers: headers.length > HEADERS_MAX ? headers.slice(-HEADERS_MAX) : headers };
153
- },
154
- stateVersion: 1
155
- };
156
- return definition;
178
+ const view = (state) => ({ headers: state.headers.map((h) => ({
179
+ ...h,
180
+ tools: h.tools.map((t) => ({ ...t }))
181
+ })) });
182
+ return {
183
+ key: "contextHeaders",
184
+ schema: contextHeadersSchema,
185
+ view,
186
+ stateSchema: contextHeadersStateSchema,
187
+ wire: {
188
+ viewSchema: contextHeadersSchema,
189
+ view
190
+ },
191
+ init: () => ({ headers: [] }),
192
+ apply: (state, event) => {
193
+ const record = recordOf(event);
194
+ if (record === null) return state;
195
+ const last = state.headers.at(-1);
196
+ if (last !== void 0 && last.seq === record.seq) return state;
197
+ const headers = [...state.headers, record];
198
+ return { headers: headers.length > HEADERS_MAX ? headers.slice(-50) : headers };
199
+ },
200
+ stateVersion: 1
201
+ };
157
202
  }
158
-
159
- // src/host/timeline.ts
160
- import { z as z3 } from "zod";
161
-
162
- // src/host/fold.ts
163
- import { deriveEventMessage } from "@deepseek-ai/dsh-session";
203
+ //#endregion
204
+ //#region src/host/fold.ts
205
+ /** Keep only the trailing `maxTurns` turn-runs of a request timeline. */
164
206
  function trimToLastTurns(requests, maxTurns) {
165
- let runs = 0;
166
- let start = requests.length;
167
- let prevTurn;
168
- for (let i = requests.length - 1; i >= 0; i--) {
169
- const turn = requests[i].turn;
170
- if (turn !== prevTurn) {
171
- if (runs >= maxTurns) break;
172
- runs++;
173
- prevTurn = turn;
174
- }
175
- start = i;
176
- }
177
- return requests.slice(start);
207
+ let runs = 0;
208
+ let start = requests.length;
209
+ let prevTurn;
210
+ for (let i = requests.length - 1; i >= 0; i--) {
211
+ const turn = requests[i].turn;
212
+ if (turn !== prevTurn) {
213
+ if (runs >= maxTurns) break;
214
+ runs++;
215
+ prevTurn = turn;
216
+ }
217
+ start = i;
218
+ }
219
+ return requests.slice(start);
178
220
  }
221
+ /** Distinct turn runs in a request timeline (consecutive equal-turn runs). */
179
222
  function countTurnRuns(requests) {
180
- let runs = 0;
181
- let prevTurn;
182
- for (const r of requests) {
183
- if (r.turn !== prevTurn) {
184
- runs++;
185
- prevTurn = r.turn;
186
- }
187
- }
188
- return runs;
223
+ let runs = 0;
224
+ let prevTurn;
225
+ for (const r of requests) if (r.turn !== prevTurn) {
226
+ runs++;
227
+ prevTurn = r.turn;
228
+ }
229
+ return runs;
189
230
  }
231
+ /** Retain the newest tail of the two unbounded lists (bounded persisted state). */
190
232
  function trimState(st, bounds) {
191
- if (countTurnRuns(st.requests) > bounds.maxKeptTurns) {
192
- st.requests = trimToLastTurns(st.requests, bounds.maxKeptTurns);
193
- }
194
- if (st.requests.length > bounds.maxRequestSteps) {
195
- st.requests = st.requests.slice(-bounds.maxRequestSteps);
196
- }
197
- if (st.events.length > bounds.maxEvents) st.events = st.events.slice(-bounds.maxEvents);
198
- if (st.archived.length > 0) {
199
- let drop = 0;
200
- const oldestReq = st.requests.length > 0 ? st.requests[0].seq : void 0;
201
- if (oldestReq !== void 0) {
202
- while (drop < st.archived.length && (st.archived[drop].gone ?? Infinity) <= oldestReq) drop++;
203
- }
204
- if (st.archived.length - drop > bounds.maxArchiveNodes) {
205
- drop = st.archived.length - bounds.maxArchiveNodes;
206
- }
207
- if (drop > 0) {
208
- const floor = st.archived[drop - 1].gone;
209
- if (floor !== void 0) st.archiveFloor = Math.max(st.archiveFloor ?? 0, floor);
210
- st.archived = st.archived.slice(drop);
211
- }
212
- }
233
+ if (countTurnRuns(st.requests) > bounds.maxKeptTurns) st.requests = trimToLastTurns(st.requests, bounds.maxKeptTurns);
234
+ if (st.requests.length > bounds.maxRequestSteps) st.requests = st.requests.slice(-bounds.maxRequestSteps);
235
+ if (st.events.length > bounds.maxEvents) st.events = st.events.slice(-bounds.maxEvents);
236
+ if (st.archived.length > 0) {
237
+ let drop = 0;
238
+ const oldestReq = st.requests.length > 0 ? st.requests[0].seq : void 0;
239
+ if (oldestReq !== void 0) while (drop < st.archived.length && (st.archived[drop].gone ?? Infinity) <= oldestReq) drop++;
240
+ if (st.archived.length - drop > bounds.maxArchiveNodes) drop = st.archived.length - bounds.maxArchiveNodes;
241
+ if (drop > 0) {
242
+ const floor = st.archived[drop - 1].gone;
243
+ if (floor !== void 0) st.archiveFloor = Math.max(st.archiveFloor ?? 0, floor);
244
+ st.archived = st.archived.slice(drop);
245
+ }
246
+ }
213
247
  }
214
248
  function createTimelineState() {
215
- return {
216
- surface: [],
217
- sums: { user: 0, inject: 0, assistant: 0, tool: 0 },
218
- systemTokens: 0,
219
- toolsTokens: 0,
220
- toolList: [],
221
- requests: [],
222
- events: [],
223
- archived: [],
224
- callNames: {}
225
- };
249
+ return {
250
+ surface: [],
251
+ sums: {
252
+ user: 0,
253
+ inject: 0,
254
+ assistant: 0,
255
+ tool: 0
256
+ },
257
+ systemTokens: 0,
258
+ toolsTokens: 0,
259
+ toolList: [],
260
+ requests: [],
261
+ events: [],
262
+ archived: [],
263
+ callNames: {}
264
+ };
226
265
  }
227
266
  function categoryOf(type, message) {
228
- if (type === "assistant/message") return "assistant";
229
- if (type === "tool/result") return "tool";
230
- if (isInjection(message?.source)) return "inject";
231
- return "user";
267
+ if (type === "assistant/message") return "assistant";
268
+ if (type === "tool/result") return "tool";
269
+ if (isInjection(message?.source)) return "inject";
270
+ return "user";
232
271
  }
272
+ /**
273
+ * Archive removed surface nodes as stamped COPIES — the objects leaving
274
+ * `st.surface` are shared with the persisted previous state, so `gone` must
275
+ * never be written onto them directly.
276
+ */
233
277
  function archiveRemoved(st, removed, goneSeq) {
234
- for (const n of removed) st.archived.push({ ...n, gone: goneSeq });
278
+ for (const n of removed) st.archived.push({
279
+ ...n,
280
+ gone: goneSeq
281
+ });
235
282
  }
236
283
  function applySurface(st, ev, type, data, message) {
237
- const cat = categoryOf(type, message ?? void 0);
238
- const node = {
239
- seq: ev.seq,
240
- time: ev.time,
241
- cat,
242
- // Empty assistant messages project to no model message (usage-only), so
243
- // they price 0 — `deriveEventMessage` returns null for that case, and
244
- // `estimateMessage(null, true)` short-circuits before ROLE_OVERHEAD.
245
- tokens: estimateMessage(message, type === "assistant/message")
246
- };
247
- const source = message?.source;
248
- const form = source?.form;
249
- if (typeof form === "string") node.form = form;
250
- if (type === "assistant/message") {
251
- const text = firstText(message?.content);
252
- if (text !== "") node.text = text;
253
- else {
254
- const names = toolCallNames(message?.content);
255
- if (names.length > 0) node.calls = names.slice(0, 3);
256
- }
257
- } else if (type === "tool/result") {
258
- const srcId = source?.callId;
259
- const srcName = typeof srcId === "string" ? st.callNames[srcId] : void 0;
260
- const block = message?.content?.[0];
261
- const blockId = block?.toolCallId;
262
- if (srcName) node.tool = srcName;
263
- else if (typeof blockId === "string") node.tool = st.callNames[blockId];
264
- if (data?.error) node.err = true;
265
- } else if (source?.kind === "skill-invocation") {
266
- node.skill = typeof source.name === "string" ? source.name : "?";
267
- } else if (source?.kind === "plugin") {
268
- if (source.form === "notice" && typeof source.summary === "string") node.text = source.summary;
269
- else if (source.form === "snapshot" && Array.isArray(source.sections)) {
270
- node.text = source.sections.map((s) => s?.name).filter(Boolean).join(", ").slice(0, 80);
271
- } else {
272
- const ptext = firstText(message?.content);
273
- if (ptext !== "") node.text = ptext;
274
- }
275
- } else {
276
- const utext = firstText(message?.content);
277
- if (utext !== "") node.text = utext;
278
- }
279
- const shadowedSeqs = st.pendingShadowedSeqs;
280
- delete st.pendingShadowedSeqs;
281
- const op = ev.surfaceOp;
282
- if (op !== null && typeof op === "object" && op.op === "replace") {
283
- if (Array.isArray(shadowedSeqs) && shadowedSeqs.length > 0) {
284
- const shadowed = new Set(shadowedSeqs);
285
- const kept = [];
286
- const removed = [];
287
- for (const n of st.surface) {
288
- if (shadowed.has(n.seq)) {
289
- st.sums[n.cat] -= n.tokens;
290
- removed.push(n);
291
- } else kept.push(n);
292
- }
293
- archiveRemoved(st, removed, ev.seq);
294
- st.surface = kept;
295
- st.sums[cat] += node.tokens;
296
- st.surface.push(node);
297
- return node;
298
- }
299
- let si = -1;
300
- let ei = -1;
301
- for (let i = 0; i < st.surface.length; i++) {
302
- if (si < 0 && st.surface[i].seq === op.start) si = i;
303
- if (st.surface[i].seq === op.end) {
304
- ei = i;
305
- break;
306
- }
307
- }
308
- if (si >= 0 && ei >= si) {
309
- const removed = st.surface.splice(si, ei - si + 1, node);
310
- archiveRemoved(st, removed, ev.seq);
311
- for (const r of removed) st.sums[r.cat] -= r.tokens;
312
- st.sums[cat] += node.tokens;
313
- return node;
314
- }
315
- }
316
- st.surface.push(node);
317
- st.sums[cat] += node.tokens;
318
- return node;
284
+ const cat = categoryOf(type, message ?? void 0);
285
+ const node = {
286
+ seq: ev.seq,
287
+ time: ev.time,
288
+ cat,
289
+ tokens: estimateMessage(message, type === "assistant/message")
290
+ };
291
+ const source = message?.source;
292
+ const form = source?.form;
293
+ if (typeof form === "string") node.form = form;
294
+ if (type === "assistant/message") {
295
+ const text = firstText(message?.content);
296
+ if (text !== "") node.text = text;
297
+ else {
298
+ const names = toolCallNames(message?.content);
299
+ if (names.length > 0) node.calls = names.slice(0, 3);
300
+ }
301
+ } else if (type === "tool/result") {
302
+ const srcId = source?.callId;
303
+ const srcName = typeof srcId === "string" ? st.callNames[srcId] : void 0;
304
+ const blockId = (message?.content?.[0])?.toolCallId;
305
+ if (srcName) node.tool = srcName;
306
+ else if (typeof blockId === "string") node.tool = st.callNames[blockId];
307
+ if (data?.error) node.err = true;
308
+ } else if (source?.kind === "skill-invocation") node.skill = typeof source.name === "string" ? source.name : "?";
309
+ else if (source?.kind === "plugin") {
310
+ if (source.form === "notice" && typeof source.summary === "string") node.text = source.summary;
311
+ else if (source.form === "snapshot" && Array.isArray(source.sections)) node.text = source.sections.map((s) => s?.name).filter(Boolean).join(", ").slice(0, 80);
312
+ else {
313
+ const ptext = firstText(message?.content);
314
+ if (ptext !== "") node.text = ptext;
315
+ }
316
+ } else {
317
+ const utext = firstText(message?.content);
318
+ if (utext !== "") node.text = utext;
319
+ }
320
+ const shadowedSeqs = st.pendingShadowedSeqs;
321
+ delete st.pendingShadowedSeqs;
322
+ const op = ev.surfaceOp;
323
+ if (op !== null && typeof op === "object" && op.op === "replace") {
324
+ if (Array.isArray(shadowedSeqs) && shadowedSeqs.length > 0) {
325
+ const shadowed = new Set(shadowedSeqs);
326
+ const kept = [];
327
+ const removed = [];
328
+ for (const n of st.surface) if (shadowed.has(n.seq)) {
329
+ st.sums[n.cat] -= n.tokens;
330
+ removed.push(n);
331
+ } else kept.push(n);
332
+ archiveRemoved(st, removed, ev.seq);
333
+ st.surface = kept;
334
+ st.sums[cat] += node.tokens;
335
+ st.surface.push(node);
336
+ return node;
337
+ }
338
+ let si = -1;
339
+ let ei = -1;
340
+ for (let i = 0; i < st.surface.length; i++) {
341
+ if (si < 0 && st.surface[i].seq === op.start) si = i;
342
+ if (st.surface[i].seq === op.end) {
343
+ ei = i;
344
+ break;
345
+ }
346
+ }
347
+ if (si >= 0 && ei >= si) {
348
+ const removed = st.surface.splice(si, ei - si + 1, node);
349
+ archiveRemoved(st, removed, ev.seq);
350
+ for (const r of removed) st.sums[r.cat] -= r.tokens;
351
+ st.sums[cat] += node.tokens;
352
+ return node;
353
+ }
354
+ }
355
+ st.surface.push(node);
356
+ st.sums[cat] += node.tokens;
357
+ return node;
319
358
  }
359
+ /**
360
+ * The DeepSeek V4 model family a model name prices as — matched on the NAME
361
+ * alone (provider-agnostic: official API, proxies, OpenRouter spellings like
362
+ * `deepseek/deepseek-v4-flash` all land here). Null for any other model:
363
+ * non-V4 usage is simply not priced.
364
+ */
320
365
  function costFamilyOf(model) {
321
- if (model === void 0) return null;
322
- const m = model.toLowerCase();
323
- if (!m.includes("v4")) return null;
324
- if (m.includes("flash")) return "flash";
325
- if (m.includes("pro")) return "pro";
326
- return null;
366
+ if (model === void 0) return null;
367
+ const m = model.toLowerCase();
368
+ if (!m.includes("v4")) return null;
369
+ if (m.includes("flash")) return "flash";
370
+ if (m.includes("pro")) return "pro";
371
+ return null;
327
372
  }
373
+ /** DeepSeek's UTC peak windows: 01:00-04:00 and 06:00-10:00 (off-peak is half price). */
328
374
  function isPeakUtc(time) {
329
- const h = new Date(time).getUTCHours();
330
- return h >= 1 && h < 4 || h >= 6 && h < 10;
375
+ const h = new Date(time).getUTCHours();
376
+ return h >= 1 && h < 4 || h >= 6 && h < 10;
331
377
  }
378
+ /**
379
+ * Fold one billed request into the session-cost totals, cloning along the
380
+ * mutated path only (the untouched branch stays shared with the persisted
381
+ * previous state — the apply contract never mutates it in place).
382
+ */
332
383
  function accumulateCost(st, time, usage) {
333
- const family = costFamilyOf(st.model);
334
- if (family === null) return;
335
- const prev = st.cost ?? {};
336
- const fam = prev[family] ?? {};
337
- const period = isPeakUtc(time) ? "peak" : "off";
338
- const b = fam[period] ?? { uncached: 0, cacheRead: 0, cacheWrite: 0, output: 0 };
339
- const nextFam = { ...fam };
340
- nextFam[period] = {
341
- uncached: b.uncached + (usage.inputTokens ?? 0),
342
- cacheRead: b.cacheRead + (usage.cacheReadTokens ?? 0),
343
- cacheWrite: b.cacheWrite + (usage.cacheWriteTokens ?? 0),
344
- output: b.output + (usage.outputTokens ?? 0)
345
- };
346
- const next = { ...prev };
347
- next[family] = nextFam;
348
- st.cost = next;
384
+ const family = costFamilyOf(st.model);
385
+ if (family === null) return;
386
+ const prev = st.cost ?? {};
387
+ const fam = prev[family] ?? {};
388
+ const period = isPeakUtc(time) ? "peak" : "off";
389
+ const b = fam[period] ?? {
390
+ uncached: 0,
391
+ cacheRead: 0,
392
+ cacheWrite: 0,
393
+ output: 0
394
+ };
395
+ const nextFam = { ...fam };
396
+ nextFam[period] = {
397
+ uncached: b.uncached + (usage.inputTokens ?? 0),
398
+ cacheRead: b.cacheRead + (usage.cacheReadTokens ?? 0),
399
+ cacheWrite: b.cacheWrite + (usage.cacheWriteTokens ?? 0),
400
+ output: b.output + (usage.outputTokens ?? 0)
401
+ };
402
+ const next = { ...prev };
403
+ next[family] = nextFam;
404
+ st.cost = next;
349
405
  }
406
+ /**
407
+ * Advance the fold over ONE committed session event under the projection
408
+ * contract. Uninteresting events return the same reference (`Object.is` gates
409
+ * the change feed); any change returns a new reference over a lazy shallow
410
+ * clone, so the persisted state is never mutated in place by the caller.
411
+ * `bounds` come from the plugin config (config.ts) — retention only, they
412
+ * never change the state shape.
413
+ */
350
414
  function applyTimeline(state, event, bounds) {
351
- let st;
352
- const ensure = () => st ??= {
353
- ...state,
354
- surface: [...state.surface],
355
- sums: { ...state.sums },
356
- toolList: [...state.toolList],
357
- requests: [...state.requests],
358
- events: [...state.events],
359
- archived: [...state.archived],
360
- callNames: { ...state.callNames }
361
- };
362
- const data = event.data;
363
- switch (event.type) {
364
- case "request/header": {
365
- const header = data?.header ?? {};
366
- const tools = Array.isArray(header.tools) ? header.tools : [];
367
- const s = ensure();
368
- s.toolList = tools.map((t) => ({
369
- name: typeof t.name === "string" ? t.name : "?",
370
- tokens: estimateToolSchema(t)
371
- }));
372
- s.toolsTokens = estimateToolsTotal(tools);
373
- s.systemTokens = estimateSystem(header.system);
374
- if (header.config && typeof header.config.model === "string") s.model = header.config.model;
375
- if (header.config && typeof header.config.provider === "string") s.provider = header.config.provider;
376
- if (data?.reason === "change" && s.model && s.lastModel && s.model !== s.lastModel) {
377
- s.events.push({ seq: event.seq, time: event.time, kind: "model", from: s.lastModel, to: s.model });
378
- }
379
- if (s.model) s.lastModel = s.model;
380
- break;
381
- }
382
- case "request/context": {
383
- const s = ensure();
384
- if (data && typeof data.contextWindow === "number") s.contextWindow = data.contextWindow;
385
- if (data && typeof data.model === "string") s.model = data.model;
386
- if (data && typeof data.provider === "string") s.provider = data.provider;
387
- break;
388
- }
389
- case "tool/call": {
390
- if (data && data.callId !== void 0 && typeof data.name === "string") {
391
- const s = ensure();
392
- s.callNames[String(data.callId)] = data.name;
393
- }
394
- break;
395
- }
396
- case "user/message": {
397
- const msg = deriveEventMessage(event);
398
- const s = ensure();
399
- const node = applySurface(s, event, event.type, data, msg);
400
- const source = msg?.source;
401
- if (isInjection(source)) {
402
- const rec = {
403
- seq: event.seq,
404
- time: event.time,
405
- kind: "inject",
406
- form: source.form || "context",
407
- tokens: node.tokens
408
- };
409
- if (source.kind === "skill-invocation") {
410
- rec.sub = "skill";
411
- rec.name = typeof source.name === "string" ? source.name : "?";
412
- } else if (typeof source.plugin === "string" && source.plugin !== "") {
413
- rec.name = source.plugin;
414
- }
415
- s.events.push(rec);
416
- }
417
- break;
418
- }
419
- case "tool/result": {
420
- const toolMsg = deriveEventMessage(event);
421
- const s = ensure();
422
- applySurface(s, event, event.type, data, toolMsg);
423
- break;
424
- }
425
- case "assistant/message": {
426
- const usage = data?.usage;
427
- const s = ensure();
428
- const total = s.systemTokens + s.toolsTokens + s.sums.user + s.sums.inject + s.sums.assistant + s.sums.tool;
429
- const record = {
430
- time: event.time,
431
- seq: event.seq,
432
- system: s.systemTokens,
433
- tools: s.toolsTokens,
434
- user: s.sums.user,
435
- inject: s.sums.inject,
436
- assistant: s.sums.assistant,
437
- tool: s.sums.tool,
438
- total
439
- };
440
- if (data && typeof data.turn === "number") record.turn = data.turn;
441
- if (data && typeof data.step === "number") record.step = data.step;
442
- if (usage && typeof usage.inputTokens === "number") {
443
- record.prompt = usage.inputTokens + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0);
444
- if (typeof usage.outputTokens === "number") record.output = usage.outputTokens;
445
- accumulateCost(s, event.time, usage);
446
- }
447
- s.requests.push(record);
448
- const asstMsg = deriveEventMessage(event);
449
- applySurface(s, event, event.type, data, asstMsg);
450
- break;
451
- }
452
- case "compaction/summary":
453
- case "compaction/prune": {
454
- const s = ensure();
455
- if (data && Array.isArray(data.shadowedSeqs)) {
456
- s.pendingShadowedSeqs = data.shadowedSeqs.filter((x) => typeof x === "number");
457
- }
458
- s.events.push({
459
- seq: event.seq,
460
- time: event.time,
461
- kind: event.type === "compaction/summary" ? "compaction" : "prune",
462
- tokens: data && typeof data.shadowedTokenCount === "number" ? data.shadowedTokenCount : 0,
463
- ...event.type === "compaction/summary" && data && Array.isArray(data.shadowedSeqs) ? { count: data.shadowedSeqs.length } : {}
464
- });
465
- break;
466
- }
467
- default:
468
- return state;
469
- }
470
- if (st !== void 0) {
471
- trimState(st, bounds);
472
- return st;
473
- }
474
- return state;
415
+ let st;
416
+ const ensure = () => st ??= {
417
+ ...state,
418
+ surface: [...state.surface],
419
+ sums: { ...state.sums },
420
+ toolList: [...state.toolList],
421
+ requests: [...state.requests],
422
+ events: [...state.events],
423
+ archived: [...state.archived],
424
+ callNames: { ...state.callNames }
425
+ };
426
+ const data = event.data;
427
+ switch (event.type) {
428
+ case "request/header": {
429
+ const header = data?.header ?? {};
430
+ const tools = Array.isArray(header.tools) ? header.tools : [];
431
+ const s = ensure();
432
+ s.toolList = tools.map((t) => ({
433
+ name: typeof t.name === "string" ? t.name : "?",
434
+ tokens: estimateToolSchema(t)
435
+ }));
436
+ s.toolsTokens = estimateToolsTotal(tools);
437
+ s.systemTokens = estimateSystem(header.system);
438
+ if (header.config && typeof header.config.model === "string") s.model = header.config.model;
439
+ if (header.config && typeof header.config.provider === "string") s.provider = header.config.provider;
440
+ if (data?.reason === "change" && s.model && s.lastModel && s.model !== s.lastModel) s.events.push({
441
+ seq: event.seq,
442
+ time: event.time,
443
+ kind: "model",
444
+ from: s.lastModel,
445
+ to: s.model
446
+ });
447
+ if (s.model) s.lastModel = s.model;
448
+ break;
449
+ }
450
+ case "request/context": {
451
+ const s = ensure();
452
+ if (data && typeof data.contextWindow === "number") s.contextWindow = data.contextWindow;
453
+ if (data && typeof data.model === "string") s.model = data.model;
454
+ if (data && typeof data.provider === "string") s.provider = data.provider;
455
+ break;
456
+ }
457
+ case "tool/call":
458
+ if (data && typeof data.callId === "string" && typeof data.name === "string") {
459
+ const s = ensure();
460
+ s.callNames[data.callId] = data.name;
461
+ }
462
+ break;
463
+ case "user/message": {
464
+ const msg = deriveEventMessage(event);
465
+ const s = ensure();
466
+ const node = applySurface(s, event, event.type, data, msg);
467
+ const source = msg?.source;
468
+ if (isInjection(source)) {
469
+ const rec = {
470
+ seq: event.seq,
471
+ time: event.time,
472
+ kind: "inject",
473
+ form: source.form || "context",
474
+ tokens: node.tokens
475
+ };
476
+ if (source.kind === "skill-invocation") {
477
+ rec.sub = "skill";
478
+ rec.name = typeof source.name === "string" ? source.name : "?";
479
+ } else if (typeof source.plugin === "string" && source.plugin !== "") rec.name = source.plugin;
480
+ s.events.push(rec);
481
+ }
482
+ break;
483
+ }
484
+ case "tool/result": {
485
+ const toolMsg = deriveEventMessage(event);
486
+ applySurface(ensure(), event, event.type, data, toolMsg);
487
+ break;
488
+ }
489
+ case "assistant/message": {
490
+ const usage = data?.usage;
491
+ const s = ensure();
492
+ const total = s.systemTokens + s.toolsTokens + s.sums.user + s.sums.inject + s.sums.assistant + s.sums.tool;
493
+ const record = {
494
+ time: event.time,
495
+ seq: event.seq,
496
+ system: s.systemTokens,
497
+ tools: s.toolsTokens,
498
+ user: s.sums.user,
499
+ inject: s.sums.inject,
500
+ assistant: s.sums.assistant,
501
+ tool: s.sums.tool,
502
+ total
503
+ };
504
+ if (data && typeof data.turn === "number") record.turn = data.turn;
505
+ if (data && typeof data.step === "number") record.step = data.step;
506
+ if (usage && typeof usage.inputTokens === "number") {
507
+ record.prompt = usage.inputTokens + (usage.cacheReadTokens || 0) + (usage.cacheWriteTokens || 0);
508
+ if (typeof usage.outputTokens === "number") record.output = usage.outputTokens;
509
+ accumulateCost(s, event.time, usage);
510
+ }
511
+ s.requests.push(record);
512
+ const asstMsg = deriveEventMessage(event);
513
+ applySurface(s, event, event.type, data, asstMsg);
514
+ break;
515
+ }
516
+ case "compaction/summary":
517
+ case "compaction/prune": {
518
+ const s = ensure();
519
+ if (data && Array.isArray(data.shadowedSeqs)) s.pendingShadowedSeqs = data.shadowedSeqs.filter((x) => typeof x === "number");
520
+ s.events.push({
521
+ seq: event.seq,
522
+ time: event.time,
523
+ kind: event.type === "compaction/summary" ? "compaction" : "prune",
524
+ tokens: data && typeof data.shadowedTokenCount === "number" ? data.shadowedTokenCount : 0,
525
+ ...event.type === "compaction/summary" && data && Array.isArray(data.shadowedSeqs) ? { count: data.shadowedSeqs.length } : {}
526
+ });
527
+ break;
528
+ }
529
+ default: return state;
530
+ }
531
+ if (st !== void 0) {
532
+ trimState(st, bounds);
533
+ return st;
534
+ }
535
+ return state;
475
536
  }
537
+ /**
538
+ * Build the wire snapshot served to the browser — the projection's `view()`.
539
+ * Bounds the surface nodes (newest carry the signal), and attributes each
540
+ * event to the request around it by stamping COPIES (the persisted state
541
+ * objects are never mutated).
542
+ */
476
543
  function buildTimelineView(state, bounds) {
477
- const surfaceTotal = state.sums.user + state.sums.inject + state.sums.assistant + state.sums.tool;
478
- const result = {
479
- ok: true,
480
- model: state.model,
481
- provider: state.provider,
482
- contextWindow: state.contextWindow,
483
- current: {
484
- system: state.systemTokens,
485
- tools: state.toolsTokens,
486
- user: state.sums.user,
487
- inject: state.sums.inject,
488
- assistant: state.sums.assistant,
489
- tool: state.sums.tool,
490
- total: surfaceTotal + state.systemTokens + state.toolsTokens
491
- },
492
- toolList: state.toolList,
493
- requests: state.requests.map((r) => ({ ...r })),
494
- events: state.events.map((e) => ({ ...e })),
495
- nodes: [],
496
- droppedNodes: 0,
497
- archive: state.archived.map((n) => ({ ...n }))
498
- };
499
- if (state.cost !== void 0) {
500
- const copyFam = (f) => {
501
- if (f === void 0) return void 0;
502
- const out = {};
503
- if (f.peak !== void 0) out.peak = { ...f.peak };
504
- if (f.off !== void 0) out.off = { ...f.off };
505
- return out;
506
- };
507
- const cost = {};
508
- const flash = copyFam(state.cost.flash);
509
- if (flash !== void 0) cost.flash = flash;
510
- const pro = copyFam(state.cost.pro);
511
- if (pro !== void 0) cost.pro = pro;
512
- result.cost = cost;
513
- }
514
- const overflowCount = Math.max(0, state.surface.length - bounds.maxNodes);
515
- const overflow = state.surface.slice(0, overflowCount);
516
- const tail = state.surface.slice(overflowCount);
517
- const pinned = overflow.filter((n) => n.cat === "inject");
518
- result.nodes = pinned.length > 0 ? [...pinned, ...tail] : tail;
519
- result.droppedNodes = overflowCount - pinned.length;
520
- if (result.droppedNodes > 0) {
521
- let floor = 0;
522
- for (const n of overflow) if (n.cat !== "inject") floor = Math.max(floor, n.seq);
523
- result.surfaceFloor = floor;
524
- }
525
- if (state.archiveFloor !== void 0) result.archiveFloor = state.archiveFloor;
526
- const requests = result.requests;
527
- const events = result.events;
528
- let ri = 0;
529
- for (const ev of events) {
530
- while (ri < requests.length && requests[ri].seq <= ev.seq) ri++;
531
- const next = requests[ri];
532
- const prev = ri > 0 ? requests[ri - 1] : void 0;
533
- if (next !== void 0 && typeof next.turn === "number" && typeof next.step === "number") {
534
- ev.turn = next.turn;
535
- ev.step = next.step;
536
- }
537
- if (prev !== void 0 && typeof prev.turn === "number" && typeof prev.step === "number") {
538
- ev.fromTurn = prev.turn;
539
- ev.fromStep = prev.step;
540
- }
541
- }
542
- return result;
544
+ const surfaceTotal = state.sums.user + state.sums.inject + state.sums.assistant + state.sums.tool;
545
+ const result = {
546
+ ok: true,
547
+ model: state.model,
548
+ provider: state.provider,
549
+ contextWindow: state.contextWindow,
550
+ current: {
551
+ system: state.systemTokens,
552
+ tools: state.toolsTokens,
553
+ user: state.sums.user,
554
+ inject: state.sums.inject,
555
+ assistant: state.sums.assistant,
556
+ tool: state.sums.tool,
557
+ total: surfaceTotal + state.systemTokens + state.toolsTokens
558
+ },
559
+ toolList: state.toolList,
560
+ requests: state.requests.map((r) => ({ ...r })),
561
+ events: state.events.map((e) => ({ ...e })),
562
+ nodes: [],
563
+ droppedNodes: 0,
564
+ archive: state.archived.map((n) => ({ ...n }))
565
+ };
566
+ if (state.cost !== void 0) {
567
+ const copyFam = (f) => {
568
+ if (f === void 0) return void 0;
569
+ const out = {};
570
+ if (f.peak !== void 0) out.peak = { ...f.peak };
571
+ if (f.off !== void 0) out.off = { ...f.off };
572
+ return out;
573
+ };
574
+ const cost = {};
575
+ const flash = copyFam(state.cost.flash);
576
+ if (flash !== void 0) cost.flash = flash;
577
+ const pro = copyFam(state.cost.pro);
578
+ if (pro !== void 0) cost.pro = pro;
579
+ result.cost = cost;
580
+ }
581
+ const overflowCount = Math.max(0, state.surface.length - bounds.maxNodes);
582
+ const overflow = state.surface.slice(0, overflowCount);
583
+ const tail = state.surface.slice(overflowCount);
584
+ const pinned = overflow.filter((n) => n.cat === "inject");
585
+ result.nodes = pinned.length > 0 ? [...pinned, ...tail] : tail;
586
+ result.droppedNodes = overflowCount - pinned.length;
587
+ if (result.droppedNodes > 0) {
588
+ let floor = 0;
589
+ for (const n of overflow) if (n.cat !== "inject") floor = Math.max(floor, n.seq);
590
+ result.surfaceFloor = floor;
591
+ }
592
+ if (state.archiveFloor !== void 0) result.archiveFloor = state.archiveFloor;
593
+ const requests = result.requests;
594
+ const events = result.events;
595
+ let ri = 0;
596
+ for (const ev of events) {
597
+ while (ri < requests.length && requests[ri].seq <= ev.seq) ri++;
598
+ const next = requests.at(ri);
599
+ const prev = ri > 0 ? requests.at(ri - 1) : void 0;
600
+ if (next !== void 0 && typeof next.turn === "number" && typeof next.step === "number") {
601
+ ev.turn = next.turn;
602
+ ev.step = next.step;
603
+ }
604
+ if (prev !== void 0 && typeof prev.turn === "number" && typeof prev.step === "number") {
605
+ ev.fromTurn = prev.turn;
606
+ ev.fromStep = prev.step;
607
+ }
608
+ }
609
+ return result;
543
610
  }
544
-
545
- // src/host/timeline.ts
546
- var surfaceNodeSchema = z3.object({
547
- seq: z3.number().int().nonnegative(),
548
- time: z3.number().optional(),
549
- cat: z3.enum(["user", "inject", "assistant", "tool"]),
550
- tokens: z3.number().int().nonnegative(),
551
- gone: z3.number().int().nonnegative().optional(),
552
- form: z3.string().optional(),
553
- text: z3.string().optional(),
554
- tool: z3.string().optional(),
555
- err: z3.boolean().optional(),
556
- skill: z3.string().optional(),
557
- calls: z3.array(z3.string()).optional()
611
+ //#endregion
612
+ //#region src/host/timeline.ts
613
+ /**
614
+ * The `contextTimeline` session projection unit — the plugin's data plane.
615
+ *
616
+ * This is the whole Host half after the v0.9 data-path migration: instead of
617
+ * serving snapshots over a custom `/dsh-context` RPC channel, the plugin
618
+ * registers one pure projection unit on the harness's
619
+ * `ctx.sessionProjections` registry. The framework then:
620
+ * - drives the fold per committed `session/event` (eager, incremental),
621
+ * - persists the unit state through `ctx.sessionProjectionCache`
622
+ * (checkpointed rows, cold-read ladder, resume-safe),
623
+ * - delivers finished values to the browser as a `session/projection` push
624
+ * frame plus a tail-page baseline, where the Client reads them through
625
+ * the framework-standard `useProjection('contextTimeline')` seat.
626
+ *
627
+ * The unit is pure mathematics (init/apply/view) — it holds no subscriptions
628
+ * and never touches the client. The wire value is the same Snapshot the UI
629
+ * has always rendered (shared/types.ts), so the Client renders unchanged.
630
+ */
631
+ /** Validate the wire payload before it leaves the host (strict: no drift). */
632
+ const surfaceNodeSchema = z.object({
633
+ seq: z.number().int().nonnegative(),
634
+ time: z.number().optional(),
635
+ cat: z.enum([
636
+ "user",
637
+ "inject",
638
+ "assistant",
639
+ "tool"
640
+ ]),
641
+ tokens: z.number().int().nonnegative(),
642
+ gone: z.number().int().nonnegative().optional(),
643
+ form: z.string().optional(),
644
+ text: z.string().optional(),
645
+ tool: z.string().optional(),
646
+ err: z.boolean().optional(),
647
+ skill: z.string().optional(),
648
+ calls: z.array(z.string()).optional()
558
649
  }).strict();
559
- var requestRecordSchema = z3.object({
560
- turn: z3.number().optional(),
561
- step: z3.number().optional(),
562
- time: z3.number(),
563
- seq: z3.number(),
564
- system: z3.number().int().nonnegative(),
565
- tools: z3.number().int().nonnegative(),
566
- user: z3.number().int().nonnegative(),
567
- inject: z3.number().int().nonnegative(),
568
- assistant: z3.number().int().nonnegative(),
569
- tool: z3.number().int().nonnegative(),
570
- total: z3.number().int().nonnegative(),
571
- prompt: z3.number().int().nonnegative().optional(),
572
- output: z3.number().int().nonnegative().optional(),
573
- stepCount: z3.number().int().positive().optional()
650
+ const requestRecordSchema = z.object({
651
+ turn: z.number().optional(),
652
+ step: z.number().optional(),
653
+ time: z.number(),
654
+ seq: z.number(),
655
+ system: z.number().int().nonnegative(),
656
+ tools: z.number().int().nonnegative(),
657
+ user: z.number().int().nonnegative(),
658
+ inject: z.number().int().nonnegative(),
659
+ assistant: z.number().int().nonnegative(),
660
+ tool: z.number().int().nonnegative(),
661
+ total: z.number().int().nonnegative(),
662
+ prompt: z.number().int().nonnegative().optional(),
663
+ output: z.number().int().nonnegative().optional(),
664
+ stepCount: z.number().int().positive().optional()
574
665
  }).strict();
575
- var contextEventSchema = z3.object({
576
- seq: z3.number(),
577
- time: z3.number(),
578
- kind: z3.enum(["compaction", "prune", "inject", "model"]),
579
- form: z3.string().optional(),
580
- tokens: z3.number().optional(),
581
- count: z3.number().optional(),
582
- sub: z3.string().optional(),
583
- name: z3.string().optional(),
584
- from: z3.string().optional(),
585
- to: z3.string().optional(),
586
- fromTurn: z3.number().optional(),
587
- fromStep: z3.number().optional(),
588
- turn: z3.number().optional(),
589
- step: z3.number().optional()
666
+ const contextEventSchema = z.object({
667
+ seq: z.number(),
668
+ time: z.number(),
669
+ kind: z.enum([
670
+ "compaction",
671
+ "prune",
672
+ "inject",
673
+ "model"
674
+ ]),
675
+ form: z.string().optional(),
676
+ tokens: z.number().optional(),
677
+ count: z.number().optional(),
678
+ sub: z.string().optional(),
679
+ name: z.string().optional(),
680
+ from: z.string().optional(),
681
+ to: z.string().optional(),
682
+ fromTurn: z.number().optional(),
683
+ fromStep: z.number().optional(),
684
+ turn: z.number().optional(),
685
+ step: z.number().optional()
590
686
  }).strict();
591
- var currentSchema = z3.object({
592
- system: z3.number().int().nonnegative(),
593
- tools: z3.number().int().nonnegative(),
594
- user: z3.number().int().nonnegative(),
595
- inject: z3.number().int().nonnegative(),
596
- assistant: z3.number().int().nonnegative(),
597
- tool: z3.number().int().nonnegative(),
598
- total: z3.number().int().nonnegative()
687
+ const currentSchema = z.object({
688
+ system: z.number().int().nonnegative(),
689
+ tools: z.number().int().nonnegative(),
690
+ user: z.number().int().nonnegative(),
691
+ inject: z.number().int().nonnegative(),
692
+ assistant: z.number().int().nonnegative(),
693
+ tool: z.number().int().nonnegative(),
694
+ total: z.number().int().nonnegative()
599
695
  }).strict();
600
- var costBucketsSchema = z3.object({
601
- uncached: z3.number().int().nonnegative(),
602
- cacheRead: z3.number().int().nonnegative(),
603
- cacheWrite: z3.number().int().nonnegative(),
604
- output: z3.number().int().nonnegative()
696
+ const costBucketsSchema = z.object({
697
+ uncached: z.number().int().nonnegative(),
698
+ cacheRead: z.number().int().nonnegative(),
699
+ cacheWrite: z.number().int().nonnegative(),
700
+ output: z.number().int().nonnegative()
605
701
  }).strict();
606
- var costFamilySchema = z3.object({
607
- peak: costBucketsSchema.optional(),
608
- off: costBucketsSchema.optional()
702
+ const costFamilySchema = z.object({
703
+ peak: costBucketsSchema.optional(),
704
+ off: costBucketsSchema.optional()
609
705
  }).strict();
610
- var contextTimelineSchema = z3.object({
611
- ok: z3.literal(true),
612
- model: z3.string().optional(),
613
- provider: z3.string().optional(),
614
- contextWindow: z3.number().optional(),
615
- current: currentSchema,
616
- toolList: z3.array(z3.object({ name: z3.string(), tokens: z3.number().int().nonnegative() }).strict()),
617
- requests: z3.array(requestRecordSchema),
618
- events: z3.array(contextEventSchema),
619
- cost: z3.object({ flash: costFamilySchema.optional(), pro: costFamilySchema.optional() }).strict().optional(),
620
- nodes: z3.array(surfaceNodeSchema),
621
- droppedNodes: z3.number().int().nonnegative(),
622
- archive: z3.array(surfaceNodeSchema),
623
- surfaceFloor: z3.number().int().nonnegative().optional(),
624
- archiveFloor: z3.number().int().nonnegative().optional()
706
+ const contextTimelineSchema = z.object({
707
+ ok: z.literal(true),
708
+ model: z.string().optional(),
709
+ provider: z.string().optional(),
710
+ contextWindow: z.number().optional(),
711
+ current: currentSchema,
712
+ toolList: z.array(z.object({
713
+ name: z.string(),
714
+ tokens: z.number().int().nonnegative()
715
+ }).strict()),
716
+ requests: z.array(requestRecordSchema),
717
+ events: z.array(contextEventSchema),
718
+ cost: z.object({
719
+ flash: costFamilySchema.optional(),
720
+ pro: costFamilySchema.optional()
721
+ }).strict().optional(),
722
+ nodes: z.array(surfaceNodeSchema),
723
+ droppedNodes: z.number().int().nonnegative(),
724
+ archive: z.array(surfaceNodeSchema),
725
+ surfaceFloor: z.number().int().nonnegative().optional(),
726
+ archiveFloor: z.number().int().nonnegative().optional()
625
727
  }).strict();
626
- var timelineStateSchema = z3.object({
627
- surface: z3.array(surfaceNodeSchema),
628
- sums: z3.object({
629
- user: z3.number().int().nonnegative(),
630
- inject: z3.number().int().nonnegative(),
631
- assistant: z3.number().int().nonnegative(),
632
- tool: z3.number().int().nonnegative()
633
- }).strict(),
634
- systemTokens: z3.number().int().nonnegative(),
635
- toolsTokens: z3.number().int().nonnegative(),
636
- toolList: z3.array(z3.object({ name: z3.string(), tokens: z3.number().int().nonnegative() }).strict()),
637
- model: z3.string().optional(),
638
- provider: z3.string().optional(),
639
- lastModel: z3.string().optional(),
640
- contextWindow: z3.number().optional(),
641
- requests: z3.array(requestRecordSchema),
642
- events: z3.array(contextEventSchema),
643
- archived: z3.array(surfaceNodeSchema),
644
- cost: z3.object({ flash: costFamilySchema.optional(), pro: costFamilySchema.optional() }).strict().optional(),
645
- archiveFloor: z3.number().optional(),
646
- callNames: z3.record(z3.string(), z3.string()),
647
- pendingShadowedSeqs: z3.array(z3.number()).optional()
728
+ /**
729
+ * The persisted fold-state schema (the dsh 0.1.1-rc.1+ `stateSchema`
730
+ * contract). Validates the plain-JSON `TimelineState` before a checkpoint
731
+ * row seeds a fold — the same shape guarantee the projection cache's
732
+ * plain-JSON precondition already enforces at write time.
733
+ */
734
+ const timelineStateSchema = z.object({
735
+ surface: z.array(surfaceNodeSchema),
736
+ sums: z.object({
737
+ user: z.number().int().nonnegative(),
738
+ inject: z.number().int().nonnegative(),
739
+ assistant: z.number().int().nonnegative(),
740
+ tool: z.number().int().nonnegative()
741
+ }).strict(),
742
+ systemTokens: z.number().int().nonnegative(),
743
+ toolsTokens: z.number().int().nonnegative(),
744
+ toolList: z.array(z.object({
745
+ name: z.string(),
746
+ tokens: z.number().int().nonnegative()
747
+ }).strict()),
748
+ model: z.string().optional(),
749
+ provider: z.string().optional(),
750
+ lastModel: z.string().optional(),
751
+ contextWindow: z.number().optional(),
752
+ requests: z.array(requestRecordSchema),
753
+ events: z.array(contextEventSchema),
754
+ archived: z.array(surfaceNodeSchema),
755
+ cost: z.object({
756
+ flash: costFamilySchema.optional(),
757
+ pro: costFamilySchema.optional()
758
+ }).strict().optional(),
759
+ archiveFloor: z.number().optional(),
760
+ callNames: z.record(z.string(), z.string()),
761
+ pendingShadowedSeqs: z.array(z.number()).optional()
648
762
  });
763
+ /**
764
+ * The context-timeline projection unit, created per plugin instance with its
765
+ * config-resolved retention bounds (config.ts), and registered on
766
+ * `ctx.sessionProjections`. Registry lifecycle notes (mirrored from the
767
+ * harness contract): registration is an effect on the caller's fiber — an
768
+ * unloaded Host half removes the key, and clients read it as capability
769
+ * absence. `stateVersion` must be bumped whenever the persisted state shape
770
+ * or fold semantics change (invalidation of cached rows); config-only
771
+ * changes never require it (bounds tune retention, not state shape).
772
+ *
773
+ * The definition carries BOTH session-projection contracts (see compat.ts):
774
+ * `schema`/`view` for dsh <= 0.1.0-rc.8, `stateSchema`/`wire` for
775
+ * dsh >= 0.1.1-rc.1 — each registry reads its own fields off the same unit.
776
+ * Without the `wire` block the 0.1.1-rc.1+ registry treats the unit as
777
+ * host-only and never delivers `contextTimeline` to the browser (the Context
778
+ * tab would stay on its loading screen forever).
779
+ */
649
780
  function createContextTimelineDefinition(config) {
650
- const bounds = resolveBounds(config);
651
- const view = (state) => buildTimelineView(state, bounds);
652
- const definition = {
653
- key: "contextTimeline",
654
- // dsh <= 0.1.0-rc.8 contract: one schema validates the wire payload, `view` is top-level.
655
- schema: contextTimelineSchema,
656
- view,
657
- // dsh >= 0.1.1-rc.1 contract: `stateSchema` validates persisted state, the client view lives in `wire`.
658
- stateSchema: timelineStateSchema,
659
- wire: { viewSchema: contextTimelineSchema, view },
660
- init: () => createTimelineState(),
661
- apply: (state, event) => applyTimeline(state, event, bounds),
662
- // 2 since 0.11: the occupancy mirror (pressureTokens/sampledSurfaceTokens/
663
- // occupancyWindow) left the persisted state — the client now reads the
664
- // official token-meter `contextPressure` projection instead. Old cached
665
- // rows are discarded and refolded.
666
- // 3 since 0.12: the removed-node archive (`archived` + `archiveFloor`)
667
- // joined the persisted state for the Context browser's per-step
668
- // reconstruction — cached rows predate the shape and are refolded.
669
- // 4 since 0.18: the persisted state no longer carries `undefined`-valued
670
- // properties (model/provider/lastModel/contextWindow are absent until
671
- // known; pendingShadowedSeqs is deleted when consumed). The previous
672
- // shape violated the plain-JSON persisted-state precondition and failed
673
- // EVERY session-projection-cache write (TypeError: projection checkpoint
674
- // is not losslessly JSON-serializable) — which also starved the `title`
675
- // projection row and broke the session list after a restart. The bump
676
- // discards old cached rows and refolds them clean.
677
- // 5: the session-cost totals (`cost` — per-family/per-period billed
678
- // tokens) joined the persisted state; cached rows predate the shape and
679
- // are refolded.
680
- stateVersion: 5
681
- };
682
- return definition;
781
+ const bounds = resolveBounds(config);
782
+ const view = (state) => buildTimelineView(state, bounds);
783
+ return {
784
+ key: "contextTimeline",
785
+ schema: contextTimelineSchema,
786
+ view,
787
+ stateSchema: timelineStateSchema,
788
+ wire: {
789
+ viewSchema: contextTimelineSchema,
790
+ view
791
+ },
792
+ init: () => createTimelineState(),
793
+ apply: (state, event) => applyTimeline(state, event, bounds),
794
+ stateVersion: 5
795
+ };
683
796
  }
684
-
685
- // src/host/index.ts
686
- var name = "dsh-context";
687
- var inject = ["sessionProjections"];
797
+ //#endregion
798
+ //#region src/host/index.ts
799
+ const name = "dsh-context";
800
+ /** Required services: the session-projection registry that drives the unit. */
801
+ const inject = ["sessionProjections"];
688
802
  function apply(ctx, config) {
689
- ctx.sessionProjections.register(createContextTimelineDefinition(config));
690
- ctx.sessionProjections.register(createContextHeadersDefinition());
803
+ ctx.sessionProjections.register(createContextTimelineDefinition(config));
804
+ ctx.sessionProjections.register(createContextHeadersDefinition());
691
805
  }
692
- export {
693
- Config,
694
- apply,
695
- inject,
696
- name
697
- };
806
+ //#endregion
807
+ export { Config, apply, inject, name };