opencandle 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (223) hide show
  1. package/README.md +12 -9
  2. package/dist/analysts/orchestrator.d.ts +3 -4
  3. package/dist/analysts/orchestrator.js +34 -44
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/cli-main.js +242 -9
  6. package/dist/cli-main.js.map +1 -1
  7. package/dist/config.d.ts +4 -9
  8. package/dist/config.js +7 -10
  9. package/dist/config.js.map +1 -1
  10. package/dist/doctor/report.js +1 -1
  11. package/dist/doctor/report.js.map +1 -1
  12. package/dist/infra/cache.d.ts +6 -0
  13. package/dist/infra/cache.js +16 -6
  14. package/dist/infra/cache.js.map +1 -1
  15. package/dist/infra/freshness.d.ts +21 -0
  16. package/dist/infra/freshness.js +119 -0
  17. package/dist/infra/freshness.js.map +1 -0
  18. package/dist/infra/market-calendar.d.ts +14 -0
  19. package/dist/infra/market-calendar.js +137 -0
  20. package/dist/infra/market-calendar.js.map +1 -0
  21. package/dist/infra/rate-limiter.js +1 -0
  22. package/dist/infra/rate-limiter.js.map +1 -1
  23. package/dist/market-state/daily-report.d.ts +1 -1
  24. package/dist/market-state/daily-report.js +5 -16
  25. package/dist/market-state/daily-report.js.map +1 -1
  26. package/dist/market-state/service.d.ts +0 -35
  27. package/dist/market-state/service.js +0 -63
  28. package/dist/market-state/service.js.map +1 -1
  29. package/dist/market-state/summaries.d.ts +8 -0
  30. package/dist/market-state/summaries.js +70 -0
  31. package/dist/market-state/summaries.js.map +1 -0
  32. package/dist/memory/sqlite.js +20 -19
  33. package/dist/memory/sqlite.js.map +1 -1
  34. package/dist/onboarding/providers.d.ts +25 -1
  35. package/dist/onboarding/providers.js +26 -0
  36. package/dist/onboarding/providers.js.map +1 -1
  37. package/dist/pi/opencandle-extension.js +57 -226
  38. package/dist/pi/opencandle-extension.js.map +1 -1
  39. package/dist/pi/session-action-dedupe.d.ts +6 -0
  40. package/dist/pi/session-action-dedupe.js +126 -0
  41. package/dist/pi/session-action-dedupe.js.map +1 -0
  42. package/dist/pi/session-writer-lock.d.ts +26 -2
  43. package/dist/pi/session-writer-lock.js +230 -18
  44. package/dist/pi/session-writer-lock.js.map +1 -1
  45. package/dist/pi/setup.js +5 -5
  46. package/dist/pi/setup.js.map +1 -1
  47. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  48. package/dist/pi/tui-session-coordinator.js +283 -0
  49. package/dist/pi/tui-session-coordinator.js.map +1 -0
  50. package/dist/prompts/context-builder.js +1 -1
  51. package/dist/prompts/policy-cards.js +1 -1
  52. package/dist/prompts/policy-cards.js.map +1 -1
  53. package/dist/providers/alpha-vantage.js +6 -0
  54. package/dist/providers/alpha-vantage.js.map +1 -1
  55. package/dist/providers/coingecko.js +1 -0
  56. package/dist/providers/coingecko.js.map +1 -1
  57. package/dist/providers/polymarket.d.ts +2 -0
  58. package/dist/providers/polymarket.js +163 -0
  59. package/dist/providers/polymarket.js.map +1 -0
  60. package/dist/providers/wrap-provider.js +5 -3
  61. package/dist/providers/wrap-provider.js.map +1 -1
  62. package/dist/providers/yahoo-finance.d.ts +3 -0
  63. package/dist/providers/yahoo-finance.js +206 -10
  64. package/dist/providers/yahoo-finance.js.map +1 -1
  65. package/dist/routing/classify-intent.js +4 -5
  66. package/dist/routing/classify-intent.js.map +1 -1
  67. package/dist/routing/entity-extractor.js +16 -0
  68. package/dist/routing/entity-extractor.js.map +1 -1
  69. package/dist/routing/route-manifest.js +1 -2
  70. package/dist/routing/route-manifest.js.map +1 -1
  71. package/dist/routing/router-llm-client.js +18 -3
  72. package/dist/routing/router-llm-client.js.map +1 -1
  73. package/dist/routing/router-prompt.js +1 -1
  74. package/dist/routing/router.d.ts +1 -1
  75. package/dist/routing/router.js +309 -9
  76. package/dist/routing/router.js.map +1 -1
  77. package/dist/routing/slot-resolver.d.ts +1 -0
  78. package/dist/routing/slot-resolver.js +1 -1
  79. package/dist/routing/slot-resolver.js.map +1 -1
  80. package/dist/runtime/evidence.d.ts +1 -0
  81. package/dist/runtime/evidence.js.map +1 -1
  82. package/dist/runtime/numeric-claims.d.ts +23 -0
  83. package/dist/runtime/numeric-claims.js +100 -0
  84. package/dist/runtime/numeric-claims.js.map +1 -0
  85. package/dist/runtime/planning-evidence.js +1 -76
  86. package/dist/runtime/planning-evidence.js.map +1 -1
  87. package/dist/runtime/prompt-step.d.ts +10 -1
  88. package/dist/runtime/prompt-step.js +116 -2
  89. package/dist/runtime/prompt-step.js.map +1 -1
  90. package/dist/runtime/session-coordinator.d.ts +11 -1
  91. package/dist/runtime/session-coordinator.js +354 -62
  92. package/dist/runtime/session-coordinator.js.map +1 -1
  93. package/dist/runtime/validation.d.ts +3 -1
  94. package/dist/runtime/workflow-events.d.ts +1 -1
  95. package/dist/runtime/workflow-events.js.map +1 -1
  96. package/dist/runtime/workflow-types.d.ts +3 -0
  97. package/dist/runtime/workflow-types.js.map +1 -1
  98. package/dist/system-prompt.js +1 -1
  99. package/dist/tools/fundamentals/comps.d.ts +1 -0
  100. package/dist/tools/fundamentals/comps.js +20 -9
  101. package/dist/tools/fundamentals/comps.js.map +1 -1
  102. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  103. package/dist/tools/fundamentals/dcf.js +190 -76
  104. package/dist/tools/fundamentals/dcf.js.map +1 -1
  105. package/dist/tools/index.d.ts +1 -1
  106. package/dist/tools/index.js +3 -3
  107. package/dist/tools/index.js.map +1 -1
  108. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  109. package/dist/tools/macro/event-probabilities.js +121 -0
  110. package/dist/tools/macro/event-probabilities.js.map +1 -0
  111. package/dist/tools/market/crypto-price.d.ts +4 -1
  112. package/dist/tools/market/crypto-price.js +10 -1
  113. package/dist/tools/market/crypto-price.js.map +1 -1
  114. package/dist/tools/market/screen-stocks.js +14 -3
  115. package/dist/tools/market/screen-stocks.js.map +1 -1
  116. package/dist/tools/market/stock-quote.d.ts +4 -1
  117. package/dist/tools/market/stock-quote.js +9 -4
  118. package/dist/tools/market/stock-quote.js.map +1 -1
  119. package/dist/tools/options/option-chain.d.ts +4 -1
  120. package/dist/tools/options/option-chain.js +12 -1
  121. package/dist/tools/options/option-chain.js.map +1 -1
  122. package/dist/tools/portfolio/daily-report.js +10 -4
  123. package/dist/tools/portfolio/daily-report.js.map +1 -1
  124. package/dist/tools/portfolio/risk-analysis.js +1 -1
  125. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  126. package/dist/tools/technical/backtest.d.ts +23 -5
  127. package/dist/tools/technical/backtest.js +131 -94
  128. package/dist/tools/technical/backtest.js.map +1 -1
  129. package/dist/tools/technical/indicators.js +1 -1
  130. package/dist/tools/technical/indicators.js.map +1 -1
  131. package/dist/types/fundamentals.d.ts +1 -0
  132. package/dist/types/index.d.ts +1 -0
  133. package/dist/types/market.d.ts +2 -0
  134. package/dist/types/options.d.ts +1 -0
  135. package/dist/types/prediction-markets.d.ts +13 -0
  136. package/dist/types/prediction-markets.js +2 -0
  137. package/dist/types/prediction-markets.js.map +1 -0
  138. package/gui/server/chat-event-adapter.ts +89 -5
  139. package/gui/server/http-routes.ts +661 -37
  140. package/gui/server/invoke-tool.ts +165 -16
  141. package/gui/server/live-chat-event-adapter.ts +37 -2
  142. package/gui/server/local-session-coordinator.ts +97 -0
  143. package/gui/server/market-state-api.ts +64 -48
  144. package/gui/server/projector.ts +132 -3
  145. package/gui/server/server.ts +51 -6
  146. package/gui/server/session-actions.ts +146 -45
  147. package/gui/server/session-entry-wait.ts +22 -3
  148. package/gui/server/tool-metadata.ts +3 -1
  149. package/gui/server/ws-hub.ts +77 -10
  150. package/gui/shared/chat-events.ts +10 -1
  151. package/gui/shared/event-reducer.ts +1 -0
  152. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  153. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  154. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  155. package/gui/web/dist/index.html +2 -2
  156. package/package.json +13 -15
  157. package/src/analysts/orchestrator.ts +35 -56
  158. package/src/cli-main.ts +253 -13
  159. package/src/config.ts +12 -20
  160. package/src/doctor/report.ts +1 -1
  161. package/src/infra/cache.ts +24 -11
  162. package/src/infra/freshness.ts +165 -0
  163. package/src/infra/market-calendar.ts +193 -0
  164. package/src/infra/rate-limiter.ts +1 -0
  165. package/src/market-state/daily-report.ts +6 -16
  166. package/src/market-state/service.ts +0 -136
  167. package/src/market-state/summaries.ts +75 -0
  168. package/src/memory/sqlite.ts +23 -19
  169. package/src/onboarding/providers.ts +29 -1
  170. package/src/pi/opencandle-extension.ts +67 -270
  171. package/src/pi/session-action-dedupe.ts +155 -0
  172. package/src/pi/session-writer-lock.ts +290 -20
  173. package/src/pi/setup.ts +6 -6
  174. package/src/pi/tui-session-coordinator.ts +351 -0
  175. package/src/prompts/context-builder.ts +1 -1
  176. package/src/prompts/policy-cards.ts +1 -1
  177. package/src/providers/alpha-vantage.ts +6 -0
  178. package/src/providers/coingecko.ts +2 -0
  179. package/src/providers/polymarket.ts +214 -0
  180. package/src/providers/wrap-provider.ts +5 -3
  181. package/src/providers/yahoo-finance.ts +339 -27
  182. package/src/routing/classify-intent.ts +4 -5
  183. package/src/routing/entity-extractor.ts +17 -0
  184. package/src/routing/route-manifest.ts +1 -2
  185. package/src/routing/router-llm-client.ts +31 -19
  186. package/src/routing/router-prompt.ts +1 -1
  187. package/src/routing/router.ts +379 -8
  188. package/src/routing/slot-resolver.ts +1 -1
  189. package/src/runtime/evidence.ts +1 -0
  190. package/src/runtime/numeric-claims.ts +108 -0
  191. package/src/runtime/planning-evidence.ts +6 -91
  192. package/src/runtime/prompt-step.ts +127 -2
  193. package/src/runtime/session-coordinator.ts +464 -74
  194. package/src/runtime/validation.ts +1 -1
  195. package/src/runtime/workflow-events.ts +2 -0
  196. package/src/runtime/workflow-types.ts +3 -0
  197. package/src/system-prompt.ts +1 -1
  198. package/src/tools/AGENTS.md +1 -1
  199. package/src/tools/fundamentals/comps.ts +25 -14
  200. package/src/tools/fundamentals/dcf.ts +217 -87
  201. package/src/tools/index.ts +3 -3
  202. package/src/tools/macro/event-probabilities.ts +141 -0
  203. package/src/tools/market/crypto-price.ts +14 -2
  204. package/src/tools/market/screen-stocks.ts +14 -3
  205. package/src/tools/market/stock-quote.ts +49 -43
  206. package/src/tools/options/option-chain.ts +16 -2
  207. package/src/tools/portfolio/daily-report.ts +10 -4
  208. package/src/tools/portfolio/risk-analysis.ts +1 -1
  209. package/src/tools/technical/backtest.ts +167 -108
  210. package/src/tools/technical/indicators.ts +1 -1
  211. package/src/types/fundamentals.ts +1 -0
  212. package/src/types/index.ts +1 -0
  213. package/src/types/market.ts +2 -0
  214. package/src/types/options.ts +1 -0
  215. package/src/types/prediction-markets.ts +13 -0
  216. package/dist/tools/portfolio/predictions.d.ts +0 -55
  217. package/dist/tools/portfolio/predictions.js +0 -422
  218. package/dist/tools/portfolio/predictions.js.map +0 -1
  219. package/gui/server/chat-run-session.ts +0 -16
  220. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
  221. package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
  222. package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
  223. package/src/tools/portfolio/predictions.ts +0 -553
@@ -0,0 +1,75 @@
1
+ import type { PortfolioLotRecord, ReportRunRecord, WatchlistItemRecord } from "./service.js";
2
+
3
+ export function formatPortfolioSummary(lots: PortfolioLotRecord[], limit = 8): string[] {
4
+ if (lots.length === 0) return [];
5
+ const lines = ["Portfolio lots:"];
6
+ for (const lot of lots.slice(0, limit)) {
7
+ const costBasis = formatMoney(lot.quantity * lot.avgCost, lot.currency);
8
+ const name = lot.name ? ` (${lot.name})` : "";
9
+ lines.push(
10
+ `- ${lot.symbol}: ${lot.quantity} @ ${formatMoney(lot.avgCost, lot.currency)}, cost basis ${costBasis}${name}`,
11
+ );
12
+ }
13
+ return lines;
14
+ }
15
+
16
+ export function formatWatchlistSummary(items: WatchlistItemRecord[], limit = 8): string[] {
17
+ if (items.length === 0) return [];
18
+ const lines = ["Watchlist:"];
19
+ for (const item of items.slice(0, limit)) {
20
+ const parts = [
21
+ item.targetPrice == null
22
+ ? null
23
+ : `target ${formatMoney(item.targetPrice, item.priceCurrency ?? item.currency ?? "USD")}`,
24
+ item.stopPrice == null
25
+ ? null
26
+ : `stop ${formatMoney(item.stopPrice, item.priceCurrency ?? item.currency ?? "USD")}`,
27
+ item.thesis ? `thesis: ${item.thesis}` : null,
28
+ item.tags && item.tags.length > 0 ? `tags: ${item.tags.join(", ")}` : null,
29
+ item.notes ? `notes: ${item.notes}` : null,
30
+ ].filter((part): part is string => part != null);
31
+ lines.push(
32
+ `- ${item.symbol}${item.name ? ` (${item.name})` : ""}${parts.length > 0 ? ` — ${parts.join("; ")}` : ""}`,
33
+ );
34
+ }
35
+ return lines;
36
+ }
37
+
38
+ export function formatLatestReportSummary(
39
+ report: ReportRunRecord | undefined,
40
+ options: { includeSummary?: boolean } = {},
41
+ ): string[] {
42
+ if (!report) return [];
43
+ const completed = report.completedAt ?? report.startedAt;
44
+ const summary = options.includeSummary === false ? "" : formatJsonSummary(report.summaryJson);
45
+ const lines = [
46
+ `Latest report run: ${report.status} at ${completed}${summary ? ` — ${summary}` : ""}`,
47
+ ];
48
+ const reportText = options.includeSummary === false ? "" : extractReportText(report.summaryJson);
49
+ if (reportText) lines.push("Report text:", truncateText(reportText, 8_000));
50
+ return lines;
51
+ }
52
+
53
+ export function formatMoney(value: number, currency: string): string {
54
+ const normalized = currency.toUpperCase();
55
+ if (normalized === "USD") return `$${value.toFixed(2)}`;
56
+ return `${normalized} ${value.toFixed(2)}`;
57
+ }
58
+
59
+ export function formatJsonSummary(value: unknown): string {
60
+ if (value == null) return "";
61
+ const json = JSON.stringify(value);
62
+ if (json.length <= 90) return json;
63
+ return `${json.slice(0, 87)}...`;
64
+ }
65
+
66
+ function extractReportText(value: unknown): string {
67
+ if (!value || typeof value !== "object" || !("text" in value)) return "";
68
+ const text = (value as { text?: unknown }).text;
69
+ return typeof text === "string" ? text.trim() : "";
70
+ }
71
+
72
+ function truncateText(value: string, maxLength: number): string {
73
+ if (value.length <= maxLength) return value;
74
+ return `${value.slice(0, maxLength - 3)}...`;
75
+ }
@@ -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
  }
@@ -12,7 +12,7 @@ import { getConfig, loadFileConfig } from "../config.js";
12
12
 
13
13
  export type ApiKeyProviderId = "alpha_vantage" | "fred" | "finnhub" | "brave" | "exa";
14
14
  export type ExternalToolProviderId = "twitter" | "reddit";
15
- export type PublicHttpProviderId = "yahoo";
15
+ export type PublicHttpProviderId = "polymarket" | "tradingview" | "yahoo";
16
16
  export type ProviderId = ApiKeyProviderId | ExternalToolProviderId | PublicHttpProviderId;
17
17
 
18
18
  export type ProviderCategory =
@@ -201,6 +201,34 @@ export const PROVIDERS = [
201
201
  snoozeDurationDays: 7,
202
202
  instructionsHint: "No account needed; OpenCandle checks public Yahoo Finance reachability",
203
203
  },
204
+ {
205
+ id: "polymarket",
206
+ kind: "public-http",
207
+ displayName: "Polymarket",
208
+ category: "macro",
209
+ tier: "hard",
210
+ aliases: ["polymarket", "prediction-markets", "event-probabilities"],
211
+ probeUrl: "https://gamma-api.polymarket.com/public-search?q=fed%20rate%20cut&limit=1",
212
+ unlocks: ["market-implied event probabilities", "prediction-market resolution criteria"],
213
+ fallbackDescription: null,
214
+ snoozeDurationDays: 7,
215
+ instructionsHint: "No account needed; OpenCandle checks public Polymarket Gamma reachability",
216
+ },
217
+ {
218
+ id: "tradingview",
219
+ kind: "public-http",
220
+ displayName: "TradingView Scanner",
221
+ category: "market",
222
+ tier: "soft",
223
+ aliases: ["tradingview", "tradingview-scanner", "screener"],
224
+ probeUrl: "https://scanner.tradingview.com/america/scan2?label-product=screener-stock",
225
+ unlocks: ["stock screens", "ticker search fallback", "watchlist quote fallback"],
226
+ fallbackDescription:
227
+ "Core market quote/history tools continue through Yahoo and Alpha Vantage when TradingView scanner is unavailable",
228
+ snoozeDurationDays: 7,
229
+ instructionsHint:
230
+ "No account needed; OpenCandle checks the public TradingView scanner endpoint",
231
+ },
204
232
  {
205
233
  id: "twitter",
206
234
  kind: "external-tool",
@@ -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,
@@ -68,6 +65,10 @@ export interface OpenCandleExtensionOptions {
68
65
  titleCompletion?: (prompt: string) => Promise<string>;
69
66
  }
70
67
 
68
+ interface OriginalInputMarkerContext {
69
+ sessionManager?: { getBranch?: () => unknown[]; getEntries?: () => unknown[] };
70
+ }
71
+
71
72
  export default function openCandleExtension(
72
73
  pi: ExtensionAPI,
73
74
  options?: OpenCandleExtensionOptions,
@@ -76,10 +77,22 @@ export default function openCandleExtension(
76
77
 
77
78
  // Workflow transforms replace the user's turn with the expanded prompt; this
78
79
  // marker lets the GUI render the user's original words instead.
79
- const markOriginalInput = (original: string): void => {
80
+ const markOriginalInput = (original: string, ctx?: OriginalInputMarkerContext): void => {
81
+ if (hasUnconsumedOriginalInputMarker(ctx)) return;
80
82
  pi.appendEntry("opencandle-user-input", { original });
81
83
  };
82
84
 
85
+ const appendComprehensiveAnalysisWorkflowEntry = (
86
+ symbol: string,
87
+ definition: ReturnType<typeof buildComprehensiveAnalysisDefinition>,
88
+ ): void => {
89
+ pi.appendEntry("opencandle-workflow", {
90
+ workflow: "comprehensive_analysis",
91
+ resolvedSlots: { symbol },
92
+ analystsTotal: definition.steps.filter((step) => step.stepType.startsWith("analyst_")).length,
93
+ });
94
+ };
95
+
83
96
  // Credential-interception state. Lifetime:
84
97
  // `sessionPromptedSet` — cleared on session_start, persists across turns
85
98
  // within a session so users don't get re-prompted after picking
@@ -118,9 +131,8 @@ export default function openCandleExtension(
118
131
  ctx.ui.notify("Usage: /analyze <ticker>", "warning");
119
132
  return;
120
133
  }
121
- const definition = buildComprehensiveAnalysisDefinition(symbol, {
122
- debate: getConfig().debate,
123
- });
134
+ const definition = buildComprehensiveAnalysisDefinition(symbol);
135
+ appendComprehensiveAnalysisWorkflowEntry(symbol, definition);
124
136
  coordinator.executeWorkflow(pi, definition, ctx);
125
137
  },
126
138
  });
@@ -674,244 +686,27 @@ export default function openCandleExtension(
674
686
  return undefined;
675
687
  });
676
688
 
677
- // Input handling — branches on OPENCANDLE_ROUTER_MODE.
689
+ // Input handling — the LLM router is the single production routing path.
678
690
  pi.on("input", async (event, ctx) => {
679
691
  if (event.source === "extension") return;
680
692
  coordinator.clearTickerValidationCache();
681
693
 
682
- // Check for comprehensive analysis pattern same in both modes.
694
+ // Check for comprehensive analysis pattern before routing.
683
695
  const analysis = isAnalysisRequest(event.text);
684
696
  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);
697
+ const definition = buildComprehensiveAnalysisDefinition(analysis.symbol);
698
+ appendComprehensiveAnalysisWorkflowEntry(analysis.symbol, definition);
804
699
  const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
805
- if (prompt) markOriginalInput(event.text);
700
+ if (prompt) markOriginalInput(event.text, ctx);
806
701
  return prompt ? { action: "transform", text: prompt } : { action: "handled" };
807
702
  }
808
703
 
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);
884
- const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
885
- if (prompt) markOriginalInput(event.text);
886
- return prompt ? { action: "transform", text: prompt } : { action: "handled" };
887
- }
888
-
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;
704
+ const dispatched = await handleLlmRouterTurn(event.text, ctx);
705
+ // Dispatched a workflow → the original user turn is now represented by
706
+ // the workflow's queued prompts; tell Pi not to also forward it.
707
+ // Fallback path (no dispatch) → let Pi pass the user turn through to the
708
+ // main agent, which will run under the router-supplied fallback context.
709
+ return dispatched || undefined;
915
710
  });
916
711
 
917
712
  /**
@@ -1067,7 +862,7 @@ export default function openCandleExtension(
1067
862
  });
1068
863
  const definition = buildPortfolioWorkflowDefinition(resolution);
1069
864
  const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
1070
- if (prompt) markOriginalInput(originalText);
865
+ if (prompt) markOriginalInput(originalText, ctx);
1071
866
  return prompt ? { action: "transform", text: prompt } : false;
1072
867
  }
1073
868
  if (workflow === "options_screener") {
@@ -1092,7 +887,7 @@ export default function openCandleExtension(
1092
887
  });
1093
888
  const definition = buildOptionsScreenerWorkflowDefinition(resolution);
1094
889
  const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
1095
- if (prompt) markOriginalInput(originalText);
890
+ if (prompt) markOriginalInput(originalText, ctx);
1096
891
  return prompt ? { action: "transform", text: prompt } : false;
1097
892
  }
1098
893
  // Missing required symbol — treat as fallback with ask_user directive.
@@ -1150,7 +945,7 @@ export default function openCandleExtension(
1150
945
  const definition = buildCompareAssetsWorkflowDefinition(preflight.resolution);
1151
946
  applyPreflightAnnotation(definition, preflight.dropped);
1152
947
  const prompt = coordinator.transformWorkflowInput(pi, definition, ctx);
1153
- if (prompt) markOriginalInput(originalText);
948
+ if (prompt) markOriginalInput(originalText, ctx);
1154
949
  return prompt ? { action: "transform", text: prompt } : false;
1155
950
  }
1156
951
 
@@ -1206,36 +1001,6 @@ export default function openCandleExtension(
1206
1001
  }
1207
1002
  }
1208
1003
 
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
1004
  async function preflightCompareResolution(
1240
1005
  resolution: SlotResolution<CompareAssetsSlots>,
1241
1006
  ): Promise<{
@@ -1436,8 +1201,8 @@ export default function openCandleExtension(
1436
1201
  ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1],
1437
1202
  ): RouterLlmClient | null {
1438
1203
  // `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).
1204
+ // configured yet), we skip the router and the main agent runs with its
1205
+ // default unrouted flow for the turn.
1441
1206
  const model = (ctx as { model?: unknown }).model;
1442
1207
  if (!model) return null;
1443
1208
  // biome-ignore lint/suspicious/noExplicitAny: Pi typings keep Model generic
@@ -1460,6 +1225,38 @@ export default function openCandleExtension(
1460
1225
  });
1461
1226
  }
1462
1227
 
1228
+ function hasUnconsumedOriginalInputMarker(ctx?: OriginalInputMarkerContext): boolean {
1229
+ const manager = ctx?.sessionManager;
1230
+ const entries =
1231
+ typeof manager?.getBranch === "function"
1232
+ ? manager.getBranch()
1233
+ : typeof manager?.getEntries === "function"
1234
+ ? manager.getEntries()
1235
+ : [];
1236
+ let markerAfterLastUser = false;
1237
+ for (const entry of entries) {
1238
+ if (!isSessionEntryRecord(entry)) continue;
1239
+ if (entry.type === "message" && entry.message?.role === "user") {
1240
+ markerAfterLastUser = false;
1241
+ continue;
1242
+ }
1243
+ if (entry.type === "custom" && entry.customType === "opencandle-user-input") {
1244
+ markerAfterLastUser = true;
1245
+ continue;
1246
+ }
1247
+ if (markerAfterLastUser) {
1248
+ markerAfterLastUser = false;
1249
+ }
1250
+ }
1251
+ return markerAfterLastUser;
1252
+ }
1253
+
1254
+ function isSessionEntryRecord(
1255
+ entry: unknown,
1256
+ ): entry is { type?: unknown; customType?: unknown; message?: { role?: unknown } } {
1257
+ return typeof entry === "object" && entry !== null;
1258
+ }
1259
+
1463
1260
  /** Concatenate text from a Pi message `content` (plain string or block array). */
1464
1261
  function extractMessageText(content: unknown): string {
1465
1262
  if (typeof content === "string") return content;