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
@@ -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({
@@ -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.
@@ -68,7 +68,7 @@ For "today", "right now", "this morning", "after close", or "why did it move" pr
68
68
  status: "implemented",
69
69
  capabilityGapIds: ["sentiment_sample_depth"],
70
70
  content: `## Sentiment Snapshot Policy
71
- For sentiment-only prompts, include the direction and strength of the sentiment signal, the score scale when available, missing sources, why missing sources matter for the user's question, source-coverage risk, low sample counts, and how those gaps downgrade confidence. For ticker-specific sentiment prompts, compare sentiment with fetched price action and state whether sentiment diverges from price action. Treat sentiment as supporting evidence, not a standalone buy/sell verdict. Disclose sparse source coverage, unavailable Twitter/X sessions, provider gaps, or low sample depth instead of implying full-market sentiment coverage.`,
71
+ For sentiment-only prompts, 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 missing sources matter for the user's question, source-coverage risk, low sample counts, and how those gaps downgrade confidence. For ticker-specific sentiment prompts, compare sentiment with fetched price action and state whether sentiment diverges from price action; if quote data is unavailable, say price-action divergence could not be evaluated. Treat sentiment as supporting evidence, not a standalone buy/sell verdict. Disclose sparse source coverage, unavailable Twitter/X sessions, provider gaps, or low sample depth instead of implying full-market sentiment coverage.`,
72
72
  },
73
73
  filing_thesis_review: {
74
74
  id: "filing_thesis_review",
@@ -0,0 +1,20 @@
1
+ export class ExternalToolError extends Error {
2
+ constructor(
3
+ public readonly toolName: string,
4
+ message: string,
5
+ public readonly code?: string,
6
+ ) {
7
+ super(message);
8
+ this.name = "ExternalToolError";
9
+ }
10
+ }
11
+
12
+ export class ExternalToolNotInstalled extends Error {
13
+ constructor(
14
+ public readonly toolName: string,
15
+ public readonly installCmd: string,
16
+ ) {
17
+ super(`${toolName} is not installed. Install it with: ${installCmd}`);
18
+ this.name = "ExternalToolNotInstalled";
19
+ }
20
+ }
@@ -0,0 +1,317 @@
1
+ import { spawn } from "node:child_process";
2
+ import { ExternalToolError, ExternalToolNotInstalled } from "./external-tool-error.js";
3
+ import type { RedditComment } from "./reddit.js";
4
+
5
+ const RDT_BINARY = "rdt";
6
+ const RDT_INSTALL_CMD = "uv tool install rdt-cli";
7
+ const COMMAND_TIMEOUT_MS = 20_000;
8
+ const MAX_OUTPUT_CHARS = 2_000_000;
9
+
10
+ interface RdtEnvelope<T> {
11
+ readonly ok: boolean;
12
+ readonly schema_version: string;
13
+ readonly data?: T;
14
+ readonly error?: {
15
+ readonly code?: string;
16
+ readonly message?: string;
17
+ };
18
+ }
19
+
20
+ export interface RdtPost {
21
+ readonly id: string;
22
+ readonly title: string;
23
+ readonly selftext: string;
24
+ readonly author: string;
25
+ readonly score: number;
26
+ readonly comments: number;
27
+ readonly subreddit?: string;
28
+ readonly url: string;
29
+ readonly permalink?: string;
30
+ readonly created: string;
31
+ }
32
+
33
+ interface RawRdtPost {
34
+ readonly id?: unknown;
35
+ readonly title?: unknown;
36
+ readonly selftext?: unknown;
37
+ readonly author?: unknown;
38
+ readonly score?: unknown;
39
+ readonly num_comments?: unknown;
40
+ readonly subreddit?: unknown;
41
+ readonly url?: unknown;
42
+ readonly permalink?: unknown;
43
+ readonly created_utc?: unknown;
44
+ }
45
+
46
+ interface RawRdtComment {
47
+ readonly id?: unknown;
48
+ readonly body?: unknown;
49
+ readonly author?: unknown;
50
+ readonly score?: unknown;
51
+ readonly permalink?: unknown;
52
+ }
53
+
54
+ interface RdtListingChild<T> {
55
+ readonly kind?: string;
56
+ readonly data?: T;
57
+ }
58
+
59
+ interface RdtListing<T> {
60
+ readonly data?: {
61
+ readonly children?: Array<RdtListingChild<T>>;
62
+ };
63
+ }
64
+
65
+ interface CommandResult {
66
+ readonly code: number | null;
67
+ readonly stdout: string;
68
+ readonly stderr: string;
69
+ }
70
+
71
+ type RdtCommandRunner = (command: string, args: readonly string[]) => Promise<CommandResult>;
72
+
73
+ let commandRunner: RdtCommandRunner = runCommand;
74
+
75
+ export function setRdtCommandRunnerForTests(runner: RdtCommandRunner): void {
76
+ commandRunner = runner;
77
+ }
78
+
79
+ export function resetRdtCommandRunnerForTests(): void {
80
+ commandRunner = runCommand;
81
+ }
82
+
83
+ export async function searchRedditPosts(
84
+ query: string,
85
+ opts: { subreddit?: string; limit: number },
86
+ ): Promise<RdtPost[]> {
87
+ const args = ["search", query];
88
+ if (opts.subreddit) {
89
+ args.push("--subreddit", opts.subreddit);
90
+ }
91
+ args.push("--json", "--compact", "-n", String(opts.limit));
92
+ const data = await runRdt<RawRdtPost[]>(args);
93
+ if (!Array.isArray(data)) {
94
+ throw new ExternalToolError(RDT_BINARY, "rdt-cli returned invalid search data");
95
+ }
96
+ return data.map(adaptPost).filter((post) => post.id.length > 0);
97
+ }
98
+
99
+ export async function listSubredditPosts(
100
+ subreddit: string,
101
+ opts: { limit: number },
102
+ ): Promise<RdtPost[]> {
103
+ const data = await runRdt<RawRdtPost[]>([
104
+ "sub",
105
+ subreddit,
106
+ "--json",
107
+ "--compact",
108
+ "-n",
109
+ String(opts.limit),
110
+ ]);
111
+ if (!Array.isArray(data)) {
112
+ throw new ExternalToolError(RDT_BINARY, "rdt-cli returned invalid subreddit data");
113
+ }
114
+ return data.map((raw) => adaptPost({ ...raw, subreddit })).filter((post) => post.id.length > 0);
115
+ }
116
+
117
+ export async function readRedditPost(
118
+ postId: string,
119
+ opts: { limit: number },
120
+ ): Promise<{ post: RdtPost; comments: RedditComment[] }> {
121
+ const data = await runRdt<Array<RdtListing<RawRdtPost | RawRdtComment>>>([
122
+ "read",
123
+ postId,
124
+ "--json",
125
+ "-n",
126
+ String(opts.limit),
127
+ ]);
128
+ if (!Array.isArray(data)) {
129
+ throw new ExternalToolError(RDT_BINARY, "rdt-cli returned invalid read data");
130
+ }
131
+
132
+ const rawPost = data[0]?.data?.children?.find((child) => child.kind === "t3")?.data;
133
+ if (!rawPost) {
134
+ throw new ExternalToolError(RDT_BINARY, "rdt-cli read output did not include a post");
135
+ }
136
+
137
+ const post = adaptPost(rawPost as RawRdtPost);
138
+ const commentListing = data[1]?.data?.children ?? [];
139
+ const comments = commentListing
140
+ .filter((child): child is RdtListingChild<RawRdtComment> => child.kind === "t1")
141
+ .map((child) => adaptComment(child.data, post.permalink))
142
+ .filter((comment): comment is RedditComment => comment !== null)
143
+ .sort((a, b) => b.score - a.score)
144
+ .slice(0, opts.limit);
145
+
146
+ return { post, comments };
147
+ }
148
+
149
+ async function runRdt<T>(args: readonly string[]): Promise<T> {
150
+ let result: CommandResult;
151
+ try {
152
+ result = await commandRunner(RDT_BINARY, args);
153
+ } catch (err) {
154
+ const nodeError = err as NodeJS.ErrnoException;
155
+ if (nodeError.code === "ENOENT") {
156
+ throw new ExternalToolNotInstalled(RDT_BINARY, RDT_INSTALL_CMD);
157
+ }
158
+ throw new ExternalToolError(
159
+ RDT_BINARY,
160
+ redactSensitiveOutput(err instanceof Error ? err.message : String(err)),
161
+ );
162
+ }
163
+
164
+ if (result.code !== 0) {
165
+ const envelopeError = parseCliErrorEnvelope(result.stdout);
166
+ if (envelopeError) {
167
+ throw new ExternalToolError(
168
+ RDT_BINARY,
169
+ redactSensitiveOutput(envelopeError.message),
170
+ envelopeError.code,
171
+ );
172
+ }
173
+ throw new ExternalToolError(
174
+ RDT_BINARY,
175
+ redactSensitiveOutput(result.stderr.trim() || `rdt-cli exited with code ${result.code}`),
176
+ );
177
+ }
178
+
179
+ let envelope: RdtEnvelope<T>;
180
+ try {
181
+ envelope = JSON.parse(result.stdout) as RdtEnvelope<T>;
182
+ } catch {
183
+ throw new ExternalToolError(
184
+ RDT_BINARY,
185
+ `rdt-cli returned non-JSON output: ${redactSensitiveOutput(result.stdout.slice(0, 200))}`,
186
+ );
187
+ }
188
+
189
+ if (!envelope.ok) {
190
+ throw new ExternalToolError(
191
+ RDT_BINARY,
192
+ redactSensitiveOutput(envelope.error?.message ?? "rdt-cli returned an error"),
193
+ envelope.error?.code,
194
+ );
195
+ }
196
+ if (envelope.data === undefined) {
197
+ throw new ExternalToolError(RDT_BINARY, "rdt-cli returned no data");
198
+ }
199
+ return envelope.data;
200
+ }
201
+
202
+ function parseCliErrorEnvelope(stdout: string): { code?: string; message: string } | null {
203
+ try {
204
+ const parsed = JSON.parse(stdout) as {
205
+ ok?: unknown;
206
+ error?: { code?: unknown; message?: unknown };
207
+ };
208
+ if (parsed.ok !== false || typeof parsed.error?.message !== "string") return null;
209
+ return {
210
+ code: typeof parsed.error.code === "string" ? parsed.error.code : undefined,
211
+ message: parsed.error.message,
212
+ };
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function adaptPost(raw: RawRdtPost): RdtPost {
219
+ const permalink = stringValue(raw.permalink);
220
+ const url = stringValue(raw.url) || (permalink ? `https://reddit.com${permalink}` : "");
221
+ return {
222
+ id: stringValue(raw.id),
223
+ title: stringValue(raw.title),
224
+ selftext: stringValue(raw.selftext),
225
+ author: stringValue(raw.author) || "unknown",
226
+ score: numberValue(raw.score),
227
+ comments: numberValue(raw.num_comments),
228
+ subreddit: stringValue(raw.subreddit) || undefined,
229
+ url,
230
+ permalink: permalink || undefined,
231
+ created: normalizeCreatedAt(raw.created_utc),
232
+ };
233
+ }
234
+
235
+ function adaptComment(
236
+ raw: RawRdtComment | undefined,
237
+ postPermalink?: string,
238
+ ): RedditComment | null {
239
+ if (!raw || typeof raw.body !== "string" || raw.body.length === 0) return null;
240
+ const permalink = stringValue(raw.permalink) || postPermalink || "";
241
+ return {
242
+ id: stringValue(raw.id),
243
+ body: raw.body,
244
+ author: stringValue(raw.author) || "unknown",
245
+ score: numberValue(raw.score),
246
+ permalink: permalink ? `https://reddit.com${permalink}` : "",
247
+ };
248
+ }
249
+
250
+ function normalizeCreatedAt(value: unknown): string {
251
+ if (typeof value === "number" && Number.isFinite(value)) {
252
+ const millis = value > 1_000_000_000_000 ? value : value * 1000;
253
+ return new Date(millis).toISOString();
254
+ }
255
+ if (typeof value === "string" && value.length > 0) {
256
+ const millis = Date.parse(value);
257
+ if (!Number.isNaN(millis)) return new Date(millis).toISOString();
258
+ }
259
+ return new Date(0).toISOString();
260
+ }
261
+
262
+ function stringValue(value: unknown): string {
263
+ return typeof value === "string" ? value : "";
264
+ }
265
+
266
+ function numberValue(value: unknown): number {
267
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
268
+ }
269
+
270
+ export function redactSensitiveOutput(input: string): string {
271
+ return input
272
+ .slice(0, MAX_OUTPUT_CHARS)
273
+ .replace(
274
+ /\b([a-z0-9_]*(?:cookie|session|token)[a-z0-9_]*)\b\s*[:=]\s*[^;\s,)]+/gi,
275
+ "$1=[redacted]",
276
+ )
277
+ .replace(/\b([a-z0-9_]*(?:cookie|session|token)[a-z0-9_]*)=([^;\s,)]+)/gi, "$1=[redacted]")
278
+ .replace(
279
+ /(?:~|\/Users\/[^/\s]+|\/home\/[^/\s]+)?\/\.config\/rdt-cli\/credential\.json/g,
280
+ "[redacted-credential-path]",
281
+ );
282
+ }
283
+
284
+ function runCommand(command: string, args: readonly string[]): Promise<CommandResult> {
285
+ return new Promise((resolve, reject) => {
286
+ const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
287
+ let stdout = "";
288
+ let stderr = "";
289
+ let settled = false;
290
+
291
+ const timeout = setTimeout(() => {
292
+ if (settled) return;
293
+ settled = true;
294
+ child.kill("SIGTERM");
295
+ reject(new Error(`${command} timed out after ${COMMAND_TIMEOUT_MS}ms`));
296
+ }, COMMAND_TIMEOUT_MS);
297
+
298
+ child.stdout.on("data", (chunk: Buffer) => {
299
+ stdout = (stdout + chunk.toString("utf8")).slice(0, MAX_OUTPUT_CHARS);
300
+ });
301
+ child.stderr.on("data", (chunk: Buffer) => {
302
+ stderr = (stderr + chunk.toString("utf8")).slice(0, MAX_OUTPUT_CHARS);
303
+ });
304
+ child.on("error", (err) => {
305
+ if (settled) return;
306
+ settled = true;
307
+ clearTimeout(timeout);
308
+ reject(err);
309
+ });
310
+ child.on("close", (code) => {
311
+ if (settled) return;
312
+ settled = true;
313
+ clearTimeout(timeout);
314
+ resolve({ code, stdout, stderr });
315
+ });
316
+ });
317
+ }