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/client.js +2361 -2307
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +584 -0
- package/lib/index.js +746 -636
- package/package.json +19 -12
package/lib/index.js
CHANGED
|
@@ -1,697 +1,807 @@
|
|
|
1
|
-
// src/host/config.ts
|
|
2
1
|
import { z } from "zod";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
39
|
+
return Config.parse(config ?? {});
|
|
22
40
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
88
|
+
return Math.ceil(JSON.stringify(tool).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD;
|
|
68
89
|
}
|
|
69
90
|
function firstText(blocks) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
102
|
+
return source !== null && source !== void 0 && (source.kind === "plugin" || source.kind === "skill-invocation" || typeof source.form === "string");
|
|
88
103
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
702
|
+
const costFamilySchema = z.object({
|
|
703
|
+
peak: costBucketsSchema.optional(),
|
|
704
|
+
off: costBucketsSchema.optional()
|
|
609
705
|
}).strict();
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
690
|
-
|
|
803
|
+
ctx.sessionProjections.register(createContextTimelineDefinition(config));
|
|
804
|
+
ctx.sessionProjections.register(createContextHeadersDefinition());
|
|
691
805
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
apply,
|
|
695
|
-
inject,
|
|
696
|
-
name
|
|
697
|
-
};
|
|
806
|
+
//#endregion
|
|
807
|
+
export { Config, apply, inject, name };
|