@shendeguize/dsh-agent-sidecar 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 +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
package/src/analysis.ts
ADDED
|
@@ -0,0 +1,782 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI bypass-analysis engine — design §5 pillar 3 / §4.e.3 (M3, T5.6).
|
|
3
|
+
* Creates a DEDICATED dsh analysis session per request via `ctx.agents.create`,
|
|
4
|
+
* feeds it a bounded summary of the observed session/project, supports
|
|
5
|
+
* incremental follow-up questions, and can be cancelled from the UI at any
|
|
6
|
+
* time. Pure engine: routing/index wiring is the integration layer's job, and
|
|
7
|
+
* the analysis INPUT (summary text assembled from fusion timelines/overviews)
|
|
8
|
+
* arrives as a structured parameter — this module never touches fusion.
|
|
9
|
+
*
|
|
10
|
+
* API facts verified against the installed SDK
|
|
11
|
+
* (`@deepseek-ai/dsh-agent@0.1.1-rc.2` d.ts, authoritative over the design
|
|
12
|
+
* sketch):
|
|
13
|
+
*
|
|
14
|
+
* - `ctx.agents.create(options): Promise<AgentHandle>` (lib/types/index.d.ts:288)
|
|
15
|
+
* with `CreateAgentOptions` requiring a CALLER-SUPPLIED `sessionId`
|
|
16
|
+
* (index.d.ts:65-118) — the engine mints one per analysis — plus an optional
|
|
17
|
+
* creation-only `signal` (index.d.ts:98). `AgentHandle = { agent; dispose():
|
|
18
|
+
* Promise<void> }` (index.d.ts:155-158); `dispose()` stops the loop and
|
|
19
|
+
* removes the session, which is exactly the UI "stop" semantics.
|
|
20
|
+
* - There is NO system-prompt field on `CreateAgentOptions` (`agentOptions` is
|
|
21
|
+
* only provider/model/maxTokens, runtime-types.d.ts:21-28; `setup` composes
|
|
22
|
+
* cordis scopes and would break pure DI), so the read-only-analyst guidance
|
|
23
|
+
* rides the FIRST user message instead.
|
|
24
|
+
* - `Agent.followup(message: UserMessage): void` is a SYNCHRONOUS inbox splice
|
|
25
|
+
* (runtime-types.d.ts:115) — same finding as T4.4. There is no per-message
|
|
26
|
+
* response promise; the result must be read back from the session.
|
|
27
|
+
* - "Getting the analysis result": `Agent.whenIdle(): Promise<void>` resolves
|
|
28
|
+
* after whole-agent quiescence (runtime-types.d.ts:87), and a waking
|
|
29
|
+
* followup flips status to running synchronously (runtime-types.d.ts:161-172),
|
|
30
|
+
* so `followup → whenIdle → read log` observes the completed turn. The text
|
|
31
|
+
* is read from `agent.session.deriveMessages()` (dsh-session
|
|
32
|
+
* index.d.ts:259, the cached surface projection); token accounting rides
|
|
33
|
+
* `assistant/message` events as `data.usage?: TokenUsage`
|
|
34
|
+
* (dsh-session types.d.ts:279-285, dsh-llm types.d.ts:123-129) — that feeds
|
|
35
|
+
* `tokensHint`.
|
|
36
|
+
* - `Agent.cancel(cause: AgentCancelCause): void` aborts the active turn
|
|
37
|
+
* (runtime-types.d.ts:80); `{ kind: 'user' }` is the honest cause for a
|
|
38
|
+
* user-facing stop/timeout (dsh-session types.d.ts:118-127).
|
|
39
|
+
*
|
|
40
|
+
* Bounds (§7-B token-cost touchpoint, design risk 12): gated by live
|
|
41
|
+
* `analysis.enabled` (default false), input truncated to `maxInputChars`,
|
|
42
|
+
* first response and every follow-up bounded by `analysisTimeoutMs` (timeout
|
|
43
|
+
* cancels the in-flight turn), and at most `maxActiveSessions` concurrent
|
|
44
|
+
* analysis sessions. No periodic/automatic analysis exists here by design.
|
|
45
|
+
*
|
|
46
|
+
* Error vocabulary (contractual):
|
|
47
|
+
* `analysis_disabled | create_failed | timeout | too_many_active | cancelled`.
|
|
48
|
+
* `create_failed` covers the whole establishment phase of `request()`
|
|
49
|
+
* (create + priming followup + first-turn wait); `cancelled` covers follow-ups
|
|
50
|
+
* against a session that is unknown, already stopped, or died mid-turn.
|
|
51
|
+
*
|
|
52
|
+
* Honesty: results are AI-generated inference; every {@link AnalysisResult}
|
|
53
|
+
* carries {@link ANALYSIS_DISCLAIMER} for the UI to display.
|
|
54
|
+
*
|
|
55
|
+
* Log lines NEVER carry the analyzed content: no `summaryText`, no follow-up
|
|
56
|
+
* question, no model reply — only kind/title/analysisSessionId/outcome/token
|
|
57
|
+
* hints and sizes (S8).
|
|
58
|
+
*
|
|
59
|
+
* Pure DI: no cordis/dsh imports; `ctx.agents.create` is injected through the
|
|
60
|
+
* minimal structural face {@link AnalysisAgentFace} (method-syntax members
|
|
61
|
+
* keep parameter checks bivariant, so the SDK's branded `SessionId` / wider
|
|
62
|
+
* `UserMessage` / union `AgentCancelCause` signatures remain assignable).
|
|
63
|
+
*
|
|
64
|
+
* @module
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// Minimal structural faces over the dsh-agent / dsh-session SDK.
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
/** Widened user-message face (same shape family as dsh-inject's, F11). */
|
|
72
|
+
export interface AnalysisUserMessageFace {
|
|
73
|
+
readonly id: string
|
|
74
|
+
readonly role: 'user'
|
|
75
|
+
readonly content: ReadonlyArray<{ readonly type: string; readonly text?: string }>
|
|
76
|
+
readonly source: { readonly kind: string; readonly plugin?: string }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Widened derived-message face over dsh-llm `Message` (message.d.ts:120-129). */
|
|
80
|
+
export interface AnalysisDerivedMessageFace {
|
|
81
|
+
readonly role: string
|
|
82
|
+
readonly content: ReadonlyArray<{ readonly type: string; readonly text?: string }>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Widened session-event envelope face (dsh-session types.d.ts:425-443). */
|
|
86
|
+
export interface AnalysisSessionEventFace {
|
|
87
|
+
readonly type: string
|
|
88
|
+
/** `assistant/message` events carry `{ usage?: TokenUsage }` here. */
|
|
89
|
+
readonly data?: unknown
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Read face over the live session log (dsh-session index.d.ts:106-267). */
|
|
93
|
+
export interface AnalysisSessionLogFace {
|
|
94
|
+
/** Cached surface projection of the derived LLM history (index.d.ts:259). */
|
|
95
|
+
deriveMessages(): ReadonlyArray<AnalysisDerivedMessageFace>
|
|
96
|
+
/** Immutable append-only event snapshot (index.d.ts:174). */
|
|
97
|
+
readonly events: ReadonlyArray<AnalysisSessionEventFace>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Cancellation cause face; `{kind:'user'}` ∈ `AgentCancelCause` (dsh-session types.d.ts:118-127). */
|
|
101
|
+
export interface AnalysisCancelCauseFace {
|
|
102
|
+
readonly kind: 'user'
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Live-agent face: prompt in, quiescence + log read back (runtime-types.d.ts:60-133). */
|
|
106
|
+
export interface AnalysisAgentDriverFace {
|
|
107
|
+
readonly session: AnalysisSessionLogFace
|
|
108
|
+
followup(message: AnalysisUserMessageFace): void
|
|
109
|
+
cancel(cause: AnalysisCancelCauseFace): void
|
|
110
|
+
whenIdle(): Promise<void>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The dedicated analysis session handle — `AgentHandle` face
|
|
115
|
+
* (index.d.ts:155-158): send messages via `agent.followup`, read responses via
|
|
116
|
+
* `agent.whenIdle` + `agent.session`, stop via `dispose()`.
|
|
117
|
+
*/
|
|
118
|
+
export interface AnalysisSession {
|
|
119
|
+
readonly agent: AnalysisAgentDriverFace
|
|
120
|
+
dispose(): Promise<void>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Per-agent options face over SDK `AgentOptions` (runtime-types.d.ts:21-28). */
|
|
124
|
+
export interface AnalysisAgentOptionsFace {
|
|
125
|
+
/** Provider route (must have a registered adapter at call time). */
|
|
126
|
+
readonly provider?: string
|
|
127
|
+
/** Model id interpreted by the selected provider adapter. */
|
|
128
|
+
readonly model?: string
|
|
129
|
+
/** Maximum output tokens for each conversation-model request. */
|
|
130
|
+
readonly maxTokens?: number
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** `CreateAgentOptions` face (index.d.ts:65-118): engine-minted id + creation abort. */
|
|
134
|
+
export interface AnalysisCreateOptions {
|
|
135
|
+
readonly sessionId: string
|
|
136
|
+
readonly signal?: AbortSignal
|
|
137
|
+
/**
|
|
138
|
+
* Provider/model routing for the analysis agent (`CreateAgentOptions.
|
|
139
|
+
* agentOptions`). The engine never sets this — the integration layer's
|
|
140
|
+
* create adapter resolves and attaches it (A-1: an agent created without
|
|
141
|
+
* provider/model fails prompt assembly on the `{{model}}` variable and
|
|
142
|
+
* `buildRequest`, completing with an empty summary and zero tokens).
|
|
143
|
+
*/
|
|
144
|
+
readonly agentOptions?: AnalysisAgentOptionsFace
|
|
145
|
+
/**
|
|
146
|
+
* Session creation metadata (`CreateAgentOptions.meta` subset). Also
|
|
147
|
+
* attached by the create adapter, never the engine: the deployment
|
|
148
|
+
* persona's `{{cwd}}` prompt variable reads `session.header.cwd`, which
|
|
149
|
+
* only `meta.cwd` populates (same reason dsh-headless passes
|
|
150
|
+
* `meta: { cwd: process.cwd() }` on its own create call).
|
|
151
|
+
*/
|
|
152
|
+
readonly meta?: { readonly cwd?: string }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Minimal `ctx.agents` face: the one factory entry point this engine uses. */
|
|
156
|
+
export interface AnalysisAgentFace {
|
|
157
|
+
/** `AgentRegistry.create` (index.d.ts:288). */
|
|
158
|
+
create(options: AnalysisCreateOptions): Promise<AnalysisSession>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Engine vocabulary.
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** Structured analysis input, pre-assembled by the integration layer. */
|
|
166
|
+
export interface AnalysisInput {
|
|
167
|
+
kind: 'session' | 'project' | 'cross-agent'
|
|
168
|
+
title: string
|
|
169
|
+
/** Bounded text already summarized from timelines/overviews by the caller. */
|
|
170
|
+
summaryText: string
|
|
171
|
+
meta?: Record<string, unknown>
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type AnalysisErrorCode =
|
|
175
|
+
| 'analysis_disabled'
|
|
176
|
+
| 'create_failed'
|
|
177
|
+
| 'timeout'
|
|
178
|
+
| 'too_many_active'
|
|
179
|
+
| 'cancelled'
|
|
180
|
+
|
|
181
|
+
export type AnalysisOutcome = 'completed' | 'timeout' | 'failed'
|
|
182
|
+
|
|
183
|
+
export interface AnalysisResult {
|
|
184
|
+
outcome: AnalysisOutcome
|
|
185
|
+
/**
|
|
186
|
+
* Present while the analysis session is still alive (completed requests,
|
|
187
|
+
* completed/timed-out follow-ups); absent when nothing remains to cancel
|
|
188
|
+
* (pre-create failures and request timeouts, where the session is disposed).
|
|
189
|
+
*/
|
|
190
|
+
analysisSessionId?: string
|
|
191
|
+
/** Assistant text produced by this turn (only on `completed`). */
|
|
192
|
+
summary?: string
|
|
193
|
+
/** Whether the input text of THIS call was truncated to `maxInputChars`. */
|
|
194
|
+
truncated: boolean
|
|
195
|
+
/** Sum of input+output tokens reported for this turn, when the adapter reported any. */
|
|
196
|
+
tokensHint?: number
|
|
197
|
+
errorCode?: AnalysisErrorCode
|
|
198
|
+
/** Diagnostic error text (never analyzed-session content). */
|
|
199
|
+
detail?: string
|
|
200
|
+
/** Honesty banner for the UI; always {@link ANALYSIS_DISCLAIMER}. */
|
|
201
|
+
disclaimer: string
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Body-free structured log entry (S8): never carries analyzed content. */
|
|
205
|
+
export interface AnalysisLogEntry {
|
|
206
|
+
op: 'create' | 'followup' | 'cancel' | 'result'
|
|
207
|
+
analysisSessionId?: string
|
|
208
|
+
kind?: AnalysisInput['kind']
|
|
209
|
+
title?: string
|
|
210
|
+
phase?: 'request' | 'followup'
|
|
211
|
+
outcome?: AnalysisOutcome
|
|
212
|
+
errorCode?: AnalysisErrorCode
|
|
213
|
+
truncated?: boolean
|
|
214
|
+
/** Size of the bounded input actually sent (chars), never its content. */
|
|
215
|
+
inputChars?: number
|
|
216
|
+
tokensHint?: number
|
|
217
|
+
/** cancel(): whether the id named a live analysis session. */
|
|
218
|
+
found?: boolean
|
|
219
|
+
elapsedMs?: number
|
|
220
|
+
detail?: string
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Tunables and fixed strings.
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/** Input bound: chars of summary/question text fed to one turn. */
|
|
228
|
+
export const DEFAULT_MAX_INPUT_CHARS = 8000
|
|
229
|
+
/** Bound on create + first response, and on each follow-up response. */
|
|
230
|
+
export const DEFAULT_ANALYSIS_TIMEOUT_MS = 60_000
|
|
231
|
+
/** Concurrent dedicated analysis sessions tracked by one engine. */
|
|
232
|
+
export const DEFAULT_MAX_ACTIVE_ANALYSES = 4
|
|
233
|
+
/** `source.plugin` attribution and analysis session id prefix. */
|
|
234
|
+
export const DEFAULT_PLUGIN_NAME = 'agent-sidecar'
|
|
235
|
+
|
|
236
|
+
/** Title chars kept in prompts and logs (titles are untrusted input too). */
|
|
237
|
+
const MAX_TITLE_CHARS = 200
|
|
238
|
+
|
|
239
|
+
/** Appended to the input text when it was cut at `maxInputChars`. */
|
|
240
|
+
export const TRUNCATION_MARKER = '\n…[输入已截断 / input truncated]'
|
|
241
|
+
|
|
242
|
+
/** Honesty banner attached to every result (design §7-B / risk 12). */
|
|
243
|
+
export const ANALYSIS_DISCLAIMER =
|
|
244
|
+
'AI 分析仅供参考,由模型基于有界摘要推断生成,可能不完整或有误 / AI-generated analysis for reference only; inferred from a bounded summary and may be incomplete or wrong.'
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Read-only-analyst guidance. `CreateAgentOptions` has no system-prompt field
|
|
248
|
+
* (d.ts fact above), so this rides the first user message.
|
|
249
|
+
*/
|
|
250
|
+
export const ANALYSIS_GUIDANCE = [
|
|
251
|
+
'你是只读分析助手:基于下面提供的 agent 会话摘要给出洞察(状态判断、异常与风险、可能的下一步建议)。',
|
|
252
|
+
'不执行任何操作、不调用任何工具、不修改任何东西;不要假设摘要之外的事实,摘要可能不完整或被截断,不确定处请如实说明。',
|
|
253
|
+
'You are a read-only analysis assistant: provide insights (state assessment, anomalies/risks, possible next steps) based solely on the agent-session summary below.',
|
|
254
|
+
'Take no actions, call no tools, change nothing; do not assume facts beyond the summary — it may be incomplete or truncated, so state uncertainty honestly.',
|
|
255
|
+
].join('\n')
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// Internals.
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
interface ActiveAnalysis {
|
|
262
|
+
analysisSessionId: string
|
|
263
|
+
kind: AnalysisInput['kind']
|
|
264
|
+
title: string
|
|
265
|
+
/** Undefined while `ctx.agents.create` is still pending (slot reservation). */
|
|
266
|
+
handle?: AnalysisSession
|
|
267
|
+
/** One turn at a time per analysis session. */
|
|
268
|
+
busy: boolean
|
|
269
|
+
/** deriveMessages() length already consumed by earlier turns. */
|
|
270
|
+
messageBaseline: number
|
|
271
|
+
/** session.events length already consumed by earlier turns. */
|
|
272
|
+
eventBaseline: number
|
|
273
|
+
/** Per-session message id counter. */
|
|
274
|
+
messageSeq: number
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function describeError(error: unknown): string {
|
|
278
|
+
return error instanceof Error ? error.message : String(error)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function boundText(
|
|
282
|
+
text: string,
|
|
283
|
+
maxChars: number,
|
|
284
|
+
): { text: string; truncated: boolean } {
|
|
285
|
+
if (text.length <= maxChars) return { text, truncated: false }
|
|
286
|
+
return { text: text.slice(0, maxChars) + TRUNCATION_MARKER, truncated: true }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function boundTitle(title: string): string {
|
|
290
|
+
return title.length <= MAX_TITLE_CHARS ? title : title.slice(0, MAX_TITLE_CHARS) + '…'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
type TimeoutRace<T> = { timedOut: false; value: T } | { timedOut: true }
|
|
294
|
+
|
|
295
|
+
/** Race a promise against a bounded timer; the timer is always cleared. */
|
|
296
|
+
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<TimeoutRace<T>> {
|
|
297
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
298
|
+
try {
|
|
299
|
+
return await Promise.race([
|
|
300
|
+
promise.then((value) => ({ timedOut: false as const, value })),
|
|
301
|
+
new Promise<{ timedOut: true }>((resolve) => {
|
|
302
|
+
timer = setTimeout(() => resolve({ timedOut: true }), Math.max(1, ms))
|
|
303
|
+
}),
|
|
304
|
+
])
|
|
305
|
+
} finally {
|
|
306
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Join the text blocks of assistant messages appended after `baseline`. */
|
|
311
|
+
function extractNewAssistantText(
|
|
312
|
+
messages: ReadonlyArray<AnalysisDerivedMessageFace>,
|
|
313
|
+
baseline: number,
|
|
314
|
+
): string {
|
|
315
|
+
const parts: string[] = []
|
|
316
|
+
for (const message of messages.slice(baseline)) {
|
|
317
|
+
if (message.role !== 'assistant') continue
|
|
318
|
+
const text = message.content
|
|
319
|
+
.filter((block) => block.type === 'text' && typeof block.text === 'string')
|
|
320
|
+
.map((block) => block.text as string)
|
|
321
|
+
.join('\n')
|
|
322
|
+
if (text.length > 0) parts.push(text)
|
|
323
|
+
}
|
|
324
|
+
return parts.join('\n\n')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Sum reported input+output tokens on new `assistant/message` events. */
|
|
328
|
+
function extractTokensHint(
|
|
329
|
+
events: ReadonlyArray<AnalysisSessionEventFace>,
|
|
330
|
+
baseline: number,
|
|
331
|
+
): number | undefined {
|
|
332
|
+
let total = 0
|
|
333
|
+
let reported = false
|
|
334
|
+
for (const event of events.slice(baseline)) {
|
|
335
|
+
if (event.type !== 'assistant/message') continue
|
|
336
|
+
const usage = (event.data as { usage?: { inputTokens?: unknown; outputTokens?: unknown } } | undefined)
|
|
337
|
+
?.usage
|
|
338
|
+
if (usage === undefined) continue
|
|
339
|
+
reported = true
|
|
340
|
+
if (typeof usage.inputTokens === 'number') total += usage.inputTokens
|
|
341
|
+
if (typeof usage.outputTokens === 'number') total += usage.outputTokens
|
|
342
|
+
}
|
|
343
|
+
return reported ? total : undefined
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
// Engine.
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
export interface AnalysisEngineDeps {
|
|
351
|
+
/** `ctx.agents.create` face (injected by the integration layer). */
|
|
352
|
+
createAgent: AnalysisAgentFace['create']
|
|
353
|
+
/** Live `analysis.enabled` gate, re-read on every call. */
|
|
354
|
+
allowAnalysis(): boolean
|
|
355
|
+
/** Structured body-free log sink. */
|
|
356
|
+
log(entry: AnalysisLogEntry): void
|
|
357
|
+
now?: () => number
|
|
358
|
+
/** Input bound per turn; default {@link DEFAULT_MAX_INPUT_CHARS}. */
|
|
359
|
+
maxInputChars?: number
|
|
360
|
+
/** Per-turn wait bound; default {@link DEFAULT_ANALYSIS_TIMEOUT_MS}. */
|
|
361
|
+
analysisTimeoutMs?: number
|
|
362
|
+
/** Concurrent session cap; default {@link DEFAULT_MAX_ACTIVE_ANALYSES}. */
|
|
363
|
+
maxActiveSessions?: number
|
|
364
|
+
/** Attribution + session id prefix; default {@link DEFAULT_PLUGIN_NAME}. */
|
|
365
|
+
pluginName?: string
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export class AnalysisEngine {
|
|
369
|
+
private readonly deps: AnalysisEngineDeps
|
|
370
|
+
private readonly now: () => number
|
|
371
|
+
private readonly maxInputChars: number
|
|
372
|
+
private readonly analysisTimeoutMs: number
|
|
373
|
+
private readonly maxActiveSessions: number
|
|
374
|
+
private readonly pluginName: string
|
|
375
|
+
private readonly active = new Map<string, ActiveAnalysis>()
|
|
376
|
+
private mintCounter = 0
|
|
377
|
+
|
|
378
|
+
constructor(deps: AnalysisEngineDeps) {
|
|
379
|
+
this.deps = deps
|
|
380
|
+
this.now = deps.now ?? Date.now
|
|
381
|
+
this.maxInputChars = deps.maxInputChars ?? DEFAULT_MAX_INPUT_CHARS
|
|
382
|
+
this.analysisTimeoutMs = deps.analysisTimeoutMs ?? DEFAULT_ANALYSIS_TIMEOUT_MS
|
|
383
|
+
this.maxActiveSessions = deps.maxActiveSessions ?? DEFAULT_MAX_ACTIVE_ANALYSES
|
|
384
|
+
this.pluginName = deps.pluginName ?? DEFAULT_PLUGIN_NAME
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Number of live (or being-created) analysis sessions. */
|
|
388
|
+
get activeCount(): number {
|
|
389
|
+
return this.active.size
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Start a dedicated analysis session and return its first insight.
|
|
394
|
+
* Establishment (create + priming prompt + first response) shares one
|
|
395
|
+
* `analysisTimeoutMs` budget; on timeout the turn is cancelled and the
|
|
396
|
+
* session disposed, so a timed-out request leaves nothing running.
|
|
397
|
+
*/
|
|
398
|
+
async request(input: AnalysisInput): Promise<AnalysisResult> {
|
|
399
|
+
const startedAt = this.now()
|
|
400
|
+
const title = boundTitle(input.title)
|
|
401
|
+
|
|
402
|
+
if (!this.deps.allowAnalysis()) {
|
|
403
|
+
return this.failResult('request', { kind: input.kind, title }, 'analysis_disabled', {
|
|
404
|
+
truncated: false,
|
|
405
|
+
startedAt,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
if (this.active.size >= this.maxActiveSessions) {
|
|
409
|
+
return this.failResult('request', { kind: input.kind, title }, 'too_many_active', {
|
|
410
|
+
truncated: false,
|
|
411
|
+
startedAt,
|
|
412
|
+
detail: `active analyses at cap (${this.maxActiveSessions})`,
|
|
413
|
+
})
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const bounded = boundText(input.summaryText, this.maxInputChars)
|
|
417
|
+
const analysisSessionId = this.mintSessionId()
|
|
418
|
+
const entry: ActiveAnalysis = {
|
|
419
|
+
analysisSessionId,
|
|
420
|
+
kind: input.kind,
|
|
421
|
+
title,
|
|
422
|
+
busy: true,
|
|
423
|
+
messageBaseline: 0,
|
|
424
|
+
eventBaseline: 0,
|
|
425
|
+
messageSeq: 0,
|
|
426
|
+
}
|
|
427
|
+
// Reserve the slot before awaiting create so concurrent requests cannot
|
|
428
|
+
// overshoot the cap.
|
|
429
|
+
this.active.set(analysisSessionId, entry)
|
|
430
|
+
|
|
431
|
+
const deadline = startedAt + this.analysisTimeoutMs
|
|
432
|
+
const controller = new AbortController()
|
|
433
|
+
let createTimedOut = false
|
|
434
|
+
const createPromise = this.deps.createAgent({
|
|
435
|
+
sessionId: analysisSessionId,
|
|
436
|
+
signal: controller.signal,
|
|
437
|
+
})
|
|
438
|
+
// A late settle after timeout must neither leak a running agent nor
|
|
439
|
+
// surface an unhandled rejection.
|
|
440
|
+
void createPromise.then(
|
|
441
|
+
(late) => {
|
|
442
|
+
if (createTimedOut) void late.dispose().catch(() => {})
|
|
443
|
+
},
|
|
444
|
+
() => {},
|
|
445
|
+
)
|
|
446
|
+
let handle: AnalysisSession
|
|
447
|
+
try {
|
|
448
|
+
const created = await withTimeout(createPromise, deadline - this.now())
|
|
449
|
+
if (created.timedOut) {
|
|
450
|
+
createTimedOut = true
|
|
451
|
+
this.active.delete(analysisSessionId)
|
|
452
|
+
controller.abort()
|
|
453
|
+
return this.timeoutResult('request', entry, bounded.truncated, startedAt, undefined)
|
|
454
|
+
}
|
|
455
|
+
handle = created.value
|
|
456
|
+
} catch (error) {
|
|
457
|
+
this.active.delete(analysisSessionId)
|
|
458
|
+
return this.failResult('request', entry, 'create_failed', {
|
|
459
|
+
truncated: bounded.truncated,
|
|
460
|
+
startedAt,
|
|
461
|
+
detail: describeError(error),
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
entry.handle = handle
|
|
465
|
+
|
|
466
|
+
this.deps.log({
|
|
467
|
+
op: 'create',
|
|
468
|
+
analysisSessionId,
|
|
469
|
+
kind: input.kind,
|
|
470
|
+
title,
|
|
471
|
+
truncated: bounded.truncated,
|
|
472
|
+
inputChars: bounded.text.length,
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
const prompt = this.buildInitialPrompt(input.kind, title, bounded)
|
|
476
|
+
const turn = await this.runTurn(entry, prompt, deadline)
|
|
477
|
+
entry.busy = false
|
|
478
|
+
|
|
479
|
+
if (turn.status === 'threw') {
|
|
480
|
+
// The session never produced a first insight: fold into create_failed
|
|
481
|
+
// and clean up (nothing valuable to keep).
|
|
482
|
+
this.active.delete(analysisSessionId)
|
|
483
|
+
await this.disposeQuietly(entry)
|
|
484
|
+
return this.failResult('request', entry, 'create_failed', {
|
|
485
|
+
truncated: bounded.truncated,
|
|
486
|
+
startedAt,
|
|
487
|
+
detail: turn.detail,
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
if (turn.status === 'timeout') {
|
|
491
|
+
// Bound token burn: cancel the in-flight turn AND dispose the session —
|
|
492
|
+
// a request that never answered holds no reusable context.
|
|
493
|
+
this.cancelQuietly(entry)
|
|
494
|
+
this.active.delete(analysisSessionId)
|
|
495
|
+
await this.disposeQuietly(entry)
|
|
496
|
+
return this.timeoutResult('request', entry, bounded.truncated, startedAt, undefined)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const result: AnalysisResult = {
|
|
500
|
+
outcome: 'completed',
|
|
501
|
+
analysisSessionId,
|
|
502
|
+
summary: turn.summary,
|
|
503
|
+
truncated: bounded.truncated,
|
|
504
|
+
...(turn.tokensHint !== undefined ? { tokensHint: turn.tokensHint } : {}),
|
|
505
|
+
disclaimer: ANALYSIS_DISCLAIMER,
|
|
506
|
+
}
|
|
507
|
+
this.logResult('request', entry, result, startedAt)
|
|
508
|
+
return result
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Ask an incremental follow-up question on an established analysis session.
|
|
513
|
+
* A timeout cancels the in-flight turn but KEEPS the session (its prior
|
|
514
|
+
* context stays valuable; the UI may retry or cancel).
|
|
515
|
+
*/
|
|
516
|
+
async followup(analysisSessionId: string, question: string): Promise<AnalysisResult> {
|
|
517
|
+
const startedAt = this.now()
|
|
518
|
+
const entry = this.active.get(analysisSessionId)
|
|
519
|
+
|
|
520
|
+
if (!this.deps.allowAnalysis()) {
|
|
521
|
+
return this.failResult('followup', entry ?? { analysisSessionId }, 'analysis_disabled', {
|
|
522
|
+
truncated: false,
|
|
523
|
+
startedAt,
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
if (entry === undefined || entry.handle === undefined) {
|
|
527
|
+
return this.failResult('followup', { analysisSessionId }, 'cancelled', {
|
|
528
|
+
truncated: false,
|
|
529
|
+
startedAt,
|
|
530
|
+
detail: 'unknown or already-cancelled analysis session',
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
if (entry.busy) {
|
|
534
|
+
return this.failResult('followup', entry, 'too_many_active', {
|
|
535
|
+
truncated: false,
|
|
536
|
+
startedAt,
|
|
537
|
+
detail: 'a turn is already in flight on this analysis session',
|
|
538
|
+
})
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const bounded = boundText(question, this.maxInputChars)
|
|
542
|
+
this.deps.log({
|
|
543
|
+
op: 'followup',
|
|
544
|
+
analysisSessionId,
|
|
545
|
+
kind: entry.kind,
|
|
546
|
+
title: entry.title,
|
|
547
|
+
truncated: bounded.truncated,
|
|
548
|
+
inputChars: bounded.text.length,
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
entry.busy = true
|
|
552
|
+
try {
|
|
553
|
+
const turn = await this.runTurn(entry, bounded.text, startedAt + this.analysisTimeoutMs)
|
|
554
|
+
if (turn.status === 'threw') {
|
|
555
|
+
// The live agent rejected the splice/wait — it is gone underneath us.
|
|
556
|
+
this.active.delete(analysisSessionId)
|
|
557
|
+
await this.disposeQuietly(entry)
|
|
558
|
+
return this.failResult('followup', entry, 'cancelled', {
|
|
559
|
+
truncated: bounded.truncated,
|
|
560
|
+
startedAt,
|
|
561
|
+
detail: turn.detail,
|
|
562
|
+
})
|
|
563
|
+
}
|
|
564
|
+
if (turn.status === 'timeout') {
|
|
565
|
+
this.cancelQuietly(entry)
|
|
566
|
+
return this.timeoutResult('followup', entry, bounded.truncated, startedAt, analysisSessionId)
|
|
567
|
+
}
|
|
568
|
+
const result: AnalysisResult = {
|
|
569
|
+
outcome: 'completed',
|
|
570
|
+
analysisSessionId,
|
|
571
|
+
summary: turn.summary,
|
|
572
|
+
truncated: bounded.truncated,
|
|
573
|
+
...(turn.tokensHint !== undefined ? { tokensHint: turn.tokensHint } : {}),
|
|
574
|
+
disclaimer: ANALYSIS_DISCLAIMER,
|
|
575
|
+
}
|
|
576
|
+
this.logResult('followup', entry, result, startedAt)
|
|
577
|
+
return result
|
|
578
|
+
} finally {
|
|
579
|
+
entry.busy = false
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Stop and dispose one analysis session (UI stop button). Idempotent: an
|
|
585
|
+
* unknown id resolves as a logged no-op.
|
|
586
|
+
*/
|
|
587
|
+
async cancel(analysisSessionId: string): Promise<void> {
|
|
588
|
+
const entry = this.active.get(analysisSessionId)
|
|
589
|
+
if (entry === undefined) {
|
|
590
|
+
this.deps.log({ op: 'cancel', analysisSessionId, found: false })
|
|
591
|
+
return
|
|
592
|
+
}
|
|
593
|
+
this.active.delete(analysisSessionId)
|
|
594
|
+
this.cancelQuietly(entry)
|
|
595
|
+
await this.disposeQuietly(entry)
|
|
596
|
+
this.deps.log({
|
|
597
|
+
op: 'cancel',
|
|
598
|
+
analysisSessionId,
|
|
599
|
+
kind: entry.kind,
|
|
600
|
+
title: entry.title,
|
|
601
|
+
found: true,
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// -------------------------------------------------------------------------
|
|
606
|
+
// Turn driving.
|
|
607
|
+
// -------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
private async runTurn(
|
|
610
|
+
entry: ActiveAnalysis,
|
|
611
|
+
text: string,
|
|
612
|
+
deadline: number,
|
|
613
|
+
): Promise<
|
|
614
|
+
| { status: 'completed'; summary: string; tokensHint?: number }
|
|
615
|
+
| { status: 'timeout' }
|
|
616
|
+
| { status: 'threw'; detail: string }
|
|
617
|
+
> {
|
|
618
|
+
const handle = entry.handle!
|
|
619
|
+
const session = handle.agent.session
|
|
620
|
+
entry.messageBaseline = session.deriveMessages().length
|
|
621
|
+
entry.eventBaseline = session.events.length
|
|
622
|
+
|
|
623
|
+
const message: AnalysisUserMessageFace = {
|
|
624
|
+
id: `${entry.analysisSessionId}-msg-${++entry.messageSeq}`,
|
|
625
|
+
role: 'user',
|
|
626
|
+
content: [{ type: 'text', text }],
|
|
627
|
+
source: { kind: 'plugin', plugin: this.pluginName },
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
try {
|
|
631
|
+
// Synchronous inbox splice; waking delivery flips status synchronously,
|
|
632
|
+
// so the subsequent whenIdle() observes this turn (d.ts facts above).
|
|
633
|
+
handle.agent.followup(message)
|
|
634
|
+
} catch (error) {
|
|
635
|
+
return { status: 'threw', detail: describeError(error) }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let idle: TimeoutRace<void>
|
|
639
|
+
try {
|
|
640
|
+
idle = await withTimeout(handle.agent.whenIdle(), deadline - this.now())
|
|
641
|
+
} catch (error) {
|
|
642
|
+
return { status: 'threw', detail: describeError(error) }
|
|
643
|
+
}
|
|
644
|
+
if (idle.timedOut) return { status: 'timeout' }
|
|
645
|
+
|
|
646
|
+
const summary = extractNewAssistantText(session.deriveMessages(), entry.messageBaseline)
|
|
647
|
+
const tokensHint = extractTokensHint(session.events, entry.eventBaseline)
|
|
648
|
+
entry.messageBaseline = session.deriveMessages().length
|
|
649
|
+
entry.eventBaseline = session.events.length
|
|
650
|
+
return {
|
|
651
|
+
status: 'completed',
|
|
652
|
+
summary,
|
|
653
|
+
...(tokensHint !== undefined ? { tokensHint } : {}),
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
private buildInitialPrompt(
|
|
658
|
+
kind: AnalysisInput['kind'],
|
|
659
|
+
title: string,
|
|
660
|
+
bounded: { text: string; truncated: boolean },
|
|
661
|
+
): string {
|
|
662
|
+
return [
|
|
663
|
+
ANALYSIS_GUIDANCE,
|
|
664
|
+
'',
|
|
665
|
+
`[分析对象 / subject] kind=${kind} title=${title}`,
|
|
666
|
+
'',
|
|
667
|
+
`--- 会话摘要开始 / summary begin (有界输入${bounded.truncated ? ',已截断 / truncated' : ''}) ---`,
|
|
668
|
+
bounded.text,
|
|
669
|
+
'--- 会话摘要结束 / summary end ---',
|
|
670
|
+
].join('\n')
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// -------------------------------------------------------------------------
|
|
674
|
+
// Cleanup and result/log helpers.
|
|
675
|
+
// -------------------------------------------------------------------------
|
|
676
|
+
|
|
677
|
+
private cancelQuietly(entry: ActiveAnalysis): void {
|
|
678
|
+
try {
|
|
679
|
+
entry.handle?.agent.cancel({ kind: 'user' })
|
|
680
|
+
} catch (error) {
|
|
681
|
+
this.deps.log({
|
|
682
|
+
op: 'cancel',
|
|
683
|
+
analysisSessionId: entry.analysisSessionId,
|
|
684
|
+
found: true,
|
|
685
|
+
detail: `cancel threw: ${describeError(error)}`,
|
|
686
|
+
})
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private async disposeQuietly(entry: ActiveAnalysis): Promise<void> {
|
|
691
|
+
if (entry.handle === undefined) return
|
|
692
|
+
try {
|
|
693
|
+
await entry.handle.dispose()
|
|
694
|
+
} catch (error) {
|
|
695
|
+
this.deps.log({
|
|
696
|
+
op: 'cancel',
|
|
697
|
+
analysisSessionId: entry.analysisSessionId,
|
|
698
|
+
found: true,
|
|
699
|
+
detail: `dispose threw: ${describeError(error)}`,
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
private mintSessionId(): string {
|
|
705
|
+
return `${this.pluginName}-analysis-${this.now().toString(36)}-${++this.mintCounter}`
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private failResult(
|
|
709
|
+
phase: 'request' | 'followup',
|
|
710
|
+
ident: Partial<Pick<ActiveAnalysis, 'analysisSessionId' | 'kind' | 'title'>>,
|
|
711
|
+
errorCode: AnalysisErrorCode,
|
|
712
|
+
opts: { truncated: boolean; startedAt: number; detail?: string },
|
|
713
|
+
): AnalysisResult {
|
|
714
|
+
const result: AnalysisResult = {
|
|
715
|
+
outcome: 'failed',
|
|
716
|
+
truncated: opts.truncated,
|
|
717
|
+
errorCode,
|
|
718
|
+
...(opts.detail !== undefined ? { detail: opts.detail } : {}),
|
|
719
|
+
disclaimer: ANALYSIS_DISCLAIMER,
|
|
720
|
+
}
|
|
721
|
+
this.deps.log({
|
|
722
|
+
op: 'result',
|
|
723
|
+
phase,
|
|
724
|
+
...(ident.analysisSessionId !== undefined
|
|
725
|
+
? { analysisSessionId: ident.analysisSessionId }
|
|
726
|
+
: {}),
|
|
727
|
+
...(ident.kind !== undefined ? { kind: ident.kind } : {}),
|
|
728
|
+
...(ident.title !== undefined ? { title: ident.title } : {}),
|
|
729
|
+
outcome: 'failed',
|
|
730
|
+
errorCode,
|
|
731
|
+
...(opts.detail !== undefined ? { detail: opts.detail } : {}),
|
|
732
|
+
elapsedMs: this.now() - opts.startedAt,
|
|
733
|
+
})
|
|
734
|
+
return result
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private timeoutResult(
|
|
738
|
+
phase: 'request' | 'followup',
|
|
739
|
+
entry: ActiveAnalysis,
|
|
740
|
+
truncated: boolean,
|
|
741
|
+
startedAt: number,
|
|
742
|
+
analysisSessionId: string | undefined,
|
|
743
|
+
): AnalysisResult {
|
|
744
|
+
const result: AnalysisResult = {
|
|
745
|
+
outcome: 'timeout',
|
|
746
|
+
...(analysisSessionId !== undefined ? { analysisSessionId } : {}),
|
|
747
|
+
truncated,
|
|
748
|
+
errorCode: 'timeout',
|
|
749
|
+
disclaimer: ANALYSIS_DISCLAIMER,
|
|
750
|
+
}
|
|
751
|
+
this.deps.log({
|
|
752
|
+
op: 'result',
|
|
753
|
+
phase,
|
|
754
|
+
analysisSessionId: entry.analysisSessionId,
|
|
755
|
+
kind: entry.kind,
|
|
756
|
+
title: entry.title,
|
|
757
|
+
outcome: 'timeout',
|
|
758
|
+
errorCode: 'timeout',
|
|
759
|
+
elapsedMs: this.now() - startedAt,
|
|
760
|
+
})
|
|
761
|
+
return result
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private logResult(
|
|
765
|
+
phase: 'request' | 'followup',
|
|
766
|
+
entry: ActiveAnalysis,
|
|
767
|
+
result: AnalysisResult,
|
|
768
|
+
startedAt: number,
|
|
769
|
+
): void {
|
|
770
|
+
this.deps.log({
|
|
771
|
+
op: 'result',
|
|
772
|
+
phase,
|
|
773
|
+
analysisSessionId: entry.analysisSessionId,
|
|
774
|
+
kind: entry.kind,
|
|
775
|
+
title: entry.title,
|
|
776
|
+
outcome: result.outcome,
|
|
777
|
+
...(result.tokensHint !== undefined ? { tokensHint: result.tokensHint } : {}),
|
|
778
|
+
truncated: result.truncated,
|
|
779
|
+
elapsedMs: this.now() - startedAt,
|
|
780
|
+
})
|
|
781
|
+
}
|
|
782
|
+
}
|