dsh-plugin-show-me-data 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 (130) hide show
  1. package/LICENSE +27 -0
  2. package/README.md +96 -0
  3. package/cordis.patch.yml +40 -0
  4. package/docs/01-product-effect.md +178 -0
  5. package/docs/02-architecture.md +275 -0
  6. package/docs/03-data-contracts.md +291 -0
  7. package/docs/04-sources.md +342 -0
  8. package/docs/05-ui-spec.md +167 -0
  9. package/docs/06-ai-layer.md +194 -0
  10. package/docs/07-implementation-plan.md +399 -0
  11. package/docs/08-test-plan.md +133 -0
  12. package/docs/09-packaging-install.md +249 -0
  13. package/docs/10-kickoff-prompt.md +94 -0
  14. package/docs/11-decisions.md +203 -0
  15. package/docs/12-runtime-verified.md +115 -0
  16. package/docs/13-acceptance.md +153 -0
  17. package/docs/14-progress.md +150 -0
  18. package/docs/15-publish.md +185 -0
  19. package/lib/app/ai-deterministic.js +327 -0
  20. package/lib/app/ai-validate.js +284 -0
  21. package/lib/app/ai.js +440 -0
  22. package/lib/app/health.js +77 -0
  23. package/lib/app/overview.js +349 -0
  24. package/lib/app/propose-indicator.js +122 -0
  25. package/lib/app/refresh.js +251 -0
  26. package/lib/app/series-view.js +195 -0
  27. package/lib/app/watchlist.js +102 -0
  28. package/lib/client.js +4322 -0
  29. package/lib/core/ai/prompts.js +213 -0
  30. package/lib/core/chart/axis.js +133 -0
  31. package/lib/core/chart/bar.js +58 -0
  32. package/lib/core/chart/candle.js +216 -0
  33. package/lib/core/chart/line.js +186 -0
  34. package/lib/core/chart/scale.js +132 -0
  35. package/lib/core/format.js +143 -0
  36. package/lib/core/indicators/catalog.js +1011 -0
  37. package/lib/core/indicators/resolve.js +196 -0
  38. package/lib/core/insight/digest.js +250 -0
  39. package/lib/core/insight/rank.js +115 -0
  40. package/lib/core/insight/related.js +90 -0
  41. package/lib/core/insight/rules.js +417 -0
  42. package/lib/core/stats/derive.js +123 -0
  43. package/lib/core/stats/series.js +465 -0
  44. package/lib/core/time/range.js +242 -0
  45. package/lib/core/types.js +478 -0
  46. package/lib/host/ai/discussion.js +559 -0
  47. package/lib/host/ai/dsh-llm-gateway.js +333 -0
  48. package/lib/host/config.js +194 -0
  49. package/lib/host/http/respond.js +165 -0
  50. package/lib/host/http/routes.js +689 -0
  51. package/lib/host/index.js +293 -0
  52. package/lib/host/infra/fs-repos.js +179 -0
  53. package/lib/host/infra/memory-fallback.js +64 -0
  54. package/lib/host/tools/define-tool.js +295 -0
  55. package/lib/host/tools/register.js +431 -0
  56. package/lib/host.js +7 -0
  57. package/lib/ports/clock.js +57 -0
  58. package/lib/ports/snapshot-repo.js +48 -0
  59. package/lib/sources/eastmoney-macro.js +197 -0
  60. package/lib/sources/eastmoney-quote.js +201 -0
  61. package/lib/sources/ecb.js +179 -0
  62. package/lib/sources/fred.js +207 -0
  63. package/lib/sources/http.js +136 -0
  64. package/lib/sources/ohlc.js +36 -0
  65. package/lib/sources/quote-cascade.js +177 -0
  66. package/lib/sources/registry.js +153 -0
  67. package/lib/sources/sina-cn.js +197 -0
  68. package/lib/sources/sina-us.js +187 -0
  69. package/lib/sources/tencent.js +158 -0
  70. package/lib/sources/us-treasury-rates.js +275 -0
  71. package/lib/sources/us-treasury.js +196 -0
  72. package/lib/sources/worldbank.js +170 -0
  73. package/package.json +69 -0
  74. package/src/app/ai-deterministic.js +327 -0
  75. package/src/app/ai-validate.js +284 -0
  76. package/src/app/ai.js +440 -0
  77. package/src/app/health.js +77 -0
  78. package/src/app/overview.js +349 -0
  79. package/src/app/propose-indicator.js +122 -0
  80. package/src/app/refresh.js +251 -0
  81. package/src/app/series-view.js +195 -0
  82. package/src/app/watchlist.js +102 -0
  83. package/src/client/api.js +323 -0
  84. package/src/client/components.js +1877 -0
  85. package/src/client/copy.js +368 -0
  86. package/src/client/index.js +169 -0
  87. package/src/client/store.js +219 -0
  88. package/src/core/ai/prompts.js +213 -0
  89. package/src/core/chart/axis.js +133 -0
  90. package/src/core/chart/bar.js +58 -0
  91. package/src/core/chart/candle.js +216 -0
  92. package/src/core/chart/line.js +186 -0
  93. package/src/core/chart/scale.js +132 -0
  94. package/src/core/format.js +143 -0
  95. package/src/core/indicators/catalog.js +1011 -0
  96. package/src/core/indicators/resolve.js +196 -0
  97. package/src/core/insight/digest.js +250 -0
  98. package/src/core/insight/rank.js +115 -0
  99. package/src/core/insight/related.js +90 -0
  100. package/src/core/insight/rules.js +417 -0
  101. package/src/core/stats/derive.js +123 -0
  102. package/src/core/stats/series.js +465 -0
  103. package/src/core/time/range.js +242 -0
  104. package/src/core/types.js +478 -0
  105. package/src/host/ai/discussion.js +559 -0
  106. package/src/host/ai/dsh-llm-gateway.js +333 -0
  107. package/src/host/config.js +194 -0
  108. package/src/host/http/respond.js +165 -0
  109. package/src/host/http/routes.js +689 -0
  110. package/src/host/index.js +293 -0
  111. package/src/host/infra/fs-repos.js +179 -0
  112. package/src/host/infra/memory-fallback.js +64 -0
  113. package/src/host/tools/define-tool.js +295 -0
  114. package/src/host/tools/register.js +431 -0
  115. package/src/ports/clock.js +57 -0
  116. package/src/ports/snapshot-repo.js +48 -0
  117. package/src/sources/eastmoney-macro.js +197 -0
  118. package/src/sources/eastmoney-quote.js +201 -0
  119. package/src/sources/ecb.js +179 -0
  120. package/src/sources/fred.js +207 -0
  121. package/src/sources/http.js +136 -0
  122. package/src/sources/ohlc.js +36 -0
  123. package/src/sources/quote-cascade.js +177 -0
  124. package/src/sources/registry.js +153 -0
  125. package/src/sources/sina-cn.js +197 -0
  126. package/src/sources/sina-us.js +187 -0
  127. package/src/sources/tencent.js +158 -0
  128. package/src/sources/us-treasury-rates.js +275 -0
  129. package/src/sources/us-treasury.js +196 -0
  130. package/src/sources/worldbank.js +170 -0
@@ -0,0 +1,559 @@
1
+ /**
2
+ * Discussion sessions — "talk about this indicator in a real DSH session".
3
+ *
4
+ * The panel's inline AI block answers one question from the digest and stops
5
+ * there: it cannot hold a conversation, change model, or be returned to later.
6
+ * When the deployment has an agent loop (`ctx.agents`) and a default model, this
7
+ * gateway opens a **real session** for the topic instead, hands it the panel's
8
+ * digest as context, asks the user's question, and returns the session id so the
9
+ * GUI can switch to it. From that point the conversation is an ordinary session:
10
+ * full composer, history, model picker, sidebar entry.
11
+ *
12
+ * Design notes:
13
+ * - every contribution is additive: the digest arrives as *context* (no wake), so
14
+ * the session's first user-visible message is the user's own question;
15
+ * - the caller can ask for a fresh session per question or reuse a topic session;
16
+ * - the gateway never throws for a missing capability — it returns a structured
17
+ * `unavailable` result so the panel can say what to do instead.
18
+ *
19
+ * @module host/ai/discussion
20
+ */
21
+ import { fingerprint } from '../../core/insight/rank.js'
22
+ import { buildDefaultQuestion } from '../../core/ai/prompts.js'
23
+
24
+ /** How long to wait for the seeded answer before returning with `pending: true`. */
25
+ export const REPLY_TIMEOUT_MS = 45_000
26
+
27
+ /**
28
+ * Build one identified user-role message for the session.
29
+ *
30
+ * @param {string} text - message text.
31
+ * @param {{ kind: 'plugin', plugin: string }|{ kind: 'user' }} source - producer tag.
32
+ * @param {string} seed - identity seed.
33
+ * @returns {object} `UserMessage`.
34
+ */
35
+ export function buildMessage(text, source, seed) {
36
+ return {
37
+ id: `smd-${fingerprint(`${seed}|${text.length}|${text.slice(0, 64)}`)}`,
38
+ role: 'user',
39
+ content: [{ type: 'text', text }],
40
+ source,
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Compose the context block that gives a fresh session the panel's data.
46
+ *
47
+ * The session is a *real* DSH session with the plugin's own `data_*` tools, so the
48
+ * context has two jobs: publish the numbers behind the discussion (so nothing is
49
+ * quoted from memory), and say what else the panel holds — a session that believes
50
+ * the panel has one indicator refuses questions it could have answered.
51
+ *
52
+ * @param {object} input - context input.
53
+ * @param {{ zh: string, en?: string }} input.label - indicator label.
54
+ * @param {string} input.indicatorId - indicator id.
55
+ * @param {string} input.digest - the digest text (docs/06 §2.3).
56
+ * @param {{ from: string, to: string }} [input.range] - the panel's range.
57
+ * @param {string} [input.inventory] - one-line-per-indicator inventory of the rest of the panel.
58
+ * @returns {string} context text.
59
+ */
60
+ export function buildContextText({ label, indicatorId, digest, range, inventory }) {
61
+ const lines = []
62
+ lines.push('[数据雷达] 本会话的上下文来自插件面板,不是用户输入。')
63
+ lines.push(`讨论主题:${label?.zh ?? indicatorId}(${indicatorId})`)
64
+ if (range !== undefined) lines.push(`面板区间:${range.from} .. ${range.to}`)
65
+ lines.push('')
66
+ lines.push('只引用面板给出的数字(下面的摘要,或你用面板工具取回的数据);不足时说明数据不足,不要凭记忆补充数值:')
67
+ lines.push('')
68
+ // An empty digest is "no data", not an empty block: the session must be told
69
+ // that the panel had nothing, or it will invent context.
70
+ const body = typeof digest === 'string' ? digest.trim() : ''
71
+ lines.push(body === '' ? '(无数据:面板当前没有可用的指标观测)' : body)
72
+ const rest = typeof inventory === 'string' ? inventory.trim() : ''
73
+ if (rest !== '') {
74
+ lines.push('')
75
+ lines.push(rest)
76
+ }
77
+ lines.push('')
78
+ lines.push('你可以直接用面板工具向数据雷达取数:data_search(搜索指标)、data_series(取某指标序列)、data_overview(面板总览)、data_digest(区间摘要)、data_health(数据源状态)。清单中的任何 id 都可以用 data_series 取完整序列。')
79
+ return lines.join('\n')
80
+ }
81
+
82
+ /**
83
+ * Read the assistant's text out of a session's event log.
84
+ *
85
+ * @param {Array<{ type?: string, data?: any }>} events - session events.
86
+ * @returns {{ text: string, turn: number|undefined }} the last assistant text.
87
+ */
88
+ export function extractAssistantText(events) {
89
+ let text = ''
90
+ let turn
91
+ for (const event of events ?? []) {
92
+ if (event?.type !== 'assistant/message') continue
93
+ const blocks = event.data?.message?.content
94
+ if (!Array.isArray(blocks)) continue
95
+ const joined = blocks
96
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
97
+ .map((block) => block.text)
98
+ .join('')
99
+ .trim()
100
+ if (joined === '') continue
101
+ text = joined
102
+ turn = event.data?.turn
103
+ }
104
+ return { text, turn }
105
+ }
106
+
107
+ /**
108
+ * Detect a turn that *failed* rather than one that simply had nothing to say.
109
+ *
110
+ * An LLM failure (a missing credential, an upstream 5xx, a refused request)
111
+ * does not reject `whenIdle()`: the loop records it and goes idle. A gateway
112
+ * that only reads text therefore reports success with an empty reply, and the
113
+ * GUI shows an empty answer as if the model had answered nothing — which is
114
+ * exactly how "why is there no output" becomes unanswerable from the panel.
115
+ *
116
+ * @param {Array<{ type?: string, data?: any }>} events - events of the observed turn.
117
+ * @returns {{ message: string, code: string|undefined }|undefined} the failure.
118
+ */
119
+ export function extractTurnFailure(events) {
120
+ let failure
121
+ for (const event of events ?? []) {
122
+ // Preferred source: the terminating chunk names provider, code and message.
123
+ if (event?.type === 'assistant/chunk' && event.data?.chunk?.type === 'finish') {
124
+ const reason = event.data.chunk.reason
125
+ if (reason?.kind === 'error' && typeof reason.failure?.message === 'string') {
126
+ failure = { message: reason.failure.message, code: reason.failure.code }
127
+ }
128
+ if (reason?.kind === 'aborted') {
129
+ failure = { message: '模型请求被中止(aborted)', code: reason.failure?.code }
130
+ }
131
+ continue
132
+ }
133
+ if (event?.type !== 'turn/end') continue
134
+ const reason = event.data?.reason
135
+ if (reason?.kind !== 'error' && reason?.kind !== 'aborted') continue
136
+ const detail = reason.error ?? reason.failure ?? {}
137
+ const message = typeof detail.message === 'string'
138
+ ? detail.message
139
+ : reason.kind === 'aborted' ? '模型请求被中止(aborted)' : 'turn ended with error'
140
+ failure = { message, code: detail.code }
141
+ }
142
+ return failure
143
+ }
144
+
145
+ /**
146
+ * Create the discussion gateway.
147
+ *
148
+ * @param {object} deps - dependencies.
149
+ * @param {object} deps.ctx - host context (for `agents`, `sessions`, `agentDefaultModel`).
150
+ * @param {import('../../ports/clock.js').Clock} deps.clock - clock.
151
+ * @param {(msg: string, meta?: object) => void} [deps.log] - logger.
152
+ * @param {{ timeoutMs?: number }} [deps.options] - tuning.
153
+ * @returns {object} gateway.
154
+ */
155
+ export function createDiscussionGateway({ ctx, clock, log = () => {}, options = {} }) {
156
+ const timeoutMs = options.timeoutMs ?? REPLY_TIMEOUT_MS
157
+ const cwd = options.cwd
158
+ // A monotonically increasing counter keeps two discussions opened in the same
159
+ // millisecond from colliding on one session id (the clock alone is not unique).
160
+ let sequence = 0
161
+ /** @type {Map<string, { sessionId: string, createdAt: string, label: string, turns: number }>} */
162
+ const topics = new Map()
163
+ /** @type {Map<string, object>} */
164
+ const agentsByTopic = new Map()
165
+ /** @type {Map<string, { agent: object, askedAt: number, from: number }>} */
166
+ const bySession = new Map()
167
+ /** @type {Array<() => Promise<void>>} */
168
+ const live = []
169
+
170
+ /**
171
+ * Whether this deployment can open sessions at all.
172
+ *
173
+ * @returns {{ available: boolean, reason?: string }} capability.
174
+ */
175
+ function capability() {
176
+ if (ctx.get('agents') === undefined) return { available: false, reason: '本部署没有 agent 运行时(ctx.agents 不可用),无法开启独立会话。' }
177
+ if (ctx.get('agentDefaultModel') === undefined) return { available: false, reason: '本部署没有默认模型选择(ctx.agentDefaultModel 不可用),无法开启独立会话。' }
178
+ return { available: true }
179
+ }
180
+
181
+ /**
182
+ * Compose the session metadata.
183
+ *
184
+ * A discussion is a **user-visible session of its own**, not a delegated
185
+ * subagent: it carries the workspace and the preset but no parent lineage, so
186
+ * it appears in the sidebar beside other sessions and can be reopened later.
187
+ *
188
+ * @param {object} [requester] - the requesting *agent*, when the caller knows it.
189
+ * @param {string} [overrideCwd] - explicit workspace for the new session.
190
+ * @returns {object} session meta.
191
+ */
192
+ function sessionMeta(requester, overrideCwd) {
193
+ const requesterCwd = requester?.session?.header?.cwd
194
+ const sessionCwd = typeof overrideCwd === 'string' && overrideCwd !== ''
195
+ ? overrideCwd
196
+ : typeof requesterCwd === 'string' && requesterCwd !== '' ? requesterCwd : cwd
197
+ // Read from the requesting *agent's* scope, not from a session object: the
198
+ // standing mount that answers "which preset is this?" lives on the agent ctx.
199
+ const preset = requester?.ctx === undefined ? undefined : ctx.get('agentPresets')?.composedPreset?.(requester.ctx)
200
+ return {
201
+ ...(typeof sessionCwd === 'string' && sessionCwd !== '' ? { cwd: sessionCwd } : {}),
202
+ ...(typeof preset === 'string' && preset !== '' ? { agentPreset: preset } : {}),
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Join the new agent to a composition, so it is not born into an empty world.
208
+ *
209
+ * An agent that joins no preset resolves its tools, prompt sections and skills
210
+ * against the empty global layer: the session sees only the tools registered on
211
+ * the host composition — this plugin's ten `data_*` tools — with no filesystem,
212
+ * no shell, no skills. An iteration session created that way told the reader it
213
+ * *could not edit the plugin* because it had no file tools, which is true.
214
+ *
215
+ * The requester's own composition is joined when the caller named the
216
+ * requesting session (the panel sends it), so the discussion runs on the same
217
+ * preset, tools and prompt the reader is already using. Otherwise the
218
+ * deployment's default preset is mounted.
219
+ *
220
+ * A failure here is logged and swallowed: composition is worth having, but it
221
+ * is never worth losing the session the reader asked for.
222
+ *
223
+ * @param {object} [requester] - the requesting agent, when known.
224
+ * @returns {((agentCtx: object) => Promise<void>)|undefined} setup hook.
225
+ */
226
+ function compositionSetup(requester) {
227
+ const presets = ctx.get('agentPresets')
228
+ if (presets === undefined) return undefined
229
+ if (typeof presets.composeFrom !== 'function' && typeof presets.mount !== 'function') return undefined
230
+ return async (agentCtx) => {
231
+ try {
232
+ if (requester?.ctx !== undefined && typeof presets.composeFrom === 'function') {
233
+ const joined = presets.composeFrom(agentCtx, requester.ctx)
234
+ if (joined !== undefined) {
235
+ log('discussion joined the requesting composition', { preset: joined })
236
+ return
237
+ }
238
+ }
239
+ if (typeof presets.mount === 'function') {
240
+ const mounted = await presets.mount(agentCtx)
241
+ log('discussion mounted the default composition', { preset: mounted?.id ?? presets.defaultId })
242
+ }
243
+ } catch (error) {
244
+ log('discussion joined no composition', { message: error?.message ?? String(error) })
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Open a discussion about one subject: an indicator, a notable item, or a group.
251
+ *
252
+ * The subject is identified by `topicKey` ('us.cpi.yoy', 'group:US', …) which
253
+ * becomes the session id and the reuse key; `indicatorId` alone still works, so
254
+ * the single-indicator path is unchanged. Only the *context* differs between
255
+ * callers, and the caller builds that — this gateway never guesses what a
256
+ * discussion is about.
257
+ *
258
+ * @param {object} request - request.
259
+ * @param {string} [request.indicatorId] - indicator id (the subject, when it is one).
260
+ * @param {string} [request.topicKey] - subject key; defaults to `indicatorId`.
261
+ * @param {{ zh: string, en?: string }} [request.label] - subject label.
262
+ * @param {string} request.digest - digest text handed to the session as context.
263
+ * @param {string} [request.question] - the user's first question.
264
+ * @param {{ from: string, to: string }} [request.range] - panel range.
265
+ * @param {'new'|'topic'} [request.mode] - a fresh session each time (`new`, default) or one session per subject (`topic`).
266
+ * @param {string} [request.sessionId] - the requesting session, for cwd/preset/tool inheritance.
267
+ * @param {string} [request.cwd] - explicit working directory for the new session
268
+ * (an iteration session has to start in the plugin workspace, not wherever the
269
+ * requesting session happens to be).
270
+ * @param {boolean} [request.ask] - `false` seeds the session and asks nothing.
271
+ * @returns {Promise<object>} `{ ok, sessionId, reply, pending, mode, error? }`.
272
+ */
273
+ async function discuss(request = {}) {
274
+ const capabilityState = capability()
275
+ if (capabilityState.available !== true) {
276
+ return { ok: false, error: { kind: 'unavailable', detail: capabilityState.reason, retryable: false } }
277
+ }
278
+ const { label, digest, question, range } = request
279
+ const indicatorId = typeof request.topicKey === 'string' && request.topicKey !== '' ? request.topicKey : request.indicatorId
280
+ if (typeof indicatorId !== 'string' || indicatorId === '') {
281
+ return { ok: false, error: { kind: 'bad-request', detail: 'indicatorId (or topicKey) is required', retryable: false } }
282
+ }
283
+
284
+ const agents = ctx.get('agents')
285
+ const defaultModel = ctx.get('agentDefaultModel')
286
+ const sessions = ctx.get('sessions')
287
+ const selection = defaultModel.currentSelection()
288
+ if (selection?.provider === undefined || selection?.model === undefined) {
289
+ return { ok: false, error: { kind: 'unavailable', detail: '默认模型未配置(agentDefaultModel.currentSelection() 为空),无法开启独立会话。', retryable: false } }
290
+ }
291
+
292
+ // Which session asked. The panel sends its own id, and that one lookup buys
293
+ // the discussion the reader's working directory, their preset, and therefore
294
+ // their tools — a session created without it joins no composition at all.
295
+ const requester = typeof request.sessionId === 'string' && request.sessionId !== '' && typeof agents.get === 'function'
296
+ ? agents.get(request.sessionId)
297
+ : undefined
298
+
299
+ // A discussion with a context message but no question is a session where
300
+ // nothing happens: the context is parked in the inbox for a turn that never
301
+ // starts, so the GUI opens an empty session and the panel — polling for text
302
+ // that cannot exist — reports a model failure. The panel's buttons are
303
+ // labelled 「讨论」, so when the reader typed nothing we ask the subject's
304
+ // opening question for them and say which question that was.
305
+ // `ask: false` is the deliberate exception, for a caller that only wants the
306
+ // session seeded and will drive it itself.
307
+ const typed = typeof question === 'string' ? question.trim() : ''
308
+ const asked = typed === ''
309
+ ? buildDefaultQuestion({ subject: request.subject ?? 'indicator', label: label?.zh ?? indicatorId })
310
+ : typed
311
+ const seedQuestion = request.ask === false ? '' : asked
312
+ // "The panel asked this" is only true when it actually did.
313
+ const autoQuestion = seedQuestion !== '' && typed === ''
314
+
315
+ // `topic` mode continues an existing discussion; `new` (the default) opens a
316
+ // fresh session for every question, which is what the panel's button says.
317
+ const reuse = request.mode === 'topic' && agentsByTopic.has(indicatorId)
318
+ const topic = reuse ? topics.get(indicatorId) : undefined
319
+ sequence += 1
320
+ const sessionId = topic?.sessionId ?? `session-smd-${clock.now().getTime().toString(36)}-${sequence.toString(36)}-${fingerprint(`${indicatorId}|${clock.now().toISOString()}|${sequence}`)}`
321
+
322
+ let agent
323
+ if (reuse) {
324
+ agent = agentsByTopic.get(indicatorId)
325
+ } else {
326
+ try {
327
+ const setup = compositionSetup(requester)
328
+ const handle = await agents.create({
329
+ sessionId,
330
+ meta: sessionMeta(requester, request.cwd),
331
+ agentOptions: { provider: selection.provider, model: selection.model },
332
+ ...(setup === undefined ? {} : { setup }),
333
+ })
334
+ agent = handle.agent
335
+ agentsByTopic.set(indicatorId, agent)
336
+ live.push(async () => {
337
+ try {
338
+ await handle.dispose()
339
+ } catch {
340
+ // disposal is best-effort
341
+ }
342
+ })
343
+ } catch (error) {
344
+ log('discussion session creation failed', { indicatorId, message: error?.message ?? String(error) })
345
+ return {
346
+ ok: false,
347
+ error: { kind: 'session-create-failed', detail: `无法创建会话:${error?.message ?? error}`, retryable: true },
348
+ }
349
+ }
350
+ }
351
+
352
+ const contextText = buildContextText({ label, indicatorId, digest, range })
353
+ const wait = request.wait !== false
354
+ // The session's event log is not readable while the loop is starting a turn
355
+ // (observed: `session.events` is undefined between `followup()` and the turn
356
+ // boundary), so the cursor is taken defensively and only when it is used.
357
+ const eventsOf = () => (Array.isArray(agent.session?.events) ? agent.session.events : [])
358
+ let pending = false
359
+ let reply = ''
360
+ try {
361
+ // Registered before the turn so `answer()` can always find the session,
362
+ // including the context-only case: the panel polls as soon as it has an
363
+ // id, and a 404 there would leave "正在生成" on screen for good.
364
+ bySession.set(sessionId, { agent, askedAt: clock.now().getTime(), from: eventsOf().length, asked: seedQuestion !== '' })
365
+ if (seedQuestion !== '') {
366
+ // The context travels as a `pre-turn` message in the SAME turn as the
367
+ // question. A `between-turns` context message is only claimed when the
368
+ // session runs another turn, so a session whose first turn is this very
369
+ // question would otherwise see no panel data at all.
370
+ agent.send(
371
+ buildMessage(contextText, { kind: 'plugin', plugin: 'show-me-data' }, `ctx|${sessionId}`),
372
+ // `InboxTarget` is the string union 'next-turn' | 'next-step'.
373
+ 'next-turn',
374
+ false,
375
+ )
376
+ const firstSeq = eventsOf().length
377
+ // The context message already counted toward the cursor; the answer is
378
+ // whatever the turn produces after it.
379
+ bySession.set(sessionId, { agent, askedAt: clock.now().getTime(), from: firstSeq, asked: true })
380
+ // A question the panel wrote for the reader is tagged as the plugin's, not
381
+ // as the reader's: the transcript must not claim the user typed it.
382
+ agent.followup(buildMessage(seedQuestion, autoQuestion ? { kind: 'plugin', plugin: 'show-me-data' } : { kind: 'user' }, `q|${sessionId}`))
383
+ if (wait) {
384
+ const settled = await withTimeout(agent.whenIdle(), timeoutMs)
385
+ // A timeout is not a failure: the turn is still running in a real
386
+ // session the caller can open, so answer 200 and say it is pending
387
+ // rather than turning a slow model into an error.
388
+ pending = settled === 'timeout'
389
+ const observed = eventsOf().slice(firstSeq)
390
+ const failure = settled === 'timeout' ? undefined : extractTurnFailure(observed)
391
+ if (failure !== undefined) {
392
+ // The session exists and is worth keeping: hand its id back so the
393
+ // GUI can still switch there, but never pretend the turn succeeded.
394
+ log('discussion turn failed', { indicatorId, sessionId, code: failure.code, message: failure.message })
395
+ return {
396
+ ok: false,
397
+ sessionId,
398
+ question: asked,
399
+ autoQuestion,
400
+ error: {
401
+ kind: 'session-turn-failed',
402
+ detail: `会话已创建,但模型请求失败:${failure.message}`,
403
+ retryable: true,
404
+ },
405
+ }
406
+ }
407
+ const { text } = extractAssistantText(observed)
408
+ reply = text
409
+ } else {
410
+ // The caller asked not to wait: the turn runs in this session, and the
411
+ // GUI it opens will show the answer as it streams.
412
+ pending = true
413
+ }
414
+ } else {
415
+ // `ask: false`: keep the panel data ready for whatever the reader asks
416
+ // first, so opening a seeded session and typing there has the context.
417
+ // Nothing runs, so the answer state is `idle`, never "done and empty".
418
+ agent.send(
419
+ buildMessage(contextText, { kind: 'plugin', plugin: 'show-me-data' }, `ctx|${sessionId}`),
420
+ 'next-turn',
421
+ false,
422
+ )
423
+ }
424
+ } catch (error) {
425
+ // The session exists but driving its first turn failed: say so with the
426
+ // cause instead of letting it surface as a bare 500.
427
+ log('discussion turn failed', { indicatorId, sessionId, message: error?.message ?? String(error) })
428
+ return {
429
+ ok: false,
430
+ sessionId,
431
+ question: asked,
432
+ autoQuestion,
433
+ error: {
434
+ kind: 'session-turn-failed',
435
+ detail: `会话已创建,但首轮提问失败:${error?.message ?? error}`,
436
+ retryable: true,
437
+ },
438
+ }
439
+ }
440
+
441
+ topics.set(indicatorId, {
442
+ sessionId,
443
+ createdAt: clock.now().toISOString(),
444
+ label: label?.zh ?? indicatorId,
445
+ turns: (topic?.turns ?? 0) + (seedQuestion === '' ? 0 : 1),
446
+ })
447
+ // Persist the discussion so it survives a restart and shows up in the
448
+ // session list. The loop's checkpoint policy may still be settling when
449
+ // `whenIdle()` resolves, so the flush is retried briefly: a session that
450
+ // exists but was never written is worse than one extra read.
451
+ if (sessions !== undefined && sessions.flush !== undefined) {
452
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
453
+ try {
454
+ const flushed = await sessions.flush(agent.session)
455
+ if (flushed !== false) break
456
+ } catch (error) {
457
+ log('discussion flush failed', { attempt, message: error?.message ?? String(error) })
458
+ }
459
+ await new Promise((resolve) => {
460
+ const timer = setTimeout(resolve, 150 * attempt)
461
+ if (typeof timer.unref === 'function') timer.unref()
462
+ })
463
+ }
464
+ }
465
+
466
+ log('discussion opened', { indicatorId, sessionId, mode: reuse ? 'topic' : 'new', replyChars: reply.length, pending })
467
+ return {
468
+ ok: true,
469
+ sessionId,
470
+ mode: reuse ? 'topic' : 'new',
471
+ reply,
472
+ pending,
473
+ question: asked,
474
+ autoQuestion,
475
+ provider: selection.provider,
476
+ model: selection.model,
477
+ topic: { turns: topics.get(indicatorId).turns },
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Collect the answer to a discussion that was opened with `wait: false`.
483
+ *
484
+ * The panel opens the session and switches to it immediately, so its own
485
+ * request cannot also wait for the turn. This is how the panel comes back for
486
+ * the answer a few seconds later instead of leaving the block empty until the
487
+ * reader notices the new session.
488
+ *
489
+ * @param {string} sessionId - session id returned by `discuss()`.
490
+ * @returns {{ status: 'unknown'|'idle'|'pending'|'done'|'failed', reply?: string, error?: { kind: string, detail: string }, eventCount?: number }} state.
491
+ */
492
+ function answer(sessionId) {
493
+ const entry = bySession.get(sessionId)
494
+ if (entry === undefined) return { status: 'unknown' }
495
+ const events = Array.isArray(entry.agent.session?.events) ? entry.agent.session.events : []
496
+ const observed = events.slice(entry.from)
497
+ const { text } = extractAssistantText(observed)
498
+ const failure = extractTurnFailure(observed)
499
+ if (failure !== undefined) {
500
+ return { status: 'failed', error: { kind: 'session-turn-failed', detail: failure.message }, eventCount: events.length }
501
+ }
502
+ // A seeded session nobody asked anything in has no answer to wait for. It is
503
+ // 'idle', not 'done with empty text': reporting the latter made the panel
504
+ // diagnose a model failure for a session that was simply never asked.
505
+ if (entry.asked !== true) return { status: 'idle', reply: text, eventCount: events.length }
506
+ const active = entry.agent.status === 'running' || entry.agent.status?.kind === 'running'
507
+ // While the turn is still running, text may only be the model narrating a tool
508
+ // call ("let me inspect the workspace source of truth") — reporting that as
509
+ // the answer left the panel showing a half-sentence instead of the conclusion.
510
+ // The final text is read once the agent is idle again.
511
+ if (active) return { status: 'pending', eventCount: events.length }
512
+ return { status: 'done', reply: text, eventCount: events.length }
513
+ }
514
+
515
+ /**
516
+ * The sessions this plugin opened in this process.
517
+ *
518
+ * @returns {Array<{ indicatorId: string, sessionId: string, createdAt: string, turns: number }>} topics.
519
+ */
520
+ function list() {
521
+ return [...topics.entries()].map(([indicatorId, entry]) => ({ indicatorId, ...entry }))
522
+ }
523
+
524
+ /**
525
+ * Dispose every session this plugin opened.
526
+ *
527
+ * @returns {Promise<void>} completion.
528
+ */
529
+ async function dispose() {
530
+ const pendingDisposals = live.splice(0, live.length)
531
+ await Promise.allSettled(pendingDisposals.map((disposeOne) => disposeOne()))
532
+ topics.clear()
533
+ agentsByTopic.clear()
534
+ bySession.clear()
535
+ }
536
+
537
+ return { discuss, answer, list, dispose, capability }
538
+ }
539
+
540
+ /**
541
+ * Await a promise with a budget.
542
+ *
543
+ * @param {Promise<any>} promise - promise to await.
544
+ * @param {number} ms - budget.
545
+ * @returns {Promise<'settled'|'timeout'>} outcome.
546
+ */
547
+ async function withTimeout(promise, ms) {
548
+ let timer
549
+ try {
550
+ return await Promise.race([
551
+ promise.then(() => 'settled'),
552
+ new Promise((resolve) => {
553
+ timer = setTimeout(() => resolve('timeout'), ms)
554
+ }),
555
+ ])
556
+ } finally {
557
+ if (timer !== undefined) clearTimeout(timer)
558
+ }
559
+ }