@v587d/capital-generation 0.0.0-stage → 2.6.1

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 (117) hide show
  1. package/CHANGELOG.md +814 -0
  2. package/LICENSE +21 -0
  3. package/README.md +118 -2
  4. package/assets/cg-icon.png +0 -0
  5. package/capital-config/client.js +28 -0
  6. package/capital-config/client.src.cjs +388 -0
  7. package/capital-config/index.js +113 -0
  8. package/capital-config/package.json +36 -0
  9. package/capital-watchlist/client.js +182 -0
  10. package/capital-watchlist/client.src.cjs +1229 -0
  11. package/capital-watchlist/index.js +772 -0
  12. package/capital-watchlist/package.json +37 -0
  13. package/chart-ui/README.md +112 -0
  14. package/chart-ui/client.js +16 -0
  15. package/chart-ui/client.src.cjs +126 -0
  16. package/chart-ui/index.js +205 -0
  17. package/chart-ui/package.json +30 -0
  18. package/cordis.patch.yml +72 -0
  19. package/lib/agents/bash-guard.d.ts +55 -0
  20. package/lib/agents/bash-guard.js +162 -0
  21. package/lib/agents/root-tool-policy.d.ts +164 -0
  22. package/lib/agents/root-tool-policy.js +309 -0
  23. package/lib/chart/artifact-ref.d.ts +65 -0
  24. package/lib/chart/artifact-ref.js +107 -0
  25. package/lib/chart/downsample.d.ts +8 -0
  26. package/lib/chart/downsample.js +53 -0
  27. package/lib/chart/errors.d.ts +16 -0
  28. package/lib/chart/errors.js +15 -0
  29. package/lib/chart/events.d.ts +120 -0
  30. package/lib/chart/events.js +471 -0
  31. package/lib/chart/html.d.ts +21 -0
  32. package/lib/chart/html.js +154 -0
  33. package/lib/chart/runtime.d.ts +20 -0
  34. package/lib/chart/runtime.js +270 -0
  35. package/lib/chart/series.d.ts +102 -0
  36. package/lib/chart/series.js +424 -0
  37. package/lib/chart/source-token.d.ts +64 -0
  38. package/lib/chart/source-token.js +217 -0
  39. package/lib/chart/spec.d.ts +58 -0
  40. package/lib/chart/spec.js +265 -0
  41. package/lib/chart/tool.d.ts +86 -0
  42. package/lib/chart/tool.js +288 -0
  43. package/lib/chart/vendor/LICENSE +201 -0
  44. package/lib/chart/vendor/VERSION.json +8 -0
  45. package/lib/chart/vendor/lightweight-charts.standalone.production.js +7 -0
  46. package/lib/data-collector/args.d.ts +38 -0
  47. package/lib/data-collector/args.js +114 -0
  48. package/lib/data-collector/dataset-tools.d.ts +3 -0
  49. package/lib/data-collector/dataset-tools.js +421 -0
  50. package/lib/data-collector/describe.d.ts +99 -0
  51. package/lib/data-collector/describe.js +256 -0
  52. package/lib/data-collector/hub.d.ts +187 -0
  53. package/lib/data-collector/hub.js +469 -0
  54. package/lib/data-collector/query-time.d.ts +58 -0
  55. package/lib/data-collector/query-time.js +101 -0
  56. package/lib/data-collector/query.d.ts +57 -0
  57. package/lib/data-collector/query.js +473 -0
  58. package/lib/data-collector/store.d.ts +499 -0
  59. package/lib/data-collector/store.js +1486 -0
  60. package/lib/data-collector/time-axis.d.ts +169 -0
  61. package/lib/data-collector/time-axis.js +419 -0
  62. package/lib/data-collector/tools.d.ts +32 -0
  63. package/lib/data-collector/tools.js +216 -0
  64. package/lib/index.d.ts +275 -0
  65. package/lib/index.js +458 -0
  66. package/lib/net/eastmoney-client.d.ts +104 -0
  67. package/lib/net/eastmoney-client.js +118 -0
  68. package/lib/net/throttle.d.ts +16 -0
  69. package/lib/net/throttle.js +34 -0
  70. package/lib/ocr/client.d.ts +82 -0
  71. package/lib/ocr/client.js +281 -0
  72. package/lib/ocr/markdown.d.ts +75 -0
  73. package/lib/ocr/markdown.js +209 -0
  74. package/lib/ocr/store.d.ts +119 -0
  75. package/lib/ocr/store.js +226 -0
  76. package/lib/sources/eastmoney-http.d.ts +2 -0
  77. package/lib/sources/eastmoney-http.js +1253 -0
  78. package/lib/sources/fuyao-core.d.ts +52 -0
  79. package/lib/sources/fuyao-core.js +96 -0
  80. package/lib/sources/fuyao-rest.d.ts +14 -0
  81. package/lib/sources/fuyao-rest.js +2057 -0
  82. package/lib/sources/security-code.d.ts +27 -0
  83. package/lib/sources/security-code.js +104 -0
  84. package/lib/sources/security-identity.d.ts +24 -0
  85. package/lib/sources/security-identity.js +89 -0
  86. package/lib/sources/tencent-http.d.ts +2 -0
  87. package/lib/sources/tencent-http.js +1053 -0
  88. package/lib/sources/wind-mcp.d.ts +8 -0
  89. package/lib/sources/wind-mcp.js +593 -0
  90. package/lib/time/tools.d.ts +74 -0
  91. package/lib/time/tools.js +464 -0
  92. package/lib/tool-exec.d.ts +29 -0
  93. package/lib/tool-exec.js +17 -0
  94. package/lib/web-retriever/engines.d.ts +61 -0
  95. package/lib/web-retriever/engines.js +202 -0
  96. package/lib/web-retriever/html-markdown.d.ts +50 -0
  97. package/lib/web-retriever/html-markdown.js +321 -0
  98. package/lib/web-retriever/local-fetch.d.ts +121 -0
  99. package/lib/web-retriever/local-fetch.js +562 -0
  100. package/lib/web-retriever/retriever.d.ts +58 -0
  101. package/lib/web-retriever/retriever.js +94 -0
  102. package/lib/web-retriever/sources.d.ts +235 -0
  103. package/lib/web-retriever/sources.js +961 -0
  104. package/lib/web-retriever/tools.d.ts +50 -0
  105. package/lib/web-retriever/tools.js +1029 -0
  106. package/lib/web-retriever/wind-client.d.ts +69 -0
  107. package/lib/web-retriever/wind-client.js +311 -0
  108. package/locale/en.json +6 -0
  109. package/locale/zh.json +6 -0
  110. package/package.json +84 -4
  111. package/preset/capital-generation/README.md +161 -0
  112. package/preset/capital-generation/agent.patch.yml +509 -0
  113. package/preset/capital-generation/skills/capital-chart-protocol/SKILL.md +137 -0
  114. package/preset/capital-generation/skills/capital-data-protocol/SKILL.md +416 -0
  115. package/preset/capital-generation/skills/capital-orchestration/SKILL.md +174 -0
  116. package/preset/capital-generation/skills/capital-visualization-protocol/SKILL.md +296 -0
  117. package/preset/capital-generation/skills/capital-web-protocol/SKILL.md +240 -0
@@ -0,0 +1,469 @@
1
+ /**
2
+ * 规范化内部 data_key 片段:斜杠/冒号等路径分隔符一律映射为点,其余非常规
3
+ * 字符折叠为点、合并连续点、去掉首尾点。data_key 只是宿主内部路由/审计身份,
4
+ * 不出现在模型可见协议、人设或工具输出中。
5
+ */
6
+ export function normalizeKeyToken(value) {
7
+ return value.replace(/[\\/:]+/g, '.')
8
+ .replace(/[^A-Za-z0-9._-]+/g, '.')
9
+ .replace(/\.{2,}/g, '.')
10
+ .replace(/^\.+|\.+$/g, '');
11
+ }
12
+ /**
13
+ * 生成内部 data_key:provider.kind.resource(例如
14
+ * buildDataKey('fuyao', 'api', '/api/a-share/prices/snapshot')
15
+ * => 'fuyao.api.api.a-share.prices.snapshot')。
16
+ */
17
+ export function buildDataKey(provider, kind, resource) {
18
+ return [provider, kind, resource].map(normalizeKeyToken).filter(Boolean).join('.');
19
+ }
20
+ /** 目录项摘要的兜底:描述缺失或没有 summary 时取首句并截断。 */
21
+ function summarize(source) {
22
+ if (typeof source.summary === 'string' && source.summary.trim().length > 0)
23
+ return source.summary.trim();
24
+ const description = (source.description ?? '').trim();
25
+ if (description.length === 0)
26
+ return source.capability;
27
+ const firstSentence = description.split(/[。;;.]/)[0]?.trim() ?? description;
28
+ return firstSentence.length > 80 ? `${firstSentence.slice(0, 79)}…` : firstSentence;
29
+ }
30
+ /**
31
+ * 目录字段的转义:`|` 与换行是行编码的分隔符,撞上去的后果是**一条能力被读成两条**
32
+ * (错了不报错、只是话说错那一族),所以由生产者转义而不是指望摘要里恰好没有。
33
+ */
34
+ function encodeDirectoryField(value) {
35
+ return value
36
+ .replace(/\\/g, '\\\\')
37
+ .replace(/\|/g, '\\|')
38
+ .replace(/\r\n?/g, '\\n')
39
+ .replace(/\n/g, '\\n')
40
+ .replace(/\t/g, '\\t');
41
+ }
42
+ function isPlainRecord(value) {
43
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
44
+ }
45
+ /** 说明段里不许有换行:一个字段变成两行会让字典的"一行一字段"承诺失效。 */
46
+ function inlineFieldText(value) {
47
+ if (typeof value !== 'string')
48
+ return undefined;
49
+ const text = value.replace(/\s+/g, ' ').trim();
50
+ return text.length === 0 ? undefined : text;
51
+ }
52
+ /** 字段字典的递归上界:真实契约最深 3 层,留余量,也不给畸形 schema 无限展开的机会。 */
53
+ const MAX_FIELD_DEPTH = 5;
54
+ function inspectField(node) {
55
+ if (!isPlainRecord(node))
56
+ return { label: 'json', nullable: false };
57
+ const outerNote = inlineFieldText(node.description);
58
+ if (Array.isArray(node.oneOf)) {
59
+ const branches = node.oneOf.filter((branch) => !isPlainRecord(branch) || branch.type !== 'null');
60
+ const nullable = branches.length !== node.oneOf.length;
61
+ // 可空字段的说明写在联合的外侧({ oneOf: [...], description }),内侧分支的说明作兜底。
62
+ const note = outerNote === undefined ? undefined : { description: outerNote };
63
+ if (branches.length === 1)
64
+ return { ...inspectField(branches[0]), nullable, ...(note ?? {}) };
65
+ return { label: branches.map((branch) => inspectField(branch).label).join('/') || 'json', nullable, ...(note ?? {}) };
66
+ }
67
+ const description = outerNote;
68
+ const properties = isPlainRecord(node.properties) ? node.properties : undefined;
69
+ if (node.type === 'array' || (node.type === undefined && node.items !== undefined)) {
70
+ const items = isPlainRecord(node.items) ? node.items : undefined;
71
+ const itemProperties = items === undefined ? undefined : (isPlainRecord(items.properties) ? items.properties : undefined);
72
+ return {
73
+ label: 'array',
74
+ nullable: false,
75
+ ...(itemProperties === undefined ? {} : { nested: itemProperties, nestedArray: true }),
76
+ ...(description === undefined ? {} : { description }),
77
+ };
78
+ }
79
+ if (properties !== undefined) {
80
+ return { label: typeof node.type === 'string' ? node.type : 'object', nullable: false, nested: properties, ...(description === undefined ? {} : { description }) };
81
+ }
82
+ // 只有 `enum` 没写 `type` 的字段(龙虎榜渠道、板块类型这类):按取值推断类型,并把取值列进说明段。
83
+ // 标成 `json` 是话说错——模型会以为取值开放,于是写出目录里不存在的值。
84
+ if (typeof node.type !== 'string' && Array.isArray(node.enum) && node.enum.length > 0) {
85
+ const kinds = new Set(node.enum.map((value) => (typeof value === 'number' && Number.isInteger(value) ? 'integer' : typeof value)));
86
+ const values = node.enum.join('/');
87
+ const note = description ?? (values.length <= 120 ? `取值 ${values}` : undefined);
88
+ return { label: kinds.size === 1 ? String([...kinds][0]) : 'string', nullable: false, ...(note === undefined ? {} : { description: note }) };
89
+ }
90
+ return { label: typeof node.type === 'string' ? node.type : 'json', nullable: false, ...(description === undefined ? {} : { description }) };
91
+ }
92
+ function emitFields(properties, prefix, lines, depth) {
93
+ for (const [name, definition] of Object.entries(properties)) {
94
+ const shape = inspectField(definition);
95
+ const nullable = shape.nullable ? '?' : '';
96
+ const note = shape.description === undefined ? '' : `:${shape.description}`;
97
+ lines.push(`${prefix}${name}:${shape.label}${nullable}${note}`);
98
+ if (shape.nested === undefined || depth >= MAX_FIELD_DEPTH)
99
+ continue;
100
+ emitFields(shape.nested, `${prefix}${name}${shape.nestedArray ? '[].' : '.'}`, lines, depth + 1);
101
+ }
102
+ }
103
+ /**
104
+ * 把声明好的 `output_schema` 投影成字段字典:一行一个字段 `路径:类型[:说明]`,
105
+ * 类型后缀 `?` 表示可为 null,`[].` 表示数组元素的字段,取值受限时说明段给出 `取值 a/b`。
106
+ *
107
+ * 为什么不直接把 JSON Schema 交给模型(实测):详情体积几乎就等于 `output_schema` 体积,
108
+ * 而它按每列 ~55 字符随**列数**线性增长——72 列的可转债档案光 schema 就 4699 字符,第一次
109
+ * 注册就打穿单能力 4096 的预算。字典把每列压到 ~15 字符,列数不再卡住补录。
110
+ * 权威声明仍住 `output_schema`:护栏与返回值契约照旧按 schema 校验,这里只是给模型看的投影,
111
+ * 不是第二份真相。
112
+ */
113
+ function renderOutputFields(schema) {
114
+ if (!isPlainRecord(schema))
115
+ return null;
116
+ const shape = inspectField(schema);
117
+ if (shape.nested === undefined)
118
+ return null;
119
+ const lines = [];
120
+ emitFields(shape.nested, shape.nestedArray === true ? '[].' : '', lines, 1);
121
+ return lines.length === 0 ? null : lines.join('\n');
122
+ }
123
+ function stableSerialize(value) {
124
+ if (value === null || typeof value !== 'object')
125
+ return JSON.stringify(value);
126
+ if (Array.isArray(value))
127
+ return `[${value.map(stableSerialize).join(',')}]`;
128
+ const record = value;
129
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`).join(',')}}`;
130
+ }
131
+ function paramsDigest(request) {
132
+ const serialized = stableSerialize(request.params);
133
+ const processLike = globalThis.process;
134
+ const createHash = processLike?.getBuiltinModule?.('node:crypto')?.createHash;
135
+ if (createHash)
136
+ return createHash('sha256').update(serialized).digest('hex');
137
+ // DSH runs on Node, but keep a deterministic opaque fallback for minimal hosts/tests.
138
+ let hash = 0xcbf29ce484222325n;
139
+ for (const character of serialized) {
140
+ hash ^= BigInt(character.codePointAt(0) ?? 0);
141
+ hash = BigInt.asUintN(64, hash * 0x100000001b3n);
142
+ }
143
+ return hash.toString(16).padStart(16, '0').repeat(4);
144
+ }
145
+ /** in-flight 合并键:同一 capability+params+task 的并发请求共享同一次执行。 */
146
+ function mergeKey(request) {
147
+ return `${request.capability}:${request.task_id ?? ''}:${stableSerialize(request.params)}`;
148
+ }
149
+ function errorMessage(error) {
150
+ return error instanceof Error ? error.message : String(error);
151
+ }
152
+ /**
153
+ * 把上游错误重新包装成 Error 时**保留稳定错误码**。
154
+ *
155
+ * DatasetStoreError 带 `code`(如 workspace_not_writable / dataset_expired),但此前一律被
156
+ * 拍成纯文本 Error,调用方只能从消息前缀里猜。协议要求失败回传携带 `error` 与 `code`,
157
+ * 因此这里把 code 原样挂到新 Error 上,工具层与上层消费者都可直接读取。
158
+ */
159
+ function errorWithCode(message, source) {
160
+ const failure = new Error(message);
161
+ if (source && typeof source === 'object' && typeof source.code === 'string') {
162
+ ;
163
+ failure.code = source.code;
164
+ }
165
+ return failure;
166
+ }
167
+ /**
168
+ * Phase 1 数据执行器:FIFO 串行执行外部数据源,成功后由宿主 store 立即
169
+ * 原子落盘为不可变 Dataset,只回传 DatasetRef。
170
+ *
171
+ * 明确不做:长期 raw data 内存缓存、按 data_key 的查询、source_preference 路由。
172
+ * 已验证 DatasetRef 的复用由 workspace manifest 提供,内存只保留 in-flight 队列。
173
+ */
174
+ export class DataCollectorHub {
175
+ queue = [];
176
+ /** 正在执行的队列项(执行期间不在 queue 中,但仍参与合并与容量判断)。 */
177
+ active = null;
178
+ sources = new Map();
179
+ cacheLookups = new Map();
180
+ store;
181
+ running = false;
182
+ maxQueueLength;
183
+ requestTimeoutMs;
184
+ now;
185
+ constructor(options) {
186
+ if (!options.store)
187
+ throw new Error('DataCollectorHub requires a dataset store');
188
+ this.store = options.store;
189
+ this.maxQueueLength = options.maxQueueLength ?? 50;
190
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;
191
+ this.now = options.now ?? (() => Date.now());
192
+ }
193
+ registerSource(source) {
194
+ const capability = source.schema.capability;
195
+ if (!capability)
196
+ throw new Error('data source requires a capability');
197
+ if (source.schema.cacheMaxAgeMs !== undefined && (!Number.isSafeInteger(source.schema.cacheMaxAgeMs) || source.schema.cacheMaxAgeMs <= 0)) {
198
+ throw new Error(`data source ${capability} cacheMaxAgeMs must be a positive safe integer`);
199
+ }
200
+ if (this.sources.has(capability))
201
+ throw new Error(`data source capability already registered: ${capability}`);
202
+ for (const other of this.sources.values()) {
203
+ if (other.schema.data_key === source.schema.data_key)
204
+ throw new Error(`data source data_key already registered: ${source.schema.data_key}`);
205
+ }
206
+ this.sources.set(capability, source);
207
+ return () => { if (this.sources.get(capability) === source)
208
+ this.sources.delete(capability); };
209
+ }
210
+ /**
211
+ * 入队并阻塞等待完成:先按 capability+params_digest 查找当前 session 下仍有效的
212
+ * manifest;force_refresh=true 或没有可复用 Dataset 时才进入数据源队列。相同的
213
+ * in-flight 请求仍共享一次执行,成功后立即由宿主 store 原子落盘。
214
+ *
215
+ * 参数规范化(source 可选)发生在这里:merge key 与 digest 都用规范化后的参数,
216
+ * 因此「大小写/空白/重复项不同、语义相同」的请求命中同一个 Dataset。未知
217
+ * capability 不做处理,仍由执行阶段抛出统一的 no data source 错误。
218
+ */
219
+ async request(request, options = {}) {
220
+ if (!request.capability)
221
+ throw new Error('capability is required');
222
+ if (!request.session)
223
+ throw new Error('a calling agent session is required');
224
+ if (options.signal?.aborted)
225
+ throw new Error('request aborted');
226
+ const normalized = this.normalizeRequest(request);
227
+ const key = mergeKey(normalized);
228
+ const existing = this.queue.find((item) => item.mergeKey === key) ?? (this.active && this.active.mergeKey === key ? this.active : undefined);
229
+ if (existing)
230
+ return this.awaitSettlement(existing, options.signal);
231
+ if (normalized.force_refresh !== true && this.store.findLatest) {
232
+ let lookup = this.cacheLookups.get(key);
233
+ if (!lookup) {
234
+ // 这个 lookup 会被同一 mergeKey 的**所有**并发调用者共享,因此绝不能绑定
235
+ // 其中任何一个人的 AbortSignal:首个调用者取消会让其余调用者一起收到同一个
236
+ // lookup 失败,而不是各自独立等待。取消只作用于调用者自己的 waiter。
237
+ lookup = this.store.findLatest({
238
+ session: normalized.session,
239
+ capability: normalized.capability,
240
+ params_digest: paramsDigest(normalized),
241
+ });
242
+ this.cacheLookups.set(key, lookup);
243
+ void lookup.finally(() => {
244
+ if (this.cacheLookups.get(key) === lookup)
245
+ this.cacheLookups.delete(key);
246
+ }).catch(() => { });
247
+ }
248
+ const reusable = await lookup;
249
+ const maxAge = this.sources.get(normalized.capability)?.schema.cacheMaxAgeMs;
250
+ const age = reusable ? this.now() - reusable.captured_at : 0;
251
+ if (reusable && (maxAge === undefined || (age >= 0 && age < maxAge)))
252
+ return reusable;
253
+ const afterLookup = this.queue.find((item) => item.mergeKey === key) ?? (this.active && this.active.mergeKey === key ? this.active : undefined);
254
+ if (afterLookup)
255
+ return this.awaitSettlement(afterLookup, options.signal);
256
+ }
257
+ if (this.queue.length + (this.active ? 1 : 0) >= this.maxQueueLength)
258
+ throw new Error(`data request queue is full (max ${this.maxQueueLength})`);
259
+ const item = { request: normalized, mergeKey: key, waiters: new Set() };
260
+ this.queue.push(item);
261
+ const pending = this.awaitSettlement(item, options.signal);
262
+ void this.processNext();
263
+ return pending;
264
+ }
265
+ /** 应用 source 的可选参数规范化;未声明或 capability 未注册时原样返回。 */
266
+ normalizeRequest(request) {
267
+ const source = this.sources.get(request.capability);
268
+ if (!source?.normalizeParams)
269
+ return request;
270
+ return { ...request, params: source.normalizeParams(request.params) };
271
+ }
272
+ /** 挂一个等待者并返回其结算承诺;signal 触发时摘除等待者并拒绝(不影响共享执行)。 */
273
+ awaitSettlement(item, signal) {
274
+ return new Promise((resolve, reject) => {
275
+ const waiter = { resolve, reject, signal };
276
+ if (signal?.aborted) {
277
+ reject(new Error('request aborted'));
278
+ return;
279
+ }
280
+ if (signal) {
281
+ waiter.onAbort = () => {
282
+ item.waiters.delete(waiter);
283
+ reject(new Error('request aborted'));
284
+ };
285
+ signal.addEventListener('abort', waiter.onAbort, { once: true });
286
+ }
287
+ item.waiters.add(waiter);
288
+ });
289
+ }
290
+ /** 结算一个等待者:无论成败都摘除监听,防止重复结算。 */
291
+ settleWaiter(item, waiter, outcome) {
292
+ item.waiters.delete(waiter);
293
+ if (waiter.onAbort && waiter.signal)
294
+ waiter.signal.removeEventListener('abort', waiter.onAbort);
295
+ if (outcome.kind === 'ok')
296
+ waiter.resolve(outcome.ref);
297
+ else
298
+ waiter.reject(outcome.error);
299
+ }
300
+ /**
301
+ * 能力目录(模型可见的最外层发现入口):只含 capability、一行摘要与是否分页。
302
+ *
303
+ * 目录刻意不带 input_schema / output_schema —— 全量 schema 一度达 23KB,
304
+ * 会被工具结果剪枝器截掉中间部分,导致排在中间的能力在发现阶段不可见。
305
+ * 目录必须一次性全部返回(体积预算由测试守住),详情走 describeCapability。
306
+ * 不含任何内部路由字段(name/source/data_key/source_label)。
307
+ */
308
+ listCapabilities() {
309
+ return [...this.sources.values()].map((source) => ({
310
+ capability: source.schema.capability,
311
+ summary: summarize(source.schema),
312
+ paginated: source.schema.paginated === true,
313
+ }));
314
+ }
315
+ /** 当前已注册的能力名(用于未知名字的引导与诊断)。 */
316
+ capabilityNames() {
317
+ return [...this.sources.keys()];
318
+ }
319
+ /**
320
+ * 能力目录的模型可见形态:一行一条 `capability|summary|paginated`(1=分页、0=不分页)。
321
+ * 同等信息量下 JSON 数组要 5530 字符(69 条实测,其中 58% 是键名与标点),行编码 2435、
322
+ * 均摊从 80 降到 35 字符——目录余量从 +7 条变成 +105 条。信息量没变、依然一次性全量返回,
323
+ * §1.1 那条"不分页、不裁剪"的硬约束原样成立。结构化形态仍由 listCapabilities 提供(测试与内部消费)。
324
+ */
325
+ capabilityDirectory() {
326
+ return this.listCapabilities()
327
+ .map((entry) => `${encodeDirectoryField(entry.capability)}|${encodeDirectoryField(entry.summary)}|${entry.paginated ? 1 : 0}`)
328
+ .join('\n');
329
+ }
330
+ /**
331
+ * 单个能力的完整契约;名字未注册时返回 undefined,由工具层分类成可引导的错误。
332
+ * 返回值只含模型可见字段,是新建的普通对象,不回传内部 live data。
333
+ * `output_fields` 是 `output_schema` 的投影:详情体积按列数增长会被宽表打穿,见 renderOutputFields。
334
+ */
335
+ describeCapability(capability) {
336
+ const source = this.sources.get(capability);
337
+ if (!source)
338
+ return undefined;
339
+ return {
340
+ capability: source.schema.capability,
341
+ summary: summarize(source.schema),
342
+ paginated: source.schema.paginated === true,
343
+ description: source.schema.description ?? '',
344
+ input_schema: source.schema.input_schema,
345
+ output_fields: renderOutputFields(source.schema.output_schema),
346
+ };
347
+ }
348
+ async processNext() {
349
+ if (this.running)
350
+ return;
351
+ const item = this.queue.shift();
352
+ if (!item)
353
+ return;
354
+ this.running = true;
355
+ this.active = item;
356
+ let timedOut = false;
357
+ try {
358
+ const source = this.sources.get(item.request.capability);
359
+ if (!source)
360
+ throw new Error(`no data source is registered for capability ${item.request.capability}`);
361
+ const controller = new AbortController();
362
+ let timeout;
363
+ const operation = source.execute(item.request, controller.signal);
364
+ // Abort cooperative sources and also release the FIFO worker if a source
365
+ // ignores the signal. Observe the underlying promise to avoid an
366
+ // unhandled rejection after the timeout race has settled.
367
+ void operation.catch(() => { });
368
+ const timeoutPromise = new Promise((_, reject) => {
369
+ timeout = setTimeout(() => {
370
+ timedOut = true;
371
+ controller.abort();
372
+ reject(new Error('request timed out'));
373
+ }, this.requestTimeoutMs);
374
+ });
375
+ try {
376
+ const output = await Promise.race([operation, timeoutPromise]);
377
+ if (timedOut)
378
+ throw new Error('request timed out');
379
+ if (source.validateOutput && !source.validateOutput(output.data)) {
380
+ throw new Error(`data source ${source.schema.name} returned data incompatible with its output contract`);
381
+ }
382
+ const { format, rowCount, rowKey } = shapeOf(output.data, source.schema.rowShape);
383
+ const ref = await this.store.save({
384
+ session: item.request.session,
385
+ capability: item.request.capability,
386
+ task_id: item.request.task_id,
387
+ params_digest: paramsDigest(item.request),
388
+ source_label: source.schema.source_label ?? source.schema.source,
389
+ format,
390
+ schema: output.schema ?? source.schema.output_schema ?? null,
391
+ row_count: rowCount,
392
+ data: output.data,
393
+ row_key: rowKey,
394
+ });
395
+ for (const waiter of [...item.waiters])
396
+ this.settleWaiter(item, waiter, { kind: 'ok', ref });
397
+ }
398
+ finally {
399
+ if (timeout)
400
+ clearTimeout(timeout);
401
+ }
402
+ }
403
+ catch (error) {
404
+ const message = timedOut ? 'request timed out' : errorMessage(error);
405
+ const failure = errorWithCode(message, error);
406
+ for (const waiter of [...item.waiters])
407
+ this.settleWaiter(item, waiter, { kind: 'error', error: failure });
408
+ }
409
+ finally {
410
+ this.running = false;
411
+ this.active = null;
412
+ void this.processNext();
413
+ }
414
+ }
415
+ }
416
+ /**
417
+ * 从数据源声明的行形状推导存储 format 与行数;未声明时退化为形状推断。
418
+ *
419
+ * 之前只认 `data.item`,于是龙虎榜(行数组在 `stock_items`)被整份判成
420
+ * `format: 'json'`,data_junior 连 inspect 都说不可读。行在哪里是数据源的
421
+ * 契约知识,不该由存储层猜。
422
+ */
423
+ function shapeOf(data, hint) {
424
+ if (Array.isArray(data))
425
+ return { format: 'json_rows', rowCount: data.length };
426
+ const keys = hint?.rowKeys ?? [hint?.rowKey ?? 'item'];
427
+ let emptyKey;
428
+ for (const key of keys) {
429
+ const candidate = rowsAtPath(data, key);
430
+ if (candidate === undefined)
431
+ continue;
432
+ if (candidate.length > 0)
433
+ return { format: 'json_rows', rowCount: candidate.length, rowKey: key };
434
+ emptyKey ??= key;
435
+ }
436
+ if (emptyKey !== undefined)
437
+ return { format: 'json_rows', rowCount: 0, rowKey: emptyKey };
438
+ return { format: 'json', rowCount: null };
439
+ }
440
+ function rowsAtPath(value, path) {
441
+ const segments = path.split('.').filter(Boolean);
442
+ const visit = (current, index) => {
443
+ if (index === segments.length)
444
+ return Array.isArray(current) ? current : undefined;
445
+ if (!current || typeof current !== 'object' || Array.isArray(current))
446
+ return undefined;
447
+ const segment = segments[index];
448
+ if (segment.endsWith('[]')) {
449
+ const child = current[segment.slice(0, -2)];
450
+ if (!Array.isArray(child))
451
+ return undefined;
452
+ const merged = [];
453
+ for (const item of child) {
454
+ const rows = visit(item, index + 1);
455
+ if (rows === undefined)
456
+ return undefined;
457
+ merged.push(...rows);
458
+ }
459
+ return merged;
460
+ }
461
+ return visit(current[segment], index + 1);
462
+ };
463
+ return visit(value, 0);
464
+ }
465
+ export function provideDataCollectorHub(ctx, options) {
466
+ const hub = new DataCollectorHub(options);
467
+ ctx.provide('dataCollectorHub', hub);
468
+ return hub;
469
+ }
@@ -0,0 +1,58 @@
1
+ import { type QuerySpec } from './query.js';
2
+ import type { SessionLike, TimeAxisSnapshot } from './store.js';
3
+ /**
4
+ * 受控查询的**时间轴归一**:唯一实现,`query_dataset` 与 `describe_dataset.queries` 共用。
5
+ *
6
+ * ⛔ 2026-09-23 事故(会话 `66fa9666`):这一层最初只接在 `query_dataset` 上,`describe_dataset`
7
+ * 内嵌的 queries 直接走引擎,于是同一个模型在同一个回合里得到两种行为——
8
+ * - `query_dataset`:`">=": "2026-09"` → 正确当作 9 月区间;
9
+ * - `describe_dataset.queries`:`">=": "2026"` → 被引擎当**数字 2026** 比较,243 行全中
10
+ * (`days_2026: 243`,静默错误);`">=": "2026-07"` → 整条被 `skip_with_warning` 跳过,
11
+ * `matched_row_count: 0`(静默空结果)。
12
+ * 协议与委派消息都写着"时间筛选直接写日期串",所以**只要有一个入口没接上,模型就一定踩**。
13
+ * 新增任何"执行 QuerySpec"的入口都必须复用本模块,不要在别处再写一份。
14
+ */
15
+ /**
16
+ * 时间轴归一:把 filter 里的日期写法(`2026-08-23`、`2026-08`、`2026`、`{period:"last_3_months"}`)
17
+ * 翻译成该 Dataset 时间列自己的边界值,并给结果补 `*_iso` 可读时间列。
18
+ *
19
+ * 为什么在工具边界做(真机会话 1e44050e 实测):`date_ms` 列只接受毫秒,模型于是把
20
+ * "近 1 月 / 近 3 月 / 52 周 / 年初至今 / 历年 10 月"全部手算成 epoch——单步思考 41,961
21
+ * 字符里绝大部分是这类算术,而且算错就是错口径。日历换算是确定性计算,属于宿主:
22
+ * 模型只写日历,宿主按**该列观测到的偏移**换算。
23
+ *
24
+ * 日期串落到数值时间列时**一律响亮失败**:旧路径下 `skip_with_warning` 会把这条 filter
25
+ * 整条跳过(`filter X skipped incompatible value`),于是"筛选消失、返回全量"却看起来成功。
26
+ */
27
+ export declare function normalizeQueryDates(input: {
28
+ store: TimeAxisProbe;
29
+ session: SessionLike;
30
+ dataset_id: string;
31
+ query: QuerySpec;
32
+ time_column?: string;
33
+ signal?: AbortSignal;
34
+ }): Promise<{
35
+ query: QuerySpec;
36
+ axis?: {
37
+ column: string;
38
+ time_zone?: string;
39
+ };
40
+ }>;
41
+ /** 时间轴只读探针:`WorkspaceDatasetStore` 与测试替身都满足这个形状。 */
42
+ export interface TimeAxisProbe {
43
+ describeTimeAxis(input: {
44
+ session: unknown;
45
+ dataset_id: string;
46
+ time_column?: string;
47
+ signal?: AbortSignal;
48
+ }): Promise<TimeAxisSnapshot | undefined>;
49
+ }
50
+ /**
51
+ * 结果补 ISO 时间列:`date_ms: 1787414400000` 旁边给 `date_ms_iso: "2026-08-23"`。
52
+ * 只加不删,原值仍是唯一权威(回传口径要求"字段按工具返回原样")。
53
+ * 已有同名 `*_iso` 列时不覆盖,避免与 Dataset 自带字段撞名。
54
+ */
55
+ export declare function withIsoTimeColumns(result: Record<string, unknown>, axis: {
56
+ column: string;
57
+ time_zone?: string;
58
+ }): void;
@@ -0,0 +1,101 @@
1
+ import { MAX_QUERY_FILTERS, DatasetQueryError } from './query.js';
2
+ import { isRecord } from './args.js';
3
+ import { isoDateFromEpoch, translateFilterDateValue } from './time-axis.js';
4
+ /**
5
+ * 受控查询的**时间轴归一**:唯一实现,`query_dataset` 与 `describe_dataset.queries` 共用。
6
+ *
7
+ * ⛔ 2026-09-23 事故(会话 `66fa9666`):这一层最初只接在 `query_dataset` 上,`describe_dataset`
8
+ * 内嵌的 queries 直接走引擎,于是同一个模型在同一个回合里得到两种行为——
9
+ * - `query_dataset`:`">=": "2026-09"` → 正确当作 9 月区间;
10
+ * - `describe_dataset.queries`:`">=": "2026"` → 被引擎当**数字 2026** 比较,243 行全中
11
+ * (`days_2026: 243`,静默错误);`">=": "2026-07"` → 整条被 `skip_with_warning` 跳过,
12
+ * `matched_row_count: 0`(静默空结果)。
13
+ * 协议与委派消息都写着"时间筛选直接写日期串",所以**只要有一个入口没接上,模型就一定踩**。
14
+ * 新增任何"执行 QuerySpec"的入口都必须复用本模块,不要在别处再写一份。
15
+ */
16
+ /**
17
+ * 时间轴归一:把 filter 里的日期写法(`2026-08-23`、`2026-08`、`2026`、`{period:"last_3_months"}`)
18
+ * 翻译成该 Dataset 时间列自己的边界值,并给结果补 `*_iso` 可读时间列。
19
+ *
20
+ * 为什么在工具边界做(真机会话 1e44050e 实测):`date_ms` 列只接受毫秒,模型于是把
21
+ * "近 1 月 / 近 3 月 / 52 周 / 年初至今 / 历年 10 月"全部手算成 epoch——单步思考 41,961
22
+ * 字符里绝大部分是这类算术,而且算错就是错口径。日历换算是确定性计算,属于宿主:
23
+ * 模型只写日历,宿主按**该列观测到的偏移**换算。
24
+ *
25
+ * 日期串落到数值时间列时**一律响亮失败**:旧路径下 `skip_with_warning` 会把这条 filter
26
+ * 整条跳过(`filter X skipped incompatible value`),于是"筛选消失、返回全量"却看起来成功。
27
+ */
28
+ export async function normalizeQueryDates(input) {
29
+ const filters = input.query.filters;
30
+ if (filters === undefined || !filters.some((filter) => /date|time|timestamp|_dt$|report$/i.test(filter.column)))
31
+ return { query: input.query };
32
+ let snapshot;
33
+ try {
34
+ snapshot = await input.store.describeTimeAxis({
35
+ session: input.session,
36
+ dataset_id: input.dataset_id,
37
+ time_column: input.time_column,
38
+ signal: input.signal,
39
+ });
40
+ }
41
+ catch {
42
+ // 探针只服务"日期写法"这一便利:探针不可用(文档型 Dataset、不可读)时保持原样,
43
+ // 让真正的查询路径去报它自己的错,不要在这里把错误提前成另一种。
44
+ return { query: input.query };
45
+ }
46
+ if (snapshot === undefined)
47
+ return { query: input.query };
48
+ const axis = { axis: snapshot.axis, dates: snapshot.dates };
49
+ const translated = [];
50
+ let changed = false;
51
+ for (const filter of filters) {
52
+ const replaced = filter.column === axis.axis.column
53
+ ? translateFilterDateValue({
54
+ axis: axis.axis,
55
+ dates: axis.dates,
56
+ column: filter.column,
57
+ operator: filter.operator,
58
+ value: filter.value,
59
+ })
60
+ : undefined;
61
+ if (replaced === undefined) {
62
+ translated.push(filter);
63
+ continue;
64
+ }
65
+ changed = true;
66
+ for (const item of replaced) {
67
+ if (translated.length >= MAX_QUERY_FILTERS) {
68
+ throw new DatasetQueryError('query_spec_invalid', `date filters expand to at most ${MAX_QUERY_FILTERS} conditions`);
69
+ }
70
+ translated.push({ column: filter.column, operator: item.operator, value: item.value });
71
+ }
72
+ }
73
+ return {
74
+ query: changed ? { ...input.query, filters: translated } : input.query,
75
+ axis: { column: snapshot.axis.column, ...(snapshot.axis.time_zone === undefined ? {} : { time_zone: snapshot.axis.time_zone }) },
76
+ };
77
+ }
78
+ /**
79
+ * 结果补 ISO 时间列:`date_ms: 1787414400000` 旁边给 `date_ms_iso: "2026-08-23"`。
80
+ * 只加不删,原值仍是唯一权威(回传口径要求"字段按工具返回原样")。
81
+ * 已有同名 `*_iso` 列时不覆盖,避免与 Dataset 自带字段撞名。
82
+ */
83
+ export function withIsoTimeColumns(result, axis) {
84
+ const rows = result.rows;
85
+ if (!Array.isArray(rows))
86
+ return;
87
+ const isoColumn = `${axis.column}_iso`;
88
+ const zone = axis.time_zone ?? 'UTC';
89
+ for (const row of rows) {
90
+ if (!isRecord(row) || isoColumn in row)
91
+ continue;
92
+ const value = row[axis.column];
93
+ if (typeof value !== 'number' || !Number.isFinite(value))
94
+ continue;
95
+ row[isoColumn] = isoDateFromEpoch(value, zone);
96
+ }
97
+ const columns = result.columns;
98
+ if (Array.isArray(columns) && !columns.includes(isoColumn) && rows.some((row) => isRecord(row) && isoColumn in row)) {
99
+ columns.push(isoColumn);
100
+ }
101
+ }