opencandle 0.6.0 → 0.8.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 (201) hide show
  1. package/README.md +43 -23
  2. package/dist/cli-main.d.ts +1 -0
  3. package/dist/cli-main.js +200 -0
  4. package/dist/cli-main.js.map +1 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +14 -194
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +10 -0
  9. package/dist/config.js +13 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/doctor/cli-command.d.ts +1 -0
  12. package/dist/doctor/cli-command.js +69 -0
  13. package/dist/doctor/cli-command.js.map +1 -0
  14. package/dist/doctor/render.d.ts +2 -0
  15. package/dist/doctor/render.js +32 -0
  16. package/dist/doctor/render.js.map +1 -0
  17. package/dist/doctor/report.d.ts +63 -0
  18. package/dist/doctor/report.js +500 -0
  19. package/dist/doctor/report.js.map +1 -0
  20. package/dist/infra/index.d.ts +0 -1
  21. package/dist/infra/index.js +0 -1
  22. package/dist/infra/index.js.map +1 -1
  23. package/dist/infra/native-dependencies.d.ts +8 -0
  24. package/dist/infra/native-dependencies.js +49 -0
  25. package/dist/infra/native-dependencies.js.map +1 -1
  26. package/dist/infra/node-version.js +2 -5
  27. package/dist/infra/node-version.js.map +1 -1
  28. package/dist/monitor.d.ts +1 -1
  29. package/dist/monitor.js +2 -1
  30. package/dist/monitor.js.map +1 -1
  31. package/dist/onboarding/connect.d.ts +2 -2
  32. package/dist/onboarding/connect.js +10 -3
  33. package/dist/onboarding/connect.js.map +1 -1
  34. package/dist/onboarding/provider-status.d.ts +46 -0
  35. package/dist/onboarding/provider-status.js +256 -0
  36. package/dist/onboarding/provider-status.js.map +1 -0
  37. package/dist/onboarding/providers.d.ts +85 -8
  38. package/dist/onboarding/providers.js +87 -9
  39. package/dist/onboarding/providers.js.map +1 -1
  40. package/dist/onboarding/state.d.ts +1 -0
  41. package/dist/onboarding/state.js +5 -0
  42. package/dist/onboarding/state.js.map +1 -1
  43. package/dist/onboarding/tool-tags.d.ts +12 -1
  44. package/dist/onboarding/tool-tags.js +31 -1
  45. package/dist/onboarding/tool-tags.js.map +1 -1
  46. package/dist/onboarding/validation.d.ts +2 -2
  47. package/dist/onboarding/validation.js.map +1 -1
  48. package/dist/pi/opencandle-extension.js +91 -15
  49. package/dist/pi/opencandle-extension.js.map +1 -1
  50. package/dist/pi/session.d.ts +0 -1
  51. package/dist/pi/session.js +2 -1
  52. package/dist/pi/session.js.map +1 -1
  53. package/dist/pi/tool-adapter.d.ts +4 -1
  54. package/dist/pi/tool-adapter.js +5 -4
  55. package/dist/pi/tool-adapter.js.map +1 -1
  56. package/dist/prompts/context-builder.js +1 -1
  57. package/dist/prompts/policy-cards.js +9 -5
  58. package/dist/prompts/policy-cards.js.map +1 -1
  59. package/dist/prompts/workflow-prompts.js +10 -6
  60. package/dist/prompts/workflow-prompts.js.map +1 -1
  61. package/dist/providers/external-tool-command.d.ts +17 -0
  62. package/dist/providers/external-tool-command.js +114 -0
  63. package/dist/providers/external-tool-command.js.map +1 -0
  64. package/dist/providers/external-tool-error.d.ts +10 -0
  65. package/dist/providers/external-tool-error.js +21 -0
  66. package/dist/providers/external-tool-error.js.map +1 -0
  67. package/dist/providers/reddit-cli.d.ts +32 -0
  68. package/dist/providers/reddit-cli.js +172 -0
  69. package/dist/providers/reddit-cli.js.map +1 -0
  70. package/dist/providers/reddit.d.ts +1 -1
  71. package/dist/providers/reddit.js +7 -35
  72. package/dist/providers/reddit.js.map +1 -1
  73. package/dist/providers/twitter-cli.d.ts +36 -0
  74. package/dist/providers/twitter-cli.js +124 -0
  75. package/dist/providers/twitter-cli.js.map +1 -0
  76. package/dist/providers/twitter.d.ts +0 -8
  77. package/dist/providers/twitter.js +4 -54
  78. package/dist/providers/twitter.js.map +1 -1
  79. package/dist/providers/wrap-provider.js +30 -0
  80. package/dist/providers/wrap-provider.js.map +1 -1
  81. package/dist/providers/yahoo-finance.js +53 -32
  82. package/dist/providers/yahoo-finance.js.map +1 -1
  83. package/dist/routing/classify-intent.js +7 -3
  84. package/dist/routing/classify-intent.js.map +1 -1
  85. package/dist/routing/planning.d.ts +1 -1
  86. package/dist/routing/planning.js +9 -6
  87. package/dist/routing/planning.js.map +1 -1
  88. package/dist/routing/router.js +7 -3
  89. package/dist/routing/router.js.map +1 -1
  90. package/dist/runtime/answer-contracts.d.ts +1 -1
  91. package/dist/runtime/answer-contracts.js +12 -1
  92. package/dist/runtime/answer-contracts.js.map +1 -1
  93. package/dist/runtime/tool-defaults-wrapper.js +6 -2
  94. package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
  95. package/dist/sentiment/index.d.ts +1 -0
  96. package/dist/sentiment/index.js +1 -0
  97. package/dist/sentiment/index.js.map +1 -1
  98. package/dist/sentiment/insights.d.ts +17 -0
  99. package/dist/sentiment/insights.js +206 -0
  100. package/dist/sentiment/insights.js.map +1 -0
  101. package/dist/sentiment/pipeline.js +13 -1
  102. package/dist/sentiment/pipeline.js.map +1 -1
  103. package/dist/sentiment/scorer.d.ts +2 -0
  104. package/dist/sentiment/scorer.js +10 -1
  105. package/dist/sentiment/scorer.js.map +1 -1
  106. package/dist/sentiment/types.d.ts +2 -0
  107. package/dist/sentiment/types.js.map +1 -1
  108. package/dist/system-prompt.js +3 -7
  109. package/dist/system-prompt.js.map +1 -1
  110. package/dist/tools/index.d.ts +5 -2
  111. package/dist/tools/index.js +8 -8
  112. package/dist/tools/index.js.map +1 -1
  113. package/dist/tools/sentiment/insight-format.d.ts +2 -0
  114. package/dist/tools/sentiment/insight-format.js +36 -0
  115. package/dist/tools/sentiment/insight-format.js.map +1 -0
  116. package/dist/tools/sentiment/query-match.d.ts +3 -0
  117. package/dist/tools/sentiment/query-match.js +113 -0
  118. package/dist/tools/sentiment/query-match.js.map +1 -0
  119. package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
  120. package/dist/tools/sentiment/reddit-sentiment.js +263 -117
  121. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  122. package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
  123. package/dist/tools/sentiment/sentiment-summary.js +217 -201
  124. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  125. package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
  126. package/dist/tools/sentiment/twitter-sentiment.js +187 -64
  127. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  128. package/dist/tools/sentiment/web-sentiment.js +4 -0
  129. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  130. package/dist/types/sentiment.d.ts +52 -0
  131. package/gui/server/http-routes.ts +419 -0
  132. package/gui/server/invoke-tool.ts +17 -3
  133. package/gui/server/model-setup.ts +10 -3
  134. package/gui/server/projector.ts +6 -2
  135. package/gui/server/server.ts +30 -275
  136. package/gui/server/tool-metadata.ts +80 -16
  137. package/gui/server/ws-hub.ts +19 -0
  138. package/gui/web/dist/assets/CatalogOverlay-CCVKwBUB.js +1 -0
  139. package/gui/web/dist/assets/index-Dm4Aom2_.js +69 -0
  140. package/gui/web/dist/assets/{index-2KZtKBmu.css → index-UzZUg3dx.css} +1 -1
  141. package/gui/web/dist/index.html +2 -2
  142. package/package.json +17 -14
  143. package/src/cli-main.ts +233 -0
  144. package/src/cli.ts +14 -226
  145. package/src/config.ts +27 -0
  146. package/src/doctor/cli-command.ts +83 -0
  147. package/src/doctor/render.ts +37 -0
  148. package/src/doctor/report.ts +638 -0
  149. package/src/infra/index.ts +0 -1
  150. package/src/infra/native-dependencies.ts +67 -0
  151. package/src/infra/node-version.ts +2 -5
  152. package/src/monitor.ts +3 -1
  153. package/src/onboarding/connect.ts +20 -4
  154. package/src/onboarding/provider-status.ts +382 -0
  155. package/src/onboarding/providers.ts +148 -18
  156. package/src/onboarding/state.ts +9 -0
  157. package/src/onboarding/tool-tags.ts +45 -2
  158. package/src/onboarding/validation.ts +2 -2
  159. package/src/pi/opencandle-extension.ts +115 -17
  160. package/src/pi/session.ts +2 -1
  161. package/src/pi/tool-adapter.ts +14 -4
  162. package/src/prompts/context-builder.ts +1 -1
  163. package/src/prompts/policy-cards.ts +13 -5
  164. package/src/prompts/workflow-prompts.ts +13 -6
  165. package/src/providers/external-tool-command.ts +164 -0
  166. package/src/providers/external-tool-error.ts +20 -0
  167. package/src/providers/reddit-cli.ts +286 -0
  168. package/src/providers/reddit.ts +7 -63
  169. package/src/providers/twitter-cli.ts +202 -0
  170. package/src/providers/twitter.ts +4 -73
  171. package/src/providers/wrap-provider.ts +34 -0
  172. package/src/providers/yahoo-finance.ts +65 -32
  173. package/src/routing/classify-intent.ts +11 -6
  174. package/src/routing/planning.ts +16 -10
  175. package/src/routing/router.ts +11 -6
  176. package/src/runtime/answer-contracts.ts +23 -2
  177. package/src/runtime/tool-defaults-wrapper.ts +12 -2
  178. package/src/sentiment/index.ts +1 -0
  179. package/src/sentiment/insights.ts +269 -0
  180. package/src/sentiment/pipeline.ts +13 -1
  181. package/src/sentiment/scorer.ts +12 -1
  182. package/src/sentiment/types.ts +3 -0
  183. package/src/system-prompt.ts +3 -7
  184. package/src/tools/index.ts +9 -8
  185. package/src/tools/sentiment/insight-format.ts +50 -0
  186. package/src/tools/sentiment/query-match.ts +117 -0
  187. package/src/tools/sentiment/reddit-sentiment.ts +354 -141
  188. package/src/tools/sentiment/sentiment-summary.ts +283 -237
  189. package/src/tools/sentiment/twitter-sentiment.ts +262 -78
  190. package/src/tools/sentiment/web-sentiment.ts +4 -0
  191. package/src/types/sentiment.ts +59 -0
  192. package/dist/infra/browser.d.ts +0 -35
  193. package/dist/infra/browser.js +0 -105
  194. package/dist/infra/browser.js.map +0 -1
  195. package/dist/tools/interaction/twitter-login.d.ts +0 -8
  196. package/dist/tools/interaction/twitter-login.js +0 -87
  197. package/dist/tools/interaction/twitter-login.js.map +0 -1
  198. package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
  199. package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
  200. package/src/infra/browser.ts +0 -113
  201. package/src/tools/interaction/twitter-login.ts +0 -105
@@ -1,23 +1,33 @@
1
- // Provider registry — single source of truth for OpenCandle's credentialed
2
- // third-party data providers. Every setup pathway iterates this registry:
1
+ // Provider registry — single source of truth for OpenCandle's third-party
2
+ // data providers. Every setup/status pathway iterates this registry:
3
3
  // first-run startup, the `/connect` command, the `tool_result` credential
4
- // interception handler, and the gap-note generator all read from here.
4
+ // interception handler, GUI provider rows, doctor checks, and gap-note
5
+ // generation all read from here.
5
6
  //
6
- // Adding a new credentialed provider is a two-step change: add its `ProviderId`
7
- // to the literal union below, and add its descriptor to the `PROVIDERS` array.
8
- // The `satisfies` check ensures TypeScript fails the build if the union and
9
- // the array ever disagree.
7
+ // Adding a new provider is a two-step change: add its id to the relevant
8
+ // literal union below, and add its descriptor to the `PROVIDERS` array. The
9
+ // `satisfies` and exhaustiveness checks keep the registry and id unions aligned.
10
10
 
11
11
  import { getConfig, loadFileConfig } from "../config.js";
12
12
 
13
- export type ProviderId = "alpha_vantage" | "fred" | "finnhub" | "brave" | "exa";
13
+ export type ApiKeyProviderId = "alpha_vantage" | "fred" | "finnhub" | "brave" | "exa";
14
+ export type ExternalToolProviderId = "twitter" | "reddit";
15
+ export type PublicHttpProviderId = "yahoo";
16
+ export type ProviderId = ApiKeyProviderId | ExternalToolProviderId | PublicHttpProviderId;
14
17
 
15
- export type ProviderCategory = "fundamentals" | "macro" | "news" | "web_search";
18
+ export type ProviderCategory =
19
+ | "fundamentals"
20
+ | "macro"
21
+ | "news"
22
+ | "web_search"
23
+ | "sentiment"
24
+ | "market";
16
25
 
17
26
  export type ProviderTier = "hard" | "soft";
18
27
 
19
- export interface ProviderDescriptor {
28
+ interface BaseProviderDescriptor {
20
29
  readonly id: ProviderId;
30
+ readonly kind: "api-key" | "external-tool" | "public-http";
21
31
  readonly displayName: string;
22
32
  readonly category: ProviderCategory;
23
33
  /**
@@ -30,11 +40,6 @@ export interface ProviderDescriptor {
30
40
  readonly tier: ProviderTier;
31
41
  /** Lowercase friendly aliases accepted by `/connect` in addition to the id. */
32
42
  readonly aliases: readonly string[];
33
- readonly signupUrl: string;
34
- readonly freeTier: boolean;
35
- readonly envVar: string;
36
- /** Nested key path into `OpenCandleFileConfig` where the key is persisted. */
37
- readonly configPath: readonly string[];
38
43
  readonly unlocks: readonly string[];
39
44
  /**
40
45
  * Human copy describing the degraded experience when missing, or `null`
@@ -45,11 +50,43 @@ export interface ProviderDescriptor {
45
50
  readonly instructionsHint: string;
46
51
  }
47
52
 
53
+ export interface ApiKeyProviderDescriptor extends BaseProviderDescriptor {
54
+ readonly id: ApiKeyProviderId;
55
+ readonly kind: "api-key";
56
+ readonly signupUrl: string;
57
+ readonly freeTier: boolean;
58
+ readonly envVar: string;
59
+ /** Nested key path into `OpenCandleFileConfig` where the key is persisted. */
60
+ readonly configPath: readonly string[];
61
+ }
62
+
63
+ export interface ExternalToolProviderDescriptor extends BaseProviderDescriptor {
64
+ readonly id: ExternalToolProviderId;
65
+ readonly kind: "external-tool";
66
+ readonly binary: string;
67
+ readonly installCmd: string;
68
+ readonly sessionSource: "browser-cookies";
69
+ readonly supportedBrowsers?: readonly string[];
70
+ readonly sessionProbeArgs?: readonly string[];
71
+ }
72
+
73
+ export interface PublicHttpProviderDescriptor extends BaseProviderDescriptor {
74
+ readonly id: PublicHttpProviderId;
75
+ readonly kind: "public-http";
76
+ readonly probeUrl: string;
77
+ }
78
+
79
+ export type ProviderDescriptor =
80
+ | ApiKeyProviderDescriptor
81
+ | ExternalToolProviderDescriptor
82
+ | PublicHttpProviderDescriptor;
83
+
48
84
  // Declaration order matters: picker display order, per-workflow prompt priority,
49
85
  // getProvidersByCategory/getProvidersByTier iteration order.
50
86
  export const PROVIDERS = [
51
87
  {
52
88
  id: "alpha_vantage",
89
+ kind: "api-key",
53
90
  displayName: "Alpha Vantage",
54
91
  category: "fundamentals",
55
92
  tier: "hard",
@@ -70,6 +107,7 @@ export const PROVIDERS = [
70
107
  },
71
108
  {
72
109
  id: "fred",
110
+ kind: "api-key",
73
111
  displayName: "FRED",
74
112
  category: "macro",
75
113
  tier: "hard",
@@ -85,6 +123,7 @@ export const PROVIDERS = [
85
123
  },
86
124
  {
87
125
  id: "finnhub",
126
+ kind: "api-key",
88
127
  displayName: "Finnhub",
89
128
  category: "news",
90
129
  tier: "soft",
@@ -104,6 +143,7 @@ export const PROVIDERS = [
104
143
  },
105
144
  {
106
145
  id: "brave",
146
+ kind: "api-key",
107
147
  displayName: "Brave Search",
108
148
  category: "web_search",
109
149
  tier: "soft",
@@ -123,6 +163,7 @@ export const PROVIDERS = [
123
163
  },
124
164
  {
125
165
  id: "exa",
166
+ kind: "api-key",
126
167
  displayName: "Exa",
127
168
  category: "web_search",
128
169
  tier: "soft",
@@ -147,8 +188,64 @@ export const PROVIDERS = [
147
188
  snoozeDurationDays: 7,
148
189
  instructionsHint: "Paid with free tier, signup opens in your browser",
149
190
  },
191
+ {
192
+ id: "yahoo",
193
+ kind: "public-http",
194
+ displayName: "Yahoo Finance",
195
+ category: "market",
196
+ tier: "hard",
197
+ aliases: ["yahoo", "yahoo-finance", "market-data", "options"],
198
+ probeUrl: "https://query1.finance.yahoo.com/v8/finance/chart/SPY?interval=1d&range=1d",
199
+ unlocks: ["quotes", "historical prices", "option chains", "market data"],
200
+ fallbackDescription: null,
201
+ snoozeDurationDays: 7,
202
+ instructionsHint: "No account needed; OpenCandle checks public Yahoo Finance reachability",
203
+ },
204
+ {
205
+ id: "twitter",
206
+ kind: "external-tool",
207
+ displayName: "X / Twitter",
208
+ category: "sentiment",
209
+ tier: "soft",
210
+ aliases: ["twitter", "x", "x-sentiment", "twitter-sentiment"],
211
+ binary: "twitter",
212
+ installCmd: "uv tool install twitter-cli",
213
+ sessionSource: "browser-cookies",
214
+ sessionProbeArgs: ["feed", "--max", "1", "--json"],
215
+ supportedBrowsers: ["Chrome", "Arc", "Edge", "Firefox", "Brave"],
216
+ unlocks: ["X/Twitter sentiment", "recent ticker mentions", "social engagement context"],
217
+ fallbackDescription:
218
+ "Sentiment summaries continue with Reddit, web search, and news when X is unavailable",
219
+ snoozeDurationDays: 7,
220
+ instructionsHint: "Install twitter-cli and stay logged into x.com in a supported browser",
221
+ },
222
+ {
223
+ id: "reddit",
224
+ kind: "external-tool",
225
+ displayName: "Reddit",
226
+ category: "sentiment",
227
+ tier: "soft",
228
+ aliases: ["reddit", "reddit-sentiment", "subreddit"],
229
+ binary: "rdt",
230
+ installCmd: "uv tool install rdt-cli",
231
+ sessionSource: "browser-cookies",
232
+ supportedBrowsers: ["Chrome", "Arc", "Edge", "Firefox", "Brave"],
233
+ sessionProbeArgs: ["status", "--json"],
234
+ unlocks: ["Reddit sentiment", "ticker discussion context", "retail investor discussion"],
235
+ fallbackDescription:
236
+ "Sentiment summaries continue with X/Twitter, web search, and news when Reddit is unavailable",
237
+ snoozeDurationDays: 7,
238
+ instructionsHint: "Install rdt-cli and stay logged into reddit.com in a supported browser",
239
+ },
150
240
  ] as const satisfies readonly ProviderDescriptor[];
151
241
 
242
+ const _providerIdExhaustivenessCheck: Record<
243
+ | Exclude<ProviderId, (typeof PROVIDERS)[number]["id"]>
244
+ | Exclude<(typeof PROVIDERS)[number]["id"], ProviderId>,
245
+ never
246
+ > = {};
247
+ void _providerIdExhaustivenessCheck;
248
+
152
249
  // -----------------------------------------------------------------------------
153
250
  // Lookup helpers
154
251
  // -----------------------------------------------------------------------------
@@ -169,6 +266,28 @@ export function listAllProviders(): readonly ProviderDescriptor[] {
169
266
  return PROVIDERS;
170
267
  }
171
268
 
269
+ export function isApiKeyProvider(
270
+ provider: ProviderDescriptor,
271
+ ): provider is ApiKeyProviderDescriptor {
272
+ return provider.kind === "api-key";
273
+ }
274
+
275
+ export function isExternalToolProvider(
276
+ provider: ProviderDescriptor,
277
+ ): provider is ExternalToolProviderDescriptor {
278
+ return provider.kind === "external-tool";
279
+ }
280
+
281
+ export function isPublicHttpProvider(
282
+ provider: ProviderDescriptor,
283
+ ): provider is PublicHttpProviderDescriptor {
284
+ return provider.kind === "public-http";
285
+ }
286
+
287
+ export function listApiKeyProviders(): readonly ApiKeyProviderDescriptor[] {
288
+ return (PROVIDERS as readonly ProviderDescriptor[]).filter(isApiKeyProvider);
289
+ }
290
+
172
291
  export function getProvider(id: ProviderId): ProviderDescriptor {
173
292
  const found = byId().get(id);
174
293
  if (!found) {
@@ -209,7 +328,7 @@ function readConfigValueByPath(
209
328
  // `hasCredential` reads from `getConfig()` so that tests mocking `getConfig`
210
329
  // see a consistent view; `getCredentialSource` reads `process.env` +
211
330
  // `loadFileConfig` directly because it needs to distinguish env from file.
212
- const CONFIG_FIELD_BY_ID: Record<ProviderId, keyof ReturnType<typeof getConfig>> = {
331
+ const CONFIG_FIELD_BY_ID: Record<ApiKeyProviderId, keyof ReturnType<typeof getConfig>> = {
213
332
  alpha_vantage: "alphaVantageApiKey",
214
333
  fred: "fredApiKey",
215
334
  finnhub: "finnhubApiKey",
@@ -218,7 +337,9 @@ const CONFIG_FIELD_BY_ID: Record<ProviderId, keyof ReturnType<typeof getConfig>>
218
337
  };
219
338
 
220
339
  export function hasCredential(id: ProviderId): boolean {
221
- const field = CONFIG_FIELD_BY_ID[id];
340
+ const descriptor = getProvider(id);
341
+ if (!isApiKeyProvider(descriptor)) return false;
342
+ const field = CONFIG_FIELD_BY_ID[descriptor.id];
222
343
  const value = getConfig()[field];
223
344
  return typeof value === "string" && value.length > 0;
224
345
  }
@@ -231,6 +352,8 @@ export function getCredential(
231
352
  id: ProviderId,
232
353
  ): { source: "env" | "file"; value: string } | { source: "absent"; value?: undefined } {
233
354
  const descriptor = getProvider(id);
355
+ if (!isApiKeyProvider(descriptor)) return { source: "absent" };
356
+
234
357
  const envValue = process.env[descriptor.envVar];
235
358
  if (envValue && envValue.length > 0) return { source: "env", value: envValue };
236
359
 
@@ -265,7 +388,14 @@ export function resolveProviderFromArgument(
265
388
  // 3. Category match: if the needle matches a category name, return the
266
389
  // providers in that category. One match → single descriptor. Multiple
267
390
  // matches → array (triggers the sub-picker in the /connect handler).
268
- const categories: readonly ProviderCategory[] = ["fundamentals", "macro", "news", "web_search"];
391
+ const categories: readonly ProviderCategory[] = [
392
+ "fundamentals",
393
+ "macro",
394
+ "news",
395
+ "web_search",
396
+ "sentiment",
397
+ "market",
398
+ ];
269
399
  const normalizedCategory = needle.replace("-", "_");
270
400
  if ((categories as readonly string[]).includes(normalizedCategory)) {
271
401
  const group = getProvidersByCategory(normalizedCategory as ProviderCategory);
@@ -155,6 +155,15 @@ export function markProviderNeverAsk(state: OnboardingState, id: ProviderId): On
155
155
  };
156
156
  }
157
157
 
158
+ export function clearProviderOnboardingEntry(
159
+ state: OnboardingState,
160
+ id: ProviderId,
161
+ ): OnboardingState {
162
+ const providers = { ...state.providers };
163
+ delete providers[id];
164
+ return { ...state, providers };
165
+ }
166
+
158
167
  export function markWelcomeShown(state: OnboardingState): OnboardingState {
159
168
  return { ...state, welcomeShownAt: nowIso() };
160
169
  }
@@ -22,6 +22,7 @@ import type { ProviderId } from "./providers.js";
22
22
  // -----------------------------------------------------------------------------
23
23
 
24
24
  export type CredentialRequiredReason = "missing" | "stale";
25
+ export type ExternalToolRequiredReason = "not_installed" | "session_missing" | "session_stale";
25
26
 
26
27
  export interface CredentialRequiredTagFields {
27
28
  provider: ProviderId;
@@ -48,11 +49,20 @@ export interface ConnectedTagFields {
48
49
  provider: ProviderId;
49
50
  }
50
51
 
52
+ export interface ExternalToolRequiredTagFields {
53
+ provider: ProviderId;
54
+ reason: ExternalToolRequiredReason;
55
+ installCmd: string;
56
+ loginCmd?: string;
57
+ fallback: string | null;
58
+ }
59
+
51
60
  export type ParsedTag =
52
61
  | ({ kind: "credential_required" } & CredentialRequiredTagFields)
53
62
  | ({ kind: "soft_degraded" } & SoftDegradedTagFields)
54
63
  | ({ kind: "skipped" } & SkippedTagFields)
55
- | ({ kind: "connected" } & ConnectedTagFields);
64
+ | ({ kind: "connected" } & ConnectedTagFields)
65
+ | ({ kind: "external_tool_required" } & ExternalToolRequiredTagFields);
56
66
 
57
67
  // -----------------------------------------------------------------------------
58
68
  // Builders
@@ -106,12 +116,24 @@ export function buildConnectedTag(fields: ConnectedTagFields): string {
106
116
  return `[OPENCANDLE_CONNECTED provider=${fields.provider}]`;
107
117
  }
108
118
 
119
+ export function buildExternalToolRequiredTag(fields: ExternalToolRequiredTagFields): string {
120
+ const parts: string[] = [
121
+ "[OPENCANDLE_EXTERNAL_TOOL_REQUIRED",
122
+ formatField("provider", fields.provider),
123
+ formatField("reason", fields.reason),
124
+ `installCmd=${quote(fields.installCmd)}`,
125
+ formatField("fallback", fields.fallback ?? "none"),
126
+ ];
127
+ if (fields.loginCmd) parts.push(`loginCmd=${quote(fields.loginCmd)}`);
128
+ return `${parts.join(" ")}]`;
129
+ }
130
+
109
131
  // -----------------------------------------------------------------------------
110
132
  // Parser
111
133
  // -----------------------------------------------------------------------------
112
134
 
113
135
  const TAG_LINE_REGEX =
114
- /\[OPENCANDLE_(CREDENTIAL_REQUIRED|SOFT_DEGRADED|SKIPPED|CONNECTED)([^\]]*)\]/;
136
+ /\[OPENCANDLE_(CREDENTIAL_REQUIRED|SOFT_DEGRADED|SKIPPED|CONNECTED|EXTERNAL_TOOL_REQUIRED)([^\]]*)\]/;
115
137
 
116
138
  function parseFields(raw: string): Record<string, string> {
117
139
  // Split on `key=value` pairs, respecting quoted values.
@@ -195,6 +217,27 @@ export function parseToolTag(text: string): ParsedTag | undefined {
195
217
  return { kind: "connected", provider: provider as ProviderId };
196
218
  }
197
219
 
220
+ case "EXTERNAL_TOOL_REQUIRED": {
221
+ const { provider, reason, installCmd, loginCmd, fallback } = fields;
222
+ if (
223
+ !provider ||
224
+ (reason !== "not_installed" &&
225
+ reason !== "session_missing" &&
226
+ reason !== "session_stale") ||
227
+ !installCmd
228
+ ) {
229
+ return undefined;
230
+ }
231
+ return {
232
+ kind: "external_tool_required",
233
+ provider: provider as ProviderId,
234
+ reason,
235
+ installCmd,
236
+ loginCmd,
237
+ fallback: fallback === undefined || fallback === "none" ? null : fallback,
238
+ };
239
+ }
240
+
198
241
  default:
199
242
  return undefined;
200
243
  }
@@ -20,7 +20,7 @@
20
20
  // All validators share a 5-second timeout via `AbortSignal.timeout` so the
21
21
  // connect flow cannot hang if a provider is unreachable.
22
22
 
23
- import type { ProviderId } from "./providers.js";
23
+ import type { ApiKeyProviderId } from "./providers.js";
24
24
 
25
25
  const VALIDATION_TIMEOUT_MS = 5_000;
26
26
 
@@ -107,7 +107,7 @@ function classifyAlphaVantageBody(body: string): ValidationResult | undefined {
107
107
  * touch the config file, onboarding state, or any in-memory cache.
108
108
  */
109
109
  export async function validateCredential(
110
- providerId: ProviderId,
110
+ providerId: ApiKeyProviderId,
111
111
  key: string,
112
112
  ): Promise<ValidationResult> {
113
113
  switch (providerId) {
@@ -44,7 +44,6 @@ import type {
44
44
  import { SessionCoordinator } from "../runtime/session-coordinator.js";
45
45
  import { generateSessionTitle } from "../runtime/session-title.js";
46
46
  import { registerAskUserTool } from "../tools/interaction/ask-user.js";
47
- import { registerTwitterLoginTool } from "../tools/interaction/twitter-login.js";
48
47
  import type { AskUserHandler } from "../types/index.js";
49
48
  import {
50
49
  buildCompareAssetsWorkflowDefinition,
@@ -105,11 +104,10 @@ export default function openCandleExtension(
105
104
  let sessionTitleAttempted = false;
106
105
 
107
106
  // Register tools
108
- for (const tool of getOpenCandleToolDefinitions()) {
107
+ for (const tool of getOpenCandleToolDefinitions({ askUserHandler: options?.askUserHandler })) {
109
108
  pi.registerTool(tool);
110
109
  }
111
110
  registerAskUserTool(pi, options?.askUserHandler);
112
- registerTwitterLoginTool(pi);
113
111
 
114
112
  // /analyze command
115
113
  pi.registerCommand("analyze", {
@@ -144,11 +142,10 @@ export default function openCandleExtension(
144
142
  // `/connect <alias|id|category>` routes to a specific provider (or a
145
143
  // sub-picker for multi-provider categories like "search").
146
144
  pi.registerCommand("connect", {
147
- description: "Connect a data provider (Alpha Vantage, FRED, Finnhub, Brave, Exa)",
145
+ description: "Connect an API-key data provider (Alpha Vantage, FRED, Finnhub, Brave, Exa)",
148
146
  handler: async (args, ctx) => {
149
- const { listAllProviders, resolveProviderFromArgument, hasCredential } = await import(
150
- "../onboarding/providers.js"
151
- );
147
+ const { listApiKeyProviders, resolveProviderFromArgument, hasCredential, isApiKeyProvider } =
148
+ await import("../onboarding/providers.js");
152
149
 
153
150
  const formatState = (id: ProviderId): string => {
154
151
  const state = loadOnboardingState().providers[id];
@@ -178,11 +175,11 @@ export default function openCandleExtension(
178
175
 
179
176
  if (trimmed === "") {
180
177
  // Bare /connect → full picker.
181
- targetId = await pickProvider(listAllProviders());
178
+ targetId = await pickProvider(listApiKeyProviders());
182
179
  } else {
183
180
  const resolved = resolveProviderFromArgument(trimmed);
184
181
  if (!resolved) {
185
- const all = listAllProviders()
182
+ const all = listApiKeyProviders()
186
183
  .map((p) => ` ${p.displayName} (${p.aliases.join(", ")})`)
187
184
  .join("\n");
188
185
  ctx.ui.notify(`Unknown provider: "${trimmed}". Available:\n${all}`, "warning");
@@ -190,9 +187,25 @@ export default function openCandleExtension(
190
187
  }
191
188
  if (Array.isArray(resolved)) {
192
189
  // Multi-provider category — show a sub-picker.
193
- targetId = await pickProvider(resolved as readonly ReturnType<typeof getProvider>[]);
190
+ const apiKeyProviders = resolved.filter(isApiKeyProvider);
191
+ if (apiKeyProviders.length === 0) {
192
+ ctx.ui.notify(
193
+ `"${trimmed}" does not use API-key setup. Run opencandle doctor for setup status.`,
194
+ "warning",
195
+ );
196
+ return;
197
+ }
198
+ targetId = await pickProvider(apiKeyProviders);
194
199
  } else {
195
- targetId = (resolved as ReturnType<typeof getProvider>).id;
200
+ const descriptor = resolved as ReturnType<typeof getProvider>;
201
+ if (!isApiKeyProvider(descriptor)) {
202
+ ctx.ui.notify(
203
+ `${descriptor.displayName} does not use API-key setup. Run opencandle doctor for setup status.`,
204
+ "warning",
205
+ );
206
+ return;
207
+ }
208
+ targetId = descriptor.id;
196
209
  }
197
210
  }
198
211
 
@@ -389,15 +402,100 @@ export default function openCandleExtension(
389
402
  }
390
403
  }
391
404
 
392
- // Second pass: look for a credential-required tag; on match, run the
393
- // interception decision and either replace the tool result or prompt
394
- // the user. Only the first credential_required tag in the content list
395
- // is acted on — subsequent hard-tier prompts are silenced by the
396
- // per-workflow cap at the decision-function level.
405
+ // Second pass: look for setup-required tags; on match, run the interception
406
+ // decision and either replace the tool result or prompt the user. Only the
407
+ // first setup tag in the content list is acted on.
397
408
  for (const block of event.content) {
398
409
  if (block.type !== "text") continue;
399
410
  const parsed = parseToolTag(block.text);
400
- if (!parsed || parsed.kind !== "credential_required") continue;
411
+ if (
412
+ !parsed ||
413
+ (parsed.kind !== "credential_required" && parsed.kind !== "external_tool_required")
414
+ ) {
415
+ continue;
416
+ }
417
+
418
+ if (parsed.kind === "external_tool_required") {
419
+ const state = loadOnboardingState();
420
+ const descriptor = getProvider(parsed.provider);
421
+ if (state.providers[parsed.provider]?.status === "never_ask") {
422
+ return {
423
+ content: [
424
+ {
425
+ type: "text",
426
+ text:
427
+ `${buildSkippedTag({
428
+ provider: parsed.provider,
429
+ reason: "credential_not_provided",
430
+ remediation: `run ${parsed.installCmd} and ${parsed.loginCmd ?? "rdt login"} to enable ${descriptor.displayName} (silenced)`,
431
+ silenced: true,
432
+ })}\n\n` +
433
+ `${descriptor.displayName} data was not fetched because you previously asked not to be reminded about this provider.`,
434
+ },
435
+ ],
436
+ };
437
+ }
438
+ const setupLabel =
439
+ parsed.reason === "not_installed"
440
+ ? `Continue after installing ${descriptor.displayName}`
441
+ : `Continue after logging in to ${descriptor.displayName}`;
442
+ const skipLabel = `Skip ${descriptor.displayName} once`;
443
+ const neverLabel = `Always skip ${descriptor.displayName}`;
444
+ const installOrLogin =
445
+ parsed.reason === "not_installed"
446
+ ? `Install command: ${parsed.installCmd}.`
447
+ : `Login command: ${parsed.loginCmd ?? "rdt login"}.`;
448
+ const questionBody =
449
+ `${descriptor.displayName} requires a local external tool before this data can be fetched. ` +
450
+ `${installOrLogin} How would you like to proceed?`;
451
+
452
+ sessionPromptedSet.add(parsed.provider);
453
+ const promptResult = await promptUser(
454
+ ctx,
455
+ {
456
+ question: questionBody,
457
+ questionType: "select",
458
+ options: [setupLabel, skipLabel, neverLabel],
459
+ },
460
+ options?.askUserHandler,
461
+ );
462
+
463
+ const answer = promptResult.cancelled ? skipLabel : (promptResult.answer ?? skipLabel);
464
+ if (answer.startsWith("Always")) {
465
+ saveOnboardingState(markProviderNeverAsk(state, parsed.provider));
466
+ }
467
+
468
+ if (answer.startsWith("Continue")) {
469
+ return {
470
+ content: [
471
+ {
472
+ type: "text",
473
+ text:
474
+ `${buildConnectedTag({ provider: parsed.provider })}\n\n` +
475
+ `${descriptor.displayName} setup was acknowledged. Re-run the original ${descriptor.displayName} request now; if setup is still unavailable, continue without ${descriptor.displayName} and disclose the source gap.`,
476
+ },
477
+ ],
478
+ };
479
+ }
480
+
481
+ const silenced = answer.startsWith("Always");
482
+ const remediation = silenced
483
+ ? `run ${parsed.installCmd} and ${parsed.loginCmd ?? "rdt login"} to enable ${descriptor.displayName} (silenced)`
484
+ : `run ${parsed.installCmd} and ${parsed.loginCmd ?? "rdt login"} to enable ${descriptor.displayName}`;
485
+ return {
486
+ content: [
487
+ {
488
+ type: "text",
489
+ text: `${buildSkippedTag({
490
+ provider: parsed.provider,
491
+ reason: "credential_not_provided",
492
+ remediation,
493
+ silenced,
494
+ })}\n\n${descriptor.displayName} data was omitted per your choice.`,
495
+ },
496
+ ],
497
+ };
498
+ }
401
499
 
402
500
  const state = loadOnboardingState();
403
501
  const action = resolveCredentialRequired({
package/src/pi/session.ts CHANGED
@@ -1,4 +1,3 @@
1
- import "../infra/node-version.js";
2
1
  import {
3
2
  type AuthStorage,
4
3
  type CreateAgentSessionResult,
@@ -10,6 +9,7 @@ import {
10
9
  type SettingsManager,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import { loadEnv } from "../config.js";
12
+ import { assertSupportedNodeVersion } from "../infra/node-version.js";
13
13
  import type { AskUserHandler } from "../types/index.js";
14
14
  import openCandleExtension from "./opencandle-extension.js";
15
15
 
@@ -28,6 +28,7 @@ export interface CreateOpenCandleSessionOptions {
28
28
  export async function createOpenCandleSession(
29
29
  options: CreateOpenCandleSessionOptions = {},
30
30
  ): Promise<CreateAgentSessionResult> {
31
+ assertSupportedNodeVersion();
31
32
  loadEnv();
32
33
 
33
34
  const cwd = options.cwd ?? process.cwd();
@@ -4,6 +4,7 @@ import type { TSchema } from "@sinclair/typebox";
4
4
  import { getDefaults } from "../memory/tool-defaults.js";
5
5
  import { wrapWithDefaults } from "../runtime/tool-defaults-wrapper.js";
6
6
  import { getAllTools } from "../tools/index.js";
7
+ import type { AskUserHandler } from "../types/index.js";
7
8
 
8
9
  export function agentToolToPiTool<TParams extends TSchema, TDetails>(
9
10
  tool: AgentTool<TParams, TDetails>,
@@ -14,14 +15,23 @@ export function agentToolToPiTool<TParams extends TSchema, TDetails>(
14
15
  description: tool.description,
15
16
  promptSnippet: `${tool.name}: ${tool.description}`,
16
17
  parameters: tool.parameters,
17
- execute: async (toolCallId, params, signal, onUpdate) => {
18
- return tool.execute(toolCallId, params, signal, onUpdate);
18
+ execute: async (toolCallId, params, signal, onUpdate, ctx) => {
19
+ const executeWithContext = tool.execute as unknown as (
20
+ id: string,
21
+ params: unknown,
22
+ signal: AbortSignal | undefined,
23
+ onUpdate: unknown,
24
+ ctx: unknown,
25
+ ) => ReturnType<typeof tool.execute>;
26
+ return executeWithContext(toolCallId, params, signal, onUpdate, ctx);
19
27
  },
20
28
  };
21
29
  }
22
30
 
23
- export function getOpenCandleToolDefinitions(): ToolDefinition[] {
24
- return getAllTools()
31
+ export function getOpenCandleToolDefinitions(
32
+ options: { askUserHandler?: AskUserHandler } = {},
33
+ ): ToolDefinition[] {
34
+ return getAllTools(options)
25
35
  .map((tool) => ({ tool, defaults: safeGetDefaults(tool.name) }))
26
36
  .filter(({ defaults }) => defaults.__enabled !== false)
27
37
  .map(({ tool, defaults }) => {
@@ -251,7 +251,7 @@ const SAFETY_RULES = `## Guidelines
251
251
  - For crypto position-sizing prompts, start with a concrete allocation range and dollar amount, then show drawdown impact on the user's portfolio, a sleep test, dollar-cost averaging, rebalancing rules, position caps, tax tracking, reputable custody/exchange considerations, and emergency-fund/high-interest-debt prerequisites. Cite the crypto tool-output date or history period and label sparse or unavailable history instead of implying unsupported precision.
252
252
  - For rate-cut market-pricing questions, use get_economic_data for the current Fed funds backdrop and search_web for CME FedWatch / Federal Funds futures probabilities before naming what the market is pricing. Distinguish historical Fed rates from futures-implied expectations.
253
253
  - For backtest_strategy results, report strategy return, buy-and-hold return, outperformance, trade count, win rate, and max drawdown. Include risk-adjusted metrics such as Sharpe or Sortino when available; otherwise say they were unavailable. Explain why the strategy worked or failed in the tested regime and discuss trading costs/slippage when the user asks whether the edge is practical. Do not reduce a backtest answer to return-only.
254
- - For sentiment-only prompts, final answer must include the direction and strength of the sentiment signal, the score scale when available, missing sources, why those missing sources matter for the user's question, the source-coverage risk, low sample counts, and how those gaps downgrade confidence. For ticker-specific sentiment prompts, call get_stock_quote before the final answer and state whether sentiment diverges from price action.
254
+ - For sentiment-only prompts, final answer must include the direction and strength of the sentiment signal, the score scale when available, the key positive and negative drivers behind the signal, representative source evidence, missing sources, why those missing sources matter for the user's question, the source-coverage risk, low sample counts, and how those gaps downgrade confidence. For ticker-specific sentiment prompts, call get_stock_quote before the final answer and state whether sentiment diverges from price action; if quote data is unavailable, say price-action divergence could not be evaluated.
255
255
  - Commit to specifics when asked for entries, targets, stops, allocations, or position sizes. Refusal is not an acceptable output shape.
256
256
  - Each committal response carries FOUR things: the specific number or range, a reasoning chain naming the data points you used, a confidence band, and an invalidation level (what would break the thesis).
257
257
  - Conceptual education prompts are not committal responses. Do not append "Analyst View", "Commitment", "Reasoning Chain", "Confidence Band", or "Invalidation Level" sections when the user asked for an explanation, definition, or learning framework rather than a trade, allocation, or recommendation.