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.
- package/README.md +10 -3
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -1
- package/dist/infra/index.d.ts +0 -1
- package/dist/infra/index.js +0 -1
- package/dist/infra/index.js.map +1 -1
- package/dist/onboarding/connect.d.ts +2 -2
- package/dist/onboarding/connect.js +10 -3
- package/dist/onboarding/connect.js.map +1 -1
- package/dist/onboarding/provider-status.d.ts +48 -0
- package/dist/onboarding/provider-status.js +285 -0
- package/dist/onboarding/provider-status.js.map +1 -0
- package/dist/onboarding/providers.d.ts +85 -8
- package/dist/onboarding/providers.js +87 -9
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/onboarding/state.d.ts +1 -0
- package/dist/onboarding/state.js +5 -0
- package/dist/onboarding/state.js.map +1 -1
- package/dist/onboarding/tool-tags.d.ts +12 -1
- package/dist/onboarding/tool-tags.js +31 -1
- package/dist/onboarding/tool-tags.js.map +1 -1
- package/dist/onboarding/validation.d.ts +2 -2
- package/dist/onboarding/validation.js.map +1 -1
- package/dist/pi/opencandle-extension.js +91 -15
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/tool-adapter.d.ts +4 -1
- package/dist/pi/tool-adapter.js +5 -4
- package/dist/pi/tool-adapter.js.map +1 -1
- package/dist/prompts/context-builder.js +1 -1
- package/dist/prompts/policy-cards.js +1 -1
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/providers/external-tool-error.d.ts +10 -0
- package/dist/providers/external-tool-error.js +21 -0
- package/dist/providers/external-tool-error.js.map +1 -0
- package/dist/providers/reddit-cli.d.ts +36 -0
- package/dist/providers/reddit-cli.js +201 -0
- package/dist/providers/reddit-cli.js.map +1 -0
- package/dist/providers/reddit.d.ts +1 -1
- package/dist/providers/reddit.js +7 -35
- package/dist/providers/reddit.js.map +1 -1
- package/dist/providers/twitter-cli.d.ts +40 -0
- package/dist/providers/twitter-cli.js +153 -0
- package/dist/providers/twitter-cli.js.map +1 -0
- package/dist/providers/twitter.d.ts +0 -8
- package/dist/providers/twitter.js +4 -54
- package/dist/providers/twitter.js.map +1 -1
- package/dist/providers/wrap-provider.js +30 -0
- package/dist/providers/wrap-provider.js.map +1 -1
- package/dist/providers/yahoo-finance.js +53 -32
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/planning.d.ts +1 -1
- package/dist/routing/planning.js.map +1 -1
- package/dist/runtime/answer-contracts.d.ts +1 -1
- package/dist/runtime/answer-contracts.js +12 -1
- package/dist/runtime/answer-contracts.js.map +1 -1
- package/dist/runtime/tool-defaults-wrapper.js +6 -2
- package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
- package/dist/sentiment/index.d.ts +1 -0
- package/dist/sentiment/index.js +1 -0
- package/dist/sentiment/index.js.map +1 -1
- package/dist/sentiment/insights.d.ts +17 -0
- package/dist/sentiment/insights.js +206 -0
- package/dist/sentiment/insights.js.map +1 -0
- package/dist/sentiment/pipeline.js +13 -1
- package/dist/sentiment/pipeline.js.map +1 -1
- package/dist/sentiment/scorer.d.ts +2 -0
- package/dist/sentiment/scorer.js +10 -1
- package/dist/sentiment/scorer.js.map +1 -1
- package/dist/sentiment/types.d.ts +2 -0
- package/dist/sentiment/types.js.map +1 -1
- package/dist/system-prompt.js +3 -7
- package/dist/system-prompt.js.map +1 -1
- package/dist/tools/index.d.ts +5 -2
- package/dist/tools/index.js +8 -8
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/sentiment/insight-format.d.ts +2 -0
- package/dist/tools/sentiment/insight-format.js +36 -0
- package/dist/tools/sentiment/insight-format.js.map +1 -0
- package/dist/tools/sentiment/query-match.d.ts +3 -0
- package/dist/tools/sentiment/query-match.js +113 -0
- package/dist/tools/sentiment/query-match.js.map +1 -0
- package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
- package/dist/tools/sentiment/reddit-sentiment.js +263 -117
- package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
- package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
- package/dist/tools/sentiment/sentiment-summary.js +217 -201
- package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
- package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
- package/dist/tools/sentiment/twitter-sentiment.js +187 -64
- package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
- package/dist/tools/sentiment/web-sentiment.js +4 -0
- package/dist/tools/sentiment/web-sentiment.js.map +1 -1
- package/dist/types/sentiment.d.ts +52 -0
- package/gui/server/invoke-tool.ts +17 -3
- package/gui/server/model-setup.ts +10 -3
- package/gui/server/projector.ts +6 -2
- package/gui/server/server.ts +18 -0
- package/gui/server/tool-metadata.ts +80 -16
- package/gui/server/ws-hub.ts +19 -0
- package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +1 -0
- package/gui/web/dist/assets/index-C6W_2eAn.js +69 -0
- package/gui/web/dist/assets/{index-2KZtKBmu.css → index-hwbx24a5.css} +1 -1
- package/gui/web/dist/index.html +2 -2
- package/package.json +5 -6
- package/src/cli.ts +41 -0
- package/src/config.ts +27 -0
- package/src/infra/index.ts +0 -1
- package/src/onboarding/connect.ts +20 -4
- package/src/onboarding/provider-status.ts +410 -0
- package/src/onboarding/providers.ts +148 -18
- package/src/onboarding/state.ts +9 -0
- package/src/onboarding/tool-tags.ts +45 -2
- package/src/onboarding/validation.ts +2 -2
- package/src/pi/opencandle-extension.ts +115 -17
- package/src/pi/tool-adapter.ts +14 -4
- package/src/prompts/context-builder.ts +1 -1
- package/src/prompts/policy-cards.ts +1 -1
- package/src/providers/external-tool-error.ts +20 -0
- package/src/providers/reddit-cli.ts +317 -0
- package/src/providers/reddit.ts +7 -63
- package/src/providers/twitter-cli.ts +233 -0
- package/src/providers/twitter.ts +4 -73
- package/src/providers/wrap-provider.ts +34 -0
- package/src/providers/yahoo-finance.ts +65 -32
- package/src/routing/planning.ts +1 -0
- package/src/runtime/answer-contracts.ts +23 -2
- package/src/runtime/tool-defaults-wrapper.ts +12 -2
- package/src/sentiment/index.ts +1 -0
- package/src/sentiment/insights.ts +269 -0
- package/src/sentiment/pipeline.ts +13 -1
- package/src/sentiment/scorer.ts +12 -1
- package/src/sentiment/types.ts +3 -0
- package/src/system-prompt.ts +3 -7
- package/src/tools/index.ts +9 -8
- package/src/tools/sentiment/insight-format.ts +50 -0
- package/src/tools/sentiment/query-match.ts +117 -0
- package/src/tools/sentiment/reddit-sentiment.ts +354 -141
- package/src/tools/sentiment/sentiment-summary.ts +283 -237
- package/src/tools/sentiment/twitter-sentiment.ts +262 -78
- package/src/tools/sentiment/web-sentiment.ts +4 -0
- package/src/types/sentiment.ts +59 -0
- package/dist/infra/browser.d.ts +0 -35
- package/dist/infra/browser.js +0 -105
- package/dist/infra/browser.js.map +0 -1
- package/dist/tools/interaction/twitter-login.d.ts +0 -8
- package/dist/tools/interaction/twitter-login.js +0 -87
- package/dist/tools/interaction/twitter-login.js.map +0 -1
- package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
- package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
- package/src/infra/browser.ts +0 -113
- 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
|
|
2
|
-
//
|
|
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,
|
|
4
|
+
// interception handler, GUI provider rows, doctor checks, and gap-note
|
|
5
|
+
// generation all read from here.
|
|
5
6
|
//
|
|
6
|
-
// Adding a new
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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<
|
|
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
|
|
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[] = [
|
|
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);
|
package/src/onboarding/state.ts
CHANGED
|
@@ -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
|
}
|