dsh-live-trace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- package/src/cli/working/state.js +528 -0
package/lib/normalize.js
ADDED
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event normalization: raw DeepSeek Harness events into flat display records.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is pure and has no Harness, filesystem, or terminal
|
|
5
|
+
* dependency, so the mapping from an event to what the dashboard shows is
|
|
6
|
+
* testable in isolation and identical in the plugin and the viewer's fixtures.
|
|
7
|
+
*
|
|
8
|
+
* Two families of input arrive:
|
|
9
|
+
*
|
|
10
|
+
* 1. Durable session-log events, delivered as `ctx.on('session/event', (session, event))`.
|
|
11
|
+
* Their envelope is `{ type, seq, time, data }`.
|
|
12
|
+
* 2. Live, in-process events the log does not record verbatim, such as
|
|
13
|
+
* `agent/assistant-stream`, `agent/status`, and `agent/error`.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-live-trace/normalize
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
capOutput,
|
|
20
|
+
diffOperationFrom,
|
|
21
|
+
filePathFrom,
|
|
22
|
+
narrowFileDiffs,
|
|
23
|
+
parseExitStatus,
|
|
24
|
+
shellCallFrom
|
|
25
|
+
} from './tools.js'
|
|
26
|
+
|
|
27
|
+
/** Display tags; the viewer maps each to a distinct color and prefix treatment. */
|
|
28
|
+
export const TAG = {
|
|
29
|
+
SESSION: 'session',
|
|
30
|
+
TURN: 'turn',
|
|
31
|
+
STEP: 'step',
|
|
32
|
+
USER: 'user',
|
|
33
|
+
ASSISTANT: 'assistant',
|
|
34
|
+
TOOL: 'tool',
|
|
35
|
+
RESULT: 'result',
|
|
36
|
+
ERROR: 'error',
|
|
37
|
+
APPROVAL: 'approval',
|
|
38
|
+
META: 'meta'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Default cap on one entry's primary text, in characters. */
|
|
42
|
+
export const DEFAULT_TEXT_LIMIT = 4000
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Event types the dashboard hides by default.
|
|
46
|
+
*
|
|
47
|
+
* These are internal transport bookkeeping records, not agent activity: they
|
|
48
|
+
* carry delivery watermarks or diagnostics and would otherwise drown the trace
|
|
49
|
+
* in one useless line per step. Entries ending in `*` are prefix matches.
|
|
50
|
+
*/
|
|
51
|
+
export const DEFAULT_MUTED_EVENT_TYPES = ['session-log-deepseek/*']
|
|
52
|
+
|
|
53
|
+
const DEFAULT_OPTIONS = {
|
|
54
|
+
showSystemMessages: false,
|
|
55
|
+
showRequestMetadata: false,
|
|
56
|
+
// An unknown event type is much more likely to be a plugin's bookkeeping
|
|
57
|
+
// record than something a human needs to watch, so the board stays quiet by
|
|
58
|
+
// default and shows them only when asked.
|
|
59
|
+
showUnknownEvents: false,
|
|
60
|
+
mutedEventTypes: DEFAULT_MUTED_EVENT_TYPES,
|
|
61
|
+
textLimit: DEFAULT_TEXT_LIMIT
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {unknown} type an event type name
|
|
66
|
+
* @param {string[]} [patterns] exact names or `prefix*` patterns
|
|
67
|
+
* @returns {boolean} whether the type is hidden
|
|
68
|
+
*/
|
|
69
|
+
export function isMutedEventType(type, patterns = DEFAULT_MUTED_EVENT_TYPES) {
|
|
70
|
+
if (typeof type !== 'string' || !Array.isArray(patterns)) return false
|
|
71
|
+
return patterns.some((pattern) =>
|
|
72
|
+
typeof pattern === 'string' && pattern.endsWith('*') ? type.startsWith(pattern.slice(0, -1)) : type === pattern
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* ------------------------------------------------------------------ *
|
|
77
|
+
* Text utilities
|
|
78
|
+
* ------------------------------------------------------------------ */
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Collapse any text to a single line: no CR/LF/TAB, no runs of whitespace.
|
|
82
|
+
* @param {unknown} value
|
|
83
|
+
* @returns {string}
|
|
84
|
+
*/
|
|
85
|
+
export function collapse(value) {
|
|
86
|
+
if (value === null || value === undefined) return ''
|
|
87
|
+
return String(value)
|
|
88
|
+
.replace(/[\r\n\t\f\v]+/g, ' ')
|
|
89
|
+
.replace(/\u00a0/g, ' ')
|
|
90
|
+
.replace(/\s{2,}/g, ' ')
|
|
91
|
+
.trim()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Collapse and cap text with a trailing ellipsis.
|
|
96
|
+
* @param {unknown} value
|
|
97
|
+
* @param {number} limit
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
export function clip(value, limit = 160) {
|
|
101
|
+
const text = collapse(value)
|
|
102
|
+
if (limit <= 0 || text.length <= limit) return text
|
|
103
|
+
return `${text.slice(0, Math.max(0, limit - 1))}…`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render an arbitrary thrown value as one line.
|
|
108
|
+
* @param {unknown} error
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
export function describeError(error) {
|
|
112
|
+
if (error === null || error === undefined) return 'unknown error'
|
|
113
|
+
if (typeof error === 'string') return clip(error, 400)
|
|
114
|
+
if (typeof error === 'object') {
|
|
115
|
+
const record = /** @type {Record<string, unknown>} */ (error)
|
|
116
|
+
const code = typeof record.code === 'string' && record.code.length > 0 ? record.code : undefined
|
|
117
|
+
const message =
|
|
118
|
+
typeof record.message === 'string' && record.message.length > 0
|
|
119
|
+
? record.message
|
|
120
|
+
: typeof record.reason === 'string' && record.reason.length > 0
|
|
121
|
+
? record.reason
|
|
122
|
+
: undefined
|
|
123
|
+
if (code !== undefined && message !== undefined) return clip(`${code}: ${message}`, 400)
|
|
124
|
+
if (message !== undefined) return clip(message, 400)
|
|
125
|
+
if (code !== undefined) return clip(code, 400)
|
|
126
|
+
try {
|
|
127
|
+
return clip(JSON.stringify(record), 400)
|
|
128
|
+
} catch {
|
|
129
|
+
return 'unserializable error'
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return clip(String(error), 400)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* ------------------------------------------------------------------ *
|
|
136
|
+
* LLM content helpers
|
|
137
|
+
* ------------------------------------------------------------------ */
|
|
138
|
+
|
|
139
|
+
/** Attachment display name, tolerating the several shapes refs use. */
|
|
140
|
+
function attachmentName(attachment) {
|
|
141
|
+
if (attachment === null || typeof attachment !== 'object') return ''
|
|
142
|
+
const record = /** @type {Record<string, unknown>} */ (attachment)
|
|
143
|
+
for (const key of ['name', 'fileName', 'filename', 'title', 'id']) {
|
|
144
|
+
const value = record[key]
|
|
145
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
146
|
+
}
|
|
147
|
+
return ''
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Flatten model-facing content blocks into one display line.
|
|
152
|
+
* @param {unknown} content
|
|
153
|
+
* @param {{ includeReasoning?: boolean, includeToolCalls?: boolean }} [options]
|
|
154
|
+
* @returns {string}
|
|
155
|
+
*/
|
|
156
|
+
export function contentText(content, options = {}) {
|
|
157
|
+
if (typeof content === 'string') return collapse(content)
|
|
158
|
+
if (!Array.isArray(content)) return ''
|
|
159
|
+
const parts = []
|
|
160
|
+
for (const block of content) {
|
|
161
|
+
if (block === null || typeof block !== 'object') continue
|
|
162
|
+
const record = /** @type {Record<string, unknown>} */ (block)
|
|
163
|
+
switch (record.type) {
|
|
164
|
+
case 'text':
|
|
165
|
+
parts.push(collapse(record.text))
|
|
166
|
+
break
|
|
167
|
+
case 'reasoning':
|
|
168
|
+
if (options.includeReasoning === true) parts.push(collapse(record.text))
|
|
169
|
+
break
|
|
170
|
+
case 'tool-call':
|
|
171
|
+
if (options.includeToolCalls === true) {
|
|
172
|
+
parts.push(`→ ${collapse(record.name)}(${clip(record.arguments, 120)})`)
|
|
173
|
+
}
|
|
174
|
+
break
|
|
175
|
+
case 'tool-addition':
|
|
176
|
+
parts.push(`+tool ${collapse(record.toolName)}`)
|
|
177
|
+
break
|
|
178
|
+
case 'tool-removal':
|
|
179
|
+
parts.push(`-tool ${collapse(record.toolName)}`)
|
|
180
|
+
break
|
|
181
|
+
case 'image': {
|
|
182
|
+
const name = attachmentName(record.attachment)
|
|
183
|
+
parts.push(name.length > 0 ? `[image ${name}]` : '[image]')
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
case 'file': {
|
|
187
|
+
const name = attachmentName(record.attachment)
|
|
188
|
+
parts.push(name.length > 0 ? `[file ${name}]` : '[file]')
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
default:
|
|
192
|
+
if (typeof record.type === 'string') parts.push(`[${record.type}]`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return parts.filter((part) => part.length > 0).join(' ')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Flatten content blocks preserving their internal line structure.
|
|
200
|
+
*
|
|
201
|
+
* {@link contentText} collapses everything to one line, which is right for a
|
|
202
|
+
* compact trace row. Markdown rendering needs the original paragraphs and code
|
|
203
|
+
* fences back, so this variant joins blocks with a blank line and keeps
|
|
204
|
+
* newlines inside each block.
|
|
205
|
+
*
|
|
206
|
+
* @param {unknown} content
|
|
207
|
+
* @param {{ includeReasoning?: boolean, includeToolCalls?: boolean }} [options]
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
export function contentBlocks(content, options = {}) {
|
|
211
|
+
if (typeof content === 'string') return content
|
|
212
|
+
if (!Array.isArray(content)) return ''
|
|
213
|
+
const parts = []
|
|
214
|
+
for (const block of content) {
|
|
215
|
+
if (block === null || typeof block !== 'object') continue
|
|
216
|
+
const record = /** @type {Record<string, unknown>} */ (block)
|
|
217
|
+
switch (record.type) {
|
|
218
|
+
case 'text':
|
|
219
|
+
if (typeof record.text === 'string' && record.text.trim().length > 0) parts.push(record.text)
|
|
220
|
+
break
|
|
221
|
+
case 'reasoning':
|
|
222
|
+
if (options.includeReasoning === true && typeof record.text === 'string' && record.text.trim().length > 0) {
|
|
223
|
+
parts.push(record.text)
|
|
224
|
+
}
|
|
225
|
+
break
|
|
226
|
+
case 'tool-call':
|
|
227
|
+
if (options.includeToolCalls === true) {
|
|
228
|
+
parts.push(`→ ${collapse(record.name)}(${clip(record.arguments, 120)})`)
|
|
229
|
+
}
|
|
230
|
+
break
|
|
231
|
+
default:
|
|
232
|
+
break
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return parts.join('\n\n')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Raw multi-line text of a model message, capped without collapsing lines. */
|
|
239
|
+
function rawMessageText(message, options = {}) {
|
|
240
|
+
const limit = options.limit ?? 4000
|
|
241
|
+
const raw = contentBlocks(/** @type {Record<string, unknown> | undefined} */ (message)?.content, options)
|
|
242
|
+
return raw.length > limit ? raw.slice(0, limit) : raw
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** First non-empty text chunk of a model message, capped. */
|
|
246
|
+
function messageText(message, options = {}) {
|
|
247
|
+
const content = /** @type {Record<string, unknown> | undefined} */ (message)?.content
|
|
248
|
+
return clip(contentText(content, options), options.limit ?? 4000)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Summarize raw tool-call arguments (an unparsed JSON string) for one line.
|
|
253
|
+
* @param {unknown} raw
|
|
254
|
+
* @param {number} [limit]
|
|
255
|
+
* @returns {string}
|
|
256
|
+
*/
|
|
257
|
+
export function summarizeToolArguments(raw, limit = 200) {
|
|
258
|
+
if (typeof raw !== 'string' || raw.trim().length === 0) return ''
|
|
259
|
+
let parsed
|
|
260
|
+
try {
|
|
261
|
+
parsed = JSON.parse(raw)
|
|
262
|
+
} catch {
|
|
263
|
+
return clip(raw, limit)
|
|
264
|
+
}
|
|
265
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return clip(raw, limit)
|
|
266
|
+
const parts = []
|
|
267
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
268
|
+
let rendered
|
|
269
|
+
if (typeof value === 'string') rendered = JSON.stringify(clip(value, 120))
|
|
270
|
+
else if (value === null) rendered = 'null'
|
|
271
|
+
else if (typeof value === 'bigint') rendered = String(value)
|
|
272
|
+
else if (typeof value === 'object') {
|
|
273
|
+
try {
|
|
274
|
+
rendered = clip(JSON.stringify(value), 80)
|
|
275
|
+
} catch {
|
|
276
|
+
rendered = '[object]'
|
|
277
|
+
}
|
|
278
|
+
} else rendered = String(value)
|
|
279
|
+
parts.push(`${key}=${rendered}`)
|
|
280
|
+
if (parts.join(' ').length >= limit) break
|
|
281
|
+
}
|
|
282
|
+
return clip(parts.join(' '), limit)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Human text for a `turn/end` reason.
|
|
287
|
+
* @param {unknown} reason
|
|
288
|
+
* @returns {string}
|
|
289
|
+
*/
|
|
290
|
+
export function turnEndReasonText(reason) {
|
|
291
|
+
if (reason === null || typeof reason !== 'object') return 'ended'
|
|
292
|
+
const record = /** @type {Record<string, unknown>} */ (reason)
|
|
293
|
+
switch (record.kind) {
|
|
294
|
+
case 'completed':
|
|
295
|
+
return 'completed'
|
|
296
|
+
case 'aborted':
|
|
297
|
+
return record.reason === undefined ? 'aborted' : `aborted (${clip(record.reason, 80)})`
|
|
298
|
+
case 'blocked':
|
|
299
|
+
return 'blocked'
|
|
300
|
+
case 'error':
|
|
301
|
+
return `error: ${describeError(record.error)}`
|
|
302
|
+
case 'max-tokens':
|
|
303
|
+
return 'max output tokens reached'
|
|
304
|
+
case 'forked':
|
|
305
|
+
return 'forked'
|
|
306
|
+
default:
|
|
307
|
+
return typeof record.kind === 'string' ? record.kind : 'ended'
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/* ------------------------------------------------------------------ *
|
|
312
|
+
* Session metadata
|
|
313
|
+
* ------------------------------------------------------------------ */
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Project a live Session into the small shape the viewer displays.
|
|
317
|
+
* @param {any} session
|
|
318
|
+
* @returns {{ id: string, title?: string, cwd?: string, createdAt?: number, origin?: string, parentSession?: string, delegationDepth?: number, agentPreset?: string, seq?: number }}
|
|
319
|
+
*/
|
|
320
|
+
export function sessionInfo(session) {
|
|
321
|
+
const header = session?.header ?? {}
|
|
322
|
+
const id = typeof session?.id === 'string' ? session.id : String(session?.id ?? 'unknown')
|
|
323
|
+
/** @type {any} */
|
|
324
|
+
const info = { id }
|
|
325
|
+
const title = session?.title ?? header.title
|
|
326
|
+
if (typeof title === 'string' && title.length > 0) info.title = clip(title, 120)
|
|
327
|
+
if (typeof header.cwd === 'string') info.cwd = header.cwd
|
|
328
|
+
if (typeof header.createdAt === 'number') info.createdAt = header.createdAt
|
|
329
|
+
if (typeof header.origin === 'string') info.origin = header.origin
|
|
330
|
+
if (header.parentSession !== undefined && header.parentSession !== null) {
|
|
331
|
+
info.parentSession = String(header.parentSession)
|
|
332
|
+
}
|
|
333
|
+
if (typeof header.delegationDepth === 'number') info.delegationDepth = header.delegationDepth
|
|
334
|
+
if (typeof header.agentPreset === 'string') info.agentPreset = header.agentPreset
|
|
335
|
+
const seq = session?.seq
|
|
336
|
+
if (typeof seq === 'number') info.seq = seq
|
|
337
|
+
return info
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Display label for a session's origin.
|
|
342
|
+
* @param {object} info a {@link sessionInfo} result
|
|
343
|
+
* @returns {string}
|
|
344
|
+
*/
|
|
345
|
+
export function sessionKindLabel(info) {
|
|
346
|
+
if (info.origin === 'subagent') {
|
|
347
|
+
const depth = typeof info.delegationDepth === 'number' ? ` L${info.delegationDepth}` : ''
|
|
348
|
+
return `subagent${depth}`
|
|
349
|
+
}
|
|
350
|
+
return 'main'
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/* ------------------------------------------------------------------ *
|
|
354
|
+
* Durable session events
|
|
355
|
+
* ------------------------------------------------------------------ */
|
|
356
|
+
|
|
357
|
+
const EVENT_PREFIX = {
|
|
358
|
+
'turn/start': 'TURN',
|
|
359
|
+
'turn/end': 'TURN',
|
|
360
|
+
'step/start': 'STEP',
|
|
361
|
+
'step/end': 'STEP'
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Normalize one append-feed session event.
|
|
366
|
+
*
|
|
367
|
+
* @param {{ type: string, seq?: number, time?: number, data?: any }} event
|
|
368
|
+
* @param {{ showSystemMessages?: boolean, showRequestMetadata?: boolean, showUnknownEvents?: boolean, mutedEventTypes?: string[], textLimit?: number, outputLimits?: object }} [options]
|
|
369
|
+
* @param {{ pendingTool?: { name?: string, shell?: object, filePath?: string } }} [context]
|
|
370
|
+
* the in-flight call a tool result settles, supplied by the tracker
|
|
371
|
+
* @returns {{ entries: object[], usage?: object, context?: object, sessionTitle?: string }}
|
|
372
|
+
*/
|
|
373
|
+
export function normalizeSessionEvent(event, options = {}, context = {}) {
|
|
374
|
+
const config = { ...DEFAULT_OPTIONS, ...options }
|
|
375
|
+
const type = event?.type
|
|
376
|
+
const data = event?.data ?? {}
|
|
377
|
+
const base = {
|
|
378
|
+
seq: typeof event?.seq === 'number' ? event.seq : null,
|
|
379
|
+
time: typeof event?.time === 'number' ? event.time : Date.now(),
|
|
380
|
+
event: typeof type === 'string' ? type : 'unknown'
|
|
381
|
+
}
|
|
382
|
+
const out = { entries: [] }
|
|
383
|
+
if (isMutedEventType(type, config.mutedEventTypes)) return out
|
|
384
|
+
|
|
385
|
+
switch (type) {
|
|
386
|
+
case 'turn/start':
|
|
387
|
+
out.entries.push({
|
|
388
|
+
...base,
|
|
389
|
+
tag: TAG.TURN,
|
|
390
|
+
label: `${EVENT_PREFIX[type]} ${data.turn}`,
|
|
391
|
+
text: 'turn started',
|
|
392
|
+
turn: data.turn
|
|
393
|
+
})
|
|
394
|
+
break
|
|
395
|
+
|
|
396
|
+
case 'turn/end':
|
|
397
|
+
out.entries.push({
|
|
398
|
+
...base,
|
|
399
|
+
tag: TAG.TURN,
|
|
400
|
+
label: `${EVENT_PREFIX[type]} ${data.turn} END`,
|
|
401
|
+
text: turnEndReasonText(data.reason),
|
|
402
|
+
turn: data.turn,
|
|
403
|
+
level: data.reason?.kind === 'error' ? 'error' : data.reason?.kind === 'completed' ? 'success' : 'info'
|
|
404
|
+
})
|
|
405
|
+
break
|
|
406
|
+
|
|
407
|
+
case 'step/start':
|
|
408
|
+
out.entries.push({
|
|
409
|
+
...base,
|
|
410
|
+
tag: TAG.STEP,
|
|
411
|
+
label: `${EVENT_PREFIX[type]} ${data.step}`,
|
|
412
|
+
text: 'model call started',
|
|
413
|
+
turn: data.turn,
|
|
414
|
+
step: data.step
|
|
415
|
+
})
|
|
416
|
+
break
|
|
417
|
+
|
|
418
|
+
case 'step/end':
|
|
419
|
+
out.entries.push({
|
|
420
|
+
...base,
|
|
421
|
+
tag: TAG.STEP,
|
|
422
|
+
label: `${EVENT_PREFIX[type]} ${data.step} END`,
|
|
423
|
+
text: 'step closed',
|
|
424
|
+
turn: data.turn,
|
|
425
|
+
step: data.step
|
|
426
|
+
})
|
|
427
|
+
break
|
|
428
|
+
|
|
429
|
+
case 'user/message': {
|
|
430
|
+
const source = data.source ?? {}
|
|
431
|
+
const injected = source.kind !== undefined && source.kind !== 'user'
|
|
432
|
+
out.entries.push({
|
|
433
|
+
...base,
|
|
434
|
+
tag: TAG.USER,
|
|
435
|
+
label: injected ? 'CONTEXT' : 'USER',
|
|
436
|
+
text: messageText(data, { limit: config.textLimit }),
|
|
437
|
+
detail: injected ? `source: ${clip(source.kind, 60)}` : undefined,
|
|
438
|
+
level: injected ? 'muted' : 'info'
|
|
439
|
+
})
|
|
440
|
+
break
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
case 'developer/message':
|
|
444
|
+
out.entries.push({
|
|
445
|
+
...base,
|
|
446
|
+
tag: TAG.META,
|
|
447
|
+
label: 'DEVELOPER',
|
|
448
|
+
text: messageText(data.message, { limit: config.textLimit }) || 'developer instruction',
|
|
449
|
+
turn: data.turn,
|
|
450
|
+
step: data.step
|
|
451
|
+
})
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
case 'system/message':
|
|
455
|
+
if (config.showSystemMessages) {
|
|
456
|
+
out.entries.push({
|
|
457
|
+
...base,
|
|
458
|
+
tag: TAG.META,
|
|
459
|
+
label: 'SYSTEM',
|
|
460
|
+
text: messageText(data.message, { limit: config.textLimit }) || '(empty system prompt)',
|
|
461
|
+
turn: data.turn,
|
|
462
|
+
step: data.step,
|
|
463
|
+
level: 'muted'
|
|
464
|
+
})
|
|
465
|
+
}
|
|
466
|
+
break
|
|
467
|
+
|
|
468
|
+
case 'assistant/message': {
|
|
469
|
+
const visible = rawMessageText(data.message, { limit: config.textLimit })
|
|
470
|
+
const thinking = rawMessageText(data.message, { includeReasoning: true, limit: config.textLimit })
|
|
471
|
+
// `contentBlocks` with reasoning included also returns the visible text,
|
|
472
|
+
// so subtract it to keep only the thinking part.
|
|
473
|
+
const reasoning = thinking === visible ? '' : thinking.replace(visible, '').trim()
|
|
474
|
+
const toolCalls = contentText(data.message?.content, { includeToolCalls: true })
|
|
475
|
+
const collapsed = collapse(visible)
|
|
476
|
+
out.entries.push({
|
|
477
|
+
...base,
|
|
478
|
+
tag: TAG.ASSISTANT,
|
|
479
|
+
label: data.interrupted === true ? 'ASSISTANT (interrupted)' : 'ASSISTANT',
|
|
480
|
+
// `text` stays the one-line form so `--plain`, filters, and the repeat
|
|
481
|
+
// collapsing keep working; `rawText` carries the markdown.
|
|
482
|
+
text: collapsed || (toolCalls.length > 0 ? toolCalls : '(no visible text)'),
|
|
483
|
+
rawText: visible,
|
|
484
|
+
reasoning: reasoning.length > 0 ? reasoning : undefined,
|
|
485
|
+
turn: data.turn,
|
|
486
|
+
step: data.step,
|
|
487
|
+
level: data.interrupted === true ? 'warn' : 'info'
|
|
488
|
+
})
|
|
489
|
+
if (data.usage !== undefined && data.usage !== null) out.usage = data.usage
|
|
490
|
+
break
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
case 'assistant/attempt': {
|
|
494
|
+
const reasoning = clip(reasoningOnly(data.stream), 400)
|
|
495
|
+
out.entries.push({
|
|
496
|
+
...base,
|
|
497
|
+
tag: TAG.META,
|
|
498
|
+
label: 'ATTEMPT',
|
|
499
|
+
text: reasoning.length > 0 ? `attempt settled without a message: ${reasoning}` : 'attempt settled without a message',
|
|
500
|
+
turn: data.turn,
|
|
501
|
+
step: data.step,
|
|
502
|
+
level: 'warn'
|
|
503
|
+
})
|
|
504
|
+
break
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
case 'tool/call': {
|
|
508
|
+
const callId = data.callId !== undefined ? String(data.callId) : undefined
|
|
509
|
+
const shell = shellCallFrom(data.name, data.arguments)
|
|
510
|
+
const filePath = filePathFrom(data.arguments)
|
|
511
|
+
out.entries.push({
|
|
512
|
+
...base,
|
|
513
|
+
// The call and its result share a key, so the viewer can upgrade the row
|
|
514
|
+
// in place instead of leaving a dangling "running" line behind.
|
|
515
|
+
key: toolKey(callId, base.seq),
|
|
516
|
+
phase: 'call',
|
|
517
|
+
tag: TAG.TOOL,
|
|
518
|
+
label: 'TOOL',
|
|
519
|
+
text: `${clip(data.name, 60)} ${summarizeToolArguments(data.arguments)}`.trim(),
|
|
520
|
+
tool: typeof data.name === 'string' ? data.name : undefined,
|
|
521
|
+
callId,
|
|
522
|
+
// The raw call arguments ride along so a viewer can describe the work
|
|
523
|
+
// in the model's own words (the mascot's telephone bubble, for one).
|
|
524
|
+
arguments: data.arguments,
|
|
525
|
+
turn: data.turn,
|
|
526
|
+
step: data.step,
|
|
527
|
+
...(shell === undefined ? {} : { shell }),
|
|
528
|
+
...(filePath === undefined ? {} : { filePath })
|
|
529
|
+
})
|
|
530
|
+
break
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
case 'tool/result': {
|
|
534
|
+
const isError = data.message?.isError === true
|
|
535
|
+
const callId = data.message?.toolCallId !== undefined ? String(data.message.toolCallId) : undefined
|
|
536
|
+
const call = context.pendingTool
|
|
537
|
+
const toolName =
|
|
538
|
+
call?.name ??
|
|
539
|
+
(typeof data.message?.source?.toolName === 'string' ? data.message.source.toolName : undefined) ??
|
|
540
|
+
'tool'
|
|
541
|
+
// Parsed from the raw multi-line text: the exit marker lives on its own
|
|
542
|
+
// final line, which a collapsed one-liner would no longer match.
|
|
543
|
+
const rawBody = rawMessageText(data.message, { limit: config.textLimit })
|
|
544
|
+
|
|
545
|
+
// A shell result carries its exit status as a trailing marker; splitting
|
|
546
|
+
// it out lets the board show the status as its own pill and keeps the
|
|
547
|
+
// marker out of the output body.
|
|
548
|
+
let output
|
|
549
|
+
let shellStatus
|
|
550
|
+
let body = rawBody
|
|
551
|
+
if (call?.shell !== undefined) {
|
|
552
|
+
const parsed = parseExitStatus(rawBody)
|
|
553
|
+
const capped = capOutput(parsed.body, config.outputLimits)
|
|
554
|
+
output = capped
|
|
555
|
+
shellStatus = { exitCode: parsed.exitCode, signal: parsed.signal }
|
|
556
|
+
body = capped.text
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const diffs = narrowFileDiffs(data.meta)
|
|
560
|
+
const operation = diffOperationFrom(data.meta)
|
|
561
|
+
// A shell command reports failure through its exit status, not through
|
|
562
|
+
// `isError` (a non-zero exit is information for the model, not an
|
|
563
|
+
// infrastructure failure), so the glyph follows the exit code.
|
|
564
|
+
const ok = shellStatus?.exitCode !== undefined ? shellStatus.exitCode === 0 : !isError
|
|
565
|
+
|
|
566
|
+
out.entries.push({
|
|
567
|
+
...base,
|
|
568
|
+
key: toolKey(callId, base.seq),
|
|
569
|
+
phase: 'result',
|
|
570
|
+
tag: TAG.TOOL,
|
|
571
|
+
// A merged block keeps reading as the tool that ran; an orphaned result
|
|
572
|
+
// (whose call the viewer never saw) still reads as a result.
|
|
573
|
+
label: call === undefined ? 'RESULT' : 'TOOL',
|
|
574
|
+
text: (call === undefined ? `${clip(toolName, 40)} ` : '') + (collapse(body) || (isError ? 'tool failed' : 'ok')),
|
|
575
|
+
rawText: body,
|
|
576
|
+
detail: isError && data.error !== undefined ? describeError(data.error) : undefined,
|
|
577
|
+
ok,
|
|
578
|
+
level: ok ? 'success' : 'error',
|
|
579
|
+
tool: toolName,
|
|
580
|
+
callId,
|
|
581
|
+
turn: data.turn,
|
|
582
|
+
step: data.step,
|
|
583
|
+
...(call?.shell === undefined ? {} : { shell: call.shell }),
|
|
584
|
+
...(call?.filePath === undefined ? {} : { filePath: call.filePath }),
|
|
585
|
+
...(output === undefined ? {} : { output }),
|
|
586
|
+
...(shellStatus === undefined ? {} : { shellStatus }),
|
|
587
|
+
...(diffs === undefined ? {} : { diffs }),
|
|
588
|
+
...(operation === undefined ? {} : { operation })
|
|
589
|
+
})
|
|
590
|
+
break
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
case 'approval/asked':
|
|
594
|
+
out.entries.push({
|
|
595
|
+
...base,
|
|
596
|
+
tag: TAG.APPROVAL,
|
|
597
|
+
label: 'APPROVAL',
|
|
598
|
+
text: `${clip(data.toolName, 60)}${data.reason !== undefined ? ` — ${clip(data.reason, 300)}` : ''}`.trim(),
|
|
599
|
+
callId: data.callId !== undefined ? String(data.callId) : undefined,
|
|
600
|
+
level: 'warn'
|
|
601
|
+
})
|
|
602
|
+
break
|
|
603
|
+
|
|
604
|
+
case 'approval/decided':
|
|
605
|
+
out.entries.push({
|
|
606
|
+
...base,
|
|
607
|
+
tag: TAG.APPROVAL,
|
|
608
|
+
label: 'APPROVAL',
|
|
609
|
+
text: `decision: ${clip(data.outcome, 40)}`,
|
|
610
|
+
level: data.outcome === 'allowed-once' ? 'success' : data.outcome === 'rejected' ? 'error' : 'warn'
|
|
611
|
+
})
|
|
612
|
+
break
|
|
613
|
+
|
|
614
|
+
case 'request/context':
|
|
615
|
+
out.context = {
|
|
616
|
+
provider: data.provider,
|
|
617
|
+
model: data.model,
|
|
618
|
+
capacity: pickNumber(data, ['capacity', 'contextWindow', 'maxContextTokens', 'contextTokens'])
|
|
619
|
+
}
|
|
620
|
+
if (config.showRequestMetadata) {
|
|
621
|
+
out.entries.push({
|
|
622
|
+
...base,
|
|
623
|
+
tag: TAG.META,
|
|
624
|
+
label: 'ROUTE',
|
|
625
|
+
text: `${clip(data.provider, 40)}/${clip(data.model, 60)}`,
|
|
626
|
+
level: 'muted'
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
break
|
|
630
|
+
|
|
631
|
+
case 'request/header':
|
|
632
|
+
if (config.showRequestMetadata) {
|
|
633
|
+
out.entries.push({
|
|
634
|
+
...base,
|
|
635
|
+
tag: TAG.META,
|
|
636
|
+
label: 'HEADER',
|
|
637
|
+
text: `request header (${clip(data.reason, 40)})`,
|
|
638
|
+
level: 'muted'
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
break
|
|
642
|
+
|
|
643
|
+
case 'session/title': {
|
|
644
|
+
const title = clip(data.title, 200)
|
|
645
|
+
if (title.length > 0) out.sessionTitle = title
|
|
646
|
+
out.entries.push({
|
|
647
|
+
...base,
|
|
648
|
+
tag: TAG.META,
|
|
649
|
+
label: 'TITLE',
|
|
650
|
+
text: title,
|
|
651
|
+
level: 'muted'
|
|
652
|
+
})
|
|
653
|
+
break
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
case 'permission/preset':
|
|
657
|
+
case 'sandbox/mode':
|
|
658
|
+
case 'approval/policy':
|
|
659
|
+
out.entries.push({
|
|
660
|
+
...base,
|
|
661
|
+
tag: TAG.META,
|
|
662
|
+
label: 'POLICY',
|
|
663
|
+
text: describePolicyEvent(type, data),
|
|
664
|
+
level: 'muted'
|
|
665
|
+
})
|
|
666
|
+
break
|
|
667
|
+
|
|
668
|
+
case 'session/end-seed':
|
|
669
|
+
break
|
|
670
|
+
|
|
671
|
+
default:
|
|
672
|
+
if (config.showUnknownEvents) {
|
|
673
|
+
out.entries.push({
|
|
674
|
+
...base,
|
|
675
|
+
tag: TAG.META,
|
|
676
|
+
label: typeof type === 'string' ? type.toUpperCase() : 'EVENT',
|
|
677
|
+
text: summarizeUnknown(data, config.textLimit),
|
|
678
|
+
level: 'muted'
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return out
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** One compact line for a startup policy record. */
|
|
687
|
+
function describePolicyEvent(type, data) {
|
|
688
|
+
switch (type) {
|
|
689
|
+
case 'permission/preset':
|
|
690
|
+
return `preset=${clip(data?.preset, 60) || 'default'}`
|
|
691
|
+
case 'sandbox/mode':
|
|
692
|
+
return `sandbox=${clip(data?.mode, 60) || 'default'}`
|
|
693
|
+
case 'approval/policy':
|
|
694
|
+
return `approval=${clip(data?.policy, 60) || 'default'}`
|
|
695
|
+
default:
|
|
696
|
+
return ''
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** Extract only reasoning text from content blocks or a compact stream record list. */
|
|
701
|
+
function reasoningOnly(source) {
|
|
702
|
+
if (!Array.isArray(source)) return ''
|
|
703
|
+
const parts = []
|
|
704
|
+
for (const item of source) {
|
|
705
|
+
if (item === null || typeof item !== 'object') continue
|
|
706
|
+
// Durable assistant streams are `AssistantStreamRecord[]`; a reasoning
|
|
707
|
+
// block shows up either as a bare content block or inside `chunk`.
|
|
708
|
+
const block = /** @type {any} */ (item)
|
|
709
|
+
const chunk = block.chunk ?? block.block ?? block
|
|
710
|
+
if (chunk?.type === 'reasoning' && typeof chunk.text === 'string') parts.push(chunk.text)
|
|
711
|
+
else if (chunk?.type === 'reasoning-delta' && typeof chunk.text === 'string') parts.push(chunk.text)
|
|
712
|
+
}
|
|
713
|
+
return collapse(parts.join(' '))
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Stable identity that lets a call and its result occupy one trace row. */
|
|
717
|
+
function toolKey(callId, seq) {
|
|
718
|
+
return callId === undefined ? `tool#${seq ?? 'x'}` : `tool:${callId}`
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function pickNumber(object, keys) {
|
|
722
|
+
for (const key of keys) {
|
|
723
|
+
const value = object?.[key]
|
|
724
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
725
|
+
}
|
|
726
|
+
return undefined
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Best-effort one-line account of an event shape we do not model. */
|
|
730
|
+
function summarizeUnknown(data, limit) {
|
|
731
|
+
if (data === null || data === undefined) return ''
|
|
732
|
+
if (typeof data !== 'object') return clip(data, 200)
|
|
733
|
+
for (const key of ['message', 'reason', 'text', 'name', 'kind', 'outcome']) {
|
|
734
|
+
if (key in data) {
|
|
735
|
+
const value = data[key]
|
|
736
|
+
if (typeof value === 'string') return clip(value, 200)
|
|
737
|
+
if (value !== null && typeof value === 'object') return clip(contentText(value?.content) || JSON.stringify(value), 200)
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
try {
|
|
741
|
+
return clip(JSON.stringify(data), Math.min(200, limit))
|
|
742
|
+
} catch {
|
|
743
|
+
return ''
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/* ------------------------------------------------------------------ *
|
|
748
|
+
* Live, in-process events
|
|
749
|
+
* ------------------------------------------------------------------ */
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Normalize an `agent/status` transition into a status value for the dashboard.
|
|
753
|
+
* @param {'idle' | 'running' | string} status
|
|
754
|
+
* @returns {'idle' | 'running'}
|
|
755
|
+
*/
|
|
756
|
+
export function agentStatusValue(status) {
|
|
757
|
+
return status === 'running' ? 'running' : 'idle'
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Normalize `agent/error`.
|
|
762
|
+
* @param {{ turn?: number, step?: number, error?: unknown, time?: number }} payload
|
|
763
|
+
* @param {{ textLimit?: number }} [options]
|
|
764
|
+
* @returns {{ entries: object[] }}
|
|
765
|
+
*/
|
|
766
|
+
export function normalizeAgentError(payload, options = {}) {
|
|
767
|
+
const config = { ...DEFAULT_OPTIONS, ...options }
|
|
768
|
+
return {
|
|
769
|
+
entries: [
|
|
770
|
+
{
|
|
771
|
+
seq: null,
|
|
772
|
+
time: typeof payload?.time === 'number' ? payload.time : Date.now(),
|
|
773
|
+
event: 'agent/error',
|
|
774
|
+
tag: TAG.ERROR,
|
|
775
|
+
label: 'ERROR',
|
|
776
|
+
text: clip(describeError(payload?.error), config.textLimit),
|
|
777
|
+
turn: payload?.turn,
|
|
778
|
+
step: payload?.step,
|
|
779
|
+
level: 'error'
|
|
780
|
+
}
|
|
781
|
+
]
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Normalize a session announcement into a lifecycle entry.
|
|
787
|
+
* @param {any} session
|
|
788
|
+
* @param {string} [source]
|
|
789
|
+
* @returns {{ entry: object, info: object }}
|
|
790
|
+
*/
|
|
791
|
+
export function normalizeSessionCreated(session, source) {
|
|
792
|
+
const info = sessionInfo(session)
|
|
793
|
+
return {
|
|
794
|
+
info,
|
|
795
|
+
entry: {
|
|
796
|
+
seq: null,
|
|
797
|
+
time: typeof info.createdAt === 'number' ? info.createdAt : Date.now(),
|
|
798
|
+
event: 'session/created',
|
|
799
|
+
tag: TAG.SESSION,
|
|
800
|
+
label: 'SESSION',
|
|
801
|
+
text: `opened${source !== undefined ? ` (${source})` : ''}${info.title !== undefined ? ` — ${info.title}` : ''}`,
|
|
802
|
+
level: 'info'
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Normalize an `agent/created` announcement into a routing entry.
|
|
809
|
+
* @param {any} agent
|
|
810
|
+
* @param {string} [source]
|
|
811
|
+
* @returns {{ entry: object | null, model?: string, provider?: string }}
|
|
812
|
+
*/
|
|
813
|
+
export function normalizeAgentCreated(agent, source) {
|
|
814
|
+
const options = agent?.options ?? {}
|
|
815
|
+
const provider = typeof options.provider === 'string' ? options.provider : undefined
|
|
816
|
+
const model = typeof options.model === 'string' ? options.model : undefined
|
|
817
|
+
if (provider === undefined && model === undefined) return { entry: null }
|
|
818
|
+
return {
|
|
819
|
+
provider,
|
|
820
|
+
model,
|
|
821
|
+
entry: {
|
|
822
|
+
seq: null,
|
|
823
|
+
time: Date.now(),
|
|
824
|
+
event: 'agent/created',
|
|
825
|
+
tag: TAG.META,
|
|
826
|
+
label: 'MODEL',
|
|
827
|
+
text: `${provider ?? '?'}/${model ?? '?'}${source !== undefined ? ` (${source})` : ''}`,
|
|
828
|
+
level: 'muted'
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/* ------------------------------------------------------------------ *
|
|
834
|
+
* Usage accounting
|
|
835
|
+
* ------------------------------------------------------------------ */
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Fold one `usage` payload into running totals.
|
|
839
|
+
* @param {{ inputTokens?: number, outputTokens?: number, totalTokens?: number, cacheReadTokens?: number, cacheWriteTokens?: number, reasoningTokens?: number } | undefined} totals mutable totals
|
|
840
|
+
* @param {object | undefined} usage one step's reported usage
|
|
841
|
+
* @returns {object} the same totals object, mutated
|
|
842
|
+
*/
|
|
843
|
+
export function accumulateUsage(totals, usage) {
|
|
844
|
+
if (usage === null || typeof usage !== 'object') return totals
|
|
845
|
+
for (const key of ['inputTokens', 'outputTokens', 'totalTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']) {
|
|
846
|
+
const value = usage[key]
|
|
847
|
+
if (typeof value === 'number' && Number.isFinite(value)) totals[key] = (totals[key] ?? 0) + value
|
|
848
|
+
}
|
|
849
|
+
return totals
|
|
850
|
+
}
|