@modesto_ma/dsh-channels 0.0.0-stage → 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.
Files changed (73) hide show
  1. package/README.md +302 -2
  2. package/cordis.patch.yml +3 -0
  3. package/docs/access-control-design.md +550 -0
  4. package/docs/assets/web-settings-panel.png +0 -0
  5. package/docs/assets/web-settings-restart-pending.png +0 -0
  6. package/docs/feasibility-and-design.md +213 -0
  7. package/docs/feishu-onboarding.md +268 -0
  8. package/docs/persistence.md +199 -0
  9. package/docs/refactor-plan.md +56 -0
  10. package/docs/review-dsh-0.2.0.md +389 -0
  11. package/docs/slash-commands-implementation-plan.md +410 -0
  12. package/docs/web-display-only-surface-roadmap.md +363 -0
  13. package/lib/client.js +2226 -0
  14. package/lib/client.js.map +1 -0
  15. package/lib/index.js +10217 -0
  16. package/lib/index.js.map +1 -0
  17. package/package.json +98 -4
  18. package/src/access/cards.ts +188 -0
  19. package/src/access/controller.ts +201 -0
  20. package/src/access/index.ts +13 -0
  21. package/src/access/policy.ts +103 -0
  22. package/src/access/requests.ts +512 -0
  23. package/src/access/store.ts +220 -0
  24. package/src/approvals.ts +8 -0
  25. package/src/cards/approval.ts +114 -0
  26. package/src/cards/index.ts +11 -0
  27. package/src/cards/primitives.ts +82 -0
  28. package/src/cards/question.ts +141 -0
  29. package/src/cards/streaming.ts +563 -0
  30. package/src/cards.ts +8 -0
  31. package/src/cleanup.ts +398 -0
  32. package/src/client/api.ts +262 -0
  33. package/src/client/catalog.ts +92 -0
  34. package/src/client/index.ts +61 -0
  35. package/src/client/panel.tsx +1475 -0
  36. package/src/client/styles.ts +98 -0
  37. package/src/commands/registry.ts +155 -0
  38. package/src/commands.ts +335 -0
  39. package/src/config.ts +206 -0
  40. package/src/domain.ts +428 -0
  41. package/src/dsh-types.ts +11 -0
  42. package/src/feishu/agent-context.ts +62 -0
  43. package/src/feishu/delivery.ts +188 -0
  44. package/src/feishu/inbound-attachments.ts +436 -0
  45. package/src/feishu-channel.ts +339 -0
  46. package/src/health.ts +233 -0
  47. package/src/host/agent.ts +181 -0
  48. package/src/host/commands.ts +63 -0
  49. package/src/host/core.ts +345 -0
  50. package/src/host/index.ts +12 -0
  51. package/src/host/interaction.ts +101 -0
  52. package/src/host/services.ts +320 -0
  53. package/src/index.ts +122 -0
  54. package/src/interactions/approvals.ts +154 -0
  55. package/src/interactions/pending.ts +162 -0
  56. package/src/interactions/questions.ts +274 -0
  57. package/src/questions.ts +8 -0
  58. package/src/routing/inbound.ts +524 -0
  59. package/src/service.ts +2138 -0
  60. package/src/session/manager.ts +352 -0
  61. package/src/session/repair.ts +157 -0
  62. package/src/session/request-policy.ts +192 -0
  63. package/src/session/routes.ts +165 -0
  64. package/src/session-commands.ts +310 -0
  65. package/src/session-export.ts +112 -0
  66. package/src/settings/catalog.ts +309 -0
  67. package/src/settings/contract.ts +508 -0
  68. package/src/settings/overlay.ts +164 -0
  69. package/src/settings/routes.ts +409 -0
  70. package/src/settings/surface.ts +773 -0
  71. package/src/stats.ts +485 -0
  72. package/src/streamer.ts +1108 -0
  73. package/src/workspaces.ts +199 -0
package/src/stats.ts ADDED
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Whole-session diagnostic folds and context measurement.
3
+ *
4
+ * All historical figures come from the durable event log, so a live Agent
5
+ * and the same Session resumed after restart produce identical output.
6
+ * Command lifecycle records are intentionally ignored.
7
+ *
8
+ * @module dsh-channels/stats
9
+ */
10
+
11
+ import type {
12
+ AssistantStreamRecord, ContextPressureView, LlmService, Session, SessionEvent, SessionProjections, SessionStatsView,
13
+ TokenMeter, TokenUsage, TokenUsageView,
14
+ } from './dsh-types.ts'
15
+
16
+ export interface SessionTotals {
17
+ inputTokens: number
18
+ outputTokens: number
19
+ cacheReadTokens: number
20
+ cacheWriteTokens: number
21
+ reasoningTokens: number
22
+ }
23
+
24
+ export const EMPTY_TOTALS: SessionTotals = {
25
+ inputTokens: 0,
26
+ outputTokens: 0,
27
+ cacheReadTokens: 0,
28
+ cacheWriteTokens: 0,
29
+ reasoningTokens: 0,
30
+ }
31
+
32
+ function usableCount(value: number | undefined): number {
33
+ return value !== undefined && Number.isFinite(value) && value >= 0 ? value : 0
34
+ }
35
+
36
+ export function addUsage(totals: SessionTotals, usage: TokenUsage): SessionTotals {
37
+ return {
38
+ inputTokens: totals.inputTokens + usableCount(usage.inputTokens),
39
+ outputTokens: totals.outputTokens + usableCount(usage.outputTokens),
40
+ cacheReadTokens: totals.cacheReadTokens + usableCount(usage.cacheReadTokens),
41
+ cacheWriteTokens: totals.cacheWriteTokens + usableCount(usage.cacheWriteTokens),
42
+ reasoningTokens: totals.reasoningTokens + usableCount(usage.reasoningTokens),
43
+ }
44
+ }
45
+
46
+ /** Latest provider usage sample carried by an `assistant/message` event. */
47
+ export interface UsageSample {
48
+ usage: TokenUsage
49
+ turn: number
50
+ step: number
51
+ }
52
+
53
+ export interface HeaderSample {
54
+ provider: string
55
+ model: string
56
+ effort?: string
57
+ }
58
+
59
+ export type ToolEventStatus = 'success' | 'error' | 'running'
60
+
61
+ export interface ToolEventSample {
62
+ callId: string
63
+ name: string
64
+ status: ToolEventStatus
65
+ durationMs?: number
66
+ startedAt?: number
67
+ }
68
+
69
+ interface OpenStep {
70
+ turn: number
71
+ step: number
72
+ startTime?: number
73
+ }
74
+
75
+ interface PendingTool {
76
+ callId: string
77
+ name: string
78
+ startedAt?: number
79
+ }
80
+
81
+ /** Complete fold state and public whole-log status figures. */
82
+ export interface StatsFold {
83
+ totals: SessionTotals
84
+ usageSamples: number
85
+ lastUsage: UsageSample | undefined
86
+ lastHeader: HeaderSample | undefined
87
+ llmMs: number
88
+ llmSteps: number
89
+ toolMs: number
90
+ toolTimedCalls: number
91
+ ttftMs: number
92
+ ttftSteps: number
93
+ decodeMs: number
94
+ decodeTokens: number
95
+ toolCalls: number
96
+ pendingTools: Map<string, PendingTool>
97
+ recentTools: ToolEventSample[]
98
+ openStep: OpenStep | undefined
99
+ }
100
+
101
+ function eventTime(event: SessionEvent): number | undefined {
102
+ return event.time !== undefined && Number.isFinite(event.time) ? event.time : undefined
103
+ }
104
+
105
+ /**
106
+ * First non-whitespace delta time inside one durable embedded assistant
107
+ * stream (`assistant/message.stream`). Local fallback for deployments where
108
+ * the host `sessionStats` projection is not mounted; the projection remains
109
+ * authoritative when present.
110
+ */
111
+ function firstTokenTime(stream: readonly AssistantStreamRecord[] | undefined): number | undefined {
112
+ let best: number | undefined
113
+ for (const record of stream ?? []) {
114
+ const time0 = record.time0
115
+ if (typeof time0 !== 'number' || !Number.isFinite(time0)) continue
116
+ if (record.type !== 'text-chunks' && record.type !== 'reasoning-chunks') continue
117
+ const texts = record.texts
118
+ const hasVisible = Array.isArray(texts)
119
+ && texts.some(text => typeof text === 'string' && /\S/u.test(text))
120
+ if (hasVisible && (best === undefined || time0 < best)) best = time0
121
+ }
122
+ return best
123
+ }
124
+
125
+ /**
126
+ * Authoritative call correlation and failure flag of one `tool/result`.
127
+ *
128
+ * DSH's `ToolResultMessage` is `{ role:'tool', source:{kind:'tool', callId},
129
+ * content, isError? }` and has no `tool-result` content-block type, so the
130
+ * message-level `source.callId` / `isError` decide; the first content block is
131
+ * only a legacy fallback.
132
+ */
133
+ function toolResultCallId(event: Extract<SessionEvent, { type: 'tool/result' }>): string | undefined {
134
+ const sourceId = event.data.message.source?.callId
135
+ if (typeof sourceId === 'string' && sourceId !== '') return sourceId
136
+ const blockId = event.data.message.content?.find(block => typeof block.toolCallId === 'string')?.toolCallId
137
+ return typeof blockId === 'string' && blockId !== '' ? blockId : undefined
138
+ }
139
+
140
+ function toolResultFailed(event: Extract<SessionEvent, { type: 'tool/result' }>): boolean {
141
+ if (event.data.error !== undefined) return true
142
+ if (event.data.message.isError === true) return true
143
+ return event.data.message.content?.some(block => block.isError === true) ?? false
144
+ }
145
+
146
+ function elapsed(start: number | undefined, end: number | undefined): number | undefined {
147
+ if (start === undefined || end === undefined) return undefined
148
+ return Math.max(0, end - start)
149
+ }
150
+
151
+ function pushRecentTool(fold: StatsFold, sample: ToolEventSample): void {
152
+ fold.recentTools.push(sample)
153
+ if (fold.recentTools.length > 5) fold.recentTools.shift()
154
+ }
155
+
156
+ /** Fold one durable event into the running Session figures. */
157
+ export function foldEvent(fold: StatsFold, event: SessionEvent): void {
158
+ switch (event.type) {
159
+ case 'step/start':
160
+ fold.openStep = {
161
+ turn: event.data.turn,
162
+ step: event.data.step,
163
+ startTime: eventTime(event),
164
+ }
165
+ break
166
+ case 'assistant/message': {
167
+ const usage = event.data.usage
168
+ if (usage !== undefined) {
169
+ fold.totals = addUsage(fold.totals, usage)
170
+ fold.usageSamples++
171
+ fold.lastUsage = { usage, turn: event.data.turn, step: event.data.step }
172
+ }
173
+ const open = fold.openStep
174
+ if (open === undefined || open.turn !== event.data.turn || open.step !== event.data.step) break
175
+ const completedAt = eventTime(event)
176
+ const llmDuration = elapsed(open.startTime, completedAt)
177
+ if (llmDuration !== undefined) {
178
+ fold.llmMs += llmDuration
179
+ fold.llmSteps++
180
+ }
181
+ // Live `assistant/chunk` events no longer exist; the first-token time is
182
+ // recovered from the durable embedded stream. The host `sessionStats`
183
+ // projection (overlaid by `applyProjectionViews`) is authoritative when
184
+ // mounted.
185
+ const firstToken = firstTokenTime(event.data.stream)
186
+ if (firstToken !== undefined) {
187
+ const ttft = elapsed(open.startTime, firstToken)
188
+ if (ttft !== undefined) {
189
+ fold.ttftMs += ttft
190
+ fold.ttftSteps++
191
+ }
192
+ const outputTokens = usage?.outputTokens
193
+ const decodeDuration = elapsed(firstToken, completedAt)
194
+ if (decodeDuration !== undefined && outputTokens !== undefined
195
+ && Number.isFinite(outputTokens) && outputTokens >= 0) {
196
+ fold.decodeMs += decodeDuration
197
+ fold.decodeTokens += outputTokens
198
+ }
199
+ }
200
+ fold.openStep = undefined
201
+ break
202
+ }
203
+ case 'step/end':
204
+ fold.openStep = undefined
205
+ break
206
+ case 'request/header': {
207
+ const config = event.data.header?.config
208
+ if (config !== undefined && typeof config === 'object') {
209
+ const cfg = config as { provider?: unknown; model?: unknown; reasoningEffort?: unknown }
210
+ fold.lastHeader = {
211
+ provider: typeof cfg.provider === 'string' ? cfg.provider : '',
212
+ model: typeof cfg.model === 'string' ? cfg.model : '',
213
+ effort: typeof cfg.reasoningEffort === 'string' ? cfg.reasoningEffort : undefined,
214
+ }
215
+ }
216
+ break
217
+ }
218
+ case 'tool/call': {
219
+ fold.toolCalls++
220
+ const pending: PendingTool = {
221
+ callId: event.data.callId,
222
+ name: event.data.name,
223
+ startedAt: eventTime(event),
224
+ }
225
+ fold.pendingTools.set(event.data.callId, pending)
226
+ pushRecentTool(fold, { ...pending, status: 'running' })
227
+ break
228
+ }
229
+ case 'tool/result': {
230
+ const callId = toolResultCallId(event)
231
+ if (callId === undefined) break
232
+ const pending = fold.pendingTools.get(callId)
233
+ if (pending === undefined) break
234
+ fold.pendingTools.delete(callId)
235
+ const durationMs = elapsed(pending.startedAt, eventTime(event))
236
+ const settled: ToolEventSample = {
237
+ callId,
238
+ name: pending.name,
239
+ status: toolResultFailed(event) ? 'error' : 'success',
240
+ startedAt: pending.startedAt,
241
+ ...(durationMs === undefined ? {} : { durationMs }),
242
+ }
243
+ const recentIndex = fold.recentTools.findIndex(sample => sample.callId === callId)
244
+ if (recentIndex !== -1) fold.recentTools.splice(recentIndex, 1)
245
+ pushRecentTool(fold, settled)
246
+ if (durationMs !== undefined) {
247
+ fold.toolMs += durationMs
248
+ fold.toolTimedCalls++
249
+ }
250
+ break
251
+ }
252
+ default:
253
+ break
254
+ }
255
+ }
256
+
257
+ /** Fold one complete Session log. */
258
+ export function foldAll(events: readonly SessionEvent[]): StatsFold {
259
+ const fold: StatsFold = {
260
+ totals: { ...EMPTY_TOTALS },
261
+ usageSamples: 0,
262
+ lastUsage: undefined,
263
+ lastHeader: undefined,
264
+ llmMs: 0,
265
+ llmSteps: 0,
266
+ toolMs: 0,
267
+ toolTimedCalls: 0,
268
+ ttftMs: 0,
269
+ ttftSteps: 0,
270
+ decodeMs: 0,
271
+ decodeTokens: 0,
272
+ toolCalls: 0,
273
+ pendingTools: new Map(),
274
+ recentTools: [],
275
+ openStep: undefined,
276
+ }
277
+ for (const event of events) foldEvent(fold, event)
278
+ return fold
279
+ }
280
+
281
+ /** Cache hit ratio of the latest usage sample: cacheRead / (cacheRead + uncached input). */
282
+ export function cacheHitPct(lastUsage: UsageSample | undefined): number | undefined {
283
+ if (lastUsage === undefined) return undefined
284
+ const hit = lastUsage.usage.cacheReadTokens
285
+ if (hit === undefined || !Number.isFinite(hit) || hit < 0) return undefined
286
+ const input = lastUsage.usage.inputTokens
287
+ if (!Number.isFinite(input) || input < 0) return undefined
288
+ const total = hit + input
289
+ return total > 0 ? hit / total : undefined
290
+ }
291
+
292
+ export interface ContextMeasurement {
293
+ contextTokens?: number
294
+ contextWindow?: number
295
+ }
296
+
297
+ export interface ContextServices {
298
+ tokenMeter?: TokenMeter
299
+ llm?: LlmService
300
+ /** Optional projection registry; supplies the authoritative context window. */
301
+ projections?: SessionProjections
302
+ }
303
+
304
+ /** Projection readings for one session; every key is optional (unmounted unit). */
305
+ export interface ProjectionViews {
306
+ sessionStats?: SessionStatsView
307
+ tokenUsage?: TokenUsageView
308
+ contextPressure?: ContextPressureView
309
+ }
310
+
311
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
312
+ return typeof value === 'object' && value !== null ? value as Record<string, unknown> : undefined
313
+ }
314
+
315
+ function finiteNonNegative(value: unknown): number | undefined {
316
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
317
+ }
318
+
319
+ function positive(value: unknown): number | undefined {
320
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
321
+ }
322
+
323
+ /** Read the host projection units this plugin consumes, tolerating absence. */
324
+ export function readProjectionViews(
325
+ projections: SessionProjections | undefined,
326
+ session: Session,
327
+ ): ProjectionViews {
328
+ if (projections === undefined) return {}
329
+ const views: ProjectionViews = {}
330
+ try {
331
+ const stats = asRecord(projections.stateOf(session, 'sessionStats'))
332
+ if (stats !== undefined) {
333
+ const parsed: SessionStatsView = {
334
+ turns: finiteNonNegative(stats.turns) ?? 0,
335
+ steps: finiteNonNegative(stats.steps) ?? 0,
336
+ llmMs: finiteNonNegative(stats.llmMs) ?? 0,
337
+ toolMs: finiteNonNegative(stats.toolMs) ?? 0,
338
+ ttftMs: finiteNonNegative(stats.ttftMs) ?? 0,
339
+ ttftSteps: finiteNonNegative(stats.ttftSteps) ?? 0,
340
+ decodeMs: finiteNonNegative(stats.decodeMs) ?? 0,
341
+ decodeTokens: finiteNonNegative(stats.decodeTokens) ?? 0,
342
+ }
343
+ views.sessionStats = parsed
344
+ }
345
+ } catch { /* a failing projection must never break the fold */ }
346
+ try {
347
+ const usage = asRecord(projections.stateOf(session, 'tokenUsage'))
348
+ const totals = asRecord(usage?.totals)
349
+ if (usage !== undefined && totals !== undefined) {
350
+ const buckets = (input: Record<string, unknown> | undefined) => ({
351
+ uncachedInputTokens: finiteNonNegative(input?.uncachedInputTokens) ?? 0,
352
+ outputTokens: finiteNonNegative(input?.outputTokens) ?? 0,
353
+ cacheReadTokens: finiteNonNegative(input?.cacheReadTokens) ?? 0,
354
+ cacheWriteTokens: finiteNonNegative(input?.cacheWriteTokens) ?? 0,
355
+ })
356
+ const last = asRecord(usage.last)
357
+ views.tokenUsage = {
358
+ totals: buckets(totals),
359
+ last: last === undefined
360
+ ? null
361
+ : {
362
+ turn: finiteNonNegative(last.turn) ?? 0,
363
+ step: finiteNonNegative(last.step) ?? 0,
364
+ buckets: buckets(asRecord(last.buckets)),
365
+ },
366
+ }
367
+ }
368
+ } catch { /* ignore malformed projection state */ }
369
+ try {
370
+ const pressure = asRecord(projections.stateOf(session, 'contextPressure'))
371
+ if (pressure !== undefined) {
372
+ views.contextPressure = {
373
+ ...(finiteNonNegative(pressure.pressureTokens) === undefined
374
+ ? {} : { pressureTokens: finiteNonNegative(pressure.pressureTokens) }),
375
+ ...(finiteNonNegative(pressure.projectedTokens) === undefined
376
+ ? {} : { projectedTokens: finiteNonNegative(pressure.projectedTokens) }),
377
+ ...(positive(pressure.contextWindow) === undefined
378
+ ? {} : { contextWindow: positive(pressure.contextWindow) }),
379
+ }
380
+ }
381
+ } catch { /* ignore malformed projection state */ }
382
+ return views
383
+ }
384
+
385
+ /** Map a projection token bucket into the plugin's usage vocabulary. */
386
+ export function usageFromBuckets(buckets: TokenUsageView['totals']): TokenUsage {
387
+ return {
388
+ inputTokens: buckets.uncachedInputTokens,
389
+ outputTokens: buckets.outputTokens,
390
+ cacheReadTokens: buckets.cacheReadTokens,
391
+ cacheWriteTokens: buckets.cacheWriteTokens,
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Overlay host projection figures onto a log fold. The host units are
397
+ * authoritative where mounted (they fold the same log, including the
398
+ * embedded streams the plugin no longer sees as live events); the log fold
399
+ * stays as the fallback and still owns tool-detail fields.
400
+ */
401
+ export function applyProjectionViews(fold: StatsFold, views: ProjectionViews): StatsFold {
402
+ const stats = views.sessionStats
403
+ if (stats !== undefined) {
404
+ // Times are host-authoritative; counts (llmSteps / toolTimedCalls) stay
405
+ // with the fold, which sees the same boundaries.
406
+ fold.llmMs = stats.llmMs
407
+ fold.toolMs = stats.toolMs
408
+ fold.ttftMs = stats.ttftMs
409
+ fold.ttftSteps = stats.ttftSteps
410
+ fold.decodeMs = stats.decodeMs
411
+ fold.decodeTokens = stats.decodeTokens
412
+ }
413
+ const usage = views.tokenUsage
414
+ if (usage !== undefined) {
415
+ fold.totals = {
416
+ inputTokens: usage.totals.uncachedInputTokens,
417
+ outputTokens: usage.totals.outputTokens,
418
+ cacheReadTokens: usage.totals.cacheReadTokens,
419
+ cacheWriteTokens: usage.totals.cacheWriteTokens,
420
+ reasoningTokens: fold.totals.reasoningTokens,
421
+ }
422
+ if (usage.last !== null) {
423
+ fold.lastUsage = {
424
+ usage: usageFromBuckets(usage.last.buckets),
425
+ turn: usage.last.turn,
426
+ step: usage.last.step,
427
+ }
428
+ fold.usageSamples = Math.max(fold.usageSamples, 1)
429
+ }
430
+ }
431
+ return fold
432
+ }
433
+
434
+ /** Read one session's figures from the projection registry, falling back to the log fold. */
435
+ export function foldSessionStats(
436
+ session: Session,
437
+ projections: SessionProjections | undefined,
438
+ ): StatsFold {
439
+ const fold = foldAll(session.snapshotEvents())
440
+ return applyProjectionViews(fold, readProjectionViews(projections, session))
441
+ }
442
+
443
+ /** Measure current surface pressure and resolve the latest model's context capacity. */
444
+ export async function measureSessionContext(
445
+ session: Session,
446
+ services: ContextServices,
447
+ header = session.requestHeader?.()?.config ?? foldAll(session.snapshotEvents()).lastHeader,
448
+ ): Promise<ContextMeasurement> {
449
+ let contextTokens: number | undefined
450
+ try {
451
+ const measured = services.tokenMeter?.measure(session).totalTokens
452
+ if (measured !== undefined && Number.isFinite(measured) && measured >= 0) contextTokens = measured
453
+ } catch {
454
+ contextTokens = undefined
455
+ }
456
+
457
+ let contextWindow: number | undefined
458
+ if (services.projections !== undefined) {
459
+ try {
460
+ contextWindow = readProjectionViews(services.projections, session).contextPressure?.contextWindow
461
+ } catch {
462
+ contextWindow = undefined
463
+ }
464
+ }
465
+ if (contextWindow === undefined && header !== undefined
466
+ && typeof header.provider === 'string' && header.provider !== ''
467
+ && typeof header.model === 'string' && header.model !== '') {
468
+ try {
469
+ const resolved = await services.llm?.resolveModelInfo(header.provider, header.model)
470
+ const window = resolved?.context?.contextWindow
471
+ if (window !== undefined && Number.isFinite(window) && window > 0) contextWindow = window
472
+ } catch {
473
+ contextWindow = undefined
474
+ }
475
+ }
476
+ return { contextTokens, contextWindow }
477
+ }
478
+
479
+ export function averageTtftMs(stats: StatsFold): number | undefined {
480
+ return stats.ttftSteps > 0 ? stats.ttftMs / stats.ttftSteps : undefined
481
+ }
482
+
483
+ export function averageDecodeTokensPerSecond(stats: StatsFold): number | undefined {
484
+ return stats.decodeMs > 0 ? stats.decodeTokens / stats.decodeMs * 1000 : undefined
485
+ }