opencandle 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +7 -5
  2. package/dist/analysts/orchestrator.d.ts +1 -4
  3. package/dist/analysts/orchestrator.js +15 -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/market-state/daily-report.d.ts +1 -1
  11. package/dist/market-state/daily-report.js +5 -16
  12. package/dist/market-state/daily-report.js.map +1 -1
  13. package/dist/market-state/service.d.ts +0 -35
  14. package/dist/market-state/service.js +0 -63
  15. package/dist/market-state/service.js.map +1 -1
  16. package/dist/memory/sqlite.js +20 -19
  17. package/dist/memory/sqlite.js.map +1 -1
  18. package/dist/pi/opencandle-extension.js +13 -221
  19. package/dist/pi/opencandle-extension.js.map +1 -1
  20. package/dist/pi/session-action-dedupe.d.ts +6 -0
  21. package/dist/pi/session-action-dedupe.js +126 -0
  22. package/dist/pi/session-action-dedupe.js.map +1 -0
  23. package/dist/pi/session-writer-lock.d.ts +26 -2
  24. package/dist/pi/session-writer-lock.js +230 -18
  25. package/dist/pi/session-writer-lock.js.map +1 -1
  26. package/dist/pi/setup.js +5 -5
  27. package/dist/pi/setup.js.map +1 -1
  28. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  29. package/dist/pi/tui-session-coordinator.js +283 -0
  30. package/dist/pi/tui-session-coordinator.js.map +1 -0
  31. package/dist/prompts/context-builder.js +1 -1
  32. package/dist/prompts/policy-cards.js +1 -1
  33. package/dist/prompts/policy-cards.js.map +1 -1
  34. package/dist/routing/classify-intent.js +4 -5
  35. package/dist/routing/classify-intent.js.map +1 -1
  36. package/dist/routing/route-manifest.js +0 -1
  37. package/dist/routing/route-manifest.js.map +1 -1
  38. package/dist/routing/router-prompt.js +1 -1
  39. package/dist/routing/router.js +35 -4
  40. package/dist/routing/router.js.map +1 -1
  41. package/dist/runtime/session-coordinator.js +2 -13
  42. package/dist/runtime/session-coordinator.js.map +1 -1
  43. package/dist/system-prompt.js +1 -1
  44. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  45. package/dist/tools/fundamentals/dcf.js +49 -6
  46. package/dist/tools/fundamentals/dcf.js.map +1 -1
  47. package/dist/tools/index.d.ts +0 -1
  48. package/dist/tools/index.js +0 -3
  49. package/dist/tools/index.js.map +1 -1
  50. package/dist/tools/portfolio/daily-report.js +10 -4
  51. package/dist/tools/portfolio/daily-report.js.map +1 -1
  52. package/dist/tools/technical/backtest.d.ts +23 -5
  53. package/dist/tools/technical/backtest.js +131 -94
  54. package/dist/tools/technical/backtest.js.map +1 -1
  55. package/gui/server/http-routes.ts +395 -19
  56. package/gui/server/invoke-tool.ts +164 -16
  57. package/gui/server/local-session-coordinator.ts +97 -0
  58. package/gui/server/market-state-api.ts +1 -43
  59. package/gui/server/server.ts +51 -6
  60. package/gui/server/session-actions.ts +117 -8
  61. package/gui/server/tool-metadata.ts +3 -1
  62. package/gui/server/ws-hub.ts +61 -6
  63. package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +1 -0
  64. package/gui/web/dist/assets/index-BzyqyVnd.css +2 -0
  65. package/gui/web/dist/assets/{index-B7QAjY5g.js → index-DvMjkOP9.js} +9 -9
  66. package/gui/web/dist/index.html +2 -2
  67. package/package.json +6 -3
  68. package/src/analysts/orchestrator.ts +15 -56
  69. package/src/cli-main.ts +253 -13
  70. package/src/config.ts +12 -20
  71. package/src/market-state/daily-report.ts +6 -16
  72. package/src/market-state/service.ts +0 -136
  73. package/src/memory/sqlite.ts +23 -19
  74. package/src/pi/opencandle-extension.ts +12 -265
  75. package/src/pi/session-action-dedupe.ts +155 -0
  76. package/src/pi/session-writer-lock.ts +290 -20
  77. package/src/pi/setup.ts +6 -6
  78. package/src/pi/tui-session-coordinator.ts +351 -0
  79. package/src/prompts/context-builder.ts +1 -1
  80. package/src/prompts/policy-cards.ts +1 -1
  81. package/src/routing/classify-intent.ts +4 -5
  82. package/src/routing/route-manifest.ts +0 -1
  83. package/src/routing/router-prompt.ts +1 -1
  84. package/src/routing/router.ts +39 -3
  85. package/src/runtime/session-coordinator.ts +2 -17
  86. package/src/system-prompt.ts +1 -1
  87. package/src/tools/AGENTS.md +1 -1
  88. package/src/tools/fundamentals/dcf.ts +57 -7
  89. package/src/tools/index.ts +0 -3
  90. package/src/tools/portfolio/daily-report.ts +10 -4
  91. package/src/tools/technical/backtest.ts +167 -108
  92. package/dist/tools/portfolio/predictions.d.ts +0 -55
  93. package/dist/tools/portfolio/predictions.js +0 -422
  94. package/dist/tools/portfolio/predictions.js.map +0 -1
  95. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
  96. package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
  97. package/src/tools/portfolio/predictions.ts +0 -553
@@ -1,8 +1,6 @@
1
1
  import type Database from "better-sqlite3";
2
2
 
3
3
  export type AssetType = "equity" | "etf" | "fund" | "crypto" | "index" | "option" | "unknown";
4
- export type PredictionDirection = "bullish" | "bearish" | "neutral";
5
- export type PredictionStatus = "open" | "resolved" | "expired" | "cancelled";
6
4
  export type AlertScopeType = "instrument" | "watchlist" | "portfolio";
7
5
 
8
6
  export interface InstrumentInput {
@@ -100,23 +98,6 @@ export interface PortfolioLotRecord {
100
98
  updatedAt: string;
101
99
  }
102
100
 
103
- export interface PredictionRecord {
104
- id: number;
105
- instrumentId: number;
106
- symbol: string;
107
- direction: PredictionDirection;
108
- conviction: number;
109
- entryPrice: number;
110
- targetPrice: number | null;
111
- openedAt: string;
112
- expiresAt: string;
113
- status: PredictionStatus;
114
- resolvedAt: string | null;
115
- resultJson: string | null;
116
- createdAt: string;
117
- updatedAt: string;
118
- }
119
-
120
101
  export interface AlertRuleRecord {
121
102
  id: number;
122
103
  scopeType: AlertScopeType;
@@ -337,23 +318,6 @@ type PortfolioLotRow = {
337
318
  updated_at: string;
338
319
  };
339
320
 
340
- type PredictionRow = {
341
- id: number;
342
- instrument_id: number;
343
- symbol: string;
344
- direction: PredictionDirection;
345
- conviction: number;
346
- entry_price: number;
347
- target_price: number | null;
348
- opened_at: string;
349
- expires_at: string;
350
- status: PredictionStatus;
351
- resolved_at: string | null;
352
- result_json: string | null;
353
- created_at: string;
354
- updated_at: string;
355
- };
356
-
357
321
  type AlertRuleRow = {
358
322
  id: number;
359
323
  scope_type: AlertScopeType;
@@ -877,75 +841,6 @@ export class MarketStateService {
877
841
  });
878
842
  }
879
843
 
880
- recordPrediction(params: {
881
- instrument: InstrumentInput;
882
- direction: PredictionDirection;
883
- conviction: number;
884
- entryPrice: number;
885
- targetPrice?: number;
886
- timeframeDays: number;
887
- now?: Date;
888
- }): PredictionRecord {
889
- const tx = this.db.transaction(() => {
890
- const instrument = this.upsertInstrument(params.instrument);
891
- const opened = params.now ?? new Date();
892
- const expires = new Date(opened);
893
- expires.setDate(expires.getDate() + params.timeframeDays);
894
- const nowIso = opened.toISOString();
895
- const result = this.db
896
- .prepare(
897
- `INSERT INTO prediction_records (
898
- instrument_id, direction, conviction, entry_price, target_price,
899
- opened_at, expires_at, status, created_at, updated_at
900
- )
901
- VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
902
- )
903
- .run(
904
- instrument.id,
905
- params.direction,
906
- params.conviction,
907
- params.entryPrice,
908
- params.targetPrice ?? null,
909
- nowIso,
910
- expires.toISOString(),
911
- nowIso,
912
- nowIso,
913
- );
914
- return Number(result.lastInsertRowid);
915
- });
916
-
917
- return this.getPrediction(tx());
918
- }
919
-
920
- listPredictions(): PredictionRecord[] {
921
- const rows = this.db
922
- .prepare(
923
- `SELECT pr.*, i.symbol
924
- FROM prediction_records pr
925
- JOIN instruments i ON i.id = pr.instrument_id
926
- ORDER BY pr.opened_at, pr.id`,
927
- )
928
- .all() as PredictionRow[];
929
- return rows.map(mapPrediction);
930
- }
931
-
932
- updatePredictionOutcome(params: {
933
- id: number;
934
- status: Exclude<PredictionStatus, "open">;
935
- resolvedAt: string;
936
- result: unknown;
937
- }): PredictionRecord {
938
- const now = new Date().toISOString();
939
- this.db
940
- .prepare(
941
- `UPDATE prediction_records
942
- SET status = ?, resolved_at = ?, result_json = ?, updated_at = ?
943
- WHERE id = ?`,
944
- )
945
- .run(params.status, params.resolvedAt, JSON.stringify(params.result), now, params.id);
946
- return this.getPrediction(params.id);
947
- }
948
-
949
844
  createAlertRule(params: {
950
845
  scopeType: AlertScopeType;
951
846
  scopeId?: number;
@@ -1998,18 +1893,6 @@ export class MarketStateService {
1998
1893
  return row == null ? null : mapPortfolioLot(row);
1999
1894
  }
2000
1895
 
2001
- private getPrediction(id: number): PredictionRecord {
2002
- const row = this.db
2003
- .prepare(
2004
- `SELECT pr.*, i.symbol
2005
- FROM prediction_records pr
2006
- JOIN instruments i ON i.id = pr.instrument_id
2007
- WHERE pr.id = ?`,
2008
- )
2009
- .get(id) as PredictionRow;
2010
- return mapPrediction(row);
2011
- }
2012
-
2013
1896
  getAlertRule(id: number): AlertRuleRecord {
2014
1897
  const row = this.db.prepare("SELECT * FROM alert_rules WHERE id = ?").get(id) as AlertRuleRow;
2015
1898
  return mapAlertRule(row);
@@ -2118,25 +2001,6 @@ function mapPortfolioLot(row: PortfolioLotRow): PortfolioLotRecord {
2118
2001
  };
2119
2002
  }
2120
2003
 
2121
- function mapPrediction(row: PredictionRow): PredictionRecord {
2122
- return {
2123
- id: row.id,
2124
- instrumentId: row.instrument_id,
2125
- symbol: row.symbol,
2126
- direction: row.direction,
2127
- conviction: row.conviction,
2128
- entryPrice: row.entry_price,
2129
- targetPrice: row.target_price,
2130
- openedAt: row.opened_at,
2131
- expiresAt: row.expires_at,
2132
- status: row.status,
2133
- resolvedAt: row.resolved_at,
2134
- resultJson: row.result_json,
2135
- createdAt: row.created_at,
2136
- updatedAt: row.updated_at,
2137
- };
2138
- }
2139
-
2140
2004
  function mapAlertRule(row: AlertRuleRow): AlertRuleRecord {
2141
2005
  return {
2142
2006
  id: row.id,
@@ -3,7 +3,7 @@ import { dirname } from "node:path";
3
3
  import Database from "better-sqlite3";
4
4
  import { ensureOpenCandleHomeDir, getStateDbPath } from "../infra/opencandle-paths.js";
5
5
 
6
- const CURRENT_SCHEMA_VERSION = 7;
6
+ const CURRENT_SCHEMA_VERSION = 8;
7
7
 
8
8
  const CURRENT_SCHEMA = `
9
9
  CREATE TABLE IF NOT EXISTS schema_version (
@@ -178,23 +178,6 @@ const CURRENT_SCHEMA = `
178
178
  ON portfolio_lots(source, source_lot_id)
179
179
  WHERE source IS NOT NULL AND source_lot_id IS NOT NULL;
180
180
 
181
- CREATE TABLE IF NOT EXISTS prediction_records (
182
- id INTEGER PRIMARY KEY AUTOINCREMENT,
183
- instrument_id INTEGER NOT NULL,
184
- direction TEXT NOT NULL,
185
- conviction REAL NOT NULL,
186
- entry_price REAL NOT NULL,
187
- target_price REAL,
188
- opened_at TEXT NOT NULL,
189
- expires_at TEXT NOT NULL,
190
- status TEXT NOT NULL,
191
- resolved_at TEXT,
192
- result_json TEXT,
193
- created_at TEXT NOT NULL,
194
- updated_at TEXT NOT NULL,
195
- FOREIGN KEY (instrument_id) REFERENCES instruments(id) ON DELETE RESTRICT
196
- );
197
-
198
181
  CREATE TABLE IF NOT EXISTS alert_rules (
199
182
  id INTEGER PRIMARY KEY AUTOINCREMENT,
200
183
  scope_type TEXT NOT NULL,
@@ -380,14 +363,21 @@ function ensureCurrentSchema(db: Database.Database): void {
380
363
  return;
381
364
  }
382
365
 
366
+ if (currentVersion === 7) {
367
+ migrateV7ToV8(db);
368
+ return;
369
+ }
370
+
383
371
  if (currentVersion === 6) {
384
372
  migrateV6ToV7(db);
373
+ migrateV7ToV8(db);
385
374
  return;
386
375
  }
387
376
 
388
377
  if (currentVersion === 5) {
389
378
  migrateV5ToV6(db);
390
379
  migrateV6ToV7(db);
380
+ migrateV7ToV8(db);
391
381
  return;
392
382
  }
393
383
 
@@ -395,6 +385,7 @@ function ensureCurrentSchema(db: Database.Database): void {
395
385
  migrateV4ToV5(db);
396
386
  migrateV5ToV6(db);
397
387
  migrateV6ToV7(db);
388
+ migrateV7ToV8(db);
398
389
  return;
399
390
  }
400
391
 
@@ -403,16 +394,19 @@ function ensureCurrentSchema(db: Database.Database): void {
403
394
  migrateV4ToV5(db);
404
395
  migrateV5ToV6(db);
405
396
  migrateV6ToV7(db);
397
+ migrateV7ToV8(db);
406
398
  return;
407
399
  }
408
400
 
409
- // Additive v2 → v3 → v4v5 → v6 migration without dropping data.
401
+ // Additive v2 → v3 → ...v7 migration without dropping data (v8 drops
402
+ // prediction_records as the explicit removal of the predictions feature).
410
403
  if (currentVersion === 2) {
411
404
  migrateV2ToV3(db);
412
405
  migrateV3ToV4(db);
413
406
  migrateV4ToV5(db);
414
407
  migrateV5ToV6(db);
415
408
  migrateV6ToV7(db);
409
+ migrateV7ToV8(db);
416
410
  return;
417
411
  }
418
412
 
@@ -487,6 +481,16 @@ function migrateV6ToV7(db: Database.Database): void {
487
481
 
488
482
  db.exec(CURRENT_SCHEMA);
489
483
 
484
+ db.prepare("DELETE FROM schema_version").run();
485
+ db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(7);
486
+ }
487
+
488
+ function migrateV7ToV8(db: Database.Database): void {
489
+ // Predictions feature removal: dropping prediction_records is the explicit,
490
+ // documented destructive step for this table; all other rows are preserved.
491
+ db.exec("DROP TABLE IF EXISTS prediction_records");
492
+ db.exec(CURRENT_SCHEMA);
493
+
490
494
  db.prepare("DELETE FROM schema_version").run();
491
495
  db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(CURRENT_SCHEMA_VERSION);
492
496
  }
@@ -25,15 +25,12 @@ import { formatPreflightDropAnnotation, preflightSymbols } from "../prompts/symb
25
25
  import { buildAssumptionsBlockFromRouter } from "../prompts/workflow-prompts.js";
26
26
  import {
27
27
  buildResolvedTurnContext,
28
- classifyWithLegacyRules,
29
28
  createPiAiRouterClient,
30
- hasFinanceSignals,
31
29
  resolveOptionsScreenerSlots,
32
30
  resolvePortfolioSlots,
33
31
  route as routeLlm,
34
32
  } from "../routing/index.js";
35
33
  import type { RouterInputContext, RouterLlmClient, RouterOutput } from "../routing/router-types.js";
36
- import { disambiguateSymbols } from "../routing/symbol-disambiguator.js";
37
34
  import type { ResolvedTurnContext } from "../routing/turn-context.js";
38
35
  import type {
39
36
  CompareAssetsSlots,
@@ -118,9 +115,7 @@ export default function openCandleExtension(
118
115
  ctx.ui.notify("Usage: /analyze <ticker>", "warning");
119
116
  return;
120
117
  }
121
- const definition = buildComprehensiveAnalysisDefinition(symbol, {
122
- debate: getConfig().debate,
123
- });
118
+ const definition = buildComprehensiveAnalysisDefinition(symbol);
124
119
  coordinator.executeWorkflow(pi, definition, ctx);
125
120
  },
126
121
  });
@@ -674,244 +669,26 @@ export default function openCandleExtension(
674
669
  return undefined;
675
670
  });
676
671
 
677
- // Input handling — branches on OPENCANDLE_ROUTER_MODE.
672
+ // Input handling — the LLM router is the single production routing path.
678
673
  pi.on("input", async (event, ctx) => {
679
674
  if (event.source === "extension") return;
680
675
  coordinator.clearTickerValidationCache();
681
676
 
682
- // Check for comprehensive analysis pattern same in both modes.
677
+ // Check for comprehensive analysis pattern before routing.
683
678
  const analysis = isAnalysisRequest(event.text);
684
679
  if (analysis.match && analysis.symbol) {
685
- const definition = buildComprehensiveAnalysisDefinition(analysis.symbol, {
686
- debate: getConfig().debate,
687
- });
688
- const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
689
- if (prompt) markOriginalInput(event.text);
690
- return prompt ? { action: "transform", text: prompt } : { action: "handled" };
691
- }
692
-
693
- const mode = getConfig().routerMode;
694
- if (mode === "llm") {
695
- const dispatched = await handleLlmRouterTurn(event.text, ctx);
696
- // Dispatched a workflow → the original user turn is now represented by
697
- // the workflow's queued prompts; tell Pi not to also forward it.
698
- // Fallback path (no dispatch) → let Pi pass the user turn through to the
699
- // main agent, which will run under the router-supplied fallback context.
700
- return dispatched || undefined;
701
- }
702
-
703
- // --- explicit legacy rules mode (`OPENCANDLE_ROUTER_MODE=rules`) ---
704
- // Extract and persist user preferences (legacy regex path)
705
- coordinator.extractAndStorePreferences(event.text);
706
- const storage = coordinator.getStorage();
707
- const workflowPrefs = storage?.getWorkflowPreferences("global") ?? {};
708
-
709
- // Classify intent
710
- let classification = classifyWithLegacyRules(event.text);
711
- const ruleModeDisambiguation = disambiguateRulesModeSymbols(
712
- event.text,
713
- classification.entities.symbols,
714
- );
715
- appendSymbolDropEntries(ruleModeDisambiguation.dropped, "rules");
716
- classification = {
717
- ...classification,
718
- entities: {
719
- ...classification.entities,
720
- symbols: ruleModeDisambiguation.kept,
721
- },
722
- };
723
- if (
724
- isComparePrompt(event.text) &&
725
- ruleModeDisambiguation.dropped.length > 0 &&
726
- classification.entities.symbols.length < 2
727
- ) {
728
- pi.appendEntry("opencandle-workflow-aborted", {
729
- reason: "symbol-disambiguation-insufficient-symbols",
730
- dropped: ruleModeDisambiguation.dropped,
731
- validSymbols: classification.entities.symbols,
732
- });
733
- const base = coordinator.buildRouterContextBase(ctx.sessionManager);
734
- const output: RouterOutput = {
735
- routeKind: "clarification",
736
- route: "fallback",
737
- workflow: "compare_assets",
738
- entities: classification.entities,
739
- slots: {},
740
- preference_updates: [],
741
- missing_required: ["symbols"],
742
- tool_bundles: ["clarification"],
743
- diagnostics: ruleModeDisambiguation.dropped.map((drop) => ({
744
- code: "symbol_dropped",
745
- message: `${drop.token} dropped: ${drop.reason}`,
746
- details: {
747
- token: drop.token,
748
- reason: drop.reason,
749
- signalsChecked: drop.signalsChecked,
750
- source: "rules",
751
- },
752
- })),
753
- reasoning: "rules-mode acronym disambiguation left fewer than two symbols for comparison",
754
- };
755
- const resolvedTurnContext = buildResolvedTurnContext({ text: event.text, ...base }, output, {
756
- availableToolNames: safeGetAllToolNames(),
757
- planning: {
758
- migrationStatuses: getConfig().planningMigrationStatuses,
759
- },
760
- });
761
- coordinator.setPendingResolvedTurnContext({
762
- ...resolvedTurnContext,
763
- diagnostics: [
764
- ...resolvedTurnContext.diagnostics,
765
- {
766
- code: "compare_workflow_aborted",
767
- message:
768
- "compare workflow needs at least two validated symbols after acronym disambiguation",
769
- },
770
- ],
771
- });
772
- coordinator.setPendingFallbackContext({
773
- assumptionsBlock: [
774
- "Assumptions Context:",
775
- classification.entities.symbols.length > 0
776
- ? ` valid symbols: ${classification.entities.symbols.join(", ")} (user)`
777
- : " valid symbols: (none)",
778
- ` dropped ambiguous ticker-like tokens: ${ruleModeDisambiguation.dropped.map((d) => d.token).join(", ")} (no positive ticker signal)`,
779
- ].join("\n"),
780
- missingRequired: ["symbols"],
781
- extraContext:
782
- "Dropped ambiguous ticker-like tokens: " +
783
- `${ruleModeDisambiguation.dropped.map((d) => d.token).join(", ")}. ` +
784
- "Ask the user which ticker symbols they want compared before calling comparison tools.",
785
- });
786
- applyRouteToolScope(resolvedTurnContext);
787
- return undefined;
788
- }
789
-
790
- if (classification.workflow === "portfolio_builder") {
791
- const resolution = resolvePortfolioSlots(classification.entities, workflowPrefs);
792
- coordinator.recordWorkflowRun(
793
- "portfolio_builder",
794
- classification.entities,
795
- resolution.resolved,
796
- resolution.defaultsUsed,
797
- );
798
- pi.appendEntry("opencandle-workflow", {
799
- workflow: "portfolio_builder",
800
- entities: classification.entities,
801
- resolved: resolution.resolved,
802
- });
803
- const definition = buildPortfolioWorkflowDefinition(resolution);
804
- const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
805
- if (prompt) markOriginalInput(event.text);
806
- return prompt ? { action: "transform", text: prompt } : { action: "handled" };
807
- }
808
-
809
- if (classification.workflow === "options_screener") {
810
- const resolution = resolveOptionsScreenerSlots(classification.entities, workflowPrefs);
811
- if (resolution.missingRequired.length === 0) {
812
- coordinator.recordWorkflowRun(
813
- "options_screener",
814
- classification.entities,
815
- resolution.resolved,
816
- resolution.defaultsUsed,
817
- );
818
- pi.appendEntry("opencandle-workflow", {
819
- workflow: "options_screener",
820
- entities: classification.entities,
821
- resolved: resolution.resolved,
822
- });
823
- const definition = buildOptionsScreenerWorkflowDefinition(resolution);
824
- const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
825
- if (prompt) markOriginalInput(event.text);
826
- return prompt ? { action: "transform", text: prompt } : { action: "handled" };
827
- }
828
- }
829
-
830
- if (
831
- classification.workflow === "compare_assets" &&
832
- classification.entities.symbols.length >= 2
833
- ) {
834
- const resolution: SlotResolution<CompareAssetsSlots> = {
835
- resolved: {
836
- symbols: classification.entities.symbols,
837
- metrics: classification.entities.compareMetrics,
838
- timeHorizon: classification.entities.timeHorizon,
839
- budget: classification.entities.budget,
840
- assetScope: classification.entities.assetScope,
841
- },
842
- sources: {
843
- symbols: "user",
844
- ...(classification.entities.timeHorizon ? { timeHorizon: "user" as const } : {}),
845
- ...(classification.entities.compareMetrics ? { metrics: "user" as const } : {}),
846
- ...(classification.entities.budget !== undefined ? { budget: "user" as const } : {}),
847
- ...(classification.entities.assetScope ? { assetScope: "user" as const } : {}),
848
- },
849
- defaultsUsed: [],
850
- missingRequired: [],
851
- };
852
- coordinator.recordWorkflowRun(
853
- "compare_assets",
854
- classification.entities,
855
- resolution.resolved,
856
- resolution.defaultsUsed,
857
- );
858
- pi.appendEntry("opencandle-workflow", {
859
- workflow: "compare_assets",
860
- symbols: classification.entities.symbols,
861
- });
862
- const preflight = await preflightCompareResolution(resolution);
863
- if (!preflight) {
864
- coordinator.recordWorkflowRun(
865
- "fallback",
866
- classification.entities,
867
- resolution.resolved,
868
- [],
869
- "clarification",
870
- );
871
- coordinator.setPendingFallbackContext({
872
- assumptionsBlock: [
873
- "Assumptions Context:",
874
- ` original symbols: ${classification.entities.symbols.join(", ")} (user)`,
875
- ].join("\n"),
876
- missingRequired: ["symbols"],
877
- extraContext:
878
- "Compare workflow aborted because ticker preflight left fewer than two valid symbols. Ask the user to clarify the intended tickers before calling comparison tools.",
879
- });
880
- return undefined;
881
- }
882
- const definition = buildCompareAssetsWorkflowDefinition(preflight.resolution);
883
- applyPreflightAnnotation(definition, preflight.dropped);
680
+ const definition = buildComprehensiveAnalysisDefinition(analysis.symbol);
884
681
  const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
885
682
  if (prompt) markOriginalInput(event.text);
886
683
  return prompt ? { action: "transform", text: prompt } : { action: "handled" };
887
684
  }
888
685
 
889
- // Rules-mode finance fallback: no workflow dispatched, but the turn is
890
- // finance-shaped (classified finance intent, extracted symbols, or finance
891
- // vocabulary). Record the fallback turn and stash a fallback context so
892
- // the system prompt carries saved market state for this turn; non-finance
893
- // prompts stay untouched.
894
- const isFinanceFallback =
895
- classification.workflow !== "unclassified" ||
896
- classification.entities.symbols.length > 0 ||
897
- hasFinanceSignals(event.text);
898
- if (isFinanceFallback) {
899
- coordinator.recordWorkflowRun("fallback", classification.entities, {}, [], "agent_task");
900
- coordinator.setPendingFallbackContext({
901
- assumptionsBlock: "",
902
- missingRequired: [],
903
- extraContext:
904
- classification.entities.symbols.length > 0
905
- ? `Rules-router extracted symbols: ${classification.entities.symbols.join(", ")}.`
906
- : undefined,
907
- });
908
- pi.appendEntry("opencandle-fallback-context", {
909
- mode: "rules",
910
- classifiedWorkflow: classification.workflow,
911
- symbols: classification.entities.symbols,
912
- });
913
- }
914
- return undefined;
686
+ const dispatched = await handleLlmRouterTurn(event.text, ctx);
687
+ // Dispatched a workflow the original user turn is now represented by
688
+ // the workflow's queued prompts; tell Pi not to also forward it.
689
+ // Fallback path (no dispatch) let Pi pass the user turn through to the
690
+ // main agent, which will run under the router-supplied fallback context.
691
+ return dispatched || undefined;
915
692
  });
916
693
 
917
694
  /**
@@ -1206,36 +983,6 @@ export default function openCandleExtension(
1206
983
  }
1207
984
  }
1208
985
 
1209
- function disambiguateRulesModeSymbols(
1210
- text: string,
1211
- extractedSymbols: string[],
1212
- ): {
1213
- kept: string[];
1214
- dropped: Array<{ token: string; reason: string; signalsChecked: string[] }>;
1215
- } {
1216
- const candidates = mergeSymbols(extractedSymbols, rawTickerLikeTokens(text));
1217
- const disambiguated = disambiguateSymbols(candidates, text);
1218
- return {
1219
- kept: disambiguated.kept.filter((symbol) => extractedSymbols.includes(symbol)),
1220
- dropped: disambiguated.dropped,
1221
- };
1222
- }
1223
-
1224
- function rawTickerLikeTokens(text: string): string[] {
1225
- const tokens: string[] = [];
1226
- for (const match of text.matchAll(/\$?([A-Za-z]{1,5})\b/g)) {
1227
- const raw = match[1];
1228
- if (raw !== raw.toUpperCase()) continue;
1229
- const token = raw.toUpperCase();
1230
- if (!tokens.includes(token)) tokens.push(token);
1231
- }
1232
- return tokens;
1233
- }
1234
-
1235
- function isComparePrompt(text: string): boolean {
1236
- return /\b(?:compare|vs\.?|versus|which\s+is\s+better)\b/i.test(text);
1237
- }
1238
-
1239
986
  async function preflightCompareResolution(
1240
987
  resolution: SlotResolution<CompareAssetsSlots>,
1241
988
  ): Promise<{
@@ -1436,8 +1183,8 @@ export default function openCandleExtension(
1436
1183
  ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1],
1437
1184
  ): RouterLlmClient | null {
1438
1185
  // `ctx.model` is the currently selected pi-ai model. When unset (no auth
1439
- // configured yet), we skip the router and the main agent will run with
1440
- // its default unrouted flow (legacy rules path is the safer default).
1186
+ // configured yet), we skip the router and the main agent runs with its
1187
+ // default unrouted flow for the turn.
1441
1188
  const model = (ctx as { model?: unknown }).model;
1442
1189
  if (!model) return null;
1443
1190
  // biome-ignore lint/suspicious/noExplicitAny: Pi typings keep Model generic