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
@@ -4,6 +4,16 @@ import type {
4
4
  ExtensionContext,
5
5
  SessionEntry,
6
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { isAnalystSplit, parseAnalystOutput, parseDebateOutput } from "../analysts/contracts.js";
8
+ import { buildAnalystVoteTallyBlock } from "../analysts/orchestrator.js";
9
+ import type { FreshnessStamp } from "../infra/freshness.js";
10
+ import { MarketStateService } from "../market-state/service.js";
11
+ import {
12
+ formatJsonSummary,
13
+ formatLatestReportSummary,
14
+ formatPortfolioSummary,
15
+ formatWatchlistSummary,
16
+ } from "../market-state/summaries.js";
7
17
  import { getAllDefaults, initDefaultDatabase, MemoryStorage } from "../memory/index.js";
8
18
 
9
19
  /**
@@ -15,7 +25,6 @@ import { getAllDefaults, initDefaultDatabase, MemoryStorage } from "../memory/in
15
25
  type ReadonlySessionManager = ExtensionContext["sessionManager"];
16
26
 
17
27
  import type Database from "better-sqlite3";
18
- import { MarketStateService } from "../market-state/service.js";
19
28
  import type { FilteredMemoryEntry } from "../memory/manager.js";
20
29
  import { MemoryManager } from "../memory/manager.js";
21
30
  import { extractPreferences } from "../memory/preference-extractor.js";
@@ -26,12 +35,21 @@ import type { SymbolValidationCache } from "../prompts/symbol-preflight.js";
26
35
  import type { RouterRouteKind } from "../routing/router-types.js";
27
36
  import type { ResolvedTurnContext } from "../routing/turn-context.js";
28
37
  import { getAddonToolDescriptions } from "../tool-kit.js";
38
+ import type { EvidenceRecord } from "./evidence.js";
39
+ import { collectToolNumbers, extractNumericClaims } from "./numeric-claims.js";
29
40
  import type { WorkflowDefinition } from "./prompt-step.js";
30
- import { promptStepOutput, toStepDefinitions } from "./prompt-step.js";
41
+ import {
42
+ captureToolEvidence,
43
+ extractAssistantText,
44
+ promptStepOutput,
45
+ toStepDefinitions,
46
+ } from "./prompt-step.js";
31
47
  import { ProviderTracker } from "./provider-tracker.js";
32
48
  import { clearRunContext, type RunContextToken, setRunContext } from "./run-context.js";
49
+ import { checkNumberMatch } from "./validation.js";
33
50
  import { WorkflowEventLogger } from "./workflow-events.js";
34
51
  import { WorkflowRunner } from "./workflow-runner.js";
52
+ import type { AnalystOutput, StepOutput, WorkflowRun } from "./workflow-types.js";
35
53
 
36
54
  const PROMPT_SETTLE_POLL_MS = 25;
37
55
  const IMMEDIATE_IDLE_GRACE_MS = 100;
@@ -41,6 +59,12 @@ interface ActiveWorkflowRunRef {
41
59
  contextToken: RunContextToken;
42
60
  }
43
61
 
62
+ interface ActiveStepCapture {
63
+ evidence: EvidenceRecord[];
64
+ pendingTools: Map<string, { tool: string; args: unknown; startedAt: string }>;
65
+ rawText: string;
66
+ }
67
+
44
68
  function parseMaybeJson(raw: unknown): Record<string, unknown> | undefined {
45
69
  if (typeof raw !== "string" || raw.length === 0) return undefined;
46
70
  try {
@@ -59,6 +83,7 @@ type QueueContext =
59
83
  isIdle(): boolean;
60
84
  hasPendingMessages?(): boolean;
61
85
  ui?: { notify(message: string, level?: string): void };
86
+ sessionManager?: { getEntries(): SessionEntry[] };
62
87
  };
63
88
 
64
89
  function hasPendingMessages(ctx: QueueContext): boolean {
@@ -69,6 +94,11 @@ function isReadyForNextPrompt(ctx: QueueContext): boolean {
69
94
  return ctx.isIdle() && !hasPendingMessages(ctx);
70
95
  }
71
96
 
97
+ function readSessionEntries(ctx: QueueContext): SessionEntry[] {
98
+ const manager = "sessionManager" in ctx ? ctx.sessionManager : undefined;
99
+ return manager?.getEntries?.() ?? [];
100
+ }
101
+
72
102
  function sleep(ms: number): Promise<void> {
73
103
  return new Promise((resolve) => setTimeout(resolve, ms));
74
104
  }
@@ -119,6 +149,8 @@ export class SessionCoordinator {
119
149
  private providerTracker: ProviderTracker;
120
150
  private activeWorkflowRunRef: ActiveWorkflowRunRef | null = null;
121
151
  private activeWorkflowType: string | undefined;
152
+ private activeStepCapture: ActiveStepCapture | null = null;
153
+ private workflowEventCaptureInstalled = false;
122
154
  private tickerValidationCache: SymbolValidationCache = new Map();
123
155
  private sessionId = "unknown";
124
156
 
@@ -416,6 +448,7 @@ export class SessionCoordinator {
416
448
  firstPromptMode: "send" | "transform",
417
449
  ): void {
418
450
  if (definition.steps.length === 0) return;
451
+ this.installWorkflowEventCapture(pi);
419
452
 
420
453
  const runner = this.runner;
421
454
  if (this.activeWorkflowRunRef) {
@@ -444,26 +477,101 @@ export class SessionCoordinator {
444
477
  // Start the runner in the background for state tracking
445
478
  const stepDefs = toStepDefinitions(definition.steps);
446
479
  void runner
447
- .start(definition.workflowType, stepDefs, async (step, stepIndex) => {
448
- // First step was already sent above — just wait for settlement
449
- if (stepIndex > 0) {
450
- const settled = await waitForPromptSettlement(ctx, () => runRef.active);
451
- if (!settled || !runRef.active) {
452
- throw new Error("run_cancelled");
480
+ .start(
481
+ definition.workflowType,
482
+ stepDefs,
483
+ async (step, stepIndex, _priorEvidence, context) => {
484
+ let entriesBeforeStep = readSessionEntries(ctx).length;
485
+ const eventCapture = this.startStepCapture();
486
+
487
+ // First step was already sent above — later steps are sent here.
488
+ if (stepIndex > 0) {
489
+ const settled = await waitForPromptSettlement(ctx, () => runRef.active);
490
+ if (!settled || !runRef.active) {
491
+ throw new Error("run_cancelled");
492
+ }
493
+ if (shouldSkipRebuttal(runner.getActiveRun(), step.stepType)) {
494
+ this.appendWorkflowEvent(pi, "step_skipped", {
495
+ stepType: step.stepType,
496
+ reason: "analyst_consensus",
497
+ });
498
+ throw new Error("analyst_consensus");
499
+ }
500
+ entriesBeforeStep = readSessionEntries(ctx).length;
501
+ const prompt = this.prepareWorkflowPrompt(
502
+ pi,
503
+ definition.steps[stepIndex].prompt,
504
+ step.stepType,
505
+ runner.getActiveRun(),
506
+ );
507
+ pi.sendUserMessage(prompt);
453
508
  }
454
- pi.sendUserMessage(definition.steps[stepIndex].prompt);
455
- } else {
456
- // For the first step, just wait for it to settle
509
+
457
510
  const settled = await waitForPromptSettlement(ctx, () => runRef.active, {
458
- requireActivity: firstPromptMode === "transform",
511
+ requireActivity: stepIndex === 0 && firstPromptMode === "transform",
459
512
  });
460
513
  if (!settled || !runRef.active) {
461
514
  throw new Error("run_cancelled");
462
515
  }
463
- }
464
- return promptStepOutput(stepIndex, step.stepType);
465
- })
516
+
517
+ let stepEntries = readSessionEntries(ctx).slice(entriesBeforeStep);
518
+ let rawText = capturedText(eventCapture, stepEntries);
519
+ let output = promptStepOutput(stepIndex, step.stepType, {
520
+ evidence: capturedEvidence(eventCapture, stepEntries),
521
+ rawText,
522
+ });
523
+
524
+ if (
525
+ isStructuredAnalystStep(step.stepType) &&
526
+ !hasStructuredContract(step.stepType, rawText)
527
+ ) {
528
+ pi.sendUserMessage(
529
+ "Please revise your previous response to include the exact required final output format from the stage prompt. Do not add new tool calls.",
530
+ );
531
+ const retrySettled = await waitForPromptSettlement(ctx, () => runRef.active);
532
+ if (!retrySettled || !runRef.active) {
533
+ throw new Error("run_cancelled");
534
+ }
535
+ stepEntries = readSessionEntries(ctx).slice(entriesBeforeStep);
536
+ rawText = capturedText(eventCapture, stepEntries);
537
+ output = promptStepOutput(stepIndex, step.stepType, {
538
+ evidence: capturedEvidence(eventCapture, stepEntries),
539
+ rawText,
540
+ });
541
+ }
542
+
543
+ for (const record of output.evidence) {
544
+ const value = isPlainObject(record.value) ? record.value : {};
545
+ this.eventLogger?.log(context.runId, stepIndex, "tool_called", {
546
+ stepType: step.stepType,
547
+ tool: value.tool,
548
+ args: value.args,
549
+ resultDigest: value.resultDigest,
550
+ });
551
+ }
552
+
553
+ if (this.activeStepCapture === eventCapture) {
554
+ this.activeStepCapture = null;
555
+ }
556
+
557
+ const structuredOutput = attachStructuredOutput(pi, output);
558
+ if (step.stepType === "synthesis") {
559
+ this.emitSynthesisValidation(
560
+ pi,
561
+ context.runId,
562
+ stepIndex,
563
+ runner.getActiveRun(),
564
+ structuredOutput,
565
+ );
566
+ }
567
+
568
+ return structuredOutput;
569
+ },
570
+ )
466
571
  .finally(() => {
572
+ if (this.activeWorkflowRunRef === runRef) {
573
+ this.activeStepCapture = null;
574
+ }
467
575
  clearRunContext(runRef.contextToken);
468
576
  if (this.activeWorkflowRunRef === runRef) {
469
577
  this.activeWorkflowRunRef = null;
@@ -479,8 +587,261 @@ export class SessionCoordinator {
479
587
  clearRunContext(activeRef.contextToken);
480
588
  this.activeWorkflowRunRef = null;
481
589
  }
590
+ this.activeStepCapture = null;
482
591
  this.runner?.cancel();
483
592
  }
593
+
594
+ private startStepCapture(): ActiveStepCapture {
595
+ const capture: ActiveStepCapture = {
596
+ evidence: [],
597
+ pendingTools: new Map(),
598
+ rawText: "",
599
+ };
600
+ this.activeStepCapture = capture;
601
+ return capture;
602
+ }
603
+
604
+ private installWorkflowEventCapture(pi: ExtensionAPI): void {
605
+ const on = (pi as { on?: unknown }).on;
606
+ if (this.workflowEventCaptureInstalled || typeof on !== "function") return;
607
+ this.workflowEventCaptureInstalled = true;
608
+
609
+ pi.on("message_update", (event) => {
610
+ const capture = this.activeStepCapture;
611
+ if (!capture || event.assistantMessageEvent.type !== "text_delta") return;
612
+ capture.rawText += event.assistantMessageEvent.delta;
613
+ });
614
+
615
+ pi.on("tool_execution_start", (event) => {
616
+ const capture = this.activeStepCapture;
617
+ if (!capture) return;
618
+ capture.pendingTools.set(event.toolCallId, {
619
+ tool: event.toolName,
620
+ args: event.args ?? {},
621
+ startedAt: new Date().toISOString(),
622
+ });
623
+ });
624
+
625
+ pi.on("tool_execution_end", (event) => {
626
+ const capture = this.activeStepCapture;
627
+ if (!capture) return;
628
+ const completedAt = new Date().toISOString();
629
+ const pending = capture.pendingTools.get(event.toolCallId);
630
+ capture.pendingTools.delete(event.toolCallId);
631
+ const tool = pending?.tool ?? event.toolName;
632
+ if (!tool) return;
633
+ capture.evidence.push(
634
+ toolEvidenceRecord({
635
+ tool,
636
+ args: pending?.args ?? {},
637
+ result: event.result,
638
+ startedAt: pending?.startedAt ?? completedAt,
639
+ completedAt,
640
+ isError: event.isError === true,
641
+ }),
642
+ );
643
+ });
644
+ }
645
+
646
+ private prepareWorkflowPrompt(
647
+ pi: ExtensionAPI,
648
+ prompt: string,
649
+ stepType: string,
650
+ run: WorkflowRun | null,
651
+ ): string {
652
+ if (stepType !== "synthesis") return prompt;
653
+ const parsedAnalysts = collectParsedAnalystOutputs(run);
654
+ const tallyBlock = buildAnalystVoteTallyBlock(parsedAnalysts);
655
+ if (!tallyBlock) {
656
+ this.eventLogger?.log(run?.runId ?? "unknown", run?.currentStepIndex ?? 0, "tally_skipped", {
657
+ reason: "tally_skipped",
658
+ parsedAnalystCount: parsedAnalysts.length,
659
+ });
660
+ this.appendWorkflowEvent(pi, "tally_skipped", {
661
+ reason: "fewer_than_2_parsed_analysts",
662
+ parsedAnalystCount: parsedAnalysts.length,
663
+ });
664
+ return prompt;
665
+ }
666
+ this.eventLogger?.log(run?.runId ?? "unknown", run?.currentStepIndex ?? 0, "tally_injected", {
667
+ tallyBlock,
668
+ parsedAnalystCount: parsedAnalysts.length,
669
+ });
670
+ this.appendWorkflowEvent(pi, "tally_injected", {
671
+ tallyBlock,
672
+ parsedAnalystCount: parsedAnalysts.length,
673
+ });
674
+ return `${tallyBlock}\n\n${prompt}`;
675
+ }
676
+
677
+ private emitSynthesisValidation(
678
+ pi: ExtensionAPI,
679
+ runId: string,
680
+ stepIndex: number,
681
+ run: WorkflowRun | null,
682
+ currentOutput: StepOutput,
683
+ ): void {
684
+ const validationInput = collectValidationInput(run, currentOutput);
685
+ const mismatches = checkNumberMatch(
686
+ validationInput.evidence,
687
+ validationInput.toolResults,
688
+ ).filter((entry) => entry.type === "failure");
689
+ const passed = mismatches.length === 0;
690
+ const payload = {
691
+ passed,
692
+ mismatches,
693
+ skipped_unparsed: validationInput.skippedUnparsed,
694
+ };
695
+ this.eventLogger?.log(runId, stepIndex, passed ? "validation_passed" : "validation_failed", {
696
+ mismatches,
697
+ skipped_unparsed: validationInput.skippedUnparsed,
698
+ });
699
+ pi.appendEntry("opencandle-validation", payload);
700
+ this.appendWorkflowEvent(pi, passed ? "validation_passed" : "validation_failed", {
701
+ mismatches,
702
+ skipped_unparsed: validationInput.skippedUnparsed,
703
+ });
704
+ }
705
+
706
+ private appendWorkflowEvent(
707
+ pi: ExtensionAPI,
708
+ eventType: string,
709
+ payload: Record<string, unknown>,
710
+ ): void {
711
+ pi.appendEntry("opencandle-workflow-event", {
712
+ eventType,
713
+ ...payload,
714
+ });
715
+ }
716
+ }
717
+
718
+ function capturedText(capture: ActiveStepCapture, entries: SessionEntry[]): string {
719
+ return capture.rawText.trim() || extractAssistantText(entries);
720
+ }
721
+
722
+ function capturedEvidence(capture: ActiveStepCapture, entries: SessionEntry[]): EvidenceRecord[] {
723
+ return capture.evidence.length > 0 ? [...capture.evidence] : captureToolEvidence(entries);
724
+ }
725
+
726
+ function collectParsedAnalystOutputs(run: WorkflowRun | null): AnalystOutput[] {
727
+ if (!run) return [];
728
+ const outputs: AnalystOutput[] = [];
729
+ for (const output of run.stepOutputs.values()) {
730
+ if (output.parsed === true && output.analystOutput) {
731
+ outputs.push(output.analystOutput);
732
+ }
733
+ }
734
+ return outputs;
735
+ }
736
+
737
+ function shouldSkipRebuttal(run: WorkflowRun | null, stepType: string): boolean {
738
+ if (stepType !== "debate_rebuttal") return false;
739
+ const parsedAnalysts = collectParsedAnalystOutputs(run);
740
+ // Degradation rule: with fewer than two parsed analyst outputs there is no
741
+ // trustworthy consensus signal to gate on — run the rebuttal exactly as the
742
+ // status quo instead of skipping because isAnalystSplit([]) is false.
743
+ if (parsedAnalysts.length < 2) return false;
744
+ return !isAnalystSplit(parsedAnalysts);
745
+ }
746
+
747
+ function collectValidationInput(
748
+ run: WorkflowRun | null,
749
+ currentOutput?: StepOutput,
750
+ ): {
751
+ evidence: EvidenceRecord[];
752
+ toolResults: Map<string, number>;
753
+ skippedUnparsed: string[];
754
+ } {
755
+ const evidence: EvidenceRecord[] = [];
756
+ const toolResults = new Map<string, number>();
757
+ const skippedUnparsed: string[] = [];
758
+ const outputs = run ? [...run.stepOutputs.values()] : [];
759
+ if (currentOutput) {
760
+ outputs.push(currentOutput);
761
+ }
762
+
763
+ for (const output of outputs) {
764
+ if (output.stepType.startsWith("analyst_") && output.parsed === false) {
765
+ skippedUnparsed.push(output.stepType);
766
+ }
767
+ for (const record of output.evidence) {
768
+ collectToolNumbers(record, toolResults);
769
+ }
770
+ }
771
+
772
+ for (const output of outputs) {
773
+ if (!output.rawText) continue;
774
+ evidence.push(...extractNumericClaims(output.rawText, toolResults));
775
+ }
776
+
777
+ return { evidence, toolResults, skippedUnparsed };
778
+ }
779
+
780
+ function toolEvidenceRecord(input: {
781
+ tool: string;
782
+ args: unknown;
783
+ result: unknown;
784
+ startedAt: string;
785
+ completedAt: string;
786
+ isError: boolean;
787
+ }): EvidenceRecord {
788
+ const serializedResult = serialize(input.result);
789
+ const freshness = extractFreshness(input.result);
790
+ return {
791
+ label: `tool:${input.tool}`,
792
+ value: {
793
+ tool: input.tool,
794
+ args: truncate(serialize(input.args), 500),
795
+ ...(freshness ? { freshness } : {}),
796
+ resultDigest: {
797
+ preview: truncate(serializedResult, 500),
798
+ totalLength: serializedResult.length,
799
+ },
800
+ startedAt: input.startedAt,
801
+ completedAt: input.completedAt,
802
+ },
803
+ provenance: {
804
+ source: "computed",
805
+ timestamp: freshness?.providerDataAt ?? freshness?.fetchedAt ?? input.completedAt,
806
+ provider: input.tool,
807
+ confidence: input.isError ? 0.5 : undefined,
808
+ },
809
+ };
810
+ }
811
+
812
+ function summarizeStepEvidence(evidence: EvidenceRecord[]): unknown[] {
813
+ return evidence.map((record) => (isPlainObject(record.value) ? record.value : record));
814
+ }
815
+
816
+ function serialize(value: unknown): string {
817
+ if (typeof value === "string") return value;
818
+ try {
819
+ return JSON.stringify(value);
820
+ } catch {
821
+ return String(value);
822
+ }
823
+ }
824
+
825
+ function truncate(value: string, maxLength: number): string {
826
+ return value.length > maxLength ? value.slice(0, maxLength) : value;
827
+ }
828
+
829
+ function extractFreshness(value: unknown): FreshnessStamp | undefined {
830
+ const record = isPlainObject(value) ? value : {};
831
+ const direct = record.freshness;
832
+ if (isFreshnessStamp(direct)) return direct;
833
+ const details = isPlainObject(record.details) ? record.details : {};
834
+ return isFreshnessStamp(details.freshness) ? details.freshness : undefined;
835
+ }
836
+
837
+ function isFreshnessStamp(value: unknown): value is FreshnessStamp {
838
+ const record = isPlainObject(value) ? value : {};
839
+ return (
840
+ typeof record.fetchedAt === "string" &&
841
+ typeof record.cacheStatus === "string" &&
842
+ typeof record.marketSession === "string" &&
843
+ typeof record.isStaleForSession === "boolean"
844
+ );
484
845
  }
485
846
 
486
847
  function formatToolDefaultsForPrompt(): string[] {
@@ -511,6 +872,89 @@ function flattenDefaults(defaults: Record<string, unknown>, prefix = ""): Array<
511
872
  return out;
512
873
  }
513
874
 
875
+ function isStructuredAnalystStep(stepType: string): boolean {
876
+ return stepType.startsWith("analyst_") || stepType.startsWith("debate_");
877
+ }
878
+
879
+ function hasStructuredContract(stepType: string, text: string): boolean {
880
+ if (stepType.startsWith("analyst_")) {
881
+ // The conviction range check must match extractConviction's 1-10
882
+ // contract: parseAnalystOutput silently defaults out-of-range values to
883
+ // 5, so accepting them here would record a fabricated conviction.
884
+ const conviction = text.match(/CONVICTION:\s*(\d+)/i);
885
+ const convictionInRange =
886
+ conviction !== null && Number(conviction[1]) >= 1 && Number(conviction[1]) <= 10;
887
+ return (
888
+ /SIGNAL:\s*(BUY|HOLD|SELL)/i.test(text) && convictionInRange && /THESIS:\s*(.+)/i.test(text)
889
+ );
890
+ }
891
+ if (stepType === "debate_bull") {
892
+ return (
893
+ /BULL THESIS:\s*(.+)/i.test(text) && /KEY RISK(?:\s+TO THIS THESIS)?:\s*(.+)/i.test(text)
894
+ );
895
+ }
896
+ if (stepType === "debate_bear") {
897
+ return /BEAR THESIS:\s*(.+)/i.test(text) && /WHAT WOULD CHANGE MY MIND:\s*(.+)/i.test(text);
898
+ }
899
+ if (stepType === "debate_rebuttal") {
900
+ return (
901
+ /^REBUTTAL SKIPPED/i.test(text.trim()) ||
902
+ (/CONCESSIONS:\s*[\s\S]+/i.test(text) && /REMAINING CONVICTION:\s*(\d+)/i.test(text))
903
+ );
904
+ }
905
+ return false;
906
+ }
907
+
908
+ function attachStructuredOutput(pi: ExtensionAPI, output: ReturnType<typeof promptStepOutput>) {
909
+ if (!isStructuredAnalystStep(output.stepType)) return output;
910
+
911
+ const rawText = output.rawText ?? "";
912
+ const evidence = summarizeStepEvidence(output.evidence);
913
+ const evidenceCount = output.evidence.length;
914
+ if (!hasStructuredContract(output.stepType, rawText)) {
915
+ const role = analystRoleFromStep(output.stepType);
916
+ pi.appendEntry("opencandle-analyst-step", {
917
+ stage: output.stepType,
918
+ ...(role ? { role } : {}),
919
+ parsed: false,
920
+ evidenceCount,
921
+ evidence,
922
+ });
923
+ return { ...output, parsed: false };
924
+ }
925
+
926
+ if (output.stepType.startsWith("analyst_")) {
927
+ const role = analystRoleFromStep(output.stepType) ?? "analyst";
928
+ const analystOutput = parseAnalystOutput(role, rawText);
929
+ const parsedOutput = { ...output, analystOutput, parsed: true };
930
+ pi.appendEntry("opencandle-analyst-step", {
931
+ stage: output.stepType,
932
+ role,
933
+ signal: analystOutput.signal,
934
+ conviction: analystOutput.conviction,
935
+ parsed: true,
936
+ evidenceCount,
937
+ evidence,
938
+ });
939
+ return parsedOutput;
940
+ }
941
+
942
+ const side = output.stepType === "debate_bear" ? "bear" : "bull";
943
+ const debateOutput = parseDebateOutput(side, rawText);
944
+ pi.appendEntry("opencandle-analyst-step", {
945
+ stage: output.stepType,
946
+ side,
947
+ parsed: true,
948
+ evidenceCount,
949
+ evidence,
950
+ });
951
+ return { ...output, debateOutput, parsed: true };
952
+ }
953
+
954
+ function analystRoleFromStep(stepType: string): string | undefined {
955
+ return stepType.startsWith("analyst_") ? stepType.slice("analyst_".length) : undefined;
956
+ }
957
+
514
958
  function buildSavedMarketStateContext(db: Database.Database): string {
515
959
  try {
516
960
  const service = new MarketStateService(db);
@@ -538,34 +982,11 @@ function buildSavedMarketStateContext(db: Database.Database): string {
538
982
  ];
539
983
 
540
984
  if (lots.length > 0) {
541
- lines.push("Portfolio lots:");
542
- for (const lot of lots.slice(0, 8)) {
543
- const costBasis = formatMoney(lot.quantity * lot.avgCost, lot.currency);
544
- const name = lot.name ? ` (${lot.name})` : "";
545
- lines.push(
546
- `- ${lot.symbol}: ${lot.quantity} @ ${formatMoney(lot.avgCost, lot.currency)}, cost basis ${costBasis}${name}`,
547
- );
548
- }
985
+ lines.push(...formatPortfolioSummary(lots));
549
986
  }
550
987
 
551
988
  if (watchlist.length > 0) {
552
- lines.push("Watchlist:");
553
- for (const item of watchlist.slice(0, 8)) {
554
- const parts = [
555
- item.targetPrice == null
556
- ? null
557
- : `target ${formatMoney(item.targetPrice, item.priceCurrency ?? item.currency ?? "USD")}`,
558
- item.stopPrice == null
559
- ? null
560
- : `stop ${formatMoney(item.stopPrice, item.priceCurrency ?? item.currency ?? "USD")}`,
561
- item.thesis ? `thesis: ${item.thesis}` : null,
562
- item.tags && item.tags.length > 0 ? `tags: ${item.tags.join(", ")}` : null,
563
- item.notes ? `notes: ${item.notes}` : null,
564
- ].filter((part): part is string => part != null);
565
- lines.push(
566
- `- ${item.symbol}${item.name ? ` (${item.name})` : ""}${parts.length > 0 ? ` — ${parts.join("; ")}` : ""}`,
567
- );
568
- }
989
+ lines.push(...formatWatchlistSummary(watchlist));
569
990
  }
570
991
 
571
992
  if (alerts.length > 0) {
@@ -589,10 +1010,7 @@ function buildSavedMarketStateContext(db: Database.Database): string {
589
1010
  }
590
1011
 
591
1012
  if (reportRuns.length > 0) {
592
- const latest = reportRuns[0];
593
- lines.push(
594
- `Latest report run: ${latest.status} at ${latest.completedAt ?? latest.startedAt}`,
595
- );
1013
+ lines.push(...formatLatestReportSummary(reportRuns[0], { includeSummary: false }));
596
1014
  }
597
1015
 
598
1016
  return lines.join("\n");
@@ -614,19 +1032,6 @@ function shouldIncludeSavedMarketStateContext(
614
1032
  return workflowType != null || fallbackContext != null;
615
1033
  }
616
1034
 
617
- function formatMoney(value: number, currency: string): string {
618
- const normalized = currency.toUpperCase();
619
- if (normalized === "USD") return `$${value.toFixed(2)}`;
620
- return `${normalized} ${value.toFixed(2)}`;
621
- }
622
-
623
- function formatJsonSummary(value: unknown): string {
624
- if (value == null) return "";
625
- const json = JSON.stringify(value);
626
- if (json.length <= 90) return json;
627
- return `${json.slice(0, 87)}...`;
628
- }
629
-
630
1035
  function isPlainObject(value: unknown): value is Record<string, unknown> {
631
1036
  return typeof value === "object" && value !== null && !Array.isArray(value);
632
1037
  }
@@ -187,7 +187,7 @@ export class RuntimeValidator {
187
187
  export function checkNumberMatch(
188
188
  evidence: EvidenceRecord[],
189
189
  toolResults: Map<string, number>,
190
- ): ValidationEntry[] {
190
+ ): Array<ValidationEntry & { type: "pass" | "failure" }> {
191
191
  const results: (ValidationEntry & { type: "pass" | "failure" })[] = [];
192
192
  for (const record of evidence) {
193
193
  if (typeof record.value !== "number") continue;
@@ -12,6 +12,8 @@ export type WorkflowEventType =
12
12
  | "step_skipped"
13
13
  | "tool_called"
14
14
  | "tool_failed"
15
+ | "tally_injected"
16
+ | "tally_skipped"
15
17
  | "validation_passed"
16
18
  | "validation_failed"
17
19
  | "workflow_completed"
@@ -44,6 +44,9 @@ export interface StepOutput {
44
44
  stepType: string;
45
45
  evidence: EvidenceRecord[];
46
46
  rawText?: string;
47
+ analystOutput?: AnalystOutput;
48
+ debateOutput?: DebateOutput;
49
+ parsed?: boolean;
47
50
  }
48
51
 
49
52
  /** Analyst signal direction. */