@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
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Per-session request policy: provider / model / reasoning-effort overrides.
3
+ *
4
+ * Cordis waterfalls run the earliest listener as the outermost wrapper, so the
5
+ * channel policy registers with `prepend: true`: preset/model-selection
6
+ * listeners may be standing registrations that predate this Agent, and they
7
+ * rewrite the request after `next()` resolves. The outer channel wrapper
8
+ * therefore writes last.
9
+ *
10
+ * Session overrides are recovered from successful command lifecycles
11
+ * (`/effort`, `/model`). Reusing Harness' durable `command/run` /
12
+ * `command/done` pair avoids inventing an out-of-repo Session event that
13
+ * persistence could not safely recognize on restore.
14
+ *
15
+ * @module dsh-channels/session/request-policy
16
+ */
17
+
18
+ import type { AgentContext, CommandId, SessionEvent } from '../dsh-types.ts'
19
+
20
+ /** A model selection override: a bare model keeps the composed provider. */
21
+ export interface ModelOverride {
22
+ /** Provider route; absent keeps the route composed by earlier listeners. */
23
+ readonly provider?: string
24
+ readonly model: string
25
+ }
26
+
27
+ /** Channel-configured defaults for sessions this plugin creates. */
28
+ export interface RequestPolicyConfig {
29
+ /** Configured reasoning effort (optional → adapter/provider default). */
30
+ effort?: string
31
+ /** Configured provider pin (optional → deployment default). */
32
+ provider?: string
33
+ /** Configured model pin (optional → deployment default). */
34
+ model?: string
35
+ }
36
+
37
+ export interface RequestPolicyState {
38
+ readonly configuredEffort: string | undefined
39
+ readonly configuredModel: ModelOverride | undefined
40
+ /** Session-level `/effort` override. */
41
+ readonly effortOverride: string | undefined
42
+ /** Session-level `/model` override. */
43
+ readonly modelOverride: ModelOverride | undefined
44
+ /** Effort this channel writes into the next request, when defined. */
45
+ readonly effectiveEffort: string | undefined
46
+ /** Model this channel writes into the next request, when defined. */
47
+ readonly effectiveModel: ModelOverride | undefined
48
+ readonly effortSource: 'session' | 'config' | 'model-default'
49
+ readonly modelSource: 'session' | 'config' | 'deployment-default'
50
+ }
51
+
52
+ export interface RequestPolicyController {
53
+ readonly state: RequestPolicyState
54
+ /** Re-fold the durable log after resume or a settled command lifecycle. */
55
+ sync(events: readonly SessionEvent[]): void
56
+ /** Remove only this controller's Agent-scoped request wrapper. */
57
+ dispose(): void
58
+ }
59
+
60
+ type PendingSelection =
61
+ | { readonly kind: 'effort'; readonly value: string | undefined }
62
+ | { readonly kind: 'model'; readonly value: ModelOverride | undefined }
63
+
64
+ /** Parse `/effort` input; only values the handler can accept. */
65
+ function effortSelection(args: string | undefined): string | undefined | 'invalid' {
66
+ if (args === undefined) return 'invalid'
67
+ const value = args.trim()
68
+ if (value === '' || /\s/u.test(value)) return 'invalid'
69
+ return value === 'reset' ? undefined : value
70
+ }
71
+
72
+ /**
73
+ * Parse `/model` input: `reset`, `<model>` (keeps the composed provider), or
74
+ * `<provider>/<model>`.
75
+ */
76
+ function modelSelection(args: string | undefined): ModelOverride | undefined | 'invalid' {
77
+ if (args === undefined) return 'invalid'
78
+ const value = args.trim()
79
+ if (value === '' || /\s/u.test(value)) return 'invalid'
80
+ if (value === 'reset') return undefined
81
+ const slash = value.indexOf('/')
82
+ if (slash === -1) return { model: value }
83
+ const provider = value.slice(0, slash)
84
+ const model = value.slice(slash + 1)
85
+ if (provider === '' || model === '' || model.includes('/')) return 'invalid'
86
+ return { provider, model }
87
+ }
88
+
89
+ /** One folded policy: the last successful Session-level selections. */
90
+ export interface FoldedRequestPolicy {
91
+ effort: string | undefined
92
+ model: ModelOverride | undefined
93
+ }
94
+
95
+ /**
96
+ * Fold the durable log into the Session-level policy.
97
+ *
98
+ * A run changes nothing until its matching `command/done` succeeds. Failed,
99
+ * incomplete, query-only, and malformed commands are therefore inert.
100
+ */
101
+ export function foldRequestPolicy(events: readonly SessionEvent[]): FoldedRequestPolicy {
102
+ const pending = new Map<CommandId, PendingSelection>()
103
+ let effort: string | undefined
104
+ let model: ModelOverride | undefined
105
+ for (const event of events) {
106
+ if (event.type === 'command/run') {
107
+ if (event.data.name === 'effort') {
108
+ const selection = effortSelection(event.data.args)
109
+ if (selection !== 'invalid') pending.set(event.data.commandId, { kind: 'effort', value: selection })
110
+ } else if (event.data.name === 'model') {
111
+ const selection = modelSelection(event.data.args)
112
+ if (selection !== 'invalid') pending.set(event.data.commandId, { kind: 'model', value: selection })
113
+ }
114
+ continue
115
+ }
116
+ if (event.type !== 'command/done') continue
117
+ const selection = pending.get(event.data.commandId)
118
+ if (selection === undefined) continue
119
+ pending.delete(event.data.commandId)
120
+ if (event.data.kind !== 'success') continue
121
+ if (selection.kind === 'effort') effort = selection.value
122
+ else model = selection.value
123
+ }
124
+ return { effort, model }
125
+ }
126
+
127
+ /** Fold only the last successful Session-level `/effort` selection. */
128
+ export function foldEffortOverride(events: readonly SessionEvent[]): string | undefined {
129
+ return foldRequestPolicy(events).effort
130
+ }
131
+
132
+ /** Fold only the last successful Session-level `/model` selection. */
133
+ export function foldModelOverride(events: readonly SessionEvent[]): ModelOverride | undefined {
134
+ return foldRequestPolicy(events).model
135
+ }
136
+
137
+ /** Install the mutable request policy outside every other request wrapper. */
138
+ export function installRequestPolicy(
139
+ agentCtx: AgentContext,
140
+ config: RequestPolicyConfig = {},
141
+ ): RequestPolicyController {
142
+ let effortOverride: string | undefined
143
+ let modelOverride: ModelOverride | undefined
144
+ let disposed = false
145
+ const configuredModel: ModelOverride | undefined = config.provider !== undefined && config.model !== undefined
146
+ ? { provider: config.provider, model: config.model }
147
+ : undefined
148
+
149
+ const disposeListener = agentCtx.on('agent/request', async (_payload, next) => {
150
+ const request = await next()
151
+ const effort = effortOverride ?? config.effort
152
+ const model = modelOverride ?? configuredModel
153
+ if (effort === undefined && model === undefined) return request
154
+ return {
155
+ ...request,
156
+ ...(effort === undefined ? {} : { reasoningEffort: effort }),
157
+ ...(model === undefined ? {} : { model: model.model }),
158
+ ...(model?.provider === undefined ? {} : { provider: model.provider }),
159
+ }
160
+ }, { prepend: true })
161
+
162
+ return {
163
+ get state(): RequestPolicyState {
164
+ const effectiveEffort = effortOverride ?? config.effort
165
+ const effectiveModel = modelOverride ?? configuredModel
166
+ return {
167
+ configuredEffort: config.effort,
168
+ configuredModel,
169
+ effortOverride,
170
+ modelOverride,
171
+ effectiveEffort,
172
+ effectiveModel,
173
+ effortSource: effortOverride !== undefined
174
+ ? 'session'
175
+ : config.effort !== undefined ? 'config' : 'model-default',
176
+ modelSource: modelOverride !== undefined
177
+ ? 'session'
178
+ : configuredModel !== undefined ? 'config' : 'deployment-default',
179
+ }
180
+ },
181
+ sync(events) {
182
+ const folded = foldRequestPolicy(events)
183
+ effortOverride = folded.effort
184
+ modelOverride = folded.model
185
+ },
186
+ dispose() {
187
+ if (disposed) return
188
+ disposed = true
189
+ disposeListener()
190
+ },
191
+ }
192
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Durable route store: thread/chat keys → bound DSH session records.
3
+ *
4
+ * Owns the hot cache over the `channels_routes` domain tables, the TTL
5
+ * retirement that leaves a persistent tombstone, and session → route lookup.
6
+ * The domain handle is late-bound (`domain()`), because the service opens it
7
+ * during `start()` while the store is constructed earlier.
8
+ *
9
+ * @module dsh-channels/session/routes
10
+ */
11
+
12
+ import type { ExpiredRouteRecord, FeishuDomainHandle, RouteRecord } from '../domain.ts'
13
+ import { normalizeRouteRecord } from '../domain.ts'
14
+
15
+ export interface RouteStoreDeps {
16
+ /** The opened domain handle, or undefined before `start()` / after shutdown. */
17
+ domain(): FeishuDomainHandle | undefined
18
+ /** Route retirement TTL in milliseconds; `0` disables retirement. */
19
+ ttlMs(): number
20
+ /** Structured warning sink (never throws). */
21
+ warn(message: string, ...args: unknown[]): void
22
+ }
23
+
24
+ export class RouteStore {
25
+ /** Active route records, hydrated once and kept in sync with writes. */
26
+ private readonly active = new Map<string, RouteRecord>()
27
+ /** Thread ids retired by TTL; retained so later messages cannot fork context. */
28
+ private readonly expired = new Map<string, ExpiredRouteRecord>()
29
+
30
+ constructor(private readonly deps: RouteStoreDeps) {}
31
+
32
+ /**
33
+ * Hydrate the caches from the opened domain. A tombstone wins over an active
34
+ * row left behind by an interrupted or failed delete: expired Sessions must
35
+ * not remain approval-routable.
36
+ */
37
+ load(): void {
38
+ const domain = this.deps.domain()
39
+ if (domain === undefined) return
40
+ for (const [key, record] of domain.routes.entries()) {
41
+ this.active.set(key, normalizeRouteRecord(record))
42
+ }
43
+ for (const [key, record] of domain.expiredRoutes.entries()) {
44
+ this.expired.set(key, record)
45
+ this.active.delete(key)
46
+ }
47
+ }
48
+
49
+ /** One active route by key (cache-first, normalized). */
50
+ get(key: string): RouteRecord | undefined {
51
+ const cached = this.active.get(key)
52
+ if (cached !== undefined) return cached
53
+ const stored = this.deps.domain()?.routes.get(key)
54
+ if (stored === undefined) return undefined
55
+ const normalized = normalizeRouteRecord(stored)
56
+ this.active.set(key, normalized)
57
+ return normalized
58
+ }
59
+
60
+ /** One durable tombstone by expired thread id. */
61
+ expiredRoute(key: string): ExpiredRouteRecord | undefined {
62
+ const cached = this.expired.get(key)
63
+ if (cached !== undefined) return cached
64
+ const stored = this.deps.domain()?.expiredRoutes.get(key)
65
+ if (stored === undefined) return undefined
66
+ this.expired.set(key, stored)
67
+ return stored
68
+ }
69
+
70
+ /** Persist one active route and refresh the cache. */
71
+ async put(key: string, record: RouteRecord): Promise<void> {
72
+ await this.deps.domain()?.routes.put(key, record)
73
+ this.active.set(key, normalizeRouteRecord(record))
74
+ }
75
+
76
+ /** Delete one active route from the medium and the cache. */
77
+ async remove(key: string): Promise<void> {
78
+ await this.deps.domain()?.routes.delete(key)
79
+ this.active.delete(key)
80
+ }
81
+
82
+ /** Drop one key from the cache only (used when a tombstone takes over). */
83
+ forget(key: string): void {
84
+ this.active.delete(key)
85
+ }
86
+
87
+ /** Find the route record owning a session (a thread route wins). */
88
+ findBySession(sessionId: string): RouteRecord | undefined {
89
+ let fallback: RouteRecord | undefined
90
+ for (const record of this.active.values()) {
91
+ if (record.sessionId !== sessionId) continue
92
+ if (record.kind === 'thread') return record
93
+ fallback ??= record
94
+ }
95
+ return fallback
96
+ }
97
+
98
+ size(): number {
99
+ return this.active.size
100
+ }
101
+
102
+ clear(): void {
103
+ this.active.clear()
104
+ this.expired.clear()
105
+ }
106
+
107
+ /** Whether one active route has crossed the configured inactivity deadline. */
108
+ private hasExpired(record: RouteRecord, now: number): boolean {
109
+ const ttlMs = this.deps.ttlMs()
110
+ return ttlMs > 0 && record.lastActivityAt < now - ttlMs
111
+ }
112
+
113
+ /**
114
+ * Resolve or create the persistent tombstone for a thread whose active route
115
+ * has crossed TTL. Tombstone publication comes first and wins even if the
116
+ * best-effort active-route delete fails.
117
+ */
118
+ async expireThread(key: string, now = Date.now()): Promise<ExpiredRouteRecord | undefined> {
119
+ const existing = this.expiredRoute(key)
120
+ if (existing !== undefined) {
121
+ this.active.delete(key)
122
+ if (this.deps.domain()?.routes.get(key) !== undefined) {
123
+ try {
124
+ await this.deps.domain()?.routes.delete(key)
125
+ } catch (error) {
126
+ this.deps.warn('dsh-channels: deleting tombstoned route %s failed: %s', key, String(error))
127
+ }
128
+ }
129
+ return existing
130
+ }
131
+ const record = this.get(key)
132
+ if (record === undefined || record.kind !== 'thread' || !this.hasExpired(record, now)) return undefined
133
+ return await this.retire(key, now)
134
+ }
135
+
136
+ /**
137
+ * Retire one active thread route into a persistent tombstone regardless of
138
+ * the configured TTL. Used by the workspace-retention cleanup, which may
139
+ * close topics earlier than the route TTL.
140
+ */
141
+ async retire(key: string, now = Date.now()): Promise<ExpiredRouteRecord | undefined> {
142
+ const record = this.get(key)
143
+ if (record === undefined) return this.expiredRoute(key)
144
+ const tombstone: ExpiredRouteRecord = {
145
+ key,
146
+ chatId: record.chatId,
147
+ chatType: record.chatType,
148
+ senderOpenId: record.senderOpenId,
149
+ expiredAt: now,
150
+ workspacePath: record.workspacePath,
151
+ lastActivityAt: record.lastActivityAt,
152
+ }
153
+ await this.deps.domain()?.expiredRoutes.put(key, tombstone)
154
+ this.expired.set(key, tombstone)
155
+ this.active.delete(key)
156
+ try {
157
+ await this.deps.domain()?.routes.delete(key)
158
+ } catch (error) {
159
+ // The durable tombstone already makes expiry fail-closed. Keep serving
160
+ // the explicit rejection and let a later sweep retry the stale delete.
161
+ this.deps.warn('dsh-channels: deleting retired route %s failed: %s', key, String(error))
162
+ }
163
+ return tombstone
164
+ }
165
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Pure result rendering and validation for Session-scoped slash commands.
3
+ *
4
+ * @module dsh-channels/session-commands
5
+ */
6
+
7
+ import path from 'node:path'
8
+ import { standaloneCard } from './cards/primitives.ts'
9
+ import type { RouteRecord, SessionHistoryEntry } from './domain.ts'
10
+ import { normalizeSessionHistory } from './domain.ts'
11
+ import type { ContextMeasurement, StatsFold, ToolEventSample } from './stats.ts'
12
+ import {
13
+ averageDecodeTokensPerSecond, averageTtftMs, cacheHitPct,
14
+ } from './stats.ts'
15
+
16
+ function fmtCount(value: number): string {
17
+ return value.toLocaleString('en-US')
18
+ }
19
+
20
+ function fmtDuration(value: number | undefined): string {
21
+ if (value === undefined || !Number.isFinite(value)) return '暂无数据'
22
+ if (value < 1000) return `${Math.round(value)} ms`
23
+ return `${(value / 1000).toFixed(2)} s`
24
+ }
25
+
26
+ function fmtPercent(value: number | undefined): string {
27
+ return value === undefined || !Number.isFinite(value) ? '暂无数据' : `${(value * 100).toFixed(1)}%`
28
+ }
29
+
30
+ function fmtRate(value: number | undefined): string {
31
+ return value === undefined || !Number.isFinite(value) ? '暂无数据' : `${value.toFixed(1)} tokens/s`
32
+ }
33
+
34
+ function contextLine(measurement: ContextMeasurement): string {
35
+ const tokens = measurement.contextTokens
36
+ const window = measurement.contextWindow
37
+ if (tokens === undefined) return '暂无数据'
38
+ if (window === undefined) return `${fmtCount(tokens)} tokens`
39
+ return `${fmtCount(tokens)} / ${fmtCount(window)} (${(tokens / window * 100).toFixed(1)}%)`
40
+ }
41
+
42
+ function escapeInlineCode(value: string): string {
43
+ return value.replaceAll('`', '\\`')
44
+ }
45
+
46
+ function toolLine(sample: ToolEventSample): string {
47
+ const status = sample.status === 'running' ? '运行中' : sample.status === 'success' ? '成功' : '失败'
48
+ const duration = sample.status === 'running' ? '尚未完成' : fmtDuration(sample.durationMs)
49
+ return `- \`${escapeInlineCode(sample.name)}\` · ${status} · ${duration}`
50
+ }
51
+
52
+ export interface SessionStatusCardInput {
53
+ sessionId: string
54
+ stats: StatsFold
55
+ context: ContextMeasurement
56
+ /** Optional operational extras (permission preset, workspace, uptime, …). */
57
+ extras?: SessionStatusExtras
58
+ }
59
+
60
+ /** Operational extras appended to the `/status` card when available. */
61
+ export interface SessionStatusExtras {
62
+ /** Effective permission preset of this Session (`ctx.permissionPresets`). */
63
+ permissionPreset?: string
64
+ /** Workspace directory bound to this route. */
65
+ workspacePath?: string
66
+ /** Plugin uptime in milliseconds. */
67
+ uptimeMs?: number
68
+ /** Live agent entries this channel owns/borrows. */
69
+ liveAgents?: number
70
+ /** Persisted route records. */
71
+ routeCount?: number
72
+ }
73
+
74
+ /** Render the complete `/status` snapshot without exposing tool arguments or results. */
75
+ export function buildSessionStatusCard(input: SessionStatusCardInput): object {
76
+ const { stats } = input
77
+ const model = stats.lastHeader === undefined
78
+ ? '尚未产生模型请求'
79
+ : `${stats.lastHeader.provider}/${stats.lastHeader.model}`
80
+ const effort = stats.lastHeader?.effort ?? '暂无数据'
81
+ const tools = stats.recentTools.length === 0
82
+ ? '暂无工具事件'
83
+ : [...stats.recentTools].reverse().map(toolLine).join('\n')
84
+ const pendingNames = [...stats.pendingTools.values()].slice(-5).map(tool => `\`${escapeInlineCode(tool.name)}\``)
85
+ const hiddenPending = stats.pendingTools.size - pendingNames.length
86
+ const pending = pendingNames.length === 0
87
+ ? '无'
88
+ : `${pendingNames.join('、')}${hiddenPending > 0 ? `,另有 ${hiddenPending} 个` : ''}`
89
+ const tokenSummary = stats.usageSamples === 0
90
+ ? '暂无数据'
91
+ : `输入 ${fmtCount(stats.totals.inputTokens)} · 输出 ${fmtCount(stats.totals.outputTokens)} · 推理 ${fmtCount(stats.totals.reasoningTokens)}`
92
+ const cacheSummary = stats.usageSamples === 0
93
+ ? '暂无数据'
94
+ : `读取 ${fmtCount(stats.totals.cacheReadTokens)} · 写入 ${fmtCount(stats.totals.cacheWriteTokens)} · 最近命中率 ${fmtPercent(cacheHitPct(stats.lastUsage))}`
95
+
96
+ return standaloneCard('**📊 Session 状态**', [
97
+ { tag: 'markdown', content: `**模型**:${model}\n**推理强度**:${effort}` },
98
+ {
99
+ tag: 'markdown',
100
+ content: `**Token 累计**:${tokenSummary}\n`
101
+ + `**缓存**:${cacheSummary}\n`
102
+ + `**上下文**:${contextLine(input.context)}`,
103
+ },
104
+ {
105
+ tag: 'markdown',
106
+ content: `**LLM 时间**:${fmtDuration(stats.llmSteps === 0 ? undefined : stats.llmMs)}\n`
107
+ + `**工具时间**:${fmtDuration(stats.toolTimedCalls === 0 ? undefined : stats.toolMs)}\n`
108
+ + `**平均首 token**:${fmtDuration(averageTtftMs(stats))}\n`
109
+ + `**平均生成速度**:${fmtRate(averageDecodeTokensPerSecond(stats))}`,
110
+ },
111
+ {
112
+ tag: 'markdown',
113
+ content: `**工具调用**:累计 ${fmtCount(stats.toolCalls)} · 未完成 ${fmtCount(stats.pendingTools.size)}(${pending})\n`
114
+ + `**最近工具事件(最多 5 条)**\n${tools}`,
115
+ },
116
+ ...statusExtrasElements(input.extras),
117
+ { tag: 'markdown', content: input.sessionId, text_size: 'notation' },
118
+ ])
119
+ }
120
+
121
+ /** Render the optional operational lines, omitting unavailable ones. */
122
+ function statusExtrasElements(extras: SessionStatusExtras | undefined): object[] {
123
+ if (extras === undefined) return []
124
+ const lines: string[] = []
125
+ if (extras.permissionPreset !== undefined) lines.push(`**权限预设**:\`${escapeInlineCode(extras.permissionPreset)}\``)
126
+ if (extras.workspacePath !== undefined) lines.push(`**工作区**:\`${escapeInlineCode(extras.workspacePath)}\``)
127
+ if (extras.uptimeMs !== undefined) lines.push(`**插件运行时长**:${fmtUptime(extras.uptimeMs)}`)
128
+ if (extras.liveAgents !== undefined || extras.routeCount !== undefined) {
129
+ const parts: string[] = []
130
+ if (extras.liveAgents !== undefined) parts.push(`live Agent ${fmtCount(extras.liveAgents)}`)
131
+ if (extras.routeCount !== undefined) parts.push(`持久化路由 ${fmtCount(extras.routeCount)}`)
132
+ lines.push(`**通道**:${parts.join(' · ')}`)
133
+ }
134
+ return lines.length === 0 ? [] : [{ tag: 'markdown', content: lines.join('\n') }]
135
+ }
136
+
137
+ /** `2 小时 5 分` / `45 秒` — coarse human uptime. */
138
+ function fmtUptime(ms: number): string {
139
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000))
140
+ if (totalSeconds < 60) return `${totalSeconds} 秒`
141
+ const minutes = Math.floor(totalSeconds / 60) % 60
142
+ const hours = Math.floor(totalSeconds / 3600)
143
+ return hours === 0 ? `${minutes} 分` : `${hours} 小时 ${minutes} 分`
144
+ }
145
+
146
+ export type WorkDirResolution =
147
+ | { kind: 'success'; path: string }
148
+ | { kind: 'missing'; text: string }
149
+ | { kind: 'mismatch'; text: string }
150
+
151
+ /** Validate the Session header cwd against the plugin's durable route. */
152
+ export function resolveWorkDir(headerCwd: string | undefined, routeWorkspacePath: string): WorkDirResolution {
153
+ if (headerCwd === undefined || headerCwd === '' || !path.isAbsolute(headerCwd)) {
154
+ return {
155
+ kind: 'missing',
156
+ text: '当前 Session header 未记录可用的绝对工作目录;请检查 Session 创建或恢复配置。',
157
+ }
158
+ }
159
+ if (!path.isAbsolute(routeWorkspacePath)) {
160
+ return {
161
+ kind: 'missing',
162
+ text: '当前飞书路由未记录可用的绝对工作目录;请检查路由持久化状态。',
163
+ }
164
+ }
165
+ if (headerCwd !== routeWorkspacePath) {
166
+ return {
167
+ kind: 'mismatch',
168
+ text: 'Session header 与飞书路由记录的工作目录不一致;为避免返回错误目录,已拒绝显示。',
169
+ }
170
+ }
171
+ return { kind: 'success', path: headerCwd }
172
+ }
173
+
174
+ /** Render one verified absolute workspace path. */
175
+ export function buildWorkDirCard(sessionId: string, workspacePath: string): object {
176
+ return standaloneCard('**📁 当前工作目录**', [
177
+ { tag: 'markdown', content: `\`${escapeInlineCode(workspacePath)}\`` },
178
+ { tag: 'markdown', content: sessionId, text_size: 'notation' },
179
+ ])
180
+ }
181
+
182
+ export interface RenewCardInput {
183
+ previousSessionId: string
184
+ currentSessionId: string
185
+ workspacePath: string
186
+ }
187
+
188
+ /** Render the committed route switch without implying that the old log was deleted. */
189
+ export function buildRenewCard(input: RenewCardInput): object {
190
+ return standaloneCard('**🔄 Session 已续建**', [
191
+ {
192
+ tag: 'markdown',
193
+ content: `**旧 Session**:${input.previousSessionId}\n`
194
+ + `**新 Session**:${input.currentSessionId}\n`
195
+ + `**复用工作区**:\`${escapeInlineCode(input.workspacePath)}\``,
196
+ },
197
+ { tag: 'markdown', content: '旧 Session 及其日志仍由 Harness 保留。', text_size: 'notation' },
198
+ ])
199
+ }
200
+
201
+ export const SESSION_HISTORY_DISPLAY_LIMIT = 20
202
+
203
+ export interface SessionHistoryItem extends SessionHistoryEntry {
204
+ current: boolean
205
+ }
206
+
207
+ export interface SessionHistoryView {
208
+ total: number
209
+ items: SessionHistoryItem[]
210
+ hidden: number
211
+ }
212
+
213
+ /** Read one route's normalized history without consulting the Session repository. */
214
+ export function querySessionHistory(record: RouteRecord): SessionHistoryView {
215
+ const history = normalizeSessionHistory(record)
216
+ const items = history
217
+ .slice(-SESSION_HISTORY_DISPLAY_LIMIT)
218
+ .reverse()
219
+ .map(entry => ({ ...entry, current: entry.sessionId === record.sessionId }))
220
+ return {
221
+ total: history.length,
222
+ items,
223
+ hidden: Math.max(0, history.length - items.length),
224
+ }
225
+ }
226
+
227
+ /** Optional per-session facts folded from the host session query. */
228
+ export interface SessionHistoryAnnotation {
229
+ /** Latest log-backed session title, when one exists. */
230
+ title?: string
231
+ /** Whether the session is currently live (held by any owner). */
232
+ live?: boolean
233
+ }
234
+
235
+ function historyLine(
236
+ item: SessionHistoryItem,
237
+ index: number,
238
+ annotation: SessionHistoryAnnotation | undefined,
239
+ ): string {
240
+ const markers = [
241
+ ...(item.current ? ['**当前**'] : []),
242
+ ...(annotation?.live === true && !item.current ? ['live'] : []),
243
+ ]
244
+ const marker = markers.length === 0 ? '' : ` · ${markers.join(' · ')}`
245
+ const title = annotation?.title === undefined || annotation.title === ''
246
+ ? ''
247
+ : `\n 标题:${escapeInlineCode(annotation.title)}`
248
+ return `${index + 1}. \`${escapeInlineCode(item.sessionId)}\`${marker}${title}\n`
249
+ + ` 创建时间:${new Date(item.createdAt).toISOString()}`
250
+ }
251
+
252
+ /** Render the newest route-local Session history with host-query annotations. */
253
+ export function buildSessionHistoryCard(
254
+ record: RouteRecord,
255
+ annotations: Readonly<Record<string, SessionHistoryAnnotation>> = {},
256
+ ): object {
257
+ const view = querySessionHistory(record)
258
+ const elements: object[] = [
259
+ { tag: 'markdown', content: `**总数**:${fmtCount(view.total)}\n**当前 Session**:\`${escapeInlineCode(record.sessionId)}\`` },
260
+ { tag: 'markdown', content: view.items.map((item, index) => historyLine(item, index, annotations[item.sessionId])).join('\n') },
261
+ ]
262
+ if (view.hidden > 0) {
263
+ elements.push({ tag: 'markdown', content: `另有 ${fmtCount(view.hidden)} 条未展示。`, text_size: 'notation' })
264
+ }
265
+ return standaloneCard('**🕘 Session 历史**', elements)
266
+ }
267
+
268
+ function comparisonLine(label: string, tokens: number | undefined, window: number | undefined): string {
269
+ if (tokens === undefined) return `${label} 暂无数据`
270
+ if (window === undefined) return `${label} ${fmtCount(tokens)} tokens`
271
+ const ratio = tokens / window
272
+ const filled = Math.min(10, Math.max(0, Math.round(ratio * 10)))
273
+ return `${label} ${'█'.repeat(filled)}${'░'.repeat(10 - filled)} ${(ratio * 100).toFixed(0)}% ${fmtCount(tokens)} / ${fmtCount(window)}`
274
+ }
275
+
276
+ export interface CompactComparisonInput {
277
+ sessionId: string
278
+ resultText: string
279
+ before: ContextMeasurement
280
+ after: ContextMeasurement
281
+ }
282
+
283
+ /** Render successful and no-op compaction outcomes with one shared 10-cell scale. */
284
+ export function buildCompactComparisonCard(input: CompactComparisonInput): object {
285
+ const window = input.after.contextWindow ?? input.before.contextWindow
286
+ const beforeTokens = input.before.contextTokens
287
+ const afterTokens = input.after.contextTokens
288
+ const released = beforeTokens === undefined || afterTokens === undefined
289
+ ? undefined
290
+ : Math.max(0, beforeTokens - afterTokens)
291
+ return standaloneCard('**🗜️ 上下文压缩完成**', [
292
+ { tag: 'markdown', content: input.resultText },
293
+ {
294
+ tag: 'markdown',
295
+ content: `${comparisonLine('压缩前', beforeTokens, window)}\n`
296
+ + `${comparisonLine('压缩后', afterTokens, window)}\n`
297
+ + `释放 ${released === undefined ? '暂无数据' : `${fmtCount(released)} tokens`}`,
298
+ },
299
+ { tag: 'markdown', content: input.sessionId, text_size: 'notation' },
300
+ ])
301
+ }
302
+
303
+ /** Describe whether a failed compaction left the measured surface unchanged. */
304
+ export function compactFailureContext(before: ContextMeasurement, after: ContextMeasurement): string {
305
+ if (before.contextTokens === undefined || after.contextTokens === undefined) return '上下文状态:暂无可用测量数据。'
306
+ if (before.contextTokens === after.contextTokens) {
307
+ return `上下文保持不变:${fmtCount(before.contextTokens)} tokens。`
308
+ }
309
+ return `上下文状态可能已变化:执行前 ${fmtCount(before.contextTokens)} tokens,当前 ${fmtCount(after.contextTokens)} tokens;请检查 Session 后再重试。`
310
+ }