opencandle 0.11.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 (162) hide show
  1. package/README.md +5 -4
  2. package/dist/analysts/orchestrator.d.ts +2 -0
  3. package/dist/analysts/orchestrator.js +19 -0
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/doctor/report.js +1 -1
  6. package/dist/doctor/report.js.map +1 -1
  7. package/dist/infra/cache.d.ts +6 -0
  8. package/dist/infra/cache.js +16 -6
  9. package/dist/infra/cache.js.map +1 -1
  10. package/dist/infra/freshness.d.ts +21 -0
  11. package/dist/infra/freshness.js +119 -0
  12. package/dist/infra/freshness.js.map +1 -0
  13. package/dist/infra/market-calendar.d.ts +14 -0
  14. package/dist/infra/market-calendar.js +137 -0
  15. package/dist/infra/market-calendar.js.map +1 -0
  16. package/dist/infra/rate-limiter.js +1 -0
  17. package/dist/infra/rate-limiter.js.map +1 -1
  18. package/dist/market-state/summaries.d.ts +8 -0
  19. package/dist/market-state/summaries.js +70 -0
  20. package/dist/market-state/summaries.js.map +1 -0
  21. package/dist/onboarding/providers.d.ts +25 -1
  22. package/dist/onboarding/providers.js +26 -0
  23. package/dist/onboarding/providers.js.map +1 -1
  24. package/dist/pi/opencandle-extension.js +44 -5
  25. package/dist/pi/opencandle-extension.js.map +1 -1
  26. package/dist/providers/alpha-vantage.js +6 -0
  27. package/dist/providers/alpha-vantage.js.map +1 -1
  28. package/dist/providers/coingecko.js +1 -0
  29. package/dist/providers/coingecko.js.map +1 -1
  30. package/dist/providers/polymarket.d.ts +2 -0
  31. package/dist/providers/polymarket.js +163 -0
  32. package/dist/providers/polymarket.js.map +1 -0
  33. package/dist/providers/wrap-provider.js +5 -3
  34. package/dist/providers/wrap-provider.js.map +1 -1
  35. package/dist/providers/yahoo-finance.d.ts +3 -0
  36. package/dist/providers/yahoo-finance.js +206 -10
  37. package/dist/providers/yahoo-finance.js.map +1 -1
  38. package/dist/routing/entity-extractor.js +16 -0
  39. package/dist/routing/entity-extractor.js.map +1 -1
  40. package/dist/routing/route-manifest.js +1 -1
  41. package/dist/routing/route-manifest.js.map +1 -1
  42. package/dist/routing/router-llm-client.js +18 -3
  43. package/dist/routing/router-llm-client.js.map +1 -1
  44. package/dist/routing/router.d.ts +1 -1
  45. package/dist/routing/router.js +274 -5
  46. package/dist/routing/router.js.map +1 -1
  47. package/dist/routing/slot-resolver.d.ts +1 -0
  48. package/dist/routing/slot-resolver.js +1 -1
  49. package/dist/routing/slot-resolver.js.map +1 -1
  50. package/dist/runtime/evidence.d.ts +1 -0
  51. package/dist/runtime/evidence.js.map +1 -1
  52. package/dist/runtime/numeric-claims.d.ts +23 -0
  53. package/dist/runtime/numeric-claims.js +100 -0
  54. package/dist/runtime/numeric-claims.js.map +1 -0
  55. package/dist/runtime/planning-evidence.js +1 -76
  56. package/dist/runtime/planning-evidence.js.map +1 -1
  57. package/dist/runtime/prompt-step.d.ts +10 -1
  58. package/dist/runtime/prompt-step.js +116 -2
  59. package/dist/runtime/prompt-step.js.map +1 -1
  60. package/dist/runtime/session-coordinator.d.ts +11 -1
  61. package/dist/runtime/session-coordinator.js +352 -49
  62. package/dist/runtime/session-coordinator.js.map +1 -1
  63. package/dist/runtime/validation.d.ts +3 -1
  64. package/dist/runtime/workflow-events.d.ts +1 -1
  65. package/dist/runtime/workflow-events.js.map +1 -1
  66. package/dist/runtime/workflow-types.d.ts +3 -0
  67. package/dist/runtime/workflow-types.js.map +1 -1
  68. package/dist/tools/fundamentals/comps.d.ts +1 -0
  69. package/dist/tools/fundamentals/comps.js +20 -9
  70. package/dist/tools/fundamentals/comps.js.map +1 -1
  71. package/dist/tools/fundamentals/dcf.js +183 -112
  72. package/dist/tools/fundamentals/dcf.js.map +1 -1
  73. package/dist/tools/index.d.ts +1 -0
  74. package/dist/tools/index.js +3 -0
  75. package/dist/tools/index.js.map +1 -1
  76. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  77. package/dist/tools/macro/event-probabilities.js +121 -0
  78. package/dist/tools/macro/event-probabilities.js.map +1 -0
  79. package/dist/tools/market/crypto-price.d.ts +4 -1
  80. package/dist/tools/market/crypto-price.js +10 -1
  81. package/dist/tools/market/crypto-price.js.map +1 -1
  82. package/dist/tools/market/screen-stocks.js +14 -3
  83. package/dist/tools/market/screen-stocks.js.map +1 -1
  84. package/dist/tools/market/stock-quote.d.ts +4 -1
  85. package/dist/tools/market/stock-quote.js +9 -4
  86. package/dist/tools/market/stock-quote.js.map +1 -1
  87. package/dist/tools/options/option-chain.d.ts +4 -1
  88. package/dist/tools/options/option-chain.js +12 -1
  89. package/dist/tools/options/option-chain.js.map +1 -1
  90. package/dist/tools/portfolio/risk-analysis.js +1 -1
  91. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  92. package/dist/tools/technical/indicators.js +1 -1
  93. package/dist/tools/technical/indicators.js.map +1 -1
  94. package/dist/types/fundamentals.d.ts +1 -0
  95. package/dist/types/index.d.ts +1 -0
  96. package/dist/types/market.d.ts +2 -0
  97. package/dist/types/options.d.ts +1 -0
  98. package/dist/types/prediction-markets.d.ts +13 -0
  99. package/dist/types/prediction-markets.js +2 -0
  100. package/dist/types/prediction-markets.js.map +1 -0
  101. package/gui/server/chat-event-adapter.ts +89 -5
  102. package/gui/server/http-routes.ts +285 -37
  103. package/gui/server/invoke-tool.ts +14 -13
  104. package/gui/server/live-chat-event-adapter.ts +37 -2
  105. package/gui/server/market-state-api.ts +63 -5
  106. package/gui/server/projector.ts +132 -3
  107. package/gui/server/session-actions.ts +32 -40
  108. package/gui/server/session-entry-wait.ts +22 -3
  109. package/gui/server/ws-hub.ts +16 -4
  110. package/gui/shared/chat-events.ts +10 -1
  111. package/gui/shared/event-reducer.ts +1 -0
  112. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  113. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  114. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  115. package/gui/web/dist/index.html +2 -2
  116. package/package.json +8 -13
  117. package/src/analysts/orchestrator.ts +20 -0
  118. package/src/doctor/report.ts +1 -1
  119. package/src/infra/cache.ts +24 -11
  120. package/src/infra/freshness.ts +165 -0
  121. package/src/infra/market-calendar.ts +193 -0
  122. package/src/infra/rate-limiter.ts +1 -0
  123. package/src/market-state/summaries.ts +75 -0
  124. package/src/onboarding/providers.ts +29 -1
  125. package/src/pi/opencandle-extension.ts +55 -5
  126. package/src/providers/alpha-vantage.ts +6 -0
  127. package/src/providers/coingecko.ts +2 -0
  128. package/src/providers/polymarket.ts +214 -0
  129. package/src/providers/wrap-provider.ts +5 -3
  130. package/src/providers/yahoo-finance.ts +339 -27
  131. package/src/routing/entity-extractor.ts +17 -0
  132. package/src/routing/route-manifest.ts +1 -1
  133. package/src/routing/router-llm-client.ts +31 -19
  134. package/src/routing/router.ts +340 -5
  135. package/src/routing/slot-resolver.ts +1 -1
  136. package/src/runtime/evidence.ts +1 -0
  137. package/src/runtime/numeric-claims.ts +108 -0
  138. package/src/runtime/planning-evidence.ts +6 -91
  139. package/src/runtime/prompt-step.ts +127 -2
  140. package/src/runtime/session-coordinator.ts +462 -57
  141. package/src/runtime/validation.ts +1 -1
  142. package/src/runtime/workflow-events.ts +2 -0
  143. package/src/runtime/workflow-types.ts +3 -0
  144. package/src/tools/fundamentals/comps.ts +25 -14
  145. package/src/tools/fundamentals/dcf.ts +204 -124
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/macro/event-probabilities.ts +141 -0
  148. package/src/tools/market/crypto-price.ts +14 -2
  149. package/src/tools/market/screen-stocks.ts +14 -3
  150. package/src/tools/market/stock-quote.ts +49 -43
  151. package/src/tools/options/option-chain.ts +16 -2
  152. package/src/tools/portfolio/risk-analysis.ts +1 -1
  153. package/src/tools/technical/indicators.ts +1 -1
  154. package/src/types/fundamentals.ts +1 -0
  155. package/src/types/index.ts +1 -0
  156. package/src/types/market.ts +2 -0
  157. package/src/types/options.ts +1 -0
  158. package/src/types/prediction-markets.ts +13 -0
  159. package/gui/server/chat-run-session.ts +0 -16
  160. package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +0 -1
  161. package/gui/web/dist/assets/index-BzyqyVnd.css +0 -2
  162. package/gui/web/dist/assets/index-DvMjkOP9.js +0 -65
@@ -26,6 +26,7 @@ import type {
26
26
  RouterSlot,
27
27
  ToolBundleName,
28
28
  } from "./router-types.js";
29
+ import { mapDteHintToTarget } from "./slot-resolver.js";
29
30
  import { disambiguateSymbols } from "./symbol-disambiguator.js";
30
31
  import type { ExtractedEntities, WorkflowType } from "./types.js";
31
32
 
@@ -57,7 +58,7 @@ export async function route(
57
58
  let firstError: string | undefined;
58
59
  try {
59
60
  const raw = await client.complete(prompt);
60
- return postProcessRouterOutput(input.text, validateRouterOutput(raw));
61
+ return postProcessRouterOutput(input.text, validateRouterOutput(raw), input);
61
62
  } catch (err) {
62
63
  firstError = err instanceof Error ? err.message : String(err);
63
64
  }
@@ -66,10 +67,10 @@ export async function route(
66
67
  try {
67
68
  const retryPrompt = `${prompt}\n\n(Your previous response failed validation: ${firstError}. Return a valid JSON object conforming to RouterOutput. Nothing else.)`;
68
69
  const raw = await client.complete(retryPrompt);
69
- return postProcessRouterOutput(input.text, validateRouterOutput(raw));
70
+ return postProcessRouterOutput(input.text, validateRouterOutput(raw), input);
70
71
  } catch {
71
72
  // Persistent failure — return a minimal fallback with regex-extracted symbols.
72
- return postProcessRouterOutput(input.text, minimalFallback(input.text));
73
+ return postProcessRouterOutput(input.text, minimalFallback(input.text), input);
73
74
  }
74
75
  }
75
76
 
@@ -192,15 +193,25 @@ function validateEntities(raw: unknown): ExtractedEntities {
192
193
  return out;
193
194
  }
194
195
 
195
- 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 {
196
201
  const extracted = extractEntities(text);
197
202
  const deterministic = classifyWithLegacyRules(text);
198
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
+ );
199
210
  let next: RouterOutput = {
200
211
  ...output,
201
212
  entities: {
202
213
  ...output.entities,
203
- symbols: output.entities.symbols.filter((symbol) => !isAmbiguousConceptUsage(text, symbol)),
214
+ symbols: symbolsAfterAmbiguousFilter,
204
215
  budget: output.entities.budget ?? extracted.budget,
205
216
  maxPremium: output.entities.maxPremium ?? extracted.maxPremium,
206
217
  timeHorizon: output.entities.timeHorizon ?? extracted.timeHorizon,
@@ -220,6 +231,169 @@ export function postProcessRouterOutput(text: string, output: RouterOutput): Rou
220
231
  diagnostics,
221
232
  };
222
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
+
223
397
  if (
224
398
  next.workflow === "options_screener" &&
225
399
  isExistingPositionOptionRequest(text, extracted) &&
@@ -544,6 +718,27 @@ export function postProcessRouterOutput(text: string, output: RouterOutput): Rou
544
718
  };
545
719
  }
546
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
+
547
742
  const missingRequired = computeMissingRequiredSlots(
548
743
  next.workflow,
549
744
  next.entities,
@@ -861,6 +1056,146 @@ function canonicalizeSymbolSlots(
861
1056
  return next;
862
1057
  }
863
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
+
864
1199
  function validatePreferenceUpdates(raw: unknown): RouterPreferenceUpdate[] {
865
1200
  if (raw === undefined || raw === null) return [];
866
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
+ }
@@ -1,3 +1,9 @@
1
+ import {
2
+ classifyMarketStatus,
3
+ KNOWN_US_MARKET_HOLIDAYS,
4
+ lastTradingDay,
5
+ localDateTimeParts,
6
+ } from "../infra/market-calendar.js";
1
7
  import type { ParsedTag } from "../onboarding/tool-tags.js";
2
8
  import { parseToolTag } from "../onboarding/tool-tags.js";
3
9
  import type { CapabilityGapId, EvidencePlanId, TaskFamily } from "../routing/planning.js";
@@ -202,19 +208,6 @@ export const EVIDENCE_PLAN_REGISTRY: Record<EvidencePlanId, EvidencePlanDefiniti
202
208
  ),
203
209
  };
204
210
 
205
- const KNOWN_US_MARKET_HOLIDAYS: Record<string, string> = {
206
- "2026-01-01": "New Year's Day",
207
- "2026-01-19": "Martin Luther King Jr. Day",
208
- "2026-02-16": "Washington's Birthday",
209
- "2026-04-03": "Good Friday",
210
- "2026-05-25": "Memorial Day",
211
- "2026-06-19": "Juneteenth",
212
- "2026-07-03": "Independence Day observed",
213
- "2026-09-07": "Labor Day",
214
- "2026-11-26": "Thanksgiving Day",
215
- "2026-12-25": "Christmas Day",
216
- };
217
-
218
211
  function placeholderPlan(
219
212
  id: EvidencePlanId,
220
213
  taskFamilies: TaskFamily[],
@@ -591,20 +584,6 @@ function temporalReferencesFor(text: string): string[] {
591
584
  return refs;
592
585
  }
593
586
 
594
- function classifyMarketStatus(
595
- local: LocalDateTimeParts,
596
- isWeekend: boolean,
597
- isMarketHoliday: boolean,
598
- temporalReferences: string[],
599
- ): string {
600
- if (isWeekend) return "closed_weekend";
601
- if (isMarketHoliday) return "closed_holiday";
602
- if (local.minutesSinceMidnight < 9 * 60 + 30) return "pre_market";
603
- if (local.minutesSinceMidnight < 16 * 60) return "open";
604
- if (temporalReferences.includes("after_close")) return "after_close";
605
- return "closed_after_hours";
606
- }
607
-
608
587
  function quoteAsOfCaveat(marketStatus: string): string {
609
588
  if (marketStatus === "open")
610
589
  return "Quote evidence may be intraday or delayed depending on provider.";
@@ -613,70 +592,6 @@ function quoteAsOfCaveat(marketStatus: string): string {
613
592
  return "Quote evidence should distinguish the current calendar date from the most recent trading session.";
614
593
  }
615
594
 
616
- interface LocalDateTimeParts {
617
- date: string;
618
- time: string;
619
- weekday: string;
620
- minutesSinceMidnight: number;
621
- }
622
-
623
- function localDateTimeParts(date: Date, timezone: string): LocalDateTimeParts {
624
- const formatter = new Intl.DateTimeFormat("en-US", {
625
- timeZone: timezone,
626
- year: "numeric",
627
- month: "2-digit",
628
- day: "2-digit",
629
- hour: "2-digit",
630
- minute: "2-digit",
631
- hour12: false,
632
- weekday: "short",
633
- });
634
- const parts = formatter.formatToParts(date).reduce<Record<string, string>>((acc, part) => {
635
- acc[part.type] = part.value;
636
- return acc;
637
- }, {});
638
- const hour = Number(parts.hour);
639
- const minute = Number(parts.minute);
640
- return {
641
- date: `${parts.year}-${parts.month}-${parts.day}`,
642
- time: `${parts.hour}:${parts.minute}`,
643
- weekday: parts.weekday ?? "",
644
- minutesSinceMidnight: hour * 60 + minute,
645
- };
646
- }
647
-
648
- function lastTradingDay(localDate: string): string {
649
- let cursor = dateFromKey(localDate);
650
- do {
651
- cursor = addDays(cursor, -1);
652
- } while (isWeekendOrKnownHoliday(dateKey(cursor)));
653
- return dateKey(cursor);
654
- }
655
-
656
- function isWeekendOrKnownHoliday(key: string): boolean {
657
- const date = dateFromKey(key);
658
- const day = date.getUTCDay();
659
- return day === 0 || day === 6 || KNOWN_US_MARKET_HOLIDAYS[key] !== undefined;
660
- }
661
-
662
- function dateFromKey(key: string): Date {
663
- const [year, month, day] = key.split("-").map(Number);
664
- return new Date(Date.UTC(year, month - 1, day));
665
- }
666
-
667
- function addDays(date: Date, days: number): Date {
668
- const next = new Date(date);
669
- next.setUTCDate(next.getUTCDate() + days);
670
- return next;
671
- }
672
-
673
- function dateKey(date: Date): string {
674
- const year = date.getUTCFullYear();
675
- const month = String(date.getUTCMonth() + 1).padStart(2, "0");
676
- const day = String(date.getUTCDate()).padStart(2, "0");
677
- return `${year}-${month}-${day}`;
678
- }
679
-
680
595
  function isRecord(value: unknown): value is Record<string, unknown> {
681
596
  return typeof value === "object" && value !== null && !Array.isArray(value);
682
597
  }