opencandle 0.10.0 → 0.11.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 (223) hide show
  1. package/README.md +12 -9
  2. package/dist/analysts/orchestrator.d.ts +3 -4
  3. package/dist/analysts/orchestrator.js +34 -44
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/cli-main.js +242 -9
  6. package/dist/cli-main.js.map +1 -1
  7. package/dist/config.d.ts +4 -9
  8. package/dist/config.js +7 -10
  9. package/dist/config.js.map +1 -1
  10. package/dist/doctor/report.js +1 -1
  11. package/dist/doctor/report.js.map +1 -1
  12. package/dist/infra/cache.d.ts +6 -0
  13. package/dist/infra/cache.js +16 -6
  14. package/dist/infra/cache.js.map +1 -1
  15. package/dist/infra/freshness.d.ts +21 -0
  16. package/dist/infra/freshness.js +119 -0
  17. package/dist/infra/freshness.js.map +1 -0
  18. package/dist/infra/market-calendar.d.ts +14 -0
  19. package/dist/infra/market-calendar.js +137 -0
  20. package/dist/infra/market-calendar.js.map +1 -0
  21. package/dist/infra/rate-limiter.js +1 -0
  22. package/dist/infra/rate-limiter.js.map +1 -1
  23. package/dist/market-state/daily-report.d.ts +1 -1
  24. package/dist/market-state/daily-report.js +5 -16
  25. package/dist/market-state/daily-report.js.map +1 -1
  26. package/dist/market-state/service.d.ts +0 -35
  27. package/dist/market-state/service.js +0 -63
  28. package/dist/market-state/service.js.map +1 -1
  29. package/dist/market-state/summaries.d.ts +8 -0
  30. package/dist/market-state/summaries.js +70 -0
  31. package/dist/market-state/summaries.js.map +1 -0
  32. package/dist/memory/sqlite.js +20 -19
  33. package/dist/memory/sqlite.js.map +1 -1
  34. package/dist/onboarding/providers.d.ts +25 -1
  35. package/dist/onboarding/providers.js +26 -0
  36. package/dist/onboarding/providers.js.map +1 -1
  37. package/dist/pi/opencandle-extension.js +57 -226
  38. package/dist/pi/opencandle-extension.js.map +1 -1
  39. package/dist/pi/session-action-dedupe.d.ts +6 -0
  40. package/dist/pi/session-action-dedupe.js +126 -0
  41. package/dist/pi/session-action-dedupe.js.map +1 -0
  42. package/dist/pi/session-writer-lock.d.ts +26 -2
  43. package/dist/pi/session-writer-lock.js +230 -18
  44. package/dist/pi/session-writer-lock.js.map +1 -1
  45. package/dist/pi/setup.js +5 -5
  46. package/dist/pi/setup.js.map +1 -1
  47. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  48. package/dist/pi/tui-session-coordinator.js +283 -0
  49. package/dist/pi/tui-session-coordinator.js.map +1 -0
  50. package/dist/prompts/context-builder.js +1 -1
  51. package/dist/prompts/policy-cards.js +1 -1
  52. package/dist/prompts/policy-cards.js.map +1 -1
  53. package/dist/providers/alpha-vantage.js +6 -0
  54. package/dist/providers/alpha-vantage.js.map +1 -1
  55. package/dist/providers/coingecko.js +1 -0
  56. package/dist/providers/coingecko.js.map +1 -1
  57. package/dist/providers/polymarket.d.ts +2 -0
  58. package/dist/providers/polymarket.js +163 -0
  59. package/dist/providers/polymarket.js.map +1 -0
  60. package/dist/providers/wrap-provider.js +5 -3
  61. package/dist/providers/wrap-provider.js.map +1 -1
  62. package/dist/providers/yahoo-finance.d.ts +3 -0
  63. package/dist/providers/yahoo-finance.js +206 -10
  64. package/dist/providers/yahoo-finance.js.map +1 -1
  65. package/dist/routing/classify-intent.js +4 -5
  66. package/dist/routing/classify-intent.js.map +1 -1
  67. package/dist/routing/entity-extractor.js +16 -0
  68. package/dist/routing/entity-extractor.js.map +1 -1
  69. package/dist/routing/route-manifest.js +1 -2
  70. package/dist/routing/route-manifest.js.map +1 -1
  71. package/dist/routing/router-llm-client.js +18 -3
  72. package/dist/routing/router-llm-client.js.map +1 -1
  73. package/dist/routing/router-prompt.js +1 -1
  74. package/dist/routing/router.d.ts +1 -1
  75. package/dist/routing/router.js +309 -9
  76. package/dist/routing/router.js.map +1 -1
  77. package/dist/routing/slot-resolver.d.ts +1 -0
  78. package/dist/routing/slot-resolver.js +1 -1
  79. package/dist/routing/slot-resolver.js.map +1 -1
  80. package/dist/runtime/evidence.d.ts +1 -0
  81. package/dist/runtime/evidence.js.map +1 -1
  82. package/dist/runtime/numeric-claims.d.ts +23 -0
  83. package/dist/runtime/numeric-claims.js +100 -0
  84. package/dist/runtime/numeric-claims.js.map +1 -0
  85. package/dist/runtime/planning-evidence.js +1 -76
  86. package/dist/runtime/planning-evidence.js.map +1 -1
  87. package/dist/runtime/prompt-step.d.ts +10 -1
  88. package/dist/runtime/prompt-step.js +116 -2
  89. package/dist/runtime/prompt-step.js.map +1 -1
  90. package/dist/runtime/session-coordinator.d.ts +11 -1
  91. package/dist/runtime/session-coordinator.js +354 -62
  92. package/dist/runtime/session-coordinator.js.map +1 -1
  93. package/dist/runtime/validation.d.ts +3 -1
  94. package/dist/runtime/workflow-events.d.ts +1 -1
  95. package/dist/runtime/workflow-events.js.map +1 -1
  96. package/dist/runtime/workflow-types.d.ts +3 -0
  97. package/dist/runtime/workflow-types.js.map +1 -1
  98. package/dist/system-prompt.js +1 -1
  99. package/dist/tools/fundamentals/comps.d.ts +1 -0
  100. package/dist/tools/fundamentals/comps.js +20 -9
  101. package/dist/tools/fundamentals/comps.js.map +1 -1
  102. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  103. package/dist/tools/fundamentals/dcf.js +190 -76
  104. package/dist/tools/fundamentals/dcf.js.map +1 -1
  105. package/dist/tools/index.d.ts +1 -1
  106. package/dist/tools/index.js +3 -3
  107. package/dist/tools/index.js.map +1 -1
  108. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  109. package/dist/tools/macro/event-probabilities.js +121 -0
  110. package/dist/tools/macro/event-probabilities.js.map +1 -0
  111. package/dist/tools/market/crypto-price.d.ts +4 -1
  112. package/dist/tools/market/crypto-price.js +10 -1
  113. package/dist/tools/market/crypto-price.js.map +1 -1
  114. package/dist/tools/market/screen-stocks.js +14 -3
  115. package/dist/tools/market/screen-stocks.js.map +1 -1
  116. package/dist/tools/market/stock-quote.d.ts +4 -1
  117. package/dist/tools/market/stock-quote.js +9 -4
  118. package/dist/tools/market/stock-quote.js.map +1 -1
  119. package/dist/tools/options/option-chain.d.ts +4 -1
  120. package/dist/tools/options/option-chain.js +12 -1
  121. package/dist/tools/options/option-chain.js.map +1 -1
  122. package/dist/tools/portfolio/daily-report.js +10 -4
  123. package/dist/tools/portfolio/daily-report.js.map +1 -1
  124. package/dist/tools/portfolio/risk-analysis.js +1 -1
  125. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  126. package/dist/tools/technical/backtest.d.ts +23 -5
  127. package/dist/tools/technical/backtest.js +131 -94
  128. package/dist/tools/technical/backtest.js.map +1 -1
  129. package/dist/tools/technical/indicators.js +1 -1
  130. package/dist/tools/technical/indicators.js.map +1 -1
  131. package/dist/types/fundamentals.d.ts +1 -0
  132. package/dist/types/index.d.ts +1 -0
  133. package/dist/types/market.d.ts +2 -0
  134. package/dist/types/options.d.ts +1 -0
  135. package/dist/types/prediction-markets.d.ts +13 -0
  136. package/dist/types/prediction-markets.js +2 -0
  137. package/dist/types/prediction-markets.js.map +1 -0
  138. package/gui/server/chat-event-adapter.ts +89 -5
  139. package/gui/server/http-routes.ts +661 -37
  140. package/gui/server/invoke-tool.ts +165 -16
  141. package/gui/server/live-chat-event-adapter.ts +37 -2
  142. package/gui/server/local-session-coordinator.ts +97 -0
  143. package/gui/server/market-state-api.ts +64 -48
  144. package/gui/server/projector.ts +132 -3
  145. package/gui/server/server.ts +51 -6
  146. package/gui/server/session-actions.ts +146 -45
  147. package/gui/server/session-entry-wait.ts +22 -3
  148. package/gui/server/tool-metadata.ts +3 -1
  149. package/gui/server/ws-hub.ts +77 -10
  150. package/gui/shared/chat-events.ts +10 -1
  151. package/gui/shared/event-reducer.ts +1 -0
  152. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  153. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  154. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  155. package/gui/web/dist/index.html +2 -2
  156. package/package.json +13 -15
  157. package/src/analysts/orchestrator.ts +35 -56
  158. package/src/cli-main.ts +253 -13
  159. package/src/config.ts +12 -20
  160. package/src/doctor/report.ts +1 -1
  161. package/src/infra/cache.ts +24 -11
  162. package/src/infra/freshness.ts +165 -0
  163. package/src/infra/market-calendar.ts +193 -0
  164. package/src/infra/rate-limiter.ts +1 -0
  165. package/src/market-state/daily-report.ts +6 -16
  166. package/src/market-state/service.ts +0 -136
  167. package/src/market-state/summaries.ts +75 -0
  168. package/src/memory/sqlite.ts +23 -19
  169. package/src/onboarding/providers.ts +29 -1
  170. package/src/pi/opencandle-extension.ts +67 -270
  171. package/src/pi/session-action-dedupe.ts +155 -0
  172. package/src/pi/session-writer-lock.ts +290 -20
  173. package/src/pi/setup.ts +6 -6
  174. package/src/pi/tui-session-coordinator.ts +351 -0
  175. package/src/prompts/context-builder.ts +1 -1
  176. package/src/prompts/policy-cards.ts +1 -1
  177. package/src/providers/alpha-vantage.ts +6 -0
  178. package/src/providers/coingecko.ts +2 -0
  179. package/src/providers/polymarket.ts +214 -0
  180. package/src/providers/wrap-provider.ts +5 -3
  181. package/src/providers/yahoo-finance.ts +339 -27
  182. package/src/routing/classify-intent.ts +4 -5
  183. package/src/routing/entity-extractor.ts +17 -0
  184. package/src/routing/route-manifest.ts +1 -2
  185. package/src/routing/router-llm-client.ts +31 -19
  186. package/src/routing/router-prompt.ts +1 -1
  187. package/src/routing/router.ts +379 -8
  188. package/src/routing/slot-resolver.ts +1 -1
  189. package/src/runtime/evidence.ts +1 -0
  190. package/src/runtime/numeric-claims.ts +108 -0
  191. package/src/runtime/planning-evidence.ts +6 -91
  192. package/src/runtime/prompt-step.ts +127 -2
  193. package/src/runtime/session-coordinator.ts +464 -74
  194. package/src/runtime/validation.ts +1 -1
  195. package/src/runtime/workflow-events.ts +2 -0
  196. package/src/runtime/workflow-types.ts +3 -0
  197. package/src/system-prompt.ts +1 -1
  198. package/src/tools/AGENTS.md +1 -1
  199. package/src/tools/fundamentals/comps.ts +25 -14
  200. package/src/tools/fundamentals/dcf.ts +217 -87
  201. package/src/tools/index.ts +3 -3
  202. package/src/tools/macro/event-probabilities.ts +141 -0
  203. package/src/tools/market/crypto-price.ts +14 -2
  204. package/src/tools/market/screen-stocks.ts +14 -3
  205. package/src/tools/market/stock-quote.ts +49 -43
  206. package/src/tools/options/option-chain.ts +16 -2
  207. package/src/tools/portfolio/daily-report.ts +10 -4
  208. package/src/tools/portfolio/risk-analysis.ts +1 -1
  209. package/src/tools/technical/backtest.ts +167 -108
  210. package/src/tools/technical/indicators.ts +1 -1
  211. package/src/types/fundamentals.ts +1 -0
  212. package/src/types/index.ts +1 -0
  213. package/src/types/market.ts +2 -0
  214. package/src/types/options.ts +1 -0
  215. package/src/types/prediction-markets.ts +13 -0
  216. package/dist/tools/portfolio/predictions.d.ts +0 -55
  217. package/dist/tools/portfolio/predictions.js +0 -422
  218. package/dist/tools/portfolio/predictions.js.map +0 -1
  219. package/gui/server/chat-run-session.ts +0 -16
  220. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
  221. package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
  222. package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
  223. package/src/tools/portfolio/predictions.ts +0 -553
@@ -12,6 +12,7 @@ import {
12
12
  legacyRouteForRouteKind,
13
13
  routeKindFromLegacyRoute,
14
14
  selectToolBundles,
15
+ workflowRequiredSlots,
15
16
  } from "./route-manifest.js";
16
17
  import { buildRouterPrompt } from "./router-prompt.js";
17
18
  import type {
@@ -25,6 +26,7 @@ import type {
25
26
  RouterSlot,
26
27
  ToolBundleName,
27
28
  } from "./router-types.js";
29
+ import { mapDteHintToTarget } from "./slot-resolver.js";
28
30
  import { disambiguateSymbols } from "./symbol-disambiguator.js";
29
31
  import type { ExtractedEntities, WorkflowType } from "./types.js";
30
32
 
@@ -56,7 +58,7 @@ export async function route(
56
58
  let firstError: string | undefined;
57
59
  try {
58
60
  const raw = await client.complete(prompt);
59
- return postProcessRouterOutput(input.text, validateRouterOutput(raw));
61
+ return postProcessRouterOutput(input.text, validateRouterOutput(raw), input);
60
62
  } catch (err) {
61
63
  firstError = err instanceof Error ? err.message : String(err);
62
64
  }
@@ -65,10 +67,10 @@ export async function route(
65
67
  try {
66
68
  const retryPrompt = `${prompt}\n\n(Your previous response failed validation: ${firstError}. Return a valid JSON object conforming to RouterOutput. Nothing else.)`;
67
69
  const raw = await client.complete(retryPrompt);
68
- return postProcessRouterOutput(input.text, validateRouterOutput(raw));
70
+ return postProcessRouterOutput(input.text, validateRouterOutput(raw), input);
69
71
  } catch {
70
72
  // Persistent failure — return a minimal fallback with regex-extracted symbols.
71
- return postProcessRouterOutput(input.text, minimalFallback(input.text));
73
+ return postProcessRouterOutput(input.text, minimalFallback(input.text), input);
72
74
  }
73
75
  }
74
76
 
@@ -127,7 +129,7 @@ export function validateRouterOutput(raw: string): RouterOutput {
127
129
  }
128
130
 
129
131
  const entities = validateEntities(obj.entities);
130
- const slots = validateSlots(obj.slots);
132
+ const slots = canonicalizeSymbolSlots(workflow, validateSlots(obj.slots));
131
133
  const preference_updates = validatePreferenceUpdates(obj.preference_updates);
132
134
  const missing_required = rawMissingRequired;
133
135
  const tool_bundles = validateToolBundles(obj.tool_bundles);
@@ -191,15 +193,25 @@ function validateEntities(raw: unknown): ExtractedEntities {
191
193
  return out;
192
194
  }
193
195
 
194
- export function postProcessRouterOutput(text: string, output: RouterOutput): RouterOutput {
196
+ export function postProcessRouterOutput(
197
+ text: string,
198
+ output: RouterOutput,
199
+ inputContext?: Pick<RouterInputContext, "priorTurns" | "profileSnapshot">,
200
+ ): RouterOutput {
195
201
  const extracted = extractEntities(text);
196
202
  const deterministic = classifyWithLegacyRules(text);
197
203
  let diagnostics: RouterDiagnostic[] = [...output.diagnostics];
204
+ const symbolsAfterAmbiguousFilter = output.entities.symbols.filter(
205
+ (symbol) => !isAmbiguousConceptUsage(text, symbol),
206
+ );
207
+ const droppedAmbiguousSymbols = output.entities.symbols.filter(
208
+ (symbol) => !symbolsAfterAmbiguousFilter.includes(symbol),
209
+ );
198
210
  let next: RouterOutput = {
199
211
  ...output,
200
212
  entities: {
201
213
  ...output.entities,
202
- symbols: output.entities.symbols.filter((symbol) => !isAmbiguousConceptUsage(text, symbol)),
214
+ symbols: symbolsAfterAmbiguousFilter,
203
215
  budget: output.entities.budget ?? extracted.budget,
204
216
  maxPremium: output.entities.maxPremium ?? extracted.maxPremium,
205
217
  timeHorizon: output.entities.timeHorizon ?? extracted.timeHorizon,
@@ -219,6 +231,169 @@ export function postProcessRouterOutput(text: string, output: RouterOutput): Rou
219
231
  diagnostics,
220
232
  };
221
233
 
234
+ if (next.workflow === "compare_assets") {
235
+ const restorableExtractedSymbols = extracted.symbols.filter(
236
+ (symbol) => disambiguateSymbols([symbol], text).kept.length > 0,
237
+ );
238
+ const orderedSymbols = orderSymbolsByText(
239
+ text,
240
+ mergeSymbols(next.entities.symbols, restorableExtractedSymbols),
241
+ );
242
+ if (!sameStringArray(orderedSymbols, next.entities.symbols)) {
243
+ diagnostics.push({
244
+ code: "compare_symbols_canonicalized",
245
+ message: "compare symbols restored from deterministic extraction and ordered by user text",
246
+ });
247
+ next = {
248
+ ...next,
249
+ entities: {
250
+ ...next.entities,
251
+ symbols: orderedSymbols,
252
+ },
253
+ slots: syncSymbolListSlot(next.slots, orderedSymbols),
254
+ diagnostics,
255
+ };
256
+ }
257
+
258
+ // Prior-turn symbols merged into the slot (by the model or by the sync
259
+ // above) must not claim user provenance: the user typed only what is in
260
+ // this turn's text, and the Assumptions block renders slot sources.
261
+ // preference/memory/default sources legitimately reference out-of-text
262
+ // symbols and are left alone.
263
+ if (symbolsSlotClaimsPriorTurnUserProvenance(next.slots, text, inputContext?.priorTurns)) {
264
+ diagnostics.push({
265
+ code: "symbols_slot_provenance_prior_context",
266
+ message:
267
+ "compare symbols slot includes symbols absent from the user text; source downgraded from user to prior_context",
268
+ });
269
+ next = {
270
+ ...next,
271
+ slots: {
272
+ ...next.slots,
273
+ symbols: {
274
+ ...next.slots.symbols,
275
+ source: "prior_context",
276
+ },
277
+ },
278
+ diagnostics,
279
+ };
280
+ }
281
+ }
282
+
283
+ // The DTE horizon is a named historical loss class; when the model drops
284
+ // the slot on an options dispatch, the deterministic extraction preserves
285
+ // the user's stated range (mirrors the profile risk-slot fill below).
286
+ const dteHint = next.entities.dteHint ?? extracted.dteHint;
287
+ if (next.workflow === "options_screener" && !next.slots.dte_target && dteHint) {
288
+ const dteTarget = mapDteHintToTarget(dteHint);
289
+ if (dteTarget) {
290
+ diagnostics.push({
291
+ code: "dte_slot_filled_from_extraction",
292
+ message: "dte_target slot filled from deterministic horizon extraction",
293
+ });
294
+ next = {
295
+ ...next,
296
+ slots: {
297
+ ...next.slots,
298
+ dte_target: {
299
+ value: dteTarget,
300
+ source: "user",
301
+ confidence: "high",
302
+ },
303
+ },
304
+ diagnostics,
305
+ };
306
+ }
307
+ }
308
+
309
+ // Model-vocabulary synonyms for the canonical asset_scope value
310
+ // ("etf_only" for "etf_focused") drift saved preferences and slot
311
+ // consumers onto private vocabularies; canonicalize before the echo
312
+ // suppression below so restatements still match the profile.
313
+ next = canonicalizeAssetScopeVocabulary(next);
314
+
315
+ // A preference update that restates the saved profile value is an echo,
316
+ // not a change; writing it pollutes preference provenance (the
317
+ // preference-ECHO contract, fixture 029).
318
+ const echoedUpdates = next.preference_updates.filter(
319
+ (update) => readProfileString(inputContext?.profileSnapshot, update.key) === update.value,
320
+ );
321
+ if (echoedUpdates.length > 0) {
322
+ diagnostics.push({
323
+ code: "preference_echo_suppressed",
324
+ message: `dropped preference update(s) restating the saved profile: ${echoedUpdates
325
+ .map((update) => update.key)
326
+ .join(", ")}`,
327
+ });
328
+ next = {
329
+ ...next,
330
+ preference_updates: next.preference_updates.filter(
331
+ (update) => !echoedUpdates.includes(update),
332
+ ),
333
+ diagnostics,
334
+ };
335
+ }
336
+
337
+ const profileRiskProfile = readProfileString(inputContext?.profileSnapshot, "risk_profile");
338
+ if (
339
+ next.workflow === "portfolio_builder" &&
340
+ profileRiskProfile &&
341
+ !next.slots.risk_profile &&
342
+ !next.entities.riskProfile
343
+ ) {
344
+ diagnostics.push({
345
+ code: "profile_risk_slot_filled",
346
+ message: "risk_profile slot filled from investor profile snapshot",
347
+ });
348
+ next = {
349
+ ...next,
350
+ slots: {
351
+ ...next.slots,
352
+ risk_profile: {
353
+ value: profileRiskProfile,
354
+ source: "preference",
355
+ confidence: "high",
356
+ },
357
+ },
358
+ diagnostics,
359
+ };
360
+ }
361
+
362
+ if (
363
+ next.routeKind === "pass_through" &&
364
+ extracted.riskProfile &&
365
+ isConversationalRiskPreferenceUpdate(text, inputContext)
366
+ ) {
367
+ diagnostics.push({
368
+ code: "conversational_risk_preference_recovered",
369
+ message: "risk preference update recovered from profile/prior-turn context",
370
+ });
371
+ next = {
372
+ ...next,
373
+ routeKind: "agent_task",
374
+ route: "fallback",
375
+ entities: {
376
+ ...next.entities,
377
+ riskProfile: extracted.riskProfile,
378
+ },
379
+ slots: {
380
+ ...next.slots,
381
+ risk_profile: {
382
+ value: extracted.riskProfile,
383
+ source: "user",
384
+ confidence: "high",
385
+ },
386
+ },
387
+ preference_updates: ensurePreferenceUpdate(next.preference_updates, {
388
+ key: "risk_profile",
389
+ value: extracted.riskProfile,
390
+ confidence: "high",
391
+ source: "inferred",
392
+ }),
393
+ diagnostics,
394
+ };
395
+ }
396
+
222
397
  if (
223
398
  next.workflow === "options_screener" &&
224
399
  isExistingPositionOptionRequest(text, extracted) &&
@@ -543,6 +718,27 @@ export function postProcessRouterOutput(text: string, output: RouterOutput): Rou
543
718
  };
544
719
  }
545
720
 
721
+ if (
722
+ next.workflow === "compare_assets" &&
723
+ (disambiguated.dropped.length > 0 || droppedAmbiguousSymbols.length > 0) &&
724
+ next.entities.symbols.length < 2 &&
725
+ isExplicitMacroDataRequest(text)
726
+ ) {
727
+ diagnostics.push({
728
+ code: "compare_route_corrected_to_macro_task",
729
+ message: "macro/source acronyms were not explicit tickers",
730
+ });
731
+ next = {
732
+ ...next,
733
+ routeKind: "agent_task",
734
+ route: "fallback",
735
+ workflow: "general_finance_qa",
736
+ missing_required: [],
737
+ slots: removeSymbolSlots(next.slots),
738
+ diagnostics,
739
+ };
740
+ }
741
+
546
742
  const missingRequired = computeMissingRequiredSlots(
547
743
  next.workflow,
548
744
  next.entities,
@@ -659,7 +855,7 @@ function isStatefulTrackingRequest(text: string): boolean {
659
855
  lower,
660
856
  );
661
857
  const hasStateObject =
662
- /\b(?:watchlist|portfolio|holding|holdings|position|positions|prediction|predictions|alert|alerts|daily\s+report|watchlist\s+report|report\s+history)\b/.test(
858
+ /\b(?:watchlist|portfolio|holding|holdings|position|positions|alert|alerts|daily\s+report|watchlist\s+report|report\s+history)\b/.test(
663
859
  lower,
664
860
  );
665
861
  const hasPortfolioLotShape =
@@ -816,7 +1012,8 @@ function validateSlots(raw: unknown): Record<string, RouterSlot> {
816
1012
  if (!VALID_CONFIDENCE.has(s.confidence as string)) {
817
1013
  throw new Error(`slot ${key} has invalid confidence: ${JSON.stringify(s.confidence)}`);
818
1014
  }
819
- out[key] = {
1015
+ const canonicalKey = canonicalSlotKey(key);
1016
+ out[canonicalKey] = {
820
1017
  value: s.value,
821
1018
  source: s.source as RouterSlot["source"],
822
1019
  confidence: s.confidence as RouterSlot["confidence"],
@@ -825,6 +1022,180 @@ function validateSlots(raw: unknown): Record<string, RouterSlot> {
825
1022
  return out;
826
1023
  }
827
1024
 
1025
+ // The slot contract uses snake_case keys; non-Claude router models sometimes
1026
+ // emit camelCase (timeHorizon, riskProfile). Canonicalize deterministically so
1027
+ // downstream slot resolution and missing-required checks match.
1028
+ function canonicalSlotKey(key: string): string {
1029
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
1030
+ }
1031
+
1032
+ // Models also drift between scalar `symbol` and one-element `symbols` slot
1033
+ // shapes. Canonicalize toward the workflow's manifest-declared required slot
1034
+ // name when the conversion is unambiguous.
1035
+ function canonicalizeSymbolSlots(
1036
+ workflow: RouterOutput["workflow"],
1037
+ slots: Record<string, RouterSlot>,
1038
+ ): Record<string, RouterSlot> {
1039
+ if (!workflow) return slots;
1040
+ const required = workflowRequiredSlots(workflow);
1041
+ const next = { ...slots };
1042
+ if (required.includes("symbol") && next.symbol == null && next.symbols != null) {
1043
+ const value = next.symbols.value;
1044
+ if (Array.isArray(value) && value.length === 1) {
1045
+ next.symbol = { ...next.symbols, value: value[0] };
1046
+ delete next.symbols;
1047
+ }
1048
+ }
1049
+ if (required.includes("symbols") && next.symbols == null && next.symbol != null) {
1050
+ const value = next.symbol.value;
1051
+ if (typeof value === "string" && value.length > 0) {
1052
+ next.symbols = { ...next.symbol, value: [value] };
1053
+ delete next.symbol;
1054
+ }
1055
+ }
1056
+ return next;
1057
+ }
1058
+
1059
+ function syncSymbolListSlot(
1060
+ slots: Record<string, RouterSlot>,
1061
+ symbols: string[],
1062
+ ): Record<string, RouterSlot> {
1063
+ if (!slots.symbols || symbols.length === 0) return slots;
1064
+ return {
1065
+ ...slots,
1066
+ symbols: {
1067
+ ...slots.symbols,
1068
+ value: symbols,
1069
+ },
1070
+ };
1071
+ }
1072
+
1073
+ const ASSET_SCOPE_SYNONYMS: Record<string, string> = {
1074
+ etf_only: "etf_focused",
1075
+ etfs_only: "etf_focused",
1076
+ only_etfs: "etf_focused",
1077
+ etf: "etf_focused",
1078
+ etfs: "etf_focused",
1079
+ };
1080
+
1081
+ function canonicalAssetScope(value: unknown): string | undefined {
1082
+ if (typeof value !== "string") return undefined;
1083
+ return ASSET_SCOPE_SYNONYMS[value.toLowerCase()];
1084
+ }
1085
+
1086
+ function canonicalizeAssetScopeVocabulary(output: RouterOutput): RouterOutput {
1087
+ let next = output;
1088
+ const slotCanonical = canonicalAssetScope(next.slots.asset_scope?.value);
1089
+ if (slotCanonical) {
1090
+ next = {
1091
+ ...next,
1092
+ slots: {
1093
+ ...next.slots,
1094
+ asset_scope: { ...next.slots.asset_scope, value: slotCanonical },
1095
+ },
1096
+ };
1097
+ }
1098
+ const entityCanonical = canonicalAssetScope(next.entities.assetScope);
1099
+ if (entityCanonical) {
1100
+ next = {
1101
+ ...next,
1102
+ entities: { ...next.entities, assetScope: entityCanonical },
1103
+ };
1104
+ }
1105
+ if (
1106
+ next.preference_updates.some(
1107
+ (update) => update.key === "asset_scope" && canonicalAssetScope(update.value),
1108
+ )
1109
+ ) {
1110
+ next = {
1111
+ ...next,
1112
+ preference_updates: next.preference_updates.map((update) =>
1113
+ update.key === "asset_scope"
1114
+ ? { ...update, value: canonicalAssetScope(update.value) ?? update.value }
1115
+ : update,
1116
+ ),
1117
+ };
1118
+ }
1119
+ return next;
1120
+ }
1121
+
1122
+ function symbolsSlotClaimsPriorTurnUserProvenance(
1123
+ slots: Record<string, RouterSlot>,
1124
+ text: string,
1125
+ priorTurns: RouterInputContext["priorTurns"] | undefined,
1126
+ ): boolean {
1127
+ const slot = slots.symbols;
1128
+ if (!slot || slot.source !== "user" || !Array.isArray(slot.value)) return false;
1129
+ if (!priorTurns || priorTurns.length === 0) return false;
1130
+ const priorText = priorTurns.map((turn) => turn.text).join("\n");
1131
+ // A symbol absent from this turn's text but present in prior turns is a
1132
+ // carryover, not a user-typed value. Symbols absent from both (e.g. tickers
1133
+ // resolved from company names in the current text) are left alone.
1134
+ return slot.value.some(
1135
+ (symbol) =>
1136
+ typeof symbol === "string" &&
1137
+ symbolPosition(text, symbol) === Number.MAX_SAFE_INTEGER &&
1138
+ symbolPosition(priorText, symbol) !== Number.MAX_SAFE_INTEGER,
1139
+ );
1140
+ }
1141
+
1142
+ function removeSymbolSlots(slots: Record<string, RouterSlot>): Record<string, RouterSlot> {
1143
+ const next = { ...slots };
1144
+ delete next.symbol;
1145
+ delete next.symbols;
1146
+ return next;
1147
+ }
1148
+
1149
+ function orderSymbolsByText(text: string, symbols: string[]): string[] {
1150
+ return [...symbols].sort((a, b) => symbolPosition(text, a) - symbolPosition(text, b));
1151
+ }
1152
+
1153
+ function symbolPosition(text: string, symbol: string): number {
1154
+ const escaped = escapeRegExp(symbol);
1155
+ const match = new RegExp(`\\$?${escaped}\\b`, "i").exec(text);
1156
+ return match?.index ?? Number.MAX_SAFE_INTEGER;
1157
+ }
1158
+
1159
+ function sameStringArray(left: string[], right: string[]): boolean {
1160
+ return left.length === right.length && left.every((value, index) => value === right[index]);
1161
+ }
1162
+
1163
+ function readProfileString(
1164
+ profileSnapshot: RouterInputContext["profileSnapshot"] | undefined,
1165
+ key: string,
1166
+ ): string | undefined {
1167
+ const value = profileSnapshot?.[key];
1168
+ return typeof value === "string" && value.length > 0 ? value : undefined;
1169
+ }
1170
+
1171
+ function isConversationalRiskPreferenceUpdate(
1172
+ text: string,
1173
+ inputContext: Pick<RouterInputContext, "priorTurns" | "profileSnapshot"> | undefined,
1174
+ ): boolean {
1175
+ if (!/\b(?:now|lately|getting|becoming|thinking|feel|feeling|prefer|preference)\b/i.test(text)) {
1176
+ return false;
1177
+ }
1178
+ // Finance context must come from the conversation itself — a saved
1179
+ // risk_profile alone must not convert non-finance chat ("more aggressive
1180
+ // on the tennis court lately") into a routed preference write.
1181
+ const priorText = inputContext?.priorTurns.map((turn) => turn.text).join(" ") ?? "";
1182
+ return /\b(?:profile|risk|portfolio|position|invest|sizing)\b/i.test(`${text} ${priorText}`);
1183
+ }
1184
+
1185
+ function ensurePreferenceUpdate(
1186
+ updates: RouterPreferenceUpdate[],
1187
+ update: RouterPreferenceUpdate,
1188
+ ): RouterPreferenceUpdate[] {
1189
+ if (updates.some((item) => item.key === update.key && item.value === update.value)) {
1190
+ return updates;
1191
+ }
1192
+ return [...updates, update];
1193
+ }
1194
+
1195
+ function escapeRegExp(value: string): string {
1196
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1197
+ }
1198
+
828
1199
  function validatePreferenceUpdates(raw: unknown): RouterPreferenceUpdate[] {
829
1200
  if (raw === undefined || raw === null) return [];
830
1201
  if (!Array.isArray(raw)) {
@@ -19,7 +19,7 @@ interface Preferences {
19
19
  liquidityMinimum?: string;
20
20
  }
21
21
 
22
- function mapDteHintToTarget(dteHint: string | undefined): string | undefined {
22
+ export function mapDteHintToTarget(dteHint: string | undefined): string | undefined {
23
23
  const normalized = dteHint?.toLowerCase().trim();
24
24
  if (!normalized) return undefined;
25
25
 
@@ -29,6 +29,7 @@ export interface ProviderResultOk<T> {
29
29
  status: "ok";
30
30
  data: T;
31
31
  timestamp: string;
32
+ cached?: boolean;
32
33
  stale?: boolean;
33
34
  }
34
35
 
@@ -0,0 +1,108 @@
1
+ import type { EvidenceRecord } from "./evidence.js";
2
+
3
+ /**
4
+ * Observe-only numeric-claim extraction for synthesis validation (I3).
5
+ *
6
+ * These heuristics bind numbers in analyst prose to tool-result metrics so
7
+ * checkNumberMatch has claims to verify. They are deliberately conservative:
8
+ * every false mismatch poisons the false-positive-rate data that gates the
9
+ * answer-receipts direction (I8 phase 1), so a missed claim is cheaper than
10
+ * a wrong one.
11
+ */
12
+
13
+ /** Flatten numeric values from a tool-evidence digest preview into labeled tool results. */
14
+ export function collectToolNumbers(record: EvidenceRecord, toolResults: Map<string, number>): void {
15
+ if (!isPlainObject(record.value)) return;
16
+ const tool = typeof record.value.tool === "string" ? record.value.tool : undefined;
17
+ const digest = isPlainObject(record.value.resultDigest) ? record.value.resultDigest : undefined;
18
+ const preview = typeof digest?.preview === "string" ? digest.preview : undefined;
19
+ if (!tool || !preview) return;
20
+ const parsed = parseMaybeJson(preview);
21
+ if (!parsed) return;
22
+ for (const [path, value] of flattenNumericValues(parsed)) {
23
+ toolResults.set(`${tool}.${path}`, value);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Extract numeric claims from step text for metrics present in toolResults.
29
+ *
30
+ * Binding rules (each guards against a live-observed false-positive class):
31
+ * - the metric term must appear as a whole word ("opening" is not `open`)
32
+ * - the number must follow within a short window (no cross-sentence binding)
33
+ * - thousands separators parse as one number ("129,935,067" is not 129)
34
+ * - a prose value that rounds to the tool result is normalized onto it
35
+ * ("197.14" claims the tool's 197.13999938964844, not a mismatch)
36
+ */
37
+ export function extractNumericClaims(
38
+ text: string,
39
+ toolResults: Map<string, number>,
40
+ ): EvidenceRecord[] {
41
+ const records: EvidenceRecord[] = [];
42
+ for (const [label, expected] of toolResults) {
43
+ const metric = label.split(".").at(-1);
44
+ if (!metric) continue;
45
+ const pattern = new RegExp(
46
+ `\\b${escapeRegex(metric)}\\b[^\\d-]{0,30}(-?\\d{1,3}(?:,\\d{3})+(?:\\.\\d+)?|-?\\d+(?:\\.\\d+)?)`,
47
+ "i",
48
+ );
49
+ const match = text.match(pattern);
50
+ if (!match) continue;
51
+ const value = Number(match[1].replace(/,/g, ""));
52
+ if (!Number.isFinite(value)) continue;
53
+ records.push({
54
+ label,
55
+ value: isRoundingOf(value, expected) ? expected : value,
56
+ provenance: { source: "computed" },
57
+ });
58
+ }
59
+ return records;
60
+ }
61
+
62
+ /** True when `claim` is `expected` rounded to the claim's own precision. */
63
+ function isRoundingOf(claim: number, expected: number): boolean {
64
+ if (claim === expected) return true;
65
+ const decimals = countDecimals(claim);
66
+ const factor = 10 ** decimals;
67
+ return Math.round(expected * factor) / factor === claim;
68
+ }
69
+
70
+ function countDecimals(value: number): number {
71
+ const text = String(value);
72
+ const dot = text.indexOf(".");
73
+ return dot === -1 ? 0 : text.length - dot - 1;
74
+ }
75
+
76
+ function flattenNumericValues(
77
+ value: Record<string, unknown>,
78
+ prefix = "",
79
+ ): Array<[string, number]> {
80
+ const values: Array<[string, number]> = [];
81
+ for (const [key, item] of Object.entries(value)) {
82
+ const path = prefix ? `${prefix}.${key}` : key;
83
+ if (typeof item === "number" && Number.isFinite(item)) {
84
+ values.push([path, item]);
85
+ } else if (isPlainObject(item)) {
86
+ values.push(...flattenNumericValues(item, path));
87
+ }
88
+ }
89
+ return values;
90
+ }
91
+
92
+ function escapeRegex(value: string): string {
93
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
94
+ }
95
+
96
+ function parseMaybeJson(raw: unknown): Record<string, unknown> | undefined {
97
+ if (typeof raw !== "string") return undefined;
98
+ try {
99
+ const parsed: unknown = JSON.parse(raw);
100
+ return isPlainObject(parsed) ? parsed : undefined;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
107
+ return typeof value === "object" && value !== null && !Array.isArray(value);
108
+ }