opencandle 0.6.0 → 0.7.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 (154) hide show
  1. package/README.md +10 -3
  2. package/dist/cli.js +36 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +13 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/infra/index.d.ts +0 -1
  8. package/dist/infra/index.js +0 -1
  9. package/dist/infra/index.js.map +1 -1
  10. package/dist/onboarding/connect.d.ts +2 -2
  11. package/dist/onboarding/connect.js +10 -3
  12. package/dist/onboarding/connect.js.map +1 -1
  13. package/dist/onboarding/provider-status.d.ts +48 -0
  14. package/dist/onboarding/provider-status.js +285 -0
  15. package/dist/onboarding/provider-status.js.map +1 -0
  16. package/dist/onboarding/providers.d.ts +85 -8
  17. package/dist/onboarding/providers.js +87 -9
  18. package/dist/onboarding/providers.js.map +1 -1
  19. package/dist/onboarding/state.d.ts +1 -0
  20. package/dist/onboarding/state.js +5 -0
  21. package/dist/onboarding/state.js.map +1 -1
  22. package/dist/onboarding/tool-tags.d.ts +12 -1
  23. package/dist/onboarding/tool-tags.js +31 -1
  24. package/dist/onboarding/tool-tags.js.map +1 -1
  25. package/dist/onboarding/validation.d.ts +2 -2
  26. package/dist/onboarding/validation.js.map +1 -1
  27. package/dist/pi/opencandle-extension.js +91 -15
  28. package/dist/pi/opencandle-extension.js.map +1 -1
  29. package/dist/pi/tool-adapter.d.ts +4 -1
  30. package/dist/pi/tool-adapter.js +5 -4
  31. package/dist/pi/tool-adapter.js.map +1 -1
  32. package/dist/prompts/context-builder.js +1 -1
  33. package/dist/prompts/policy-cards.js +1 -1
  34. package/dist/prompts/policy-cards.js.map +1 -1
  35. package/dist/providers/external-tool-error.d.ts +10 -0
  36. package/dist/providers/external-tool-error.js +21 -0
  37. package/dist/providers/external-tool-error.js.map +1 -0
  38. package/dist/providers/reddit-cli.d.ts +36 -0
  39. package/dist/providers/reddit-cli.js +201 -0
  40. package/dist/providers/reddit-cli.js.map +1 -0
  41. package/dist/providers/reddit.d.ts +1 -1
  42. package/dist/providers/reddit.js +7 -35
  43. package/dist/providers/reddit.js.map +1 -1
  44. package/dist/providers/twitter-cli.d.ts +40 -0
  45. package/dist/providers/twitter-cli.js +153 -0
  46. package/dist/providers/twitter-cli.js.map +1 -0
  47. package/dist/providers/twitter.d.ts +0 -8
  48. package/dist/providers/twitter.js +4 -54
  49. package/dist/providers/twitter.js.map +1 -1
  50. package/dist/providers/wrap-provider.js +30 -0
  51. package/dist/providers/wrap-provider.js.map +1 -1
  52. package/dist/providers/yahoo-finance.js +53 -32
  53. package/dist/providers/yahoo-finance.js.map +1 -1
  54. package/dist/routing/planning.d.ts +1 -1
  55. package/dist/routing/planning.js.map +1 -1
  56. package/dist/runtime/answer-contracts.d.ts +1 -1
  57. package/dist/runtime/answer-contracts.js +12 -1
  58. package/dist/runtime/answer-contracts.js.map +1 -1
  59. package/dist/runtime/tool-defaults-wrapper.js +6 -2
  60. package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
  61. package/dist/sentiment/index.d.ts +1 -0
  62. package/dist/sentiment/index.js +1 -0
  63. package/dist/sentiment/index.js.map +1 -1
  64. package/dist/sentiment/insights.d.ts +17 -0
  65. package/dist/sentiment/insights.js +206 -0
  66. package/dist/sentiment/insights.js.map +1 -0
  67. package/dist/sentiment/pipeline.js +13 -1
  68. package/dist/sentiment/pipeline.js.map +1 -1
  69. package/dist/sentiment/scorer.d.ts +2 -0
  70. package/dist/sentiment/scorer.js +10 -1
  71. package/dist/sentiment/scorer.js.map +1 -1
  72. package/dist/sentiment/types.d.ts +2 -0
  73. package/dist/sentiment/types.js.map +1 -1
  74. package/dist/system-prompt.js +3 -7
  75. package/dist/system-prompt.js.map +1 -1
  76. package/dist/tools/index.d.ts +5 -2
  77. package/dist/tools/index.js +8 -8
  78. package/dist/tools/index.js.map +1 -1
  79. package/dist/tools/sentiment/insight-format.d.ts +2 -0
  80. package/dist/tools/sentiment/insight-format.js +36 -0
  81. package/dist/tools/sentiment/insight-format.js.map +1 -0
  82. package/dist/tools/sentiment/query-match.d.ts +3 -0
  83. package/dist/tools/sentiment/query-match.js +113 -0
  84. package/dist/tools/sentiment/query-match.js.map +1 -0
  85. package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
  86. package/dist/tools/sentiment/reddit-sentiment.js +263 -117
  87. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  88. package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
  89. package/dist/tools/sentiment/sentiment-summary.js +217 -201
  90. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  91. package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
  92. package/dist/tools/sentiment/twitter-sentiment.js +187 -64
  93. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  94. package/dist/tools/sentiment/web-sentiment.js +4 -0
  95. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  96. package/dist/types/sentiment.d.ts +52 -0
  97. package/gui/server/invoke-tool.ts +17 -3
  98. package/gui/server/model-setup.ts +10 -3
  99. package/gui/server/projector.ts +6 -2
  100. package/gui/server/server.ts +18 -0
  101. package/gui/server/tool-metadata.ts +80 -16
  102. package/gui/server/ws-hub.ts +19 -0
  103. package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +1 -0
  104. package/gui/web/dist/assets/index-C6W_2eAn.js +69 -0
  105. package/gui/web/dist/assets/{index-2KZtKBmu.css → index-hwbx24a5.css} +1 -1
  106. package/gui/web/dist/index.html +2 -2
  107. package/package.json +5 -6
  108. package/src/cli.ts +41 -0
  109. package/src/config.ts +27 -0
  110. package/src/infra/index.ts +0 -1
  111. package/src/onboarding/connect.ts +20 -4
  112. package/src/onboarding/provider-status.ts +410 -0
  113. package/src/onboarding/providers.ts +148 -18
  114. package/src/onboarding/state.ts +9 -0
  115. package/src/onboarding/tool-tags.ts +45 -2
  116. package/src/onboarding/validation.ts +2 -2
  117. package/src/pi/opencandle-extension.ts +115 -17
  118. package/src/pi/tool-adapter.ts +14 -4
  119. package/src/prompts/context-builder.ts +1 -1
  120. package/src/prompts/policy-cards.ts +1 -1
  121. package/src/providers/external-tool-error.ts +20 -0
  122. package/src/providers/reddit-cli.ts +317 -0
  123. package/src/providers/reddit.ts +7 -63
  124. package/src/providers/twitter-cli.ts +233 -0
  125. package/src/providers/twitter.ts +4 -73
  126. package/src/providers/wrap-provider.ts +34 -0
  127. package/src/providers/yahoo-finance.ts +65 -32
  128. package/src/routing/planning.ts +1 -0
  129. package/src/runtime/answer-contracts.ts +23 -2
  130. package/src/runtime/tool-defaults-wrapper.ts +12 -2
  131. package/src/sentiment/index.ts +1 -0
  132. package/src/sentiment/insights.ts +269 -0
  133. package/src/sentiment/pipeline.ts +13 -1
  134. package/src/sentiment/scorer.ts +12 -1
  135. package/src/sentiment/types.ts +3 -0
  136. package/src/system-prompt.ts +3 -7
  137. package/src/tools/index.ts +9 -8
  138. package/src/tools/sentiment/insight-format.ts +50 -0
  139. package/src/tools/sentiment/query-match.ts +117 -0
  140. package/src/tools/sentiment/reddit-sentiment.ts +354 -141
  141. package/src/tools/sentiment/sentiment-summary.ts +283 -237
  142. package/src/tools/sentiment/twitter-sentiment.ts +262 -78
  143. package/src/tools/sentiment/web-sentiment.ts +4 -0
  144. package/src/types/sentiment.ts +59 -0
  145. package/dist/infra/browser.d.ts +0 -35
  146. package/dist/infra/browser.js +0 -105
  147. package/dist/infra/browser.js.map +0 -1
  148. package/dist/tools/interaction/twitter-login.d.ts +0 -8
  149. package/dist/tools/interaction/twitter-login.js +0 -87
  150. package/dist/tools/interaction/twitter-login.js.map +0 -1
  151. package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
  152. package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
  153. package/src/infra/browser.ts +0 -113
  154. package/src/tools/interaction/twitter-login.ts +0 -105
@@ -0,0 +1,410 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { ProviderId } from "./providers.js";
3
+ import {
4
+ getCredentialSource,
5
+ getProvider,
6
+ isApiKeyProvider,
7
+ isExternalToolProvider,
8
+ isPublicHttpProvider,
9
+ } from "./providers.js";
10
+ import { loadOnboardingState } from "./state.js";
11
+
12
+ const STATUS_TTL_MS = 60_000;
13
+ const COMMAND_TIMEOUT_MS = 5_000;
14
+ const PUBLIC_HTTP_TIMEOUT_MS = 3_000;
15
+ const MAX_OUTPUT_CHARS = 32_000;
16
+
17
+ export interface CommandResult {
18
+ readonly code: number | null;
19
+ readonly stdout: string;
20
+ readonly stderr: string;
21
+ }
22
+
23
+ export type CommandRunner = (
24
+ command: string,
25
+ args: readonly string[],
26
+ options?: { timeoutMs: number },
27
+ ) => Promise<CommandResult>;
28
+
29
+ interface ProviderStatusBase {
30
+ readonly providerId: ProviderId;
31
+ readonly kind: "api-key" | "external-tool" | "public-http";
32
+ readonly state: string;
33
+ readonly checkedAt: string;
34
+ readonly cacheHit: boolean;
35
+ readonly message?: string;
36
+ }
37
+
38
+ export interface ApiKeyProviderStatus extends ProviderStatusBase {
39
+ readonly kind: "api-key";
40
+ readonly state: "configured" | "missing";
41
+ readonly credentialSource: "env" | "file" | "absent";
42
+ }
43
+
44
+ export interface ExternalToolProviderStatus extends ProviderStatusBase {
45
+ readonly kind: "external-tool";
46
+ readonly mode: "install" | "session";
47
+ readonly state:
48
+ | "installed"
49
+ | "missing"
50
+ | "session_ok"
51
+ | "session_missing"
52
+ | "session_stale"
53
+ | "skipped"
54
+ | "error";
55
+ readonly installCmd?: string;
56
+ }
57
+
58
+ export interface PublicHttpProviderStatus extends ProviderStatusBase {
59
+ readonly kind: "public-http";
60
+ readonly state: "reachable" | "unreachable" | "error";
61
+ readonly statusCode?: number;
62
+ }
63
+
64
+ export type ProviderStatus =
65
+ | ApiKeyProviderStatus
66
+ | ExternalToolProviderStatus
67
+ | PublicHttpProviderStatus;
68
+
69
+ export interface ProbeProviderStatusOptions {
70
+ readonly mode?: "install" | "session";
71
+ readonly force?: boolean;
72
+ readonly commandRunner?: CommandRunner;
73
+ readonly fetchImpl?: typeof fetch;
74
+ readonly now?: Date;
75
+ }
76
+
77
+ interface CachedStatus {
78
+ readonly expiresAt: number;
79
+ readonly status: ProviderStatus;
80
+ }
81
+
82
+ const statusCache = new Map<string, CachedStatus>();
83
+
84
+ export function clearProviderStatusCache(): void {
85
+ statusCache.clear();
86
+ }
87
+
88
+ export async function probeProviderStatus(
89
+ providerId: ProviderId,
90
+ options: ProbeProviderStatusOptions = {},
91
+ ): Promise<ProviderStatus> {
92
+ const provider = getProvider(providerId);
93
+ const mode = isExternalToolProvider(provider) ? (options.mode ?? "install") : "default";
94
+ const cacheKey = `${providerId}:${mode}`;
95
+ const now = options.now ?? new Date();
96
+ const skippedByPreference =
97
+ isExternalToolProvider(provider) &&
98
+ loadOnboardingState().providers[providerId]?.status === "never_ask";
99
+
100
+ if (skippedByPreference && !options.force) {
101
+ return {
102
+ providerId,
103
+ kind: "external-tool",
104
+ mode: mode as "install" | "session",
105
+ state: "skipped",
106
+ installCmd: provider.installCmd,
107
+ message: `Skipped by user preference; run opencandle doctor --enable ${providerId} to re-enable.`,
108
+ checkedAt: now.toISOString(),
109
+ cacheHit: false,
110
+ };
111
+ }
112
+
113
+ if (!options.force) {
114
+ const cached = statusCache.get(cacheKey);
115
+ if (cached && cached.expiresAt > now.getTime()) {
116
+ return { ...cached.status, cacheHit: true };
117
+ }
118
+ }
119
+
120
+ let status: ProviderStatus;
121
+ if (isApiKeyProvider(provider)) {
122
+ const source = getCredentialSource(provider.id);
123
+ status = {
124
+ providerId,
125
+ kind: "api-key",
126
+ state: source === "absent" ? "missing" : "configured",
127
+ credentialSource: source,
128
+ checkedAt: now.toISOString(),
129
+ cacheHit: false,
130
+ };
131
+ } else if (isExternalToolProvider(provider)) {
132
+ status = await probeExternalTool(providerId, mode as "install" | "session", {
133
+ now,
134
+ commandRunner: options.commandRunner ?? runCommand,
135
+ });
136
+ } else if (isPublicHttpProvider(provider)) {
137
+ status = await probePublicHttp(providerId, {
138
+ now,
139
+ fetchImpl: options.fetchImpl ?? fetch,
140
+ });
141
+ } else {
142
+ const exhaustive: never = provider;
143
+ throw new Error(`Unsupported provider kind: ${JSON.stringify(exhaustive)}`);
144
+ }
145
+
146
+ statusCache.set(cacheKey, { status, expiresAt: now.getTime() + STATUS_TTL_MS });
147
+ return status;
148
+ }
149
+
150
+ export async function probeAllProviderStatuses(
151
+ options: Omit<ProbeProviderStatusOptions, "mode"> = {},
152
+ ): Promise<ProviderStatus[]> {
153
+ const { listAllProviders } = await import("./providers.js");
154
+ const statuses: ProviderStatus[] = [];
155
+ for (const provider of listAllProviders()) {
156
+ statuses.push(await probeProviderStatus(provider.id, options));
157
+ }
158
+ return statuses;
159
+ }
160
+
161
+ export function formatProviderStatus(status: ProviderStatus): string {
162
+ const provider = getProvider(status.providerId);
163
+ if (status.kind === "api-key") {
164
+ return `${provider.displayName}: ${status.state} (${status.credentialSource})`;
165
+ }
166
+ if (status.kind === "external-tool") {
167
+ const suffix = status.message ? ` - ${status.message}` : "";
168
+ return `${provider.displayName}: ${status.state} (${status.mode})${suffix}`;
169
+ }
170
+ const http = status.statusCode === undefined ? "" : ` HTTP ${status.statusCode}`;
171
+ const suffix = status.message ? ` - ${status.message}` : "";
172
+ return `${provider.displayName}: ${status.state}${http}${suffix}`;
173
+ }
174
+
175
+ async function probeExternalTool(
176
+ providerId: ProviderId,
177
+ mode: "install" | "session",
178
+ options: { now: Date; commandRunner: CommandRunner },
179
+ ): Promise<ExternalToolProviderStatus> {
180
+ const provider = getProvider(providerId);
181
+ if (!isExternalToolProvider(provider)) {
182
+ throw new Error(`Provider ${providerId} is not an external tool`);
183
+ }
184
+
185
+ const args =
186
+ mode === "install"
187
+ ? ["--version"]
188
+ : (provider.sessionProbeArgs ?? ["feed", "--max", "1", "--json"]);
189
+ try {
190
+ const result = await options.commandRunner(provider.binary, args, {
191
+ timeoutMs: COMMAND_TIMEOUT_MS,
192
+ });
193
+ const output = redactSensitiveOutput(`${result.stderr}\n${result.stdout}`.trim());
194
+
195
+ if (mode === "install") {
196
+ return {
197
+ providerId,
198
+ kind: "external-tool",
199
+ mode,
200
+ state: result.code === 0 ? "installed" : "error",
201
+ installCmd: provider.installCmd,
202
+ message: result.code === 0 ? undefined : output,
203
+ checkedAt: options.now.toISOString(),
204
+ cacheHit: false,
205
+ };
206
+ }
207
+
208
+ if (result.code === 0) {
209
+ const parsed = parseCliEnvelope(result.stdout, provider.binary);
210
+ const session = classifySessionEnvelope(providerId, parsed);
211
+ return {
212
+ providerId,
213
+ kind: "external-tool",
214
+ mode,
215
+ state: session.state,
216
+ installCmd: provider.installCmd,
217
+ message: session.message,
218
+ checkedAt: options.now.toISOString(),
219
+ cacheHit: false,
220
+ };
221
+ }
222
+
223
+ return {
224
+ providerId,
225
+ kind: "external-tool",
226
+ mode,
227
+ state: classifySessionFailure(output),
228
+ installCmd: provider.installCmd,
229
+ message: output,
230
+ checkedAt: options.now.toISOString(),
231
+ cacheHit: false,
232
+ };
233
+ } catch (err) {
234
+ const nodeError = err as NodeJS.ErrnoException;
235
+ if (nodeError.code === "ENOENT") {
236
+ return {
237
+ providerId,
238
+ kind: "external-tool",
239
+ mode,
240
+ state: "missing",
241
+ installCmd: provider.installCmd,
242
+ checkedAt: options.now.toISOString(),
243
+ cacheHit: false,
244
+ };
245
+ }
246
+ return {
247
+ providerId,
248
+ kind: "external-tool",
249
+ mode,
250
+ state: "error",
251
+ installCmd: provider.installCmd,
252
+ message: redactSensitiveOutput(err instanceof Error ? err.message : String(err)),
253
+ checkedAt: options.now.toISOString(),
254
+ cacheHit: false,
255
+ };
256
+ }
257
+ }
258
+
259
+ async function probePublicHttp(
260
+ providerId: ProviderId,
261
+ options: { now: Date; fetchImpl: typeof fetch },
262
+ ): Promise<PublicHttpProviderStatus> {
263
+ const provider = getProvider(providerId);
264
+ if (!isPublicHttpProvider(provider)) {
265
+ throw new Error(`Provider ${providerId} is not a public HTTP provider`);
266
+ }
267
+
268
+ try {
269
+ const response = await options.fetchImpl(provider.probeUrl, {
270
+ method: "GET",
271
+ signal: AbortSignal.timeout(PUBLIC_HTTP_TIMEOUT_MS),
272
+ });
273
+ return {
274
+ providerId,
275
+ kind: "public-http",
276
+ state: response.ok ? "reachable" : "unreachable",
277
+ statusCode: response.status,
278
+ checkedAt: options.now.toISOString(),
279
+ cacheHit: false,
280
+ };
281
+ } catch (err) {
282
+ return {
283
+ providerId,
284
+ kind: "public-http",
285
+ state: "error",
286
+ message: err instanceof Error ? err.message : String(err),
287
+ checkedAt: options.now.toISOString(),
288
+ cacheHit: false,
289
+ };
290
+ }
291
+ }
292
+
293
+ function parseCliEnvelope(
294
+ stdout: string,
295
+ binary: string,
296
+ ): { ok: boolean; message?: string; data?: unknown } {
297
+ try {
298
+ const parsed = JSON.parse(stdout) as {
299
+ ok?: unknown;
300
+ data?: unknown;
301
+ error?: { message?: unknown };
302
+ };
303
+ return {
304
+ ok: parsed.ok === true,
305
+ data: parsed.data,
306
+ message:
307
+ typeof parsed.error?.message === "string"
308
+ ? redactSensitiveOutput(parsed.error.message)
309
+ : undefined,
310
+ };
311
+ } catch {
312
+ return { ok: false, message: `${binary} returned non-JSON output` };
313
+ }
314
+ }
315
+
316
+ function classifySessionEnvelope(
317
+ providerId: ProviderId,
318
+ parsed: { ok: boolean; message?: string; data?: unknown },
319
+ ): { state: ExternalToolProviderStatus["state"]; message?: string } {
320
+ if (!parsed.ok) {
321
+ return {
322
+ state: classifySessionFailure(parsed.message),
323
+ message: parsed.message,
324
+ };
325
+ }
326
+
327
+ if (providerId === "reddit" && isRecord(parsed.data) && parsed.data.authenticated === false) {
328
+ return {
329
+ state: "session_missing",
330
+ message: "Reddit is not authenticated. Run rdt login.",
331
+ };
332
+ }
333
+
334
+ return { state: "session_ok" };
335
+ }
336
+
337
+ function isRecord(value: unknown): value is Record<string, unknown> {
338
+ return typeof value === "object" && value !== null;
339
+ }
340
+
341
+ function classifySessionFailure(message: string | undefined): ExternalToolProviderStatus["state"] {
342
+ const lower = (message ?? "").toLowerCase();
343
+ if (
344
+ lower.includes("no twitter cookies") ||
345
+ lower.includes("no reddit cookies") ||
346
+ lower.includes("not authenticated") ||
347
+ lower.includes("no cookies")
348
+ ) {
349
+ return "session_missing";
350
+ }
351
+ if (lower.includes("401") || lower.includes("unauthorized") || lower.includes("expired")) {
352
+ return "session_stale";
353
+ }
354
+ return "error";
355
+ }
356
+
357
+ export function redactSensitiveOutput(input: string): string {
358
+ const xCookieNames =
359
+ "auth_token|ct0|twid|kdt|guest_id|guest_id_ads|guest_id_marketing|personalization_id";
360
+ const genericSecretName =
361
+ "[a-z0-9_]*(?:session|token)[a-z0-9_]*|[a-z0-9_]+cookie[a-z0-9_]*|[a-z0-9_]*cookie[a-z0-9_]+";
362
+ return input
363
+ .slice(0, MAX_OUTPUT_CHARS)
364
+ .replace(/\b(cookie|set-cookie)\s*:\s*[^\r\n]+/gi, "$1: [redacted]")
365
+ .replace(new RegExp(`\\b(${xCookieNames})\\b\\s*[:=]\\s*[^;\\s,)]+`, "gi"), "$1=[redacted]")
366
+ .replace(new RegExp(`\\b(${xCookieNames})=([^;\\s,)]+)`, "gi"), "$1=[redacted]")
367
+ .replace(
368
+ new RegExp(`\\b(${genericSecretName})\\b\\s*[:=]\\s*[^;\\s,)]+`, "gi"),
369
+ "$1=[redacted]",
370
+ )
371
+ .replace(new RegExp(`\\b(${genericSecretName})=([^;\\s,)]+)`, "gi"), "$1=[redacted]")
372
+ .replace(
373
+ /(?:~|\/Users\/[^/\s]+|\/home\/[^/\s]+)?\/\.config\/rdt-cli\/credential\.json/g,
374
+ "[redacted-credential-path]",
375
+ );
376
+ }
377
+
378
+ export const runCommand: CommandRunner = (command, args, options) =>
379
+ new Promise((resolve, reject) => {
380
+ const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
381
+ let stdout = "";
382
+ let stderr = "";
383
+ let settled = false;
384
+
385
+ const timeout = setTimeout(() => {
386
+ if (settled) return;
387
+ settled = true;
388
+ child.kill("SIGTERM");
389
+ reject(new Error(`${command} timed out after ${options?.timeoutMs ?? COMMAND_TIMEOUT_MS}ms`));
390
+ }, options?.timeoutMs ?? COMMAND_TIMEOUT_MS);
391
+
392
+ child.stdout.on("data", (chunk: Buffer) => {
393
+ stdout = (stdout + chunk.toString("utf8")).slice(0, MAX_OUTPUT_CHARS);
394
+ });
395
+ child.stderr.on("data", (chunk: Buffer) => {
396
+ stderr = (stderr + chunk.toString("utf8")).slice(0, MAX_OUTPUT_CHARS);
397
+ });
398
+ child.on("error", (err) => {
399
+ if (settled) return;
400
+ settled = true;
401
+ clearTimeout(timeout);
402
+ reject(err);
403
+ });
404
+ child.on("close", (code) => {
405
+ if (settled) return;
406
+ settled = true;
407
+ clearTimeout(timeout);
408
+ resolve({ code, stdout, stderr });
409
+ });
410
+ });
@@ -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
  }