@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.
- package/README.md +302 -2
- package/cordis.patch.yml +3 -0
- package/docs/access-control-design.md +550 -0
- package/docs/assets/web-settings-panel.png +0 -0
- package/docs/assets/web-settings-restart-pending.png +0 -0
- package/docs/feasibility-and-design.md +213 -0
- package/docs/feishu-onboarding.md +268 -0
- package/docs/persistence.md +199 -0
- package/docs/refactor-plan.md +56 -0
- package/docs/review-dsh-0.2.0.md +389 -0
- package/docs/slash-commands-implementation-plan.md +410 -0
- package/docs/web-display-only-surface-roadmap.md +363 -0
- package/lib/client.js +2226 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +10217 -0
- package/lib/index.js.map +1 -0
- package/package.json +98 -4
- package/src/access/cards.ts +188 -0
- package/src/access/controller.ts +201 -0
- package/src/access/index.ts +13 -0
- package/src/access/policy.ts +103 -0
- package/src/access/requests.ts +512 -0
- package/src/access/store.ts +220 -0
- package/src/approvals.ts +8 -0
- package/src/cards/approval.ts +114 -0
- package/src/cards/index.ts +11 -0
- package/src/cards/primitives.ts +82 -0
- package/src/cards/question.ts +141 -0
- package/src/cards/streaming.ts +563 -0
- package/src/cards.ts +8 -0
- package/src/cleanup.ts +398 -0
- package/src/client/api.ts +262 -0
- package/src/client/catalog.ts +92 -0
- package/src/client/index.ts +61 -0
- package/src/client/panel.tsx +1475 -0
- package/src/client/styles.ts +98 -0
- package/src/commands/registry.ts +155 -0
- package/src/commands.ts +335 -0
- package/src/config.ts +206 -0
- package/src/domain.ts +428 -0
- package/src/dsh-types.ts +11 -0
- package/src/feishu/agent-context.ts +62 -0
- package/src/feishu/delivery.ts +188 -0
- package/src/feishu/inbound-attachments.ts +436 -0
- package/src/feishu-channel.ts +339 -0
- package/src/health.ts +233 -0
- package/src/host/agent.ts +181 -0
- package/src/host/commands.ts +63 -0
- package/src/host/core.ts +345 -0
- package/src/host/index.ts +12 -0
- package/src/host/interaction.ts +101 -0
- package/src/host/services.ts +320 -0
- package/src/index.ts +122 -0
- package/src/interactions/approvals.ts +154 -0
- package/src/interactions/pending.ts +162 -0
- package/src/interactions/questions.ts +274 -0
- package/src/questions.ts +8 -0
- package/src/routing/inbound.ts +524 -0
- package/src/service.ts +2138 -0
- package/src/session/manager.ts +352 -0
- package/src/session/repair.ts +157 -0
- package/src/session/request-policy.ts +192 -0
- package/src/session/routes.ts +165 -0
- package/src/session-commands.ts +310 -0
- package/src/session-export.ts +112 -0
- package/src/settings/catalog.ts +309 -0
- package/src/settings/contract.ts +508 -0
- package/src/settings/overlay.ts +164 -0
- package/src/settings/routes.ts +409 -0
- package/src/settings/surface.ts +773 -0
- package/src/stats.ts +485 -0
- package/src/streamer.ts +1108 -0
- 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
|
+
}
|