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,333 @@
1
+ /**
2
+ * The 'ctx.llm' AI gateway (docs/06 §1, docs/07 T9.3).
3
+ *
4
+ * It maps 'GenerateOptions'/'StreamChunk' onto this plugin's 'AiGateway'
5
+ * contract, and it is the only place that talks to a model. Everything it does
6
+ * is optional: if the service is missing, the request fails, or the provider is
7
+ * unset, the caller falls back to the deterministic gateway.
8
+ *
9
+ * @module host/ai/dsh-llm-gateway
10
+ */
11
+ import { fingerprint } from '../../core/insight/rank.js'
12
+ import { SYSTEM_PROMPT } from '../../core/ai/prompts.js'
13
+
14
+ /** Gateway mode label. */
15
+ export const MODE = 'llm'
16
+
17
+ /** Request timeout (docs/05 §5: 30s, partial output preserved). */
18
+ export const LLM_TIMEOUT_MS = 30_000
19
+
20
+ /**
21
+ * Output budget for one answer.
22
+ *
23
+ * Reasoning models spend tokens on `reasoning-delta` before emitting any
24
+ * `text-delta`, so a budget sized for the answer alone is entirely consumed by
25
+ * thinking and the response arrives with no text at all (observed live with
26
+ * deepseek-v4-flash at 600 tokens). The budget therefore has to cover thinking
27
+ * plus a {@link MAX_CHARS}-ish answer.
28
+ */
29
+ export const DEFAULT_MAX_TOKENS = 4000
30
+
31
+ /**
32
+ * Build the message object the 'llm' service expects.
33
+ *
34
+ * The service wants 'Message[]' (with 'id' and 'source'), and its constructors
35
+ * live in packages this plugin deliberately does not import, so a minimal valid
36
+ * message is built here.
37
+ *
38
+ * @param {string} text - user content.
39
+ * @param {{ idSeed: string }} options - identity seed for deterministic ids.
40
+ * @returns {object} message.
41
+ */
42
+ export function buildUserMessage(text, { idSeed }) {
43
+ return {
44
+ id: `smd-${fingerprint(`${idSeed}|${text.length}|${text.slice(0, 64)}`)}`,
45
+ role: 'user',
46
+ content: [{ type: 'text', text }],
47
+ source: { kind: 'plugin', plugin: 'show-me-data' },
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Create the LLM gateway.
53
+ *
54
+ * @param {object} deps - dependencies.
55
+ * @param {object} deps.llm - 'ctx.llm' service.
56
+ * @param {object} [deps.defaultModel] - 'ctx.agentDefaultModel' service.
57
+ * @param {number} [deps.maxChars] - output budget passed to the model.
58
+ * @param {number} [deps.timeoutMs] - request timeout.
59
+ * @param {(msg: string, meta?: object) => void} [deps.log] - logger.
60
+ * @returns {object} 'AiGateway'.
61
+ */
62
+ export function createDshLlmGateway({ llm, defaultModel, maxChars = 1200, timeoutMs = LLM_TIMEOUT_MS, log = () => {} }) {
63
+ if (llm === undefined || typeof llm.stream !== 'function') {
64
+ throw new Error('createDshLlmGateway: an `llm` service with `stream()` is required')
65
+ }
66
+
67
+ /**
68
+ * Resolve the provider/model to call.
69
+ *
70
+ * @param {object} [override] - per-request override.
71
+ * @returns {{ provider: string, model: string, reasoningEffort?: string }} selection.
72
+ */
73
+ function selection(override) {
74
+ if (override?.provider !== undefined && override?.model !== undefined) return override
75
+ const current = defaultModel?.currentSelection?.()
76
+ if (current?.provider !== undefined && current?.model !== undefined) return current
77
+ const providers = typeof llm.listProviders === 'function' ? llm.listProviders() : []
78
+ const first = providers[0]
79
+ if (first !== undefined) {
80
+ const model = first.models?.[0]?.id ?? first.defaultModel ?? first.models?.[0]
81
+ if (model !== undefined) return { provider: first.provider ?? first.id, model: String(model) }
82
+ }
83
+ throw new Error('no LLM provider/model is configured (agentDefaultModel.currentSelection() is empty)')
84
+ }
85
+
86
+ /**
87
+ * Run one completion, collecting streamed text.
88
+ *
89
+ * @param {object} input - invocation.
90
+ * @param {string} input.system - system prompt.
91
+ * @param {string} input.user - user prompt.
92
+ * @param {string} input.idSeed - identity seed for the message id.
93
+ * @param {number} [input.maxTokens] - output budget.
94
+ * @returns {Promise<{ text: string, usage?: object, interrupted: boolean }>} completion.
95
+ */
96
+ async function complete({ system, user, idSeed, maxTokens }) {
97
+ const target = selection()
98
+ const controller = new AbortController()
99
+ const timer = setTimeout(() => controller.abort(new Error('llm timeout')), timeoutMs)
100
+ let text = ''
101
+ // Reasoning text is kept separately: a reasoning model that runs out of
102
+ // budget mid-thought emits no `text` block at all, and showing the user the
103
+ // model's own thinking beats showing them nothing.
104
+ let reasoning = ''
105
+ let usage
106
+ let interrupted = false
107
+ /** Chunk-type census, so an empty answer can explain itself. */
108
+ const census = {}
109
+ let finishReason
110
+ let failure
111
+ try {
112
+ const stream = llm.stream({
113
+ provider: target.provider,
114
+ model: target.model,
115
+ ...(target.reasoningEffort === undefined ? {} : { reasoningEffort: target.reasoningEffort }),
116
+ system,
117
+ messages: [buildUserMessage(user, { idSeed })],
118
+ ...(maxTokens === undefined ? {} : { maxTokens }),
119
+ signal: controller.signal,
120
+ })
121
+ for await (const chunk of stream) {
122
+ if (chunk?.type !== undefined) census[chunk.type] = (census[chunk.type] ?? 0) + 1
123
+ if (chunk?.type === 'text-delta') text += chunk.text
124
+ else if (chunk?.type === 'reasoning-delta') reasoning += chunk.text
125
+ else if (chunk?.type === 'usage') usage = chunk.usage
126
+ else if (chunk?.type === 'finish') {
127
+ finishReason = chunk.reason?.kind
128
+ if (chunk.reason?.kind === 'aborted' || chunk.reason?.kind === 'error') {
129
+ interrupted = true
130
+ failure = chunk.reason.failure
131
+ }
132
+ }
133
+ }
134
+ } catch (error) {
135
+ if (text.length === 0) throw error
136
+ // Keep what we already received: a partial answer beats no answer.
137
+ interrupted = true
138
+ failure = { message: error?.message ?? String(error), code: error?.code }
139
+ log('llm stream interrupted, keeping partial output', { message: error?.message ?? String(error) })
140
+ } finally {
141
+ clearTimeout(timer)
142
+ }
143
+ const textOut = text
144
+ const reasoningOnly = textOut.trim() === '' && reasoning.trim() !== ''
145
+ if (reasoningOnly) {
146
+ // Budget exhausted during thinking. Reasoning is NOT an answer: handing it
147
+ // back as `markdown` let a model's private deliberation ("用户要求重写,且
148
+ // 上次因未引用数据点未通过") reach the panel as if it were the analysis, and
149
+ // it once passed the validator because it restated digest numbers. The
150
+ // answer stays empty — the caller then retries and degrades to the
151
+ // deterministic summary — while the reasoning is kept for diagnostics.
152
+ log('llm produced reasoning but no text (budget likely exhausted during thinking)', {
153
+ provider: target.provider,
154
+ model: target.model,
155
+ finishReason,
156
+ maxTokens,
157
+ reasoningChars: reasoning.length,
158
+ })
159
+ }
160
+ if (textOut.trim() === '') {
161
+ // An empty completion is a provider-level failure, and the caller must be
162
+ // able to say WHY instead of showing a generic "no model configured".
163
+ log('llm returned no text', { provider: target.provider, model: target.model, census, finishReason, failure })
164
+ }
165
+ return {
166
+ text: textOut,
167
+ // The raw reasoning is diagnostics, never an answer.
168
+ reasoning: reasoningOnly ? reasoning.trim() : undefined,
169
+ usage,
170
+ interrupted,
171
+ reasoningOnly,
172
+ census,
173
+ finishReason,
174
+ failure,
175
+ provider: target.provider,
176
+ model: target.model,
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Build an 'AiResult' from a completion.
182
+ *
183
+ * @param {object} input - invocation.
184
+ * @param {string} input.text - completion text.
185
+ * @param {object} input.usage - token usage.
186
+ * @param {boolean} input.interrupted - whether the stream was cut short.
187
+ * @param {string} input.idSeed - fingerprint seed.
188
+ * @returns {object} 'AiResult'.
189
+ */
190
+ function resultOf({ text, usage, interrupted, idSeed, census, finishReason, failure, provider, model, reasoningOnly, reasoning }) {
191
+ const described = describe()
192
+ return {
193
+ markdown: text,
194
+ // Citations are produced by the validator from the text; an empty list here
195
+ // means the caller must validate before showing anything.
196
+ usedPoints: [],
197
+ mode: MODE,
198
+ interrupted: interrupted === true,
199
+ reasoningOnly: reasoningOnly === true,
200
+ usage,
201
+ provider: described.provider ?? provider,
202
+ model: described.model ?? model,
203
+ // Diagnostic facts travel with the result so the panel can explain an
204
+ // empty answer instead of pretending no model is configured.
205
+ emptyReason:
206
+ text === ''
207
+ ? {
208
+ kind: failure !== undefined ? 'provider-error' : reasoningOnly ? 'reasoning-only' : 'no-text',
209
+ detail: failure?.message ??
210
+ (reasoningOnly
211
+ ? `模型把全部输出预算用在了思考上(${reasoning?.length ?? 0} 字),没有产出回答`
212
+ : `模型只返回了 ${Object.keys(census ?? {}).join('/') || '无'} 类型的内容,没有文本`),
213
+ code: failure?.code,
214
+ finishReason,
215
+ census,
216
+ }
217
+ : undefined,
218
+ fingerprint: fingerprint(`${idSeed}|${text.length}`),
219
+ }
220
+ }
221
+
222
+ /**
223
+ * @param {object} request - request with 'system'/'user' prompts already built.
224
+ * @returns {Promise<object>} 'AiResult'.
225
+ */
226
+ async function explain(request) {
227
+ const completion = await complete({
228
+ system: request.system ?? SYSTEM_PROMPT,
229
+ user: request.user ?? '',
230
+ idSeed: `explain|${request.indicatorId ?? ''}`,
231
+ maxTokens: DEFAULT_MAX_TOKENS,
232
+ })
233
+ return resultOf({ ...completion, idSeed: `explain|${request.indicatorId ?? ''}` })
234
+ }
235
+
236
+ /**
237
+ * @param {object} request - request.
238
+ * @returns {Promise<object>} 'AiResult'.
239
+ */
240
+ async function summarize(request) {
241
+ const completion = await complete({
242
+ system: request.system ?? SYSTEM_PROMPT,
243
+ user: request.user ?? '',
244
+ idSeed: `summary|${request.range?.from ?? ''}`,
245
+ maxTokens: DEFAULT_MAX_TOKENS,
246
+ })
247
+ return resultOf({ ...completion, idSeed: `summary|${request.range?.from ?? ''}` })
248
+ }
249
+
250
+ /**
251
+ * @param {object} request - request.
252
+ * @returns {Promise<object>} 'AiResult'.
253
+ */
254
+ async function answer(request) {
255
+ const completion = await complete({
256
+ system: request.system ?? SYSTEM_PROMPT,
257
+ user: request.user ?? '',
258
+ idSeed: `answer|${request.question ?? ''}`,
259
+ maxTokens: DEFAULT_MAX_TOKENS,
260
+ })
261
+ return resultOf({ ...completion, idSeed: `answer|${request.question ?? ''}` })
262
+ }
263
+
264
+ /**
265
+ * Ask the model for structured JSON candidates.
266
+ *
267
+ * @param {object} request - request with 'system'/'user'.
268
+ * @returns {Promise<{ candidates: object[], unsupported: object[], mode: string, raw: string }>} proposal.
269
+ */
270
+ async function propose(request) {
271
+ const completion = await complete({
272
+ system: request.system ?? SYSTEM_PROMPT,
273
+ user: request.user ?? '',
274
+ idSeed: `propose|${request.request ?? ''}`,
275
+ maxTokens: 1500,
276
+ })
277
+ try {
278
+ const parsed = JSON.parse(extractJson(completion.text))
279
+ return {
280
+ candidates: Array.isArray(parsed?.candidates) ? parsed.candidates : [],
281
+ unsupported: Array.isArray(parsed?.unsupported) ? parsed.unsupported : [],
282
+ mode: MODE,
283
+ raw: completion.text,
284
+ }
285
+ } catch (error) {
286
+ // Unparseable model output is not a candidate list: say so rather than
287
+ // inventing one.
288
+ return {
289
+ candidates: [],
290
+ unsupported: [
291
+ {
292
+ request: request.request ?? '',
293
+ reason: `模型返回的不是合法 JSON(${error?.message ?? error}),未做任何猜测。`,
294
+ alternatives: ['换一种说法重试', '在指标目录中手动检索'],
295
+ },
296
+ ],
297
+ mode: MODE,
298
+ raw: completion.text,
299
+ }
300
+ }
301
+ }
302
+
303
+ /**
304
+ * @returns {{ mode: string, provider?: string, model?: string }} description.
305
+ */
306
+ function describe() {
307
+ try {
308
+ const target = selection()
309
+ return { mode: MODE, provider: target.provider, model: target.model }
310
+ } catch {
311
+ return { mode: MODE }
312
+ }
313
+ }
314
+
315
+ return { explain, summarize, answer, propose, describe }
316
+ }
317
+
318
+ /**
319
+ * Pull the first JSON object out of model text (models like to wrap JSON in
320
+ * prose or fences).
321
+ *
322
+ * @param {string} text - model output.
323
+ * @returns {string} JSON candidate.
324
+ */
325
+ export function extractJson(text) {
326
+ const source = String(text ?? '')
327
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(source)
328
+ if (fenced !== null) return fenced[1].trim()
329
+ const start = source.indexOf('{')
330
+ const end = source.lastIndexOf('}')
331
+ if (start !== -1 && end > start) return source.slice(start, end + 1)
332
+ return source.trim()
333
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Plugin configuration: defaults, validation and the Standard Schema export
3
+ * (docs/09 §5).
4
+ *
5
+ * **Why this file exists.** Cordis validates a loader row's 'config' *before*
6
+ * 'apply' runs, through 'plugin.Config["~standard"].validate(config)'. A plugin
7
+ * that exports a plain defaults object as 'Config' therefore crashes the whole
8
+ * profile at boot ('Cannot read properties of undefined (reading 'validate')'),
9
+ * not just its own row. The exported schema below is a real Standard Schema:
10
+ * valid configuration passes through normalized, invalid configuration becomes
11
+ * 'issues' that the loader reports as a readable boot error.
12
+ *
13
+ * @module host/config
14
+ */
15
+
16
+ /** The row defaults. Kept as data so {@link ConfigSchema} and the docs agree. */
17
+ export const DEFAULT_CONFIG = {
18
+ refreshMinutes: 30,
19
+ cacheTtl: { daily: 15, weekly: 180, monthly: 360, quarterly: 1440 },
20
+ groups: ['US', 'CN', 'GLOBAL', 'CUSTOM'],
21
+ noteworthyLimit: 5,
22
+ storageDir: undefined,
23
+ /** Print AI diagnostics to stderr as well; for troubleshooting a silent model. */
24
+ debug: false,
25
+ ai: { enabled: true, mode: 'auto', maxChars: 1200, cacheMinutes: 60 },
26
+ sources: {
27
+ fred: true,
28
+ 'eastmoney-quote': true,
29
+ 'eastmoney-macro': true,
30
+ 'us-treasury': true,
31
+ worldbank: true,
32
+ ecb: true,
33
+ },
34
+ }
35
+
36
+ /** The AI modes a row may select. */
37
+ export const AI_MODES = ['auto', 'llm', 'deterministic', 'relay']
38
+
39
+ /**
40
+ * Validate and normalize one raw row config.
41
+ *
42
+ * @param {unknown} raw - configuration from the loader row.
43
+ * @returns {{ value: object, issues: Array<{ message: string, path?: string[] }> }} normalized config and issues.
44
+ */
45
+ export function resolveConfig(raw) {
46
+ const issues = []
47
+ const config = raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw) ? {} : raw
48
+ if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
49
+ issues.push({ message: 'config must be an object', path: [] })
50
+ }
51
+
52
+ const takeNumber = (key, { min, max, integer = true }) => {
53
+ const value = config[key]
54
+ if (value === undefined) return DEFAULT_CONFIG[key]
55
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
56
+ issues.push({ message: `${key} must be a finite number`, path: [key] })
57
+ return DEFAULT_CONFIG[key]
58
+ }
59
+ if (integer && !Number.isInteger(value)) {
60
+ issues.push({ message: `${key} must be an integer`, path: [key] })
61
+ return Math.trunc(value)
62
+ }
63
+ if (value < min || value > max) {
64
+ issues.push({ message: `${key} must be between ${min} and ${max}`, path: [key] })
65
+ return Math.min(Math.max(value, min), max)
66
+ }
67
+ return value
68
+ }
69
+
70
+ const refreshMinutes = takeNumber('refreshMinutes', { min: 0, max: 24 * 60 })
71
+ const noteworthyLimit = takeNumber('noteworthyLimit', { min: 1, max: 20 })
72
+
73
+ let groups = DEFAULT_CONFIG.groups
74
+ if (config.groups !== undefined) {
75
+ if (!Array.isArray(config.groups) || config.groups.length === 0 || config.groups.some((entry) => typeof entry !== 'string')) {
76
+ issues.push({ message: 'groups must be a non-empty array of strings', path: ['groups'] })
77
+ } else {
78
+ groups = [...config.groups]
79
+ }
80
+ }
81
+
82
+ let storageDir
83
+ if (config.storageDir !== undefined) {
84
+ if (typeof config.storageDir !== 'string' || config.storageDir === '') {
85
+ issues.push({ message: 'storageDir must be a non-empty string when set', path: ['storageDir'] })
86
+ } else {
87
+ storageDir = config.storageDir
88
+ }
89
+ }
90
+
91
+ const cacheTtl = { ...DEFAULT_CONFIG.cacheTtl }
92
+ if (config.cacheTtl !== undefined) {
93
+ if (config.cacheTtl === null || typeof config.cacheTtl !== 'object' || Array.isArray(config.cacheTtl)) {
94
+ issues.push({ message: 'cacheTtl must be an object of minutes per frequency', path: ['cacheTtl'] })
95
+ } else {
96
+ for (const [key, value] of Object.entries(config.cacheTtl)) {
97
+ if (!(key in DEFAULT_CONFIG.cacheTtl)) {
98
+ issues.push({ message: `cacheTtl.${key} is not a known frequency`, path: ['cacheTtl', key] })
99
+ continue
100
+ }
101
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
102
+ issues.push({ message: `cacheTtl.${key} must be a non-negative number`, path: ['cacheTtl', key] })
103
+ continue
104
+ }
105
+ cacheTtl[key] = value
106
+ }
107
+ }
108
+ }
109
+
110
+ const ai = { ...DEFAULT_CONFIG.ai }
111
+ if (config.ai !== undefined) {
112
+ if (config.ai === null || typeof config.ai !== 'object' || Array.isArray(config.ai)) {
113
+ issues.push({ message: 'ai must be an object', path: ['ai'] })
114
+ } else {
115
+ if (config.ai.enabled !== undefined) {
116
+ if (typeof config.ai.enabled !== 'boolean') issues.push({ message: 'ai.enabled must be a boolean', path: ['ai', 'enabled'] })
117
+ else ai.enabled = config.ai.enabled
118
+ }
119
+ if (config.ai.mode !== undefined) {
120
+ if (!AI_MODES.includes(config.ai.mode)) {
121
+ issues.push({ message: `ai.mode must be one of ${AI_MODES.join('|')}`, path: ['ai', 'mode'] })
122
+ } else {
123
+ ai.mode = config.ai.mode
124
+ }
125
+ }
126
+ if (config.ai.maxChars !== undefined) {
127
+ if (!Number.isInteger(config.ai.maxChars) || config.ai.maxChars < 200 || config.ai.maxChars > 8000) {
128
+ issues.push({ message: 'ai.maxChars must be an integer between 200 and 8000', path: ['ai', 'maxChars'] })
129
+ } else {
130
+ ai.maxChars = config.ai.maxChars
131
+ }
132
+ }
133
+ if (config.ai.cacheMinutes !== undefined) {
134
+ if (!Number.isInteger(config.ai.cacheMinutes) || config.ai.cacheMinutes < 0) {
135
+ issues.push({ message: 'ai.cacheMinutes must be a non-negative integer', path: ['ai', 'cacheMinutes'] })
136
+ } else {
137
+ ai.cacheMinutes = config.ai.cacheMinutes
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ const sources = { ...DEFAULT_CONFIG.sources }
144
+ if (config.sources !== undefined) {
145
+ if (config.sources === null || typeof config.sources !== 'object' || Array.isArray(config.sources)) {
146
+ issues.push({ message: 'sources must be an object of booleans', path: ['sources'] })
147
+ } else {
148
+ for (const [key, value] of Object.entries(config.sources)) {
149
+ if (typeof value !== 'boolean') {
150
+ issues.push({ message: `sources.${key} must be a boolean`, path: ['sources', key] })
151
+ continue
152
+ }
153
+ sources[key] = value
154
+ }
155
+ }
156
+ }
157
+
158
+ let debug = DEFAULT_CONFIG.debug
159
+ if (config.debug !== undefined) {
160
+ if (typeof config.debug !== 'boolean') issues.push({ message: 'debug must be a boolean', path: ['debug'] })
161
+ else debug = config.debug
162
+ }
163
+
164
+ return {
165
+ value: { refreshMinutes, cacheTtl, groups, noteworthyLimit, storageDir, debug, ai, sources },
166
+ issues,
167
+ }
168
+ }
169
+
170
+ /**
171
+ * The Standard Schema Cordis reads from 'plugin.Config'.
172
+ *
173
+ * 'validate' never throws: an unexpected failure becomes an 'issues' entry so a
174
+ * configuration mistake surfaces as a readable boot message instead of a
175
+ * process-level TypeError.
176
+ */
177
+ export const ConfigSchema = {
178
+ '~standard': {
179
+ version: 1,
180
+ vendor: 'dsh-plugin-show-me-data',
181
+ /**
182
+ * @param {unknown} value - raw row config.
183
+ * @returns {{ value: object, issues?: Array<{ message: string, path?: string[] }> }} validation result.
184
+ */
185
+ validate(value) {
186
+ try {
187
+ const { value: normalized, issues } = resolveConfig(value)
188
+ return issues.length === 0 ? { value: normalized } : { value: normalized, issues }
189
+ } catch (error) {
190
+ return { value: DEFAULT_CONFIG, issues: [{ message: `config validation failed: ${error?.message ?? error}` }] }
191
+ }
192
+ },
193
+ },
194
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * HTTP plumbing for the browser half's data channel (docs/07 T7.2).
3
+ *
4
+ * The route table is registered with 'kind: 'prefix'' for the whole
5
+ * '/api/show-me-data' subtree, and every handler is a thin adapter: parse the
6
+ * request, call a use case, serialise. Errors are structured and never leak a
7
+ * stack (docs/05 §3).
8
+ *
9
+ * @module host/http/respond
10
+ */
11
+
12
+ /** The prefix every route lives under. */
13
+ export const API_PREFIX = '/api/show-me-data'
14
+
15
+ /**
16
+ * Read a request body, with a hard size cap.
17
+ *
18
+ * @param {import('node:http').IncomingMessage} req - request.
19
+ * @param {number} [maxBytes] - cap.
20
+ * @returns {Promise<any>} parsed JSON body ('{}' when empty).
21
+ */
22
+ export async function readJsonBody(req, maxBytes = 256 * 1024) {
23
+ const chunks = []
24
+ let size = 0
25
+ for await (const chunk of req) {
26
+ size += chunk.length
27
+ if (size > maxBytes) {
28
+ const error = new Error(`request body exceeds ${maxBytes} bytes`)
29
+ error.code = 'BODY_TOO_LARGE'
30
+ throw error
31
+ }
32
+ chunks.push(chunk)
33
+ }
34
+ if (size === 0) return {}
35
+ const text = Buffer.concat(chunks).toString('utf8')
36
+ try {
37
+ const parsed = JSON.parse(text)
38
+ return parsed === null || typeof parsed !== 'object' ? {} : parsed
39
+ } catch {
40
+ const error = new Error('request body is not valid JSON')
41
+ error.code = 'BAD_JSON'
42
+ throw error
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Send a JSON response.
48
+ *
49
+ * @param {import('node:http').ServerResponse} res - response.
50
+ * @param {number} status - status code.
51
+ * @param {any} payload - JSON payload.
52
+ * @returns {void}
53
+ */
54
+ export function sendJson(res, status, payload) {
55
+ const body = JSON.stringify(payload)
56
+ res.writeHead(status, {
57
+ 'content-type': 'application/json; charset=utf-8',
58
+ 'content-length': Buffer.byteLength(body),
59
+ 'cache-control': 'no-store',
60
+ })
61
+ res.end(body)
62
+ }
63
+
64
+ /**
65
+ * Send a structured error. Stacks stay in the log, never on the wire.
66
+ *
67
+ * `payload` merges top-level fields beside `error`. A failure that still produced
68
+ * something the caller needs — a discussion whose session exists even though its
69
+ * first turn failed — has to hand that id over, or the reader is told to open a
70
+ * session whose id they never receive.
71
+ *
72
+ * @param {import('node:http').ServerResponse} res - response.
73
+ * @param {number} status - status code.
74
+ * @param {string} kind - machine-readable kind.
75
+ * @param {string} detail - human-readable detail.
76
+ * @param {{ retryable?: boolean }} [extra] - extra fields.
77
+ * @param {object} [payload] - top-level fields to merge beside `error`.
78
+ * @returns {void}
79
+ */
80
+ export function sendError(res, status, kind, detail, extra = {}, payload = {}) {
81
+ sendJson(res, status, { ...payload, error: { kind, detail, retryable: extra.retryable === true } })
82
+ }
83
+
84
+ /**
85
+ * Start a 'text/event-stream' response.
86
+ *
87
+ * @param {import('node:http').ServerResponse} res - response.
88
+ * @param {Record<string, string>} [headers] - extra headers.
89
+ * @returns {(event: string, data: any) => void} event writer.
90
+ */
91
+ export function startSse(res, headers = {}) {
92
+ res.writeHead(200, {
93
+ 'content-type': 'text/event-stream; charset=utf-8',
94
+ 'cache-control': 'no-store',
95
+ connection: 'keep-alive',
96
+ 'x-accel-buffering': 'no',
97
+ ...headers,
98
+ })
99
+ if (typeof res.flushHeaders === 'function') res.flushHeaders()
100
+ let closed = false
101
+ res.on('close', () => {
102
+ closed = true
103
+ })
104
+ return (event, data) => {
105
+ if (closed || res.writableEnded) return
106
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Parse a URL into its pathname and query.
112
+ *
113
+ * @param {import('node:http').IncomingMessage} req - request.
114
+ * @param {string} prefix - route prefix.
115
+ * @returns {{ route: string, query: URLSearchParams }} route name and query.
116
+ */
117
+ export function parseRoute(req, prefix = API_PREFIX) {
118
+ const url = new URL(req.url ?? '/', 'http://localhost')
119
+ const path = url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) : url.pathname
120
+ return { route: path.replace(/^\/+|\/+$/g, ''), query: url.searchParams }
121
+ }
122
+
123
+ /**
124
+ * Read a positive integer query parameter.
125
+ *
126
+ * @param {URLSearchParams} query - query.
127
+ * @param {string} key - parameter name.
128
+ * @param {number} fallback - default.
129
+ * @param {{ min?: number, max?: number }} [bounds] - bounds.
130
+ * @returns {number} value.
131
+ */
132
+ export function intParam(query, key, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
133
+ const raw = query.get(key)
134
+ if (raw === null) return fallback
135
+ const value = Number(raw)
136
+ if (!Number.isFinite(value)) return fallback
137
+ return Math.min(Math.max(Math.trunc(value), min), max)
138
+ }
139
+
140
+ /**
141
+ * Read a comma-separated list parameter.
142
+ *
143
+ * @param {URLSearchParams} query - query.
144
+ * @param {string} key - parameter name.
145
+ * @returns {string[]|undefined} values.
146
+ */
147
+ export function listParam(query, key) {
148
+ const raw = query.get(key)
149
+ if (raw === null || raw.trim() === '') return undefined
150
+ return raw.split(',').map((entry) => entry.trim()).filter((entry) => entry !== '')
151
+ }
152
+
153
+ /**
154
+ * Normalise any thrown value into a structured error payload.
155
+ *
156
+ * @param {unknown} error - thrown value.
157
+ * @returns {{ status: number, kind: string, detail: string, retryable: boolean }} payload.
158
+ */
159
+ export function errorPayload(error) {
160
+ if (error?.code === 'BODY_TOO_LARGE') return { status: 413, kind: 'payload-too-large', detail: error.message, retryable: false }
161
+ if (error?.code === 'BAD_JSON') return { status: 400, kind: 'bad-request', detail: error.message, retryable: false }
162
+ if (error instanceof RangeError) return { status: 400, kind: 'bad-request', detail: error.message, retryable: false }
163
+ if (error?.name === 'ToolArgsError') return { status: 400, kind: 'bad-request', detail: error.message, retryable: false }
164
+ return { status: 500, kind: 'internal', detail: 'plugin error (details in the host log)', retryable: true }
165
+ }