dsh-plugin-weapp-gateway 0.8.13 → 0.8.23
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/PROTOCOL.md +168 -1
- package/README.md +63 -14
- package/THIRD-PARTY-NOTICES/dsh-cost-meter.txt +31 -0
- package/bin/setup-ip.mjs +0 -0
- package/lib/account-bridge.mjs +57 -0
- package/lib/client.js +5 -5
- package/lib/context-bridge.mjs +1715 -0
- package/lib/cost-meter-bridge.mjs +794 -0
- package/lib/cost-meter-dashboard-bridge.mjs +1449 -0
- package/lib/dsh-host-adapter.mjs +1 -1
- package/lib/index.mjs +111 -15
- package/lib/plugin-updates.mjs +103 -10
- package/lib/vendor/cost-meter/aliyun-balance.mjs +123 -0
- package/lib/vendor/cost-meter/coding-plans.mjs +1668 -0
- package/lib/vendor/cost-meter/credentials-shim.mjs +8 -0
- package/lib/vendor/cost-meter/custom-balance.mjs +281 -0
- package/lib/vendor/cost-meter/factory-quota.mjs +69 -0
- package/lib/vendor/cost-meter/gateway-quota-adapters.mjs +1006 -0
- package/lib/vendor/cost-meter/gateway-quotas.mjs +664 -0
- package/lib/vendor/cost-meter/home-paths-shim.mjs +13 -0
- package/lib/vendor/cost-meter/net.mjs +183 -0
- package/lib/vendor/cost-meter/opendesign.mjs +204 -0
- package/lib/vendor/cost-meter/tokenrhythm-balance.mjs +95 -0
- package/lib/vendor/cost-meter/workbuddy-accounts.mjs +44 -0
- package/lib/vendor/cost-meter/workbuddy-transport.mjs +38 -0
- package/lib/vendor/cost-meter/workbuddy.mjs +356 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +54 -54
|
@@ -0,0 +1,1715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context dashboard bridge for the mobile gateway.
|
|
3
|
+
*
|
|
4
|
+
* Registers one pure session projection (`mobileContext`) that folds a session's
|
|
5
|
+
* durable event log into the composition / request-trend / event data the mini
|
|
6
|
+
* program's 「上下文」 tab renders, and serves it over a single mobile query:
|
|
7
|
+
*
|
|
8
|
+
* { type: 'context-dashboard', sessionId } ->
|
|
9
|
+
* { kind: 'context-dashboard', sessionId, asOfSeq, value: { ...mobileContext, breakdown, usage, stats } }
|
|
10
|
+
*
|
|
11
|
+
* Token pricing and the surface-replacement rules mirror DSH's own token meter
|
|
12
|
+
* (`@deepseek-ai/dsh-token-meter`, fixed-density heuristic + planner), so the
|
|
13
|
+
* folded `systemTokens` / `toolsTokens` / message total match the harness's
|
|
14
|
+
* `contextBreakdown` projection exactly. Category classification and the
|
|
15
|
+
* request/event tallies follow `dsh-context` (Apache-2.0, bowenliang123).
|
|
16
|
+
*
|
|
17
|
+
* Wire v2 (docs/context-dashboard.md §8.1/§8.2) adds the desktop dashboard's
|
|
18
|
+
* missing data as additive-optional fields: per-category activity counters
|
|
19
|
+
* (`activity`), the six-way duration split plus its timeline (`timing`/`spans`),
|
|
20
|
+
* per-request surface item counts and provider-reported usage
|
|
21
|
+
* (`requests[].items/prompt/output`), the newest answer preview (`lastAnswer`),
|
|
22
|
+
* and, for `context-fleet`, the host `agentTeam` roster/task board (`team`) plus
|
|
23
|
+
* each agent's `lastAnswer`/`phase`. The embedded assistant `data.stream`
|
|
24
|
+
* decoders under "Assistant stream decoding" are ported from
|
|
25
|
+
* `dsh-context/src/host/logShapes.ts` (Apache-2.0) so the split matches the
|
|
26
|
+
* desktop plugin's semantics.
|
|
27
|
+
*
|
|
28
|
+
* The fold state is plain JSON and bounded so the harness projection cache can
|
|
29
|
+
* checkpoint it: at most MAX_SURFACE nodes, MAX_REQUESTS request records,
|
|
30
|
+
* MAX_EVENTS context events and MAX_SPANS timeline segments. `init`/`apply`/
|
|
31
|
+
* `view` are copied on every change (never mutated in place) because the
|
|
32
|
+
* registry shares the previous state with the persisted cache.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const CHARS_PER_TOKEN = 4
|
|
36
|
+
const BLOCK_OVERHEAD = 4
|
|
37
|
+
const ROLE_OVERHEAD = 4
|
|
38
|
+
|
|
39
|
+
const SURFACE_TYPES = new Set(['system/message', 'developer/message', 'user/message', 'assistant/message', 'tool/result'])
|
|
40
|
+
const MESSAGE_CATS = ['user', 'inject', 'skill', 'assistant', 'tool']
|
|
41
|
+
|
|
42
|
+
const MAX_SURFACE = 4000
|
|
43
|
+
const MAX_ARCHIVE_NODES = 400
|
|
44
|
+
const MAX_HEADER_EPOCHS = 100
|
|
45
|
+
const MAX_FILE_OPS = 400
|
|
46
|
+
const MAX_REQUESTS = 200
|
|
47
|
+
const MAX_EVENTS = 120
|
|
48
|
+
const MAX_TOOL_NAMES = 20
|
|
49
|
+
const MAX_CALL_NAMES = 2000
|
|
50
|
+
const MAX_SPANS = 600
|
|
51
|
+
const MAX_STEP_SPANS = 2000
|
|
52
|
+
const MAX_LAST_ANSWER = 240
|
|
53
|
+
const MAX_TEAM_ROWS = 20
|
|
54
|
+
|
|
55
|
+
// Timeline span kinds on the wire (docs §8.1): the client colors the strip by
|
|
56
|
+
// these indices, so the vocabulary is frozen here.
|
|
57
|
+
const SPAN_WAIT = 0
|
|
58
|
+
const SPAN_REASONING = 1
|
|
59
|
+
const SPAN_TEXT = 2
|
|
60
|
+
const SPAN_TOOLARG = 3
|
|
61
|
+
const SPAN_TOOLS = 4
|
|
62
|
+
const SPAN_OTHER = 5
|
|
63
|
+
const DECODE_SPAN_KINDS = { reasoning: SPAN_REASONING, text: SPAN_TEXT, toolarg: SPAN_TOOLARG }
|
|
64
|
+
const TEAM_PHASES = new Set(['provisioning', 'active', 'failed'])
|
|
65
|
+
|
|
66
|
+
// Activity classification of a PAIRED tool call by its durable tool name
|
|
67
|
+
// (docs §8.1). Prefixes stay permissive for read*/write*/search* variants while
|
|
68
|
+
// the explicit names cover the harness's editor/search tools.
|
|
69
|
+
const WRITE_TOOL_NAMES = new Set(['edit', 'apply_patch', 'str_replace_editor'])
|
|
70
|
+
const SEARCH_TOOL_NAMES = new Set(['grep', 'glob', 'list', 'search', 'web_search', 'ripgrep', 'search_files'])
|
|
71
|
+
|
|
72
|
+
function toolActivityOf(name) {
|
|
73
|
+
if (typeof name !== 'string' || name === '') return ''
|
|
74
|
+
if (name.startsWith('read')) return 'reads'
|
|
75
|
+
if (name.startsWith('write') || WRITE_TOOL_NAMES.has(name)) return 'writes'
|
|
76
|
+
if (SEARCH_TOOL_NAMES.has(name) || name.startsWith('search')) return 'searches'
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// A plain object with the only method the projection registry calls. Keeping the
|
|
81
|
+
// bridge free of a zod dependency lets the same module load in the host plugin
|
|
82
|
+
// and in plain-Node unit tests.
|
|
83
|
+
const passthroughSchema = Object.freeze({
|
|
84
|
+
parse: value => value,
|
|
85
|
+
safeParse: value => ({ success: true, data: value }),
|
|
86
|
+
// Older registries may validate through the Standard Schema face instead of
|
|
87
|
+
// calling parse(); accept every plain-JSON fold state there too.
|
|
88
|
+
'~standard': Object.freeze({
|
|
89
|
+
version: 1,
|
|
90
|
+
vendor: 'mobile-gateway',
|
|
91
|
+
validate: value => ({ value }),
|
|
92
|
+
}),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
export const CONTEXT_REQUEST_TYPES = new Set(['context-dashboard', 'context-fleet', 'context-content'])
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Token pricing (DSH token-meter fixed-density heuristic)
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
function estimateStructuralBlock(block) {
|
|
102
|
+
if (block !== null && typeof block === 'object' && block.type === 'image') {
|
|
103
|
+
const reference = { ...block }
|
|
104
|
+
delete reference.offloaded
|
|
105
|
+
return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(reference).length / CHARS_PER_TOKEN)
|
|
106
|
+
}
|
|
107
|
+
return BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function estimateSystemMessage(message) {
|
|
111
|
+
const blocks = Array.isArray(message && message.content) ? message.content : []
|
|
112
|
+
if (blocks.length === 0) return 0
|
|
113
|
+
let characters = 0
|
|
114
|
+
for (const block of blocks) {
|
|
115
|
+
if (block !== null && typeof block === 'object' && block.type === 'text') {
|
|
116
|
+
characters += typeof block.text === 'string' ? block.text.length : 0
|
|
117
|
+
} else {
|
|
118
|
+
characters += JSON.stringify(block).length
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return Math.ceil(characters / CHARS_PER_TOKEN) + ROLE_OVERHEAD
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function estimateContent(blocks) {
|
|
125
|
+
let tokens = 0
|
|
126
|
+
if (!Array.isArray(blocks)) return tokens
|
|
127
|
+
for (const block of blocks) {
|
|
128
|
+
if (block === null || typeof block !== 'object') {
|
|
129
|
+
tokens += estimateStructuralBlock(block)
|
|
130
|
+
continue
|
|
131
|
+
}
|
|
132
|
+
switch (block.type) {
|
|
133
|
+
case 'text':
|
|
134
|
+
case 'reasoning':
|
|
135
|
+
tokens += Math.ceil((typeof block.text === 'string' ? block.text : '').length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
|
|
136
|
+
break
|
|
137
|
+
case 'tool-call':
|
|
138
|
+
tokens += Math.ceil((typeof block.name === 'string' ? block.name : '').length / CHARS_PER_TOKEN)
|
|
139
|
+
+ Math.ceil((typeof block.arguments === 'string' ? block.arguments : '').length / CHARS_PER_TOKEN)
|
|
140
|
+
+ BLOCK_OVERHEAD
|
|
141
|
+
break
|
|
142
|
+
default:
|
|
143
|
+
tokens += estimateStructuralBlock(block)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return tokens
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function estimateMessage(message) {
|
|
150
|
+
if (message === null || message === undefined) return 0
|
|
151
|
+
if (message.role === 'system') return estimateSystemMessage(message)
|
|
152
|
+
return estimateContent(message.content) + ROLE_OVERHEAD
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function canonicalHeader(header) {
|
|
156
|
+
if (header === null || typeof header !== 'object') return {}
|
|
157
|
+
const adapterDefaults = header.adapterDefaults
|
|
158
|
+
return {
|
|
159
|
+
config: header.config,
|
|
160
|
+
...(adapterDefaults && (adapterDefaults.reasoningEffort === true || adapterDefaults.maxTokens === true)
|
|
161
|
+
? { adapterDefaults }
|
|
162
|
+
: {}),
|
|
163
|
+
...(Array.isArray(header.tools) && header.tools.length > 0 ? { tools: header.tools } : {}),
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function estimateToolsTokens(header) {
|
|
168
|
+
const canonical = canonicalHeader(header)
|
|
169
|
+
if (!Array.isArray(canonical.tools) || canonical.tools.length === 0) return 0
|
|
170
|
+
return Math.ceil(JSON.stringify(canonical.tools).length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// Assistant stream decoding (ported from dsh-context/src/host/logShapes.ts,
|
|
175
|
+
// Apache-2.0, bowenliang123) — total over untrusted input; unknown chunks,
|
|
176
|
+
// missing times and non-array streams are skipped rather than thrown.
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/** Whether a raw stream chunk carries a token delta (mirrors dsh-llm's `isTokenDelta`). */
|
|
180
|
+
function isTokenChunk(chunk) {
|
|
181
|
+
if (chunk === null || typeof chunk !== 'object') return false
|
|
182
|
+
switch (chunk.type) {
|
|
183
|
+
case 'text-delta':
|
|
184
|
+
case 'reasoning-delta':
|
|
185
|
+
return typeof chunk.text === 'string' && chunk.text !== ''
|
|
186
|
+
case 'tool-call-delta':
|
|
187
|
+
return (typeof chunk.argumentsDelta === 'string' && chunk.argumentsDelta !== '') || chunk.name !== undefined
|
|
188
|
+
default:
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The decode bucket a `block-start.blockType` names, or undefined for a marker we cannot price. */
|
|
194
|
+
function decodeKindOfBlock(blockType) {
|
|
195
|
+
if (blockType === 'reasoning') return 'reasoning'
|
|
196
|
+
if (blockType === 'text') return 'text'
|
|
197
|
+
if (blockType === 'tool-call') return 'toolarg'
|
|
198
|
+
return undefined
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Decode spans and `block-start` counts inside one embedded assistant stream.
|
|
203
|
+
* Each marker owns the interval up to the next one; the last one owns up to
|
|
204
|
+
* `endTime`. Unknown markers leave their interval unpriced.
|
|
205
|
+
*
|
|
206
|
+
* @param {unknown} stream
|
|
207
|
+
* @param {number} endTime
|
|
208
|
+
* @returns {{spans: {reasoning:number,text:number,toolarg:number}, blocks: {reasoning:number,text:number,toolarg:number}}}
|
|
209
|
+
*/
|
|
210
|
+
function decodeTallyOfStream(stream, endTime) {
|
|
211
|
+
const tally = {
|
|
212
|
+
spans: { reasoning: 0, text: 0, toolarg: 0 },
|
|
213
|
+
blocks: { reasoning: 0, text: 0, toolarg: 0 },
|
|
214
|
+
}
|
|
215
|
+
if (!Array.isArray(stream) || !Number.isFinite(endTime)) return tally
|
|
216
|
+
let kind
|
|
217
|
+
let since = 0
|
|
218
|
+
for (const record of stream) {
|
|
219
|
+
if (record === null || typeof record !== 'object') continue
|
|
220
|
+
if (record.type !== 'chunk' || record.chunk === null || typeof record.chunk !== 'object') continue
|
|
221
|
+
const chunk = record.chunk
|
|
222
|
+
if (chunk.type !== 'block-start') continue
|
|
223
|
+
const time = record.time
|
|
224
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) continue
|
|
225
|
+
if (kind !== undefined) tally.spans[kind] += Math.max(0, time - since)
|
|
226
|
+
kind = decodeKindOfBlock(chunk.blockType)
|
|
227
|
+
if (kind !== undefined) tally.blocks[kind] += 1
|
|
228
|
+
since = time
|
|
229
|
+
}
|
|
230
|
+
if (kind !== undefined) tally.spans[kind] += Math.max(0, endTime - since)
|
|
231
|
+
return tally
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The ORDERED decode spans of one embedded assistant stream (same ownership rule as the tally). */
|
|
235
|
+
function decodeSpansOfStream(stream, endTime) {
|
|
236
|
+
const spans = []
|
|
237
|
+
if (!Array.isArray(stream) || !Number.isFinite(endTime)) return spans
|
|
238
|
+
let kind
|
|
239
|
+
let since = 0
|
|
240
|
+
const close = to => {
|
|
241
|
+
if (kind !== undefined && to > since) spans.push({ kind, start: since, end: to })
|
|
242
|
+
}
|
|
243
|
+
for (const record of stream) {
|
|
244
|
+
if (record === null || typeof record !== 'object') continue
|
|
245
|
+
if (record.type !== 'chunk' || record.chunk === null || typeof record.chunk !== 'object') continue
|
|
246
|
+
const chunk = record.chunk
|
|
247
|
+
if (chunk.type !== 'block-start') continue
|
|
248
|
+
const time = record.time
|
|
249
|
+
if (typeof time !== 'number' || !Number.isFinite(time)) continue
|
|
250
|
+
close(time)
|
|
251
|
+
kind = decodeKindOfBlock(chunk.blockType)
|
|
252
|
+
since = time
|
|
253
|
+
}
|
|
254
|
+
close(endTime)
|
|
255
|
+
return spans
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The first token's instant inside one packed delta run, or undefined. */
|
|
259
|
+
function runFirstTokenTime(record) {
|
|
260
|
+
const time0 = record.time0
|
|
261
|
+
if (typeof time0 !== 'number' || !Number.isFinite(time0)) return undefined
|
|
262
|
+
if (record.type === 'tool-call-chunks' && record.name !== undefined) return time0
|
|
263
|
+
const fragments = record.type === 'tool-call-chunks' ? record.args : record.texts
|
|
264
|
+
if (!Array.isArray(fragments)) return undefined
|
|
265
|
+
const dt = Array.isArray(record.dt) ? record.dt : []
|
|
266
|
+
let time = time0
|
|
267
|
+
for (let index = 0; index < fragments.length; index += 1) {
|
|
268
|
+
if (index > 0) {
|
|
269
|
+
const step = dt[index - 1]
|
|
270
|
+
if (typeof step !== 'number' || !Number.isFinite(step)) return undefined
|
|
271
|
+
time += step
|
|
272
|
+
}
|
|
273
|
+
if (typeof fragments[index] === 'string' && fragments[index] !== '') return time
|
|
274
|
+
}
|
|
275
|
+
return undefined
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The first token's instant inside an embedded assistant stream, or undefined when it carries no token. */
|
|
279
|
+
function firstTokenTimeOfStream(stream) {
|
|
280
|
+
if (!Array.isArray(stream)) return undefined
|
|
281
|
+
for (const record of stream) {
|
|
282
|
+
if (record === null || typeof record !== 'object') continue
|
|
283
|
+
if (record.type === 'chunk') {
|
|
284
|
+
const time = record.time
|
|
285
|
+
if (typeof time === 'number' && Number.isFinite(time) && isTokenChunk(record.chunk)) return time
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
288
|
+
const time = runFirstTokenTime(record)
|
|
289
|
+
if (time !== undefined) return time
|
|
290
|
+
}
|
|
291
|
+
return undefined
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** One provider-reported usage bucket as a non-negative integer, or null when unreadable. */
|
|
295
|
+
function tokenCountOf(value) {
|
|
296
|
+
if (typeof value === 'number') {
|
|
297
|
+
return Number.isFinite(value) ? Math.max(0, Math.round(value)) : null
|
|
298
|
+
}
|
|
299
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
300
|
+
const parsed = Number(value)
|
|
301
|
+
return Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : null
|
|
302
|
+
}
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** A non-negative duration between two instants; 0 for non-finite input. */
|
|
307
|
+
function durOf(from, to) {
|
|
308
|
+
if (typeof from !== 'number' || typeof to !== 'number' || !Number.isFinite(from) || !Number.isFinite(to)) return 0
|
|
309
|
+
return Math.max(0, to - from)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Image blocks inside one user message (the activity ledger's `images`, unclipped). */
|
|
313
|
+
function countImageBlocks(message) {
|
|
314
|
+
if (message === null || typeof message !== 'object' || !Array.isArray(message.content)) return 0
|
|
315
|
+
let count = 0
|
|
316
|
+
for (const block of message.content) {
|
|
317
|
+
if (block !== null && typeof block === 'object' && block.type === 'image') count += 1
|
|
318
|
+
}
|
|
319
|
+
return count
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parseCallArgs(raw) {
|
|
323
|
+
if (typeof raw !== 'string' || raw === '') return null
|
|
324
|
+
try {
|
|
325
|
+
const value = JSON.parse(raw)
|
|
326
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null
|
|
327
|
+
} catch (_) { return null }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function fileCallMeta(name, raw) {
|
|
331
|
+
const args = parseCallArgs(raw)
|
|
332
|
+
if (args === null) return null
|
|
333
|
+
let kind = ''
|
|
334
|
+
if (name === 'read' || name === 'read_image') kind = 'read'
|
|
335
|
+
else if (name === 'write' || name === 'edit') kind = 'write'
|
|
336
|
+
else if (name === 'grep' || name === 'glob') kind = 'search'
|
|
337
|
+
else if (name === 'str_replace_editor') kind = args.command === 'view' ? 'read' : 'write'
|
|
338
|
+
if (kind === '') return null
|
|
339
|
+
let path = ''
|
|
340
|
+
if ((name === 'grep' || name === 'glob') && typeof args.path !== 'string') {
|
|
341
|
+
path = typeof args.pattern === 'string' ? args.pattern : ''
|
|
342
|
+
} else {
|
|
343
|
+
for (const key of ['file_path', 'filePath', 'path']) {
|
|
344
|
+
if (typeof args[key] === 'string' && args[key] !== '') { path = args[key]; break }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (!path) return null
|
|
348
|
+
const added = name === 'write' ? lineCount(args.content)
|
|
349
|
+
: name === 'edit' ? lineCount(args.new_string)
|
|
350
|
+
: name === 'str_replace_editor' && ['str_replace', 'insert', 'create'].includes(args.command)
|
|
351
|
+
? lineCount(args.command === 'create' ? args.file_text : args.new_str) : 0
|
|
352
|
+
const removed = name === 'edit' ? lineCount(args.old_string)
|
|
353
|
+
: name === 'str_replace_editor' && args.command === 'str_replace' ? lineCount(args.old_str) : 0
|
|
354
|
+
return {
|
|
355
|
+
kind, path: path.slice(0, 2000), added, removed,
|
|
356
|
+
...(kind === 'search' && !(typeof args.path === 'string' && args.path !== '') ? { pattern: true } : {}),
|
|
357
|
+
...(kind === 'read' && Number.isFinite(args.limit) && args.limit > 0 ? { readCount: Math.floor(args.limit) } : {}),
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function lineCount(value) {
|
|
362
|
+
if (typeof value !== 'string' || value === '') return 0
|
|
363
|
+
const breaks = (value.match(/\n/g) || []).length
|
|
364
|
+
return value.endsWith('\n') ? breaks : breaks + 1
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function fileOpsOf(event, message, pending) {
|
|
368
|
+
const meta = pending && pending.file
|
|
369
|
+
if (!meta) return []
|
|
370
|
+
const common = {
|
|
371
|
+
seq: event.seq, time: timeOf(event), path: meta.path, kind: meta.kind,
|
|
372
|
+
tool: pending.name, err: !!(message && message.isError === true),
|
|
373
|
+
added: meta.added, removed: meta.removed,
|
|
374
|
+
...(meta.pattern === true ? { pattern: true } : {}),
|
|
375
|
+
...(meta.readCount !== undefined ? { readCount: meta.readCount } : {}),
|
|
376
|
+
}
|
|
377
|
+
const eventMeta = event && event.data && event.data.meta
|
|
378
|
+
const resultMeta = message && message.meta !== null && typeof message.meta === 'object'
|
|
379
|
+
? message.meta : eventMeta !== null && typeof eventMeta === 'object' ? eventMeta : null
|
|
380
|
+
if (meta.kind === 'search' && resultMeta && resultMeta.truncated === false) {
|
|
381
|
+
const found = []
|
|
382
|
+
if (resultMeta.shape === 'matches' && Array.isArray(resultMeta.files)) {
|
|
383
|
+
for (const row of resultMeta.files.slice(0, 100)) {
|
|
384
|
+
if (row && typeof row.path === 'string' && Array.isArray(row.matches)) found.push({ path: row.path.slice(0, 2000), hits: row.matches.length })
|
|
385
|
+
}
|
|
386
|
+
} else if (resultMeta.shape === 'paths' && Array.isArray(resultMeta.paths)) {
|
|
387
|
+
for (const path of resultMeta.paths.slice(0, 100)) if (typeof path === 'string') found.push({ path: path.slice(0, 2000), hits: 0 })
|
|
388
|
+
}
|
|
389
|
+
if (found.length > 0) return found.map(file => ({ ...common, ...file, pattern: false }))
|
|
390
|
+
}
|
|
391
|
+
if (meta.kind === 'read' && resultMeta && typeof resultMeta.offset === 'number'
|
|
392
|
+
&& Array.isArray(resultMeta.lines) && resultMeta.lines.length > 0) {
|
|
393
|
+
common.readStart = Math.max(1, Math.floor(resultMeta.offset))
|
|
394
|
+
common.readCount = resultMeta.lines.length
|
|
395
|
+
}
|
|
396
|
+
return [common]
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Fold state
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
function emptyTiming() {
|
|
404
|
+
return {
|
|
405
|
+
ttftMs: 0,
|
|
406
|
+
reasoningMs: 0,
|
|
407
|
+
textMs: 0,
|
|
408
|
+
toolArgMs: 0,
|
|
409
|
+
toolsMs: 0,
|
|
410
|
+
waitCount: 0,
|
|
411
|
+
reasoningCount: 0,
|
|
412
|
+
textCount: 0,
|
|
413
|
+
toolArgCount: 0,
|
|
414
|
+
toolCount: 0,
|
|
415
|
+
speedTokens: 0,
|
|
416
|
+
speedMs: 0,
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function emptyActivity() {
|
|
421
|
+
return { reads: 0, writes: 0, searches: 0, images: 0 }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function emptySurfaceCounts() {
|
|
425
|
+
return { user: 0, inject: 0, skill: 0, assistant: 0, tool: 0 }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** A bounded, plain-JSON fold state (see the module header). */
|
|
429
|
+
export function createState() {
|
|
430
|
+
return {
|
|
431
|
+
surface: [],
|
|
432
|
+
archive: [],
|
|
433
|
+
headerEpochs: [],
|
|
434
|
+
fileOps: [],
|
|
435
|
+
sums: { user: 0, inject: 0, skill: 0, assistant: 0, tool: 0 },
|
|
436
|
+
systemTokens: 0,
|
|
437
|
+
toolsTokens: 0,
|
|
438
|
+
toolDefs: 0,
|
|
439
|
+
counts: { humanInputs: 0, answers: 0, skillLoads: 0, injects: 0, compactions: 0, prunes: 0, toolCalls: 0 },
|
|
440
|
+
activity: emptyActivity(),
|
|
441
|
+
timing: emptyTiming(),
|
|
442
|
+
surfaceCounts: emptySurfaceCounts(),
|
|
443
|
+
toolNames: {},
|
|
444
|
+
wallMs: 0,
|
|
445
|
+
callNames: {},
|
|
446
|
+
requests: [],
|
|
447
|
+
events: [],
|
|
448
|
+
spans: [],
|
|
449
|
+
truncated: { requests: false, events: false, surface: false },
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function cloneState(state) {
|
|
454
|
+
return {
|
|
455
|
+
surface: [...state.surface],
|
|
456
|
+
archive: Array.isArray(state.archive) ? [...state.archive] : [],
|
|
457
|
+
...(Number.isFinite(state.archiveFloor) ? { archiveFloor: state.archiveFloor } : {}),
|
|
458
|
+
headerEpochs: Array.isArray(state.headerEpochs) ? [...state.headerEpochs] : [],
|
|
459
|
+
fileOps: Array.isArray(state.fileOps) ? [...state.fileOps] : [],
|
|
460
|
+
...(Number.isFinite(state.fileOpsFloor) ? { fileOpsFloor: state.fileOpsFloor } : {}),
|
|
461
|
+
sums: { ...state.sums },
|
|
462
|
+
systemTokens: state.systemTokens,
|
|
463
|
+
toolsTokens: state.toolsTokens,
|
|
464
|
+
toolDefs: safeInt(state.toolDefs),
|
|
465
|
+
...(state.model !== undefined ? { model: state.model } : {}),
|
|
466
|
+
...(state.provider !== undefined ? { provider: state.provider } : {}),
|
|
467
|
+
...(state.contextWindow !== undefined ? { contextWindow: state.contextWindow } : {}),
|
|
468
|
+
counts: { ...state.counts },
|
|
469
|
+
activity: { ...emptyActivity(), ...(state.activity !== null && typeof state.activity === 'object' ? state.activity : {}) },
|
|
470
|
+
timing: { ...emptyTiming(), ...(state.timing !== null && typeof state.timing === 'object' ? state.timing : {}) },
|
|
471
|
+
surfaceCounts: { ...emptySurfaceCounts(), ...(state.surfaceCounts !== null && typeof state.surfaceCounts === 'object' ? state.surfaceCounts : {}) },
|
|
472
|
+
toolNames: { ...state.toolNames },
|
|
473
|
+
wallMs: state.wallMs,
|
|
474
|
+
callNames: { ...state.callNames },
|
|
475
|
+
requests: [...state.requests],
|
|
476
|
+
events: [...state.events],
|
|
477
|
+
// Shared copy-on-write: only `step/end` appends, and it re-slices before
|
|
478
|
+
// touching the array, so a shared reference can never be mutated.
|
|
479
|
+
spans: Array.isArray(state.spans) ? state.spans : [],
|
|
480
|
+
truncated: { ...state.truncated },
|
|
481
|
+
...(typeof state.lastAnswer === 'string' && state.lastAnswer !== '' ? { lastAnswer: state.lastAnswer } : {}),
|
|
482
|
+
...(state.stepStart !== undefined
|
|
483
|
+
? {
|
|
484
|
+
stepStart: {
|
|
485
|
+
time: state.stepStart.time,
|
|
486
|
+
waits: Array.isArray(state.stepStart.waits) ? [...state.stepStart.waits] : [],
|
|
487
|
+
...(typeof state.stepStart.firstToken === 'number' && Number.isFinite(state.stepStart.firstToken)
|
|
488
|
+
? { firstToken: state.stepStart.firstToken }
|
|
489
|
+
: {}),
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
: {}),
|
|
493
|
+
...(Array.isArray(state.stepSpans) ? { stepSpans: state.stepSpans.map(span => [span[0], span[1], span[2]]) } : {}),
|
|
494
|
+
...(state.approvals !== undefined ? { approvals: { ...state.approvals } } : {}),
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function deriveEventMessage(event) {
|
|
499
|
+
const data = event && event.data
|
|
500
|
+
if (data === null || typeof data !== 'object') return null
|
|
501
|
+
switch (event.type) {
|
|
502
|
+
case 'user/message':
|
|
503
|
+
return data
|
|
504
|
+
case 'system/message':
|
|
505
|
+
case 'developer/message':
|
|
506
|
+
case 'assistant/message': {
|
|
507
|
+
const message = data.message
|
|
508
|
+
if (message === null || typeof message !== 'object') return null
|
|
509
|
+
if (!Array.isArray(message.content) || message.content.length === 0) return null
|
|
510
|
+
return message
|
|
511
|
+
}
|
|
512
|
+
case 'tool/result':
|
|
513
|
+
return data.message !== null && typeof data.message === 'object' ? data.message : null
|
|
514
|
+
default:
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function isSurfaceEvent(event) {
|
|
520
|
+
return SURFACE_TYPES.has(event.type) && event.surfaceOp !== undefined
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** {start,end} for a replacement event, or null for an append / legacy spelling. */
|
|
524
|
+
function replaceRangeOf(surfaceOp) {
|
|
525
|
+
if (surfaceOp === null || typeof surfaceOp !== 'object') return null
|
|
526
|
+
if (surfaceOp.op !== 'replace') return null
|
|
527
|
+
const start = surfaceOp.startSeq
|
|
528
|
+
const end = surfaceOp.endSeq
|
|
529
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) return null
|
|
530
|
+
return { start, end }
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function addNodeSums(state, node) {
|
|
534
|
+
if (node.cat === 'system') return
|
|
535
|
+
if (MESSAGE_CATS.includes(node.cat)) {
|
|
536
|
+
state.sums[node.cat] += node.tokens
|
|
537
|
+
state.surfaceCounts[node.cat] = (state.surfaceCounts[node.cat] || 0) + 1
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function removeNodeSums(state, node) {
|
|
542
|
+
if (node.cat === 'system') return
|
|
543
|
+
if (MESSAGE_CATS.includes(node.cat)) {
|
|
544
|
+
state.sums[node.cat] = Math.max(0, state.sums[node.cat] - node.tokens)
|
|
545
|
+
state.surfaceCounts[node.cat] = Math.max(0, (state.surfaceCounts[node.cat] || 0) - 1)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function recomputeSystemTokens(state) {
|
|
550
|
+
for (let i = state.surface.length - 1; i >= 0; i -= 1) {
|
|
551
|
+
const node = state.surface[i]
|
|
552
|
+
if (node.cat === 'system' && node.tokens > 0) {
|
|
553
|
+
state.systemTokens = node.tokens
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
state.systemTokens = 0
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function applySurface(state, event, cat, message) {
|
|
561
|
+
const node = { seq: event.seq, cat, tokens: estimateMessage(message) }
|
|
562
|
+
const eventTime = timeOf(event)
|
|
563
|
+
if (eventTime > 0) node.time = eventTime
|
|
564
|
+
const source = message !== null && typeof message === 'object' ? message.source : null
|
|
565
|
+
if (source !== null && typeof source === 'object') {
|
|
566
|
+
if (typeof source.form === 'string' && source.form !== '') node.form = source.form
|
|
567
|
+
if (typeof source.name === 'string' && source.name !== '') node.name = source.name.slice(0, 160)
|
|
568
|
+
}
|
|
569
|
+
if (cat === 'assistant') {
|
|
570
|
+
const answer = firstText(Array.isArray(message && message.content) ? message.content : [])
|
|
571
|
+
if (answer !== '') node.text = answer.replace(/\s+/g, ' ').trim().slice(0, MAX_LAST_ANSWER)
|
|
572
|
+
else {
|
|
573
|
+
const calls = Array.isArray(message && message.content)
|
|
574
|
+
? message.content.filter(block => block && typeof block === 'object' && block.type === 'tool-call')
|
|
575
|
+
.map(block => typeof block.name === 'string' ? block.name.slice(0, 100) : '')
|
|
576
|
+
.filter(Boolean).slice(0, 3)
|
|
577
|
+
: []
|
|
578
|
+
if (calls.length > 0) node.calls = calls
|
|
579
|
+
}
|
|
580
|
+
} else if (cat === 'tool') {
|
|
581
|
+
const callId = source && typeof source.callId === 'string' ? source.callId : ''
|
|
582
|
+
const call = callId !== '' ? state.callNames[callId] : null
|
|
583
|
+
if (call && typeof call.name === 'string') node.tool = call.name.slice(0, 100)
|
|
584
|
+
if (message && message.isError === true) node.err = true
|
|
585
|
+
}
|
|
586
|
+
const images = countImageBlocks(message)
|
|
587
|
+
if (images > 0) node.imgs = images
|
|
588
|
+
const range = replaceRangeOf(event.surfaceOp)
|
|
589
|
+
if (range !== null) {
|
|
590
|
+
let startIndex = -1
|
|
591
|
+
let endIndex = -1
|
|
592
|
+
for (let i = 0; i < state.surface.length; i += 1) {
|
|
593
|
+
if (startIndex < 0 && state.surface[i].seq === range.start) startIndex = i
|
|
594
|
+
if (state.surface[i].seq === range.end) {
|
|
595
|
+
endIndex = i
|
|
596
|
+
break
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (startIndex >= 0 && endIndex >= startIndex) {
|
|
600
|
+
const removed = state.surface.splice(startIndex, endIndex - startIndex + 1, node)
|
|
601
|
+
for (const old of removed) {
|
|
602
|
+
removeNodeSums(state, old)
|
|
603
|
+
state.archive.push({ ...old, gone: event.seq })
|
|
604
|
+
for (let index = 0; index < state.fileOps.length; index += 1) {
|
|
605
|
+
const op = state.fileOps[index]
|
|
606
|
+
if (op.seq === old.seq && op.gone === undefined) state.fileOps[index] = { ...op, gone: event.seq }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
addNodeSums(state, node)
|
|
610
|
+
if (cat === 'system') recomputeSystemTokens(state)
|
|
611
|
+
trimState(state)
|
|
612
|
+
return node
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
state.surface.push(node)
|
|
616
|
+
addNodeSums(state, node)
|
|
617
|
+
if (cat === 'system') recomputeSystemTokens(state)
|
|
618
|
+
return node
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function trimState(state) {
|
|
622
|
+
if (state.surface.length > MAX_SURFACE) {
|
|
623
|
+
const drop = state.surface.length - MAX_SURFACE
|
|
624
|
+
const removed = state.surface.splice(0, drop)
|
|
625
|
+
for (const old of removed) removeNodeSums(state, old)
|
|
626
|
+
state.truncated.surface = true
|
|
627
|
+
recomputeSystemTokens(state)
|
|
628
|
+
}
|
|
629
|
+
if (state.requests.length > MAX_REQUESTS) {
|
|
630
|
+
state.requests.splice(0, state.requests.length - MAX_REQUESTS)
|
|
631
|
+
state.truncated.requests = true
|
|
632
|
+
}
|
|
633
|
+
if (state.events.length > MAX_EVENTS) {
|
|
634
|
+
state.events.splice(0, state.events.length - MAX_EVENTS)
|
|
635
|
+
state.truncated.events = true
|
|
636
|
+
}
|
|
637
|
+
if (Array.isArray(state.spans) && state.spans.length > MAX_SPANS) {
|
|
638
|
+
state.spans = state.spans.slice(state.spans.length - MAX_SPANS)
|
|
639
|
+
}
|
|
640
|
+
if (Array.isArray(state.archive) && state.archive.length > MAX_ARCHIVE_NODES) {
|
|
641
|
+
const removed = state.archive.splice(0, state.archive.length - MAX_ARCHIVE_NODES)
|
|
642
|
+
for (const node of removed) state.archiveFloor = Math.max(state.archiveFloor || 0, safeInt(node.gone))
|
|
643
|
+
}
|
|
644
|
+
if (Array.isArray(state.headerEpochs) && state.headerEpochs.length > MAX_HEADER_EPOCHS) {
|
|
645
|
+
state.headerEpochs.splice(0, state.headerEpochs.length - MAX_HEADER_EPOCHS)
|
|
646
|
+
}
|
|
647
|
+
if (Array.isArray(state.fileOps) && state.fileOps.length > MAX_FILE_OPS) {
|
|
648
|
+
const removed = state.fileOps.splice(0, state.fileOps.length - MAX_FILE_OPS)
|
|
649
|
+
for (const op of removed) state.fileOpsFloor = Math.max(state.fileOpsFloor || 0, safeInt(op.seq))
|
|
650
|
+
}
|
|
651
|
+
if (Object.keys(state.callNames).length > MAX_CALL_NAMES) {
|
|
652
|
+
const keys = Object.keys(state.callNames)
|
|
653
|
+
for (const key of keys.slice(0, keys.length - MAX_CALL_NAMES)) delete state.callNames[key]
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Buffer one span of the open step (`[kind, start, end]`, absolute ms). The
|
|
659
|
+
* buffer is bounded: a hostile stream or replay cannot grow the persisted state,
|
|
660
|
+
* and a dropped span simply becomes part of the step/end residue.
|
|
661
|
+
*/
|
|
662
|
+
function pushStepSpan(state, span) {
|
|
663
|
+
if (!Array.isArray(span)) return
|
|
664
|
+
const start = span[1]
|
|
665
|
+
const end = span[2]
|
|
666
|
+
if (typeof start !== 'number' || !Number.isFinite(start)) return
|
|
667
|
+
if (typeof end !== 'number' || !Number.isFinite(end) || end <= start) return
|
|
668
|
+
if (!Array.isArray(state.stepSpans)) state.stepSpans = []
|
|
669
|
+
if (state.stepSpans.length >= MAX_STEP_SPANS) return
|
|
670
|
+
state.stepSpans.push([span[0], start, end])
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** A non-negative integer for the wire; non-finite/hostile input degrades to 0. */
|
|
674
|
+
function safeInt(value) {
|
|
675
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value) : 0
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function firstText(blocks) {
|
|
679
|
+
if (!Array.isArray(blocks)) return ''
|
|
680
|
+
for (const block of blocks) {
|
|
681
|
+
if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') {
|
|
682
|
+
return block.text
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
return ''
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function skillNameOf(message) {
|
|
689
|
+
if (message === null || typeof message !== 'object') return ''
|
|
690
|
+
const text = firstText(Array.isArray(message.content) ? message.content : [])
|
|
691
|
+
const match = text.match(/<skill_content\s+name="([^"]+)"/)
|
|
692
|
+
return match === null ? '' : match[1]
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function isInjectionSource(source) {
|
|
696
|
+
if (source === null || source === undefined || typeof source !== 'object') return false
|
|
697
|
+
return (typeof source.kind === 'string' && source.kind !== '' && source.kind !== 'user') || typeof source.form === 'string'
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function injectionSourceName(source) {
|
|
701
|
+
if (source === null || source === undefined || typeof source !== 'object') return ''
|
|
702
|
+
if (source.kind === 'agent-instructions' && Array.isArray(source.changes)) {
|
|
703
|
+
const paths = []
|
|
704
|
+
for (const change of source.changes) {
|
|
705
|
+
const path = change && typeof change.path === 'string' ? change.path : ''
|
|
706
|
+
if (path !== '' && !paths.includes(path)) paths.push(path)
|
|
707
|
+
}
|
|
708
|
+
if (paths.length > 0) return paths.join(', ')
|
|
709
|
+
}
|
|
710
|
+
if (typeof source.plugin === 'string' && source.plugin !== '') return source.plugin
|
|
711
|
+
if (typeof source.kind === 'string' && source.kind !== '') return source.kind
|
|
712
|
+
return ''
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function clampWaitMs(stepStart, endTime) {
|
|
716
|
+
const waits = Array.isArray(stepStart.waits) ? stepStart.waits : []
|
|
717
|
+
if (waits.length === 0) return 0
|
|
718
|
+
const spans = waits
|
|
719
|
+
.map(wait => ({ start: Math.max(stepStart.time, wait.start), end: Math.min(endTime, wait.end) }))
|
|
720
|
+
.filter(span => span.end > span.start)
|
|
721
|
+
.sort((a, b) => (a.start - b.start) || (a.end - b.end))
|
|
722
|
+
let total = 0
|
|
723
|
+
let cursor = -Infinity
|
|
724
|
+
for (const span of spans) {
|
|
725
|
+
const start = Math.max(span.start, cursor)
|
|
726
|
+
if (span.end > start) {
|
|
727
|
+
total += span.end - start
|
|
728
|
+
cursor = span.end
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return total
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Fold one committed session event. Returns the same state reference when the
|
|
736
|
+
* event cannot change the served value (the registry's change feed relies on
|
|
737
|
+
* `Object.is`), otherwise a fresh bounded state.
|
|
738
|
+
*
|
|
739
|
+
* @param {object} state previous `createState()`-shaped state
|
|
740
|
+
* @param {{type:string,seq:number,time?:number,data?:object,surfaceOp?:unknown}} event
|
|
741
|
+
* @returns {object} next state
|
|
742
|
+
*/
|
|
743
|
+
export function applyContextEvent(state, event) {
|
|
744
|
+
if (state === null || typeof state !== 'object') state = createState()
|
|
745
|
+
if (event === null || typeof event !== 'object' || typeof event.type !== 'string') return state
|
|
746
|
+
const data = event.data !== null && typeof event.data === 'object' ? event.data : undefined
|
|
747
|
+
try {
|
|
748
|
+
switch (event.type) {
|
|
749
|
+
case 'request/header': {
|
|
750
|
+
const header = data && data.header !== null && typeof data.header === 'object' ? data.header : undefined
|
|
751
|
+
const next = cloneState(state)
|
|
752
|
+
next.toolsTokens = estimateToolsTokens(header)
|
|
753
|
+
next.toolDefs = header !== undefined && Array.isArray(header.tools) ? header.tools.length : 0
|
|
754
|
+
if (header !== undefined) {
|
|
755
|
+
const tools = Array.isArray(header.tools) ? header.tools.slice(0, 100).map(tool => {
|
|
756
|
+
const item = tool !== null && typeof tool === 'object' ? tool : {}
|
|
757
|
+
return { name: typeof item.name === 'string' ? item.name.slice(0, 120) : '?' }
|
|
758
|
+
}) : []
|
|
759
|
+
next.headerEpochs.push({ seq: event.seq, time: timeOf(event), tools })
|
|
760
|
+
}
|
|
761
|
+
const config = header && header.config !== null && typeof header.config === 'object' ? header.config : undefined
|
|
762
|
+
if (config && typeof config.model === 'string') next.model = config.model
|
|
763
|
+
if (config && typeof config.provider === 'string') next.provider = config.provider
|
|
764
|
+
return next
|
|
765
|
+
}
|
|
766
|
+
case 'request/context': {
|
|
767
|
+
if (data === undefined) return state
|
|
768
|
+
let next
|
|
769
|
+
if (typeof data.contextWindow === 'number') {
|
|
770
|
+
next = next || cloneState(state)
|
|
771
|
+
next.contextWindow = data.contextWindow
|
|
772
|
+
}
|
|
773
|
+
if (typeof data.model === 'string') {
|
|
774
|
+
next = next || cloneState(state)
|
|
775
|
+
next.model = data.model
|
|
776
|
+
}
|
|
777
|
+
if (typeof data.provider === 'string') {
|
|
778
|
+
next = next || cloneState(state)
|
|
779
|
+
next.provider = data.provider
|
|
780
|
+
}
|
|
781
|
+
return next || state
|
|
782
|
+
}
|
|
783
|
+
case 'system/message': {
|
|
784
|
+
if (!isSurfaceEvent(event)) return state
|
|
785
|
+
const next = cloneState(state)
|
|
786
|
+
applySurface(next, event, 'system', deriveEventMessage(event))
|
|
787
|
+
trimState(next)
|
|
788
|
+
return next
|
|
789
|
+
}
|
|
790
|
+
case 'user/message':
|
|
791
|
+
case 'developer/message': {
|
|
792
|
+
const message = deriveEventMessage(event)
|
|
793
|
+
// The image tally is independent of the CURRENT surface: the desktop
|
|
794
|
+
// activity card counts every user upload in the log, including ones a
|
|
795
|
+
// later compaction replaced (docs §8.1).
|
|
796
|
+
const images = event.type === 'user/message' ? countImageBlocks(message) : 0
|
|
797
|
+
if (!isSurfaceEvent(event)) {
|
|
798
|
+
if (images === 0) return state
|
|
799
|
+
const next = cloneState(state)
|
|
800
|
+
next.activity.images += images
|
|
801
|
+
return next
|
|
802
|
+
}
|
|
803
|
+
const source = message && message.source
|
|
804
|
+
// Skill machinery is its own bucket: skill invocations and the
|
|
805
|
+
// available-skills catalog are durable user/message injections that the
|
|
806
|
+
// plain injected-context check would absorb.
|
|
807
|
+
const isSkillSource = source !== undefined && source !== null
|
|
808
|
+
&& (source.kind === 'skill-invocation' || source.kind === 'skill-catalog')
|
|
809
|
+
const cat = event.type === 'developer/message' || isInjectionSource(source)
|
|
810
|
+
? (isSkillSource ? 'skill' : 'inject')
|
|
811
|
+
: 'user'
|
|
812
|
+
const next = cloneState(state)
|
|
813
|
+
if (images > 0) next.activity.images += images
|
|
814
|
+
applySurface(next, event, cat, message)
|
|
815
|
+
if (event.type === 'developer/message' || isInjectionSource(source)) {
|
|
816
|
+
const source = message && message.source
|
|
817
|
+
const record = { seq: event.seq, time: timeOf(event), kind: 'inject' }
|
|
818
|
+
const form = source && typeof source.form === 'string' && source.form !== '' ? source.form : 'context'
|
|
819
|
+
record.form = form
|
|
820
|
+
if (source && source.kind === 'skill-invocation') {
|
|
821
|
+
record.sub = 'skill'
|
|
822
|
+
record.name = typeof source.name === 'string' ? source.name : '?'
|
|
823
|
+
next.counts.skillLoads += 1
|
|
824
|
+
} else if (message !== null) {
|
|
825
|
+
const label = injectionSourceName(source)
|
|
826
|
+
if (label !== '') record.name = label
|
|
827
|
+
}
|
|
828
|
+
next.events.push(record)
|
|
829
|
+
next.counts.injects += 1
|
|
830
|
+
} else {
|
|
831
|
+
next.counts.humanInputs += 1
|
|
832
|
+
}
|
|
833
|
+
trimState(next)
|
|
834
|
+
return next
|
|
835
|
+
}
|
|
836
|
+
case 'assistant/message': {
|
|
837
|
+
const message = deriveEventMessage(event)
|
|
838
|
+
const eventTime = timeOf(event)
|
|
839
|
+
const usage = data && data.usage !== null && typeof data.usage === 'object' ? data.usage : null
|
|
840
|
+
// `null` means "no readable output bucket", not a fabricated 0 (see the
|
|
841
|
+
// output-speed rule: it only counts when provider usage is readable).
|
|
842
|
+
const output = usage !== null ? tokenCountOf(usage.outputTokens) : null
|
|
843
|
+
const answerText = firstText(message && message.content)
|
|
844
|
+
const next = cloneState(state)
|
|
845
|
+
// The request record is the prompt this response was assembled for:
|
|
846
|
+
// capture it BEFORE the response joins the surface.
|
|
847
|
+
const record = {
|
|
848
|
+
seq: event.seq,
|
|
849
|
+
time: eventTime,
|
|
850
|
+
...(data && Number.isFinite(data.turn) ? { turn: data.turn } : {}),
|
|
851
|
+
...(data && Number.isFinite(data.step) ? { step: data.step } : {}),
|
|
852
|
+
system: next.systemTokens,
|
|
853
|
+
tools: next.toolsTokens,
|
|
854
|
+
total: next.systemTokens + next.toolsTokens
|
|
855
|
+
+ next.sums.user + next.sums.inject + next.sums.skill + next.sums.assistant + next.sums.tool,
|
|
856
|
+
}
|
|
857
|
+
for (const cat of MESSAGE_CATS) {
|
|
858
|
+
if (next.sums[cat] > 0) record[cat] = next.sums[cat]
|
|
859
|
+
}
|
|
860
|
+
// v2 request detail (docs §8.1): the surface node counts the request was
|
|
861
|
+
// assembled from (`system` is always the one live system prompt, `tools`
|
|
862
|
+
// the current header's tool definition count) and the provider-reported
|
|
863
|
+
// billed prompt / output buckets.
|
|
864
|
+
const items = { system: 1, tools: next.toolDefs }
|
|
865
|
+
for (const cat of MESSAGE_CATS) {
|
|
866
|
+
if (next.surfaceCounts[cat] > 0) items[cat] = next.surfaceCounts[cat]
|
|
867
|
+
}
|
|
868
|
+
record.items = items
|
|
869
|
+
if (usage !== null) {
|
|
870
|
+
const input = tokenCountOf(usage.inputTokens)
|
|
871
|
+
const cacheRead = tokenCountOf(usage.cacheReadTokens)
|
|
872
|
+
const cacheWrite = tokenCountOf(usage.cacheWriteTokens)
|
|
873
|
+
if (input !== null || cacheRead !== null || cacheWrite !== null || output !== null) {
|
|
874
|
+
record.prompt = (input ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0)
|
|
875
|
+
if (output !== null) record.output = output
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
next.requests.push(record)
|
|
879
|
+
// v2 last answer: the newest assistant text, whitespace-collapsed and
|
|
880
|
+
// bounded; tool-only steps leave the previous preview in place.
|
|
881
|
+
if (answerText !== '') {
|
|
882
|
+
const compressed = answerText.replace(/\s+/g, ' ').trim()
|
|
883
|
+
if (compressed !== '') {
|
|
884
|
+
next.lastAnswer = compressed.length > MAX_LAST_ANSWER ? compressed.slice(0, MAX_LAST_ANSWER) : compressed
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
// v2 timing split (docs §8.1): only an open step with an observable
|
|
888
|
+
// first token is priced; an unattributed stream lands in the card's
|
|
889
|
+
// `other` residue instead of charging the wait.
|
|
890
|
+
const stepStart = state.stepStart
|
|
891
|
+
if (stepStart !== undefined && Number.isFinite(stepStart.time)) {
|
|
892
|
+
const stream = data && data.stream
|
|
893
|
+
const firstToken = typeof stepStart.firstToken === 'number'
|
|
894
|
+
? stepStart.firstToken
|
|
895
|
+
: firstTokenTimeOfStream(stream)
|
|
896
|
+
if (firstToken !== undefined) {
|
|
897
|
+
const decodeSpans = decodeSpansOfStream(stream, eventTime)
|
|
898
|
+
let decodeStart = firstToken
|
|
899
|
+
for (const span of decodeSpans) decodeStart = Math.min(decodeStart, span.start)
|
|
900
|
+
next.timing.ttftMs += durOf(stepStart.time, decodeStart)
|
|
901
|
+
next.timing.waitCount += 1
|
|
902
|
+
pushStepSpan(next, [SPAN_WAIT, stepStart.time, decodeStart])
|
|
903
|
+
// The decode blocks tile [decodeStart, eventTime] in stream order; the
|
|
904
|
+
// first block reaches back to the wait boundary, later ones keep their
|
|
905
|
+
// own start so the rows and the strip read the same window.
|
|
906
|
+
let pushedAny = false
|
|
907
|
+
for (const span of decodeSpans) {
|
|
908
|
+
const to = Math.min(eventTime, span.end)
|
|
909
|
+
if (to <= span.start) continue
|
|
910
|
+
const start = pushedAny ? span.start : decodeStart
|
|
911
|
+
if (to > start) {
|
|
912
|
+
pushStepSpan(next, [DECODE_SPAN_KINDS[span.kind], start, to])
|
|
913
|
+
pushedAny = true
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const tally = decodeTallyOfStream(stream, eventTime)
|
|
917
|
+
next.timing.reasoningMs += tally.spans.reasoning
|
|
918
|
+
next.timing.reasoningCount += tally.blocks.reasoning
|
|
919
|
+
next.timing.textMs += tally.spans.text
|
|
920
|
+
next.timing.textCount += tally.blocks.text
|
|
921
|
+
next.timing.toolArgMs += tally.spans.toolarg
|
|
922
|
+
next.timing.toolArgCount += tally.blocks.toolarg
|
|
923
|
+
// Output speed pairs the first-token anchor with the provider's own
|
|
924
|
+
// output count; it only counts when both are known.
|
|
925
|
+
if (output !== null) {
|
|
926
|
+
next.timing.speedMs += durOf(firstToken, eventTime)
|
|
927
|
+
next.timing.speedTokens += output
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (isSurfaceEvent(event)) {
|
|
932
|
+
const node = applySurface(next, event, 'assistant', message)
|
|
933
|
+
if (node.tokens > 0 && answerText !== '') next.counts.answers += 1
|
|
934
|
+
}
|
|
935
|
+
trimState(next)
|
|
936
|
+
return next
|
|
937
|
+
}
|
|
938
|
+
case 'assistant/attempt': {
|
|
939
|
+
// An attempt that committed no surface message can still carry the
|
|
940
|
+
// step's first token (a retried / aborted call): stamp it on the open
|
|
941
|
+
// step so TTFT stays real instead of falling into the residue.
|
|
942
|
+
const start = state.stepStart
|
|
943
|
+
if (start === undefined || typeof start.firstToken === 'number') return state
|
|
944
|
+
const first = firstTokenTimeOfStream(data && data.stream)
|
|
945
|
+
if (first === undefined) return state
|
|
946
|
+
const next = cloneState(state)
|
|
947
|
+
next.stepStart.firstToken = first
|
|
948
|
+
return next
|
|
949
|
+
}
|
|
950
|
+
case 'tool/call': {
|
|
951
|
+
const callId = data && typeof data.callId === 'string' ? data.callId : ''
|
|
952
|
+
const name = data && typeof data.name === 'string' ? data.name : ''
|
|
953
|
+
if (callId === '' || name === '') return state
|
|
954
|
+
const next = cloneState(state)
|
|
955
|
+
const file = fileCallMeta(name, data && data.arguments)
|
|
956
|
+
next.callNames[callId] = { name, start: timeOf(event), ...(file !== null ? { file } : {}) }
|
|
957
|
+
trimState(next)
|
|
958
|
+
return next
|
|
959
|
+
}
|
|
960
|
+
case 'tool/result': {
|
|
961
|
+
const message = deriveEventMessage(event)
|
|
962
|
+
const source = message && message.source !== null && typeof message.source === 'object' ? message.source : undefined
|
|
963
|
+
const callId = source && typeof source.callId === 'string' ? source.callId : ''
|
|
964
|
+
const pending = callId !== '' ? state.callNames[callId] : undefined
|
|
965
|
+
const eventTime = timeOf(event)
|
|
966
|
+
if (!isSurfaceEvent(event)) {
|
|
967
|
+
if (pending === undefined) return state
|
|
968
|
+
const ops = fileOpsOf(event, message, pending)
|
|
969
|
+
if (ops.length === 0) return state
|
|
970
|
+
const next = cloneState(state)
|
|
971
|
+
next.fileOps.push(...ops)
|
|
972
|
+
if (callId !== '') delete next.callNames[callId]
|
|
973
|
+
trimState(next)
|
|
974
|
+
return next
|
|
975
|
+
}
|
|
976
|
+
const next = cloneState(state)
|
|
977
|
+
const node = applySurface(next, event, 'tool', message)
|
|
978
|
+
if (pending !== undefined) {
|
|
979
|
+
next.fileOps.push(...fileOpsOf(event, message, pending))
|
|
980
|
+
next.counts.toolCalls += 1
|
|
981
|
+
next.toolNames[pending.name] = (next.toolNames[pending.name] || 0) + 1
|
|
982
|
+
// v2 activity: classify the paired run by its durable tool name.
|
|
983
|
+
const activityKey = toolActivityOf(pending.name)
|
|
984
|
+
if (activityKey !== '') next.activity[activityKey] += 1
|
|
985
|
+
// v2 tool timing: the paired run's wall slice, priced on the same
|
|
986
|
+
// call/result pairing the tool tallies use.
|
|
987
|
+
next.timing.toolsMs += durOf(pending.start, eventTime)
|
|
988
|
+
next.timing.toolCount += 1
|
|
989
|
+
pushStepSpan(next, [SPAN_TOOLS, pending.start, eventTime])
|
|
990
|
+
if (pending.name === 'ask_user_question' && next.stepStart !== undefined) {
|
|
991
|
+
next.stepStart.waits.push({ start: pending.start, end: eventTime })
|
|
992
|
+
}
|
|
993
|
+
if (callId !== '') delete next.callNames[callId]
|
|
994
|
+
// A skill tool's result is harness-injected skill content: give it the
|
|
995
|
+
// skill bucket instead of burying it among ordinary tool results.
|
|
996
|
+
if (pending.name === 'skill') {
|
|
997
|
+
const skillName = skillNameOf(message)
|
|
998
|
+
if (skillName !== '') {
|
|
999
|
+
next.sums.tool = Math.max(0, next.sums.tool - node.tokens)
|
|
1000
|
+
node.cat = 'skill'
|
|
1001
|
+
next.sums.skill += node.tokens
|
|
1002
|
+
next.surfaceCounts.tool = Math.max(0, next.surfaceCounts.tool - 1)
|
|
1003
|
+
next.surfaceCounts.skill += 1
|
|
1004
|
+
next.counts.skillLoads += 1
|
|
1005
|
+
next.events.push({ seq: event.seq, time: eventTime, kind: 'inject', form: 'instructions', sub: 'skill', name: skillName })
|
|
1006
|
+
next.counts.injects += 1
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
} else if (node.tool !== undefined) {
|
|
1010
|
+
// No paired call (a trimmed window): still count the tool run.
|
|
1011
|
+
next.counts.toolCalls += 1
|
|
1012
|
+
next.toolNames[node.tool] = (next.toolNames[node.tool] || 0) + 1
|
|
1013
|
+
}
|
|
1014
|
+
trimState(next)
|
|
1015
|
+
return next
|
|
1016
|
+
}
|
|
1017
|
+
case 'step/start': {
|
|
1018
|
+
const next = cloneState(state)
|
|
1019
|
+
next.stepStart = { time: timeOf(event), waits: [] }
|
|
1020
|
+
// A superseded step's span buffer belongs to the old window.
|
|
1021
|
+
delete next.stepSpans
|
|
1022
|
+
return next
|
|
1023
|
+
}
|
|
1024
|
+
case 'step/end': {
|
|
1025
|
+
if (state.stepStart === undefined) return state
|
|
1026
|
+
const next = cloneState(state)
|
|
1027
|
+
const start = state.stepStart
|
|
1028
|
+
const endTime = timeOf(event)
|
|
1029
|
+
const duration = Math.max(0, endTime - start.time)
|
|
1030
|
+
next.wallMs = state.wallMs + Math.max(0, duration - clampWaitMs(start, endTime))
|
|
1031
|
+
// Tile the step's span buffer across [step start, step end]: first-wins
|
|
1032
|
+
// de-overlap so parallel tool runs paint their union once, and every hole
|
|
1033
|
+
// is filled with the `other` residue so the strip stays gapless.
|
|
1034
|
+
const painted = []
|
|
1035
|
+
for (const span of Array.isArray(state.stepSpans) ? state.stepSpans : []) {
|
|
1036
|
+
const from = Math.max(start.time, span[1])
|
|
1037
|
+
const to = Math.min(endTime, span[2])
|
|
1038
|
+
if (to > from) painted.push([span[0], from, to])
|
|
1039
|
+
}
|
|
1040
|
+
painted.sort((left, right) => (left[1] - right[1]) || (left[2] - right[2]))
|
|
1041
|
+
next.spans = state.spans.slice()
|
|
1042
|
+
let cursor = start.time
|
|
1043
|
+
for (const span of painted) {
|
|
1044
|
+
const from = Math.max(cursor, span[1])
|
|
1045
|
+
if (span[2] <= from) continue
|
|
1046
|
+
if (from > cursor) next.spans.push([SPAN_OTHER, cursor, from])
|
|
1047
|
+
next.spans.push([span[0], from, span[2]])
|
|
1048
|
+
cursor = span[2]
|
|
1049
|
+
}
|
|
1050
|
+
if (endTime > cursor) next.spans.push([SPAN_OTHER, cursor, endTime])
|
|
1051
|
+
if (next.spans.length > MAX_SPANS) next.spans = next.spans.slice(next.spans.length - MAX_SPANS)
|
|
1052
|
+
delete next.stepStart
|
|
1053
|
+
delete next.stepSpans
|
|
1054
|
+
if (next.approvals !== undefined && Object.keys(next.approvals).length === 0) delete next.approvals
|
|
1055
|
+
return next
|
|
1056
|
+
}
|
|
1057
|
+
case 'approval/asked': {
|
|
1058
|
+
if (state.stepStart === undefined) return state
|
|
1059
|
+
const id = data && typeof data.id === 'string' ? data.id : ''
|
|
1060
|
+
if (id === '') return state
|
|
1061
|
+
const next = cloneState(state)
|
|
1062
|
+
next.approvals = { ...(next.approvals || {}), [id]: timeOf(event) }
|
|
1063
|
+
return next
|
|
1064
|
+
}
|
|
1065
|
+
case 'approval/decided': {
|
|
1066
|
+
const id = data && typeof data.id === 'string' ? data.id : ''
|
|
1067
|
+
const askedAt = id !== '' && state.approvals !== undefined ? state.approvals[id] : undefined
|
|
1068
|
+
if (askedAt === undefined || state.stepStart === undefined) return state
|
|
1069
|
+
const next = cloneState(state)
|
|
1070
|
+
next.stepStart.waits.push({ start: askedAt, end: timeOf(event) })
|
|
1071
|
+
delete next.approvals[id]
|
|
1072
|
+
return next
|
|
1073
|
+
}
|
|
1074
|
+
case 'compaction/summary':
|
|
1075
|
+
case 'compaction/prune': {
|
|
1076
|
+
const next = cloneState(state)
|
|
1077
|
+
const compaction = event.type === 'compaction/summary'
|
|
1078
|
+
next.events.push({
|
|
1079
|
+
seq: event.seq,
|
|
1080
|
+
time: timeOf(event),
|
|
1081
|
+
kind: compaction ? 'compaction' : 'prune',
|
|
1082
|
+
...(data && Number.isFinite(data.shadowedTokenCount) ? { tokens: data.shadowedTokenCount } : {}),
|
|
1083
|
+
...(compaction && data && Array.isArray(data.shadowedSeqs) ? { count: data.shadowedSeqs.length } : {}),
|
|
1084
|
+
})
|
|
1085
|
+
if (compaction) next.counts.compactions += 1
|
|
1086
|
+
else next.counts.prunes += 1
|
|
1087
|
+
trimState(next)
|
|
1088
|
+
return next
|
|
1089
|
+
}
|
|
1090
|
+
default:
|
|
1091
|
+
return state
|
|
1092
|
+
}
|
|
1093
|
+
} catch (_) {
|
|
1094
|
+
// Durable logs are untrusted input and the registry drives this fold with
|
|
1095
|
+
// no error boundary: drop a malformed event whole instead of stalling.
|
|
1096
|
+
return state
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function timeOf(event) {
|
|
1101
|
+
return typeof event.time === 'number' && Number.isFinite(event.time) ? event.time : 0
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function sortedToolNames(state) {
|
|
1105
|
+
return Object.entries(state.toolNames)
|
|
1106
|
+
.sort((a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0]))
|
|
1107
|
+
.slice(0, MAX_TOOL_NAMES)
|
|
1108
|
+
.map(([name, count]) => ({ name, count }))
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** The projection's wire value: a detached, bounded, lossless JSON snapshot. */
|
|
1112
|
+
export function contextView(state) {
|
|
1113
|
+
const current = {
|
|
1114
|
+
system: state.systemTokens,
|
|
1115
|
+
tools: state.toolsTokens,
|
|
1116
|
+
user: state.sums.user,
|
|
1117
|
+
inject: state.sums.inject,
|
|
1118
|
+
skill: state.sums.skill,
|
|
1119
|
+
assistant: state.sums.assistant,
|
|
1120
|
+
tool: state.sums.tool,
|
|
1121
|
+
}
|
|
1122
|
+
current.total = current.system + current.tools + current.user + current.inject + current.skill + current.assistant + current.tool
|
|
1123
|
+
const activity = state.activity !== null && typeof state.activity === 'object' ? state.activity : {}
|
|
1124
|
+
const timingRaw = state.timing !== null && typeof state.timing === 'object' ? state.timing : {}
|
|
1125
|
+
// The six-way split: the five measured buckets plus the residue, so the card's
|
|
1126
|
+
// percentages always add up and `otherMs` is never negative (docs §8.1).
|
|
1127
|
+
const wallMs = safeInt(state.wallMs)
|
|
1128
|
+
const ttftMs = safeInt(timingRaw.ttftMs)
|
|
1129
|
+
const reasoningMs = safeInt(timingRaw.reasoningMs)
|
|
1130
|
+
const textMs = safeInt(timingRaw.textMs)
|
|
1131
|
+
const toolArgMs = safeInt(timingRaw.toolArgMs)
|
|
1132
|
+
const toolsMs = safeInt(timingRaw.toolsMs)
|
|
1133
|
+
const measuredMs = ttftMs + reasoningMs + textMs + toolArgMs + toolsMs
|
|
1134
|
+
return {
|
|
1135
|
+
v: 1,
|
|
1136
|
+
...(state.model !== undefined ? { model: state.model } : {}),
|
|
1137
|
+
...(state.provider !== undefined ? { provider: state.provider } : {}),
|
|
1138
|
+
...(state.contextWindow !== undefined ? { contextWindow: state.contextWindow } : {}),
|
|
1139
|
+
current,
|
|
1140
|
+
counts: {
|
|
1141
|
+
humanInputs: state.counts.humanInputs,
|
|
1142
|
+
answers: state.counts.answers,
|
|
1143
|
+
toolCalls: state.counts.toolCalls,
|
|
1144
|
+
skillLoads: state.counts.skillLoads,
|
|
1145
|
+
injects: state.counts.injects,
|
|
1146
|
+
compactions: state.counts.compactions,
|
|
1147
|
+
prunes: state.counts.prunes,
|
|
1148
|
+
},
|
|
1149
|
+
// v2 activity ledger (docs §8.1): `userInputs` mirrors the v1 `humanInputs`.
|
|
1150
|
+
activity: {
|
|
1151
|
+
userInputs: state.counts.humanInputs,
|
|
1152
|
+
reads: safeInt(activity.reads),
|
|
1153
|
+
writes: safeInt(activity.writes),
|
|
1154
|
+
searches: safeInt(activity.searches),
|
|
1155
|
+
images: safeInt(activity.images),
|
|
1156
|
+
},
|
|
1157
|
+
tools: { total: state.counts.toolCalls, names: sortedToolNames(state) },
|
|
1158
|
+
timing: {
|
|
1159
|
+
wallMs,
|
|
1160
|
+
ttftMs,
|
|
1161
|
+
reasoningMs,
|
|
1162
|
+
textMs,
|
|
1163
|
+
toolArgMs,
|
|
1164
|
+
toolsMs,
|
|
1165
|
+
otherMs: Math.max(0, wallMs - measuredMs),
|
|
1166
|
+
waitCount: safeInt(timingRaw.waitCount),
|
|
1167
|
+
reasoningCount: safeInt(timingRaw.reasoningCount),
|
|
1168
|
+
textCount: safeInt(timingRaw.textCount),
|
|
1169
|
+
toolArgCount: safeInt(timingRaw.toolArgCount),
|
|
1170
|
+
toolCount: safeInt(timingRaw.toolCount),
|
|
1171
|
+
speedTokens: safeInt(timingRaw.speedTokens),
|
|
1172
|
+
speedMs: safeInt(timingRaw.speedMs),
|
|
1173
|
+
},
|
|
1174
|
+
// v2 timeline: newest MAX_SPANS segments, each [kind, start, end].
|
|
1175
|
+
spans: (Array.isArray(state.spans) ? state.spans : []).map(span => [span[0], span[1], span[2]]),
|
|
1176
|
+
// Slim browser index; full message/header bodies are fetched only when a row is opened.
|
|
1177
|
+
surface: (Array.isArray(state.surface) ? state.surface : []).map(node => ({ ...node })),
|
|
1178
|
+
archive: (Array.isArray(state.archive) ? state.archive : []).map(node => ({ ...node })),
|
|
1179
|
+
headerEpochs: (Array.isArray(state.headerEpochs) ? state.headerEpochs : []).map(epoch => ({
|
|
1180
|
+
...epoch,
|
|
1181
|
+
tools: Array.isArray(epoch.tools) ? epoch.tools.map(tool => ({ ...tool })) : [],
|
|
1182
|
+
})),
|
|
1183
|
+
fileOps: (Array.isArray(state.fileOps) ? state.fileOps : []).map(op => ({ ...op })),
|
|
1184
|
+
...(Number.isFinite(state.archiveFloor) ? { archiveFloor: state.archiveFloor } : {}),
|
|
1185
|
+
...(Number.isFinite(state.fileOpsFloor) ? { fileOpsFloor: state.fileOpsFloor } : {}),
|
|
1186
|
+
...(typeof state.lastAnswer === 'string' && state.lastAnswer !== '' ? { lastAnswer: state.lastAnswer } : {}),
|
|
1187
|
+
requests: state.requests.map(record => ({
|
|
1188
|
+
...record,
|
|
1189
|
+
...(record.items !== undefined && record.items !== null ? { items: { ...record.items } } : {}),
|
|
1190
|
+
})),
|
|
1191
|
+
events: state.events.map(record => ({ ...record })),
|
|
1192
|
+
truncated: { ...state.truncated },
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** The `mobileContext` projection definition registered on `ctx.sessionProjections`. */
|
|
1197
|
+
export const contextProjectionDefinition = {
|
|
1198
|
+
key: 'mobileContext',
|
|
1199
|
+
// v4 adds bounded file-operation records; older cache rows refold to populate File Activity.
|
|
1200
|
+
stateVersion: 4,
|
|
1201
|
+
stateSchema: passthroughSchema,
|
|
1202
|
+
init: () => createState(),
|
|
1203
|
+
apply: (state, event) => applyContextEvent(state, event),
|
|
1204
|
+
wire: { viewSchema: passthroughSchema, view: state => contextView(state) },
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
// ---------------------------------------------------------------------------
|
|
1209
|
+
// Fleet (agent family) data
|
|
1210
|
+
// ---------------------------------------------------------------------------
|
|
1211
|
+
|
|
1212
|
+
/** Harness services captured from the plugin context (absent on older hosts). */
|
|
1213
|
+
let hostSessions = null
|
|
1214
|
+
let hostProjections = null
|
|
1215
|
+
let hostAgents = null
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* Capture the host services the fleet query needs. Registration is optional and
|
|
1219
|
+
* lifecycle-bound: when a service is absent the query answers context-unavailable.
|
|
1220
|
+
*
|
|
1221
|
+
* @param {object} ctx gateway plugin context
|
|
1222
|
+
* @returns {boolean} whether an injection was attempted
|
|
1223
|
+
*/
|
|
1224
|
+
export function registerContextServices(ctx) {
|
|
1225
|
+
if (ctx === null || typeof ctx !== 'object' || typeof ctx.inject !== 'function') return false
|
|
1226
|
+
try {
|
|
1227
|
+
ctx.inject(['sessions', 'sessionProjections'], (sub) => {
|
|
1228
|
+
if (!sub || !sub.sessions || !sub.sessionProjections) return
|
|
1229
|
+
hostSessions = sub.sessions
|
|
1230
|
+
hostProjections = sub.sessionProjections
|
|
1231
|
+
sub.effect(() => () => {
|
|
1232
|
+
hostSessions = null
|
|
1233
|
+
hostProjections = null
|
|
1234
|
+
})
|
|
1235
|
+
})
|
|
1236
|
+
} catch (_) { /* optional service */ }
|
|
1237
|
+
try {
|
|
1238
|
+
ctx.inject(['agents'], (sub) => {
|
|
1239
|
+
if (!sub || !sub.agents) return
|
|
1240
|
+
hostAgents = sub.agents
|
|
1241
|
+
sub.effect(() => () => { hostAgents = null })
|
|
1242
|
+
})
|
|
1243
|
+
} catch (_) { /* optional service */ }
|
|
1244
|
+
return true
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const MAX_FLEET_AGENTS = 40
|
|
1248
|
+
|
|
1249
|
+
function fleetNumber(value) {
|
|
1250
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Bound one host `agentTeam` projection value to the wire shape docs §8.2
|
|
1255
|
+
* names: at most MAX_TEAM_ROWS members and MAX_TEAM_ROWS tasks, minimal fields,
|
|
1256
|
+
* always a plain-JSON object. Returns null when the value carries no roster.
|
|
1257
|
+
*/
|
|
1258
|
+
function teamOf(agentTeam) {
|
|
1259
|
+
if (agentTeam === null || typeof agentTeam !== 'object' || Array.isArray(agentTeam)) return null
|
|
1260
|
+
if (!Array.isArray(agentTeam.members) && !Array.isArray(agentTeam.tasks)) return null
|
|
1261
|
+
const members = []
|
|
1262
|
+
for (const member of Array.isArray(agentTeam.members) ? agentTeam.members : []) {
|
|
1263
|
+
if (members.length >= MAX_TEAM_ROWS) break
|
|
1264
|
+
if (member === null || typeof member !== 'object') continue
|
|
1265
|
+
if (typeof member.id !== 'string' || member.id === '') continue
|
|
1266
|
+
const entry = {
|
|
1267
|
+
id: member.id,
|
|
1268
|
+
name: typeof member.name === 'string' ? member.name : '',
|
|
1269
|
+
role: member.role === 'lead' ? 'lead' : 'teammate',
|
|
1270
|
+
phase: TEAM_PHASES.has(member.phase) ? member.phase : 'active',
|
|
1271
|
+
}
|
|
1272
|
+
if (typeof member.error === 'string' && member.error !== '') entry.error = member.error
|
|
1273
|
+
members.push(entry)
|
|
1274
|
+
}
|
|
1275
|
+
const tasks = []
|
|
1276
|
+
for (const task of Array.isArray(agentTeam.tasks) ? agentTeam.tasks : []) {
|
|
1277
|
+
if (tasks.length >= MAX_TEAM_ROWS) break
|
|
1278
|
+
if (task === null || typeof task !== 'object') continue
|
|
1279
|
+
if (typeof task.id !== 'string' || task.id === '') continue
|
|
1280
|
+
const entry = {
|
|
1281
|
+
id: task.id,
|
|
1282
|
+
subject: typeof task.subject === 'string' ? task.subject : '',
|
|
1283
|
+
status: typeof task.status === 'string' ? task.status : 'pending',
|
|
1284
|
+
}
|
|
1285
|
+
if (typeof task.ownerName === 'string' && task.ownerName !== '') entry.ownerName = task.ownerName
|
|
1286
|
+
if (typeof task.ownerId === 'string' && task.ownerId !== '') entry.ownerId = task.ownerId
|
|
1287
|
+
tasks.push(entry)
|
|
1288
|
+
}
|
|
1289
|
+
return {
|
|
1290
|
+
members,
|
|
1291
|
+
tasks,
|
|
1292
|
+
failure: typeof agentTeam.failure === 'string' ? agentTeam.failure : '',
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/**
|
|
1297
|
+
* Pure fleet payload builder: `records` are session headers
|
|
1298
|
+
* `{id, parentSessionId, origin, createdAt, title?}`; `snapshotOf(id)` returns
|
|
1299
|
+
* one session's projection values (or null); `runningOf(id)` returns whether
|
|
1300
|
+
* that agent is live.
|
|
1301
|
+
*
|
|
1302
|
+
* @param {string} currentSessionId
|
|
1303
|
+
* @param {Array<object>} records
|
|
1304
|
+
* @param {(id: string) => (object|null)} snapshotOf
|
|
1305
|
+
* @param {(id: string) => boolean} [runningOf]
|
|
1306
|
+
* @returns {object} context-fleet value
|
|
1307
|
+
*/
|
|
1308
|
+
export function buildFleetValue(currentSessionId, records, snapshotOf, runningOf) {
|
|
1309
|
+
const byId = new Map()
|
|
1310
|
+
for (const record of Array.isArray(records) ? records : []) {
|
|
1311
|
+
if (record === null || typeof record !== 'object' || typeof record.id !== 'string' || record.id === '') continue
|
|
1312
|
+
if (!byId.has(record.id)) byId.set(record.id, record)
|
|
1313
|
+
}
|
|
1314
|
+
const current = typeof currentSessionId === 'string' ? currentSessionId : ''
|
|
1315
|
+
const resolve = typeof snapshotOf === 'function' ? snapshotOf : () => null
|
|
1316
|
+
const isRunning = typeof runningOf === 'function' ? runningOf : () => false
|
|
1317
|
+
|
|
1318
|
+
// Ancestor chain (current -> root); guards against a cyclic or damaged header chain.
|
|
1319
|
+
const chain = []
|
|
1320
|
+
const chainSeen = new Set()
|
|
1321
|
+
const ancestorLimit = MAX_FLEET_AGENTS * 5
|
|
1322
|
+
let cursor = current
|
|
1323
|
+
while (cursor !== '' && byId.has(cursor) && !chainSeen.has(cursor) && chain.length < ancestorLimit) {
|
|
1324
|
+
chainSeen.add(cursor)
|
|
1325
|
+
const record = byId.get(cursor)
|
|
1326
|
+
chain.push(record)
|
|
1327
|
+
cursor = typeof record.parentSessionId === 'string' ? record.parentSessionId : ''
|
|
1328
|
+
}
|
|
1329
|
+
const ancestors = chain.slice().reverse()
|
|
1330
|
+
const rootId = ancestors.length > 0 ? ancestors[0].id : (byId.has(current) ? current : '')
|
|
1331
|
+
|
|
1332
|
+
// Subagent edges only: a fork shares the lineage field but is an independent conversation.
|
|
1333
|
+
const childrenOf = new Map()
|
|
1334
|
+
for (const record of byId.values()) {
|
|
1335
|
+
const parent = record.parentSessionId
|
|
1336
|
+
if (typeof parent !== 'string' || parent === '' || !byId.has(parent)) continue
|
|
1337
|
+
if (record.origin !== 'subagent') continue
|
|
1338
|
+
if (!childrenOf.has(parent)) childrenOf.set(parent, [])
|
|
1339
|
+
childrenOf.get(parent).push(record)
|
|
1340
|
+
}
|
|
1341
|
+
for (const children of childrenOf.values()) {
|
|
1342
|
+
children.sort((left, right) => (fleetNumber(left.createdAt) - fleetNumber(right.createdAt))
|
|
1343
|
+
|| String(left.id).localeCompare(String(right.id)))
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
const order = []
|
|
1347
|
+
const visited = new Set()
|
|
1348
|
+
const walk = (id, depth) => {
|
|
1349
|
+
if (order.length >= MAX_FLEET_AGENTS || visited.has(id)) return
|
|
1350
|
+
const record = byId.get(id)
|
|
1351
|
+
if (record === undefined) return
|
|
1352
|
+
visited.add(id)
|
|
1353
|
+
order.push({ record, depth })
|
|
1354
|
+
for (const child of childrenOf.get(id) || []) walk(child.id, depth + 1)
|
|
1355
|
+
}
|
|
1356
|
+
if (rootId !== '') walk(rootId, 0)
|
|
1357
|
+
// The current session may be a fork (not a subagent edge): keep the ancestor
|
|
1358
|
+
// chain visible after the subagent tree so the current card always renders.
|
|
1359
|
+
for (let i = 0; i < ancestors.length && order.length < MAX_FLEET_AGENTS; i += 1) {
|
|
1360
|
+
if (!visited.has(ancestors[i].id)) {
|
|
1361
|
+
visited.add(ancestors[i].id)
|
|
1362
|
+
order.push({ record: ancestors[i], depth: i })
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
// A family deeper than the bound must still show where the user is right now:
|
|
1366
|
+
// trade the last tree row for the current agent.
|
|
1367
|
+
if (!visited.has(current) && byId.has(current)) {
|
|
1368
|
+
if (order.length >= MAX_FLEET_AGENTS) order.pop()
|
|
1369
|
+
order.push({ record: byId.get(current), depth: Math.max(0, ancestors.length - 1) })
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Resolve every family snapshot once: the team roster comes from the ROOT
|
|
1373
|
+
// session's `agentTeam` projection (each session's team projection is scoped
|
|
1374
|
+
// to itself), and the per-agent cards read from the same snapshot.
|
|
1375
|
+
const resolved = order.map(item => {
|
|
1376
|
+
try {
|
|
1377
|
+
return resolve(item.record.id)
|
|
1378
|
+
} catch (_) {
|
|
1379
|
+
return null
|
|
1380
|
+
}
|
|
1381
|
+
})
|
|
1382
|
+
const valuesOf = index => resolved[index] !== null && typeof resolved[index] === 'object' ? resolved[index] : {}
|
|
1383
|
+
let team = null
|
|
1384
|
+
let teamPhaseById = null
|
|
1385
|
+
const teamCandidateOrder = []
|
|
1386
|
+
const rootIndex = order.findIndex(item => item.record.id === rootId)
|
|
1387
|
+
if (rootIndex >= 0) teamCandidateOrder.push(rootIndex)
|
|
1388
|
+
for (let index = 0; index < order.length; index += 1) if (index !== rootIndex) teamCandidateOrder.push(index)
|
|
1389
|
+
for (const index of teamCandidateOrder) {
|
|
1390
|
+
const candidate = teamOf(valuesOf(index).agentTeam)
|
|
1391
|
+
if (candidate !== null) {
|
|
1392
|
+
team = candidate
|
|
1393
|
+
break
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
if (team !== null) {
|
|
1397
|
+
teamPhaseById = new Map()
|
|
1398
|
+
for (const member of team.members) teamPhaseById.set(member.id, member.phase)
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
const agents = []
|
|
1402
|
+
let running = 0
|
|
1403
|
+
let subagents = 0
|
|
1404
|
+
for (let index = 0; index < order.length; index += 1) {
|
|
1405
|
+
const item = order[index]
|
|
1406
|
+
const record = item.record
|
|
1407
|
+
const v = valuesOf(index)
|
|
1408
|
+
const subagent = v.subagent !== null && typeof v.subagent === 'object' ? v.subagent : null
|
|
1409
|
+
const pressure = v.contextPressure !== null && typeof v.contextPressure === 'object' ? v.contextPressure : null
|
|
1410
|
+
const usage = v.tokenUsage !== null && typeof v.tokenUsage === 'object' ? v.tokenUsage : null
|
|
1411
|
+
const stats = v.sessionStats !== null && typeof v.sessionStats === 'object' ? v.sessionStats : null
|
|
1412
|
+
const context = v.mobileContext !== null && typeof v.mobileContext === 'object' ? v.mobileContext : null
|
|
1413
|
+
const compositionSource = context !== null && context.current !== null && typeof context.current === 'object' ? context.current : null
|
|
1414
|
+
const composition = {
|
|
1415
|
+
system: fleetNumber(compositionSource && compositionSource.system),
|
|
1416
|
+
tools: fleetNumber(compositionSource && compositionSource.tools),
|
|
1417
|
+
user: fleetNumber(compositionSource && compositionSource.user),
|
|
1418
|
+
inject: fleetNumber(compositionSource && compositionSource.inject),
|
|
1419
|
+
skill: fleetNumber(compositionSource && compositionSource.skill),
|
|
1420
|
+
assistant: fleetNumber(compositionSource && compositionSource.assistant),
|
|
1421
|
+
tool: fleetNumber(compositionSource && compositionSource.tool),
|
|
1422
|
+
}
|
|
1423
|
+
composition.total = composition.system + composition.tools + composition.user + composition.inject
|
|
1424
|
+
+ composition.skill + composition.assistant + composition.tool
|
|
1425
|
+
const isAgentRunning = isRunning(record.id) === true
|
|
1426
|
+
if (isAgentRunning) running += 1
|
|
1427
|
+
if (record.origin === 'subagent') subagents += 1
|
|
1428
|
+
const title = typeof v.title === 'string' && v.title.trim() !== ''
|
|
1429
|
+
? v.title.trim()
|
|
1430
|
+
: (typeof record.title === 'string' && record.title.trim() !== '' ? record.title.trim() : record.id)
|
|
1431
|
+
const entry = {
|
|
1432
|
+
sessionId: record.id,
|
|
1433
|
+
title,
|
|
1434
|
+
depth: item.depth,
|
|
1435
|
+
isCurrent: record.id === current,
|
|
1436
|
+
isRoot: record.id === rootId,
|
|
1437
|
+
running: isAgentRunning,
|
|
1438
|
+
createdAt: fleetNumber(record.createdAt),
|
|
1439
|
+
composition,
|
|
1440
|
+
}
|
|
1441
|
+
if (typeof record.parentSessionId === 'string' && record.parentSessionId !== '') entry.parentSessionId = record.parentSessionId
|
|
1442
|
+
if (subagent !== null && (subagent.mode === 'one-shot' || subagent.mode === 'continuable')) entry.mode = subagent.mode
|
|
1443
|
+
if (subagent !== null && typeof subagent.label === 'string' && subagent.label !== '') entry.label = subagent.label
|
|
1444
|
+
if (context !== null && typeof context.model === 'string') entry.model = context.model
|
|
1445
|
+
if (context !== null && typeof context.provider === 'string') entry.provider = context.provider
|
|
1446
|
+
if (pressure !== null && typeof pressure.contextWindow === 'number' && pressure.contextWindow > 0) entry.contextWindow = pressure.contextWindow
|
|
1447
|
+
if (pressure !== null && typeof pressure.pressureTokens === 'number' && pressure.pressureTokens >= 0) entry.pressureTokens = pressure.pressureTokens
|
|
1448
|
+
if (usage !== null) {
|
|
1449
|
+
const total = fleetNumber(usage.uncachedInputTokens) + fleetNumber(usage.cacheReadTokens)
|
|
1450
|
+
+ fleetNumber(usage.cacheWriteTokens) + fleetNumber(usage.outputTokens)
|
|
1451
|
+
if (total > 0) entry.tokensTotal = total
|
|
1452
|
+
}
|
|
1453
|
+
if (stats !== null && Number.isFinite(stats.turns)) entry.turns = stats.turns
|
|
1454
|
+
if (stats !== null && Number.isFinite(stats.steps)) entry.steps = stats.steps
|
|
1455
|
+
const timing = v.subagentTiming !== null && typeof v.subagentTiming === 'object' ? v.subagentTiming : null
|
|
1456
|
+
if (timing !== null) {
|
|
1457
|
+
const active = timing.active !== null && typeof timing.active === 'object' ? timing.active : null
|
|
1458
|
+
const activeMs = fleetNumber(timing.settledMs)
|
|
1459
|
+
+ (active !== null ? Math.max(0, fleetNumber(active.through) - fleetNumber(active.since)) : 0)
|
|
1460
|
+
if (activeMs > 0) entry.activeMs = activeMs
|
|
1461
|
+
} else if (context !== null && context.timing !== null && typeof context.timing === 'object') {
|
|
1462
|
+
const wallMs = fleetNumber(context.timing.wallMs)
|
|
1463
|
+
if (wallMs > 0) entry.activeMs = wallMs
|
|
1464
|
+
}
|
|
1465
|
+
// v2 fleet card fields (docs §8.2): the newest answer preview and the team
|
|
1466
|
+
// member phase; the current session defaults to `active` when the roster
|
|
1467
|
+
// exists but does not list it.
|
|
1468
|
+
if (context !== null && typeof context.lastAnswer === 'string' && context.lastAnswer !== '') {
|
|
1469
|
+
entry.lastAnswer = context.lastAnswer
|
|
1470
|
+
}
|
|
1471
|
+
if (teamPhaseById !== null) {
|
|
1472
|
+
const phase = teamPhaseById.get(record.id)
|
|
1473
|
+
if (typeof phase === 'string' && TEAM_PHASES.has(phase)) entry.phase = phase
|
|
1474
|
+
else if (record.id === current) entry.phase = 'active'
|
|
1475
|
+
}
|
|
1476
|
+
agents.push(entry)
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
return {
|
|
1480
|
+
rootSessionId: rootId,
|
|
1481
|
+
currentSessionId: current,
|
|
1482
|
+
agents,
|
|
1483
|
+
...(team !== null ? { team } : {}),
|
|
1484
|
+
counts: { agents: agents.length, running, subagents },
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* Serve one `context-fleet` request: list the host's sessions, walk the current
|
|
1490
|
+
* agent's family, and summarize each member from its projection snapshot.
|
|
1491
|
+
*
|
|
1492
|
+
* @param {{type:string,sessionId?:string}} msg mobile request frame
|
|
1493
|
+
* @returns {Promise<object>} wire frame
|
|
1494
|
+
*/
|
|
1495
|
+
export async function handleContextFleet(msg) {
|
|
1496
|
+
const requestType = msg && msg.type
|
|
1497
|
+
const sessionId = msg && typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1498
|
+
if (sessionId === null) {
|
|
1499
|
+
return { kind: 'error', code: 'bad-request', requestType, message: 'context-fleet requires a sessionId' }
|
|
1500
|
+
}
|
|
1501
|
+
if (hostSessions === null || hostProjections === null || typeof hostSessions.list !== 'function'
|
|
1502
|
+
|| typeof hostProjections.snapshot !== 'function') {
|
|
1503
|
+
return unavailable(requestType, sessionId, 'Session services are unavailable on this host')
|
|
1504
|
+
}
|
|
1505
|
+
try {
|
|
1506
|
+
const sessions = hostSessions.list()
|
|
1507
|
+
const list = Array.isArray(sessions) ? sessions : []
|
|
1508
|
+
const byId = new Map()
|
|
1509
|
+
const records = []
|
|
1510
|
+
for (const session of list) {
|
|
1511
|
+
if (session === null || typeof session !== 'object') continue
|
|
1512
|
+
const header = session.header !== null && typeof session.header === 'object' ? session.header : {}
|
|
1513
|
+
const id = typeof session.id === 'string' && session.id !== '' ? session.id : (typeof header.id === 'string' ? header.id : '')
|
|
1514
|
+
if (id === '' || byId.has(id)) continue
|
|
1515
|
+
byId.set(id, session)
|
|
1516
|
+
records.push({
|
|
1517
|
+
id,
|
|
1518
|
+
parentSessionId: typeof header.parentSession === 'string' ? header.parentSession : '',
|
|
1519
|
+
origin: header.origin === 'subagent' ? 'subagent' : '',
|
|
1520
|
+
createdAt: fleetNumber(header.createdAt),
|
|
1521
|
+
})
|
|
1522
|
+
}
|
|
1523
|
+
const value = buildFleetValue(sessionId, records, (id) => {
|
|
1524
|
+
const session = byId.get(id)
|
|
1525
|
+
if (session === undefined) return null
|
|
1526
|
+
const snapshot = hostProjections.snapshot(session)
|
|
1527
|
+
return snapshot !== null && typeof snapshot === 'object' && snapshot.values !== null && typeof snapshot.values === 'object'
|
|
1528
|
+
? snapshot.values
|
|
1529
|
+
: null
|
|
1530
|
+
}, (id) => {
|
|
1531
|
+
try {
|
|
1532
|
+
return !!(hostAgents && typeof hostAgents.get === 'function' && hostAgents.get(id) && hostAgents.get(id).status === 'running')
|
|
1533
|
+
} catch (_) {
|
|
1534
|
+
return false
|
|
1535
|
+
}
|
|
1536
|
+
})
|
|
1537
|
+
return { kind: 'context-fleet', sessionId, value }
|
|
1538
|
+
} catch (_) {
|
|
1539
|
+
return unavailable(requestType, sessionId, 'Unable to read the agent family')
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Register the projection on the host's session-projection registry. Optional:
|
|
1545
|
+
* hosts without the registry (older DSH) keep the gateway working, and the
|
|
1546
|
+
* `context-dashboard` query then answers `context-unavailable`.
|
|
1547
|
+
*
|
|
1548
|
+
* @param {object} ctx gateway plugin context
|
|
1549
|
+
* @returns {boolean} whether an injection was attempted
|
|
1550
|
+
*/
|
|
1551
|
+
export function registerContextProjection(ctx) {
|
|
1552
|
+
if (ctx === null || typeof ctx !== 'object' || typeof ctx.inject !== 'function') return false
|
|
1553
|
+
try {
|
|
1554
|
+
ctx.inject(['sessionProjections'], (sub) => {
|
|
1555
|
+
if (!sub || !sub.sessionProjections || typeof sub.sessionProjections.register !== 'function') return
|
|
1556
|
+
sub.sessionProjections.register(contextProjectionDefinition)
|
|
1557
|
+
})
|
|
1558
|
+
return true
|
|
1559
|
+
} catch (_) {
|
|
1560
|
+
return false
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function unavailable(requestType, sessionId, message) {
|
|
1565
|
+
return { kind: 'error', code: 'context-unavailable', requestType, sessionId, message }
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
function boundedContextText(value, max, meta) {
|
|
1569
|
+
if (typeof value !== 'string') return ''
|
|
1570
|
+
if (value.length > max && meta) meta.truncated = true
|
|
1571
|
+
return value.slice(0, max)
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
function eventContentText(event, meta) {
|
|
1575
|
+
const data = event && event.data && typeof event.data === 'object' ? event.data : {}
|
|
1576
|
+
if (event.type === 'request/header') {
|
|
1577
|
+
const header = data.header && typeof data.header === 'object' ? data.header : {}
|
|
1578
|
+
const tools = Array.isArray(header.tools) ? header.tools.slice(0, 100) : []
|
|
1579
|
+
const lines = []
|
|
1580
|
+
const config = header.config && typeof header.config === 'object' ? header.config : {}
|
|
1581
|
+
if (typeof config.provider === 'string' || typeof config.model === 'string') {
|
|
1582
|
+
lines.push(`Provider: ${config.provider || '—'} · Model: ${config.model || '—'}`)
|
|
1583
|
+
}
|
|
1584
|
+
for (const tool of tools) {
|
|
1585
|
+
if (tool === null || typeof tool !== 'object') continue
|
|
1586
|
+
const name = typeof tool.name === 'string' ? tool.name : 'Tool'
|
|
1587
|
+
lines.push(`\n${name}${typeof tool.description === 'string' && tool.description !== '' ? `\n${tool.description}` : ''}`)
|
|
1588
|
+
if (tool.parameters !== undefined || tool.inputSchema !== undefined || tool.schema !== undefined) {
|
|
1589
|
+
let schema
|
|
1590
|
+
try { schema = JSON.stringify(tool.parameters ?? tool.inputSchema ?? tool.schema) } catch (_) { schema = '' }
|
|
1591
|
+
if (schema) lines.push(`\nSchema: ${boundedContextText(schema, 5000, meta)}`)
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
return lines.join('\n')
|
|
1595
|
+
}
|
|
1596
|
+
if (event.type === 'request/context') {
|
|
1597
|
+
return [
|
|
1598
|
+
`Model: ${typeof data.model === 'string' ? data.model : '—'}`,
|
|
1599
|
+
`Provider: ${typeof data.provider === 'string' ? data.provider : '—'}`,
|
|
1600
|
+
`Context window: ${Number.isFinite(data.contextWindow) ? data.contextWindow : '—'}`,
|
|
1601
|
+
].join('\n')
|
|
1602
|
+
}
|
|
1603
|
+
const message = event.type === 'user/message' ? data
|
|
1604
|
+
: data.message !== null && typeof data.message === 'object' ? data.message : null
|
|
1605
|
+
const blocks = message && Array.isArray(message.content) ? message.content : []
|
|
1606
|
+
const lines = []
|
|
1607
|
+
for (const block of blocks) {
|
|
1608
|
+
if (block === null || typeof block !== 'object') continue
|
|
1609
|
+
if ((block.type === 'text' || block.type === 'reasoning') && typeof block.text === 'string') {
|
|
1610
|
+
lines.push(block.text)
|
|
1611
|
+
} else if (block.type === 'tool-call') {
|
|
1612
|
+
const name = typeof block.name === 'string' ? block.name : 'tool'
|
|
1613
|
+
lines.push(`[${name}]${typeof block.arguments === 'string' && block.arguments !== '' ? `\n${boundedContextText(block.arguments, 8000, meta)}` : ''}`)
|
|
1614
|
+
} else if (block.type === 'image') {
|
|
1615
|
+
lines.push('[image attachment]')
|
|
1616
|
+
} else {
|
|
1617
|
+
let summary
|
|
1618
|
+
try { summary = JSON.stringify(block) } catch (_) { summary = '' }
|
|
1619
|
+
if (summary) lines.push(boundedContextText(summary, 1000, meta))
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return lines.join('\n\n')
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
/** Fetch one durable event body only when the user opens that row in the browser. */
|
|
1626
|
+
export async function handleContextContent(api, msg) {
|
|
1627
|
+
const requestType = msg && msg.type
|
|
1628
|
+
const sessionId = msg && typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1629
|
+
const seq = msg && Number.isSafeInteger(msg.seq) && msg.seq >= 0 ? msg.seq : null
|
|
1630
|
+
if (sessionId === null || seq === null) {
|
|
1631
|
+
return { kind: 'error', code: 'bad-request', requestType, ...(sessionId !== null ? { sessionId } : {}), message: 'context-content requires a sessionId and seq' }
|
|
1632
|
+
}
|
|
1633
|
+
if (!api || !api.sessions || typeof api.sessions.history !== 'function') {
|
|
1634
|
+
return unavailable(requestType, sessionId, 'Session history is unavailable on this host')
|
|
1635
|
+
}
|
|
1636
|
+
try {
|
|
1637
|
+
const history = await api.sessions.history({ sessionId, throughSeq: seq, beforeSeq: seq + 1, maxMessages: 1 })
|
|
1638
|
+
if (history && history.kind === 'error') return { ...history, requestType, sessionId }
|
|
1639
|
+
const entries = history && Array.isArray(history.events) ? history.events : []
|
|
1640
|
+
let found = null
|
|
1641
|
+
for (const entry of entries) {
|
|
1642
|
+
const candidate = entry && typeof entry === 'object' && entry.event && typeof entry.event === 'object'
|
|
1643
|
+
? entry.event : entry
|
|
1644
|
+
if (candidate && candidate.seq === seq && typeof candidate.type === 'string') { found = candidate; break }
|
|
1645
|
+
}
|
|
1646
|
+
if (found === null) return unavailable(requestType, sessionId, 'The selected context item is no longer available')
|
|
1647
|
+
const allowed = new Set(['system/message', 'developer/message', 'user/message', 'assistant/message', 'tool/result', 'request/header', 'request/context'])
|
|
1648
|
+
if (!allowed.has(found.type)) return { kind: 'error', code: 'bad-request', requestType, sessionId, message: 'The selected event is not a context item' }
|
|
1649
|
+
const textMeta = { truncated: false }
|
|
1650
|
+
const text = boundedContextText(eventContentText(found, textMeta), 24000, textMeta)
|
|
1651
|
+
return {
|
|
1652
|
+
kind: 'context-content', sessionId, seq,
|
|
1653
|
+
value: { seq, time: timeOf(found), type: found.type, text, ...(textMeta.truncated ? { truncated: true } : {}) },
|
|
1654
|
+
}
|
|
1655
|
+
} catch (_) {
|
|
1656
|
+
return unavailable(requestType, sessionId, 'Unable to read the selected context item')
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* Serve one `context-dashboard` request: read the session's projection snapshot
|
|
1662
|
+
* (which carries every registered wire unit) and augment `mobileContext` with
|
|
1663
|
+
* the harness's official breakdown / usage / stats projections.
|
|
1664
|
+
*
|
|
1665
|
+
* @param {object} api DSH host adapter (needs `sessions.history`)
|
|
1666
|
+
* @param {{type:string,sessionId?:string}} msg mobile request frame
|
|
1667
|
+
* @returns {Promise<object>} wire frame
|
|
1668
|
+
*/
|
|
1669
|
+
export async function handleContextQuery(api, msg) {
|
|
1670
|
+
const requestType = msg && msg.type
|
|
1671
|
+
const sessionId = msg && typeof msg.sessionId === 'string' && msg.sessionId.trim() !== '' ? msg.sessionId.trim() : null
|
|
1672
|
+
if (sessionId === null) {
|
|
1673
|
+
return { kind: 'error', code: 'bad-request', requestType, message: 'context-dashboard requires a sessionId' }
|
|
1674
|
+
}
|
|
1675
|
+
if (!api || !api.sessions || typeof api.sessions.history !== 'function') {
|
|
1676
|
+
return unavailable(requestType, sessionId, 'Session history is unavailable on this host')
|
|
1677
|
+
}
|
|
1678
|
+
try {
|
|
1679
|
+
// The host adapter returns the raw `session/follow` snapshot (no `kind`):
|
|
1680
|
+
// only the gateway's proxyQuery helper stamps a kind. Accept both shapes so
|
|
1681
|
+
// the handler works with the adapter directly and with legacy mocks.
|
|
1682
|
+
const history = await api.sessions.history({ sessionId, maxMessages: 1 })
|
|
1683
|
+
if (history && history.kind === 'error') {
|
|
1684
|
+
return { ...history, requestType, sessionId }
|
|
1685
|
+
}
|
|
1686
|
+
const projections = history !== null && typeof history === 'object'
|
|
1687
|
+
&& history.projections !== null && typeof history.projections === 'object'
|
|
1688
|
+
? history.projections
|
|
1689
|
+
: undefined
|
|
1690
|
+
if (projections === undefined) {
|
|
1691
|
+
return unavailable(requestType, sessionId, 'The session projection snapshot is unavailable')
|
|
1692
|
+
}
|
|
1693
|
+
const values = projections && projections.values !== null && typeof projections.values === 'object' ? projections.values : {}
|
|
1694
|
+
const context = values.mobileContext
|
|
1695
|
+
if (context === null || typeof context !== 'object') {
|
|
1696
|
+
return unavailable(requestType, sessionId, 'The context projection is not registered on this host')
|
|
1697
|
+
}
|
|
1698
|
+
const usage = values.tokenUsage !== null && typeof values.tokenUsage === 'object' ? values.tokenUsage : null
|
|
1699
|
+
const stats = values.sessionStats !== null && typeof values.sessionStats === 'object' ? values.sessionStats : null
|
|
1700
|
+
const breakdown = values.contextBreakdown !== null && typeof values.contextBreakdown === 'object' ? values.contextBreakdown : null
|
|
1701
|
+
return {
|
|
1702
|
+
kind: 'context-dashboard',
|
|
1703
|
+
sessionId,
|
|
1704
|
+
asOfSeq: projections ? projections.asOfSeq : undefined,
|
|
1705
|
+
value: {
|
|
1706
|
+
...context,
|
|
1707
|
+
breakdown,
|
|
1708
|
+
usage,
|
|
1709
|
+
stats,
|
|
1710
|
+
},
|
|
1711
|
+
}
|
|
1712
|
+
} catch (_) {
|
|
1713
|
+
return unavailable(requestType, sessionId, 'Unable to read the context projection')
|
|
1714
|
+
}
|
|
1715
|
+
}
|