opencandle 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +34 -21
  2. package/dist/cli-main.d.ts +1 -0
  3. package/dist/cli-main.js +200 -0
  4. package/dist/cli-main.js.map +1 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +13 -229
  7. package/dist/cli.js.map +1 -1
  8. package/dist/doctor/cli-command.d.ts +1 -0
  9. package/dist/doctor/cli-command.js +69 -0
  10. package/dist/doctor/cli-command.js.map +1 -0
  11. package/dist/doctor/render.d.ts +2 -0
  12. package/dist/doctor/render.js +32 -0
  13. package/dist/doctor/render.js.map +1 -0
  14. package/dist/doctor/report.d.ts +63 -0
  15. package/dist/doctor/report.js +500 -0
  16. package/dist/doctor/report.js.map +1 -0
  17. package/dist/infra/native-dependencies.d.ts +8 -0
  18. package/dist/infra/native-dependencies.js +49 -0
  19. package/dist/infra/native-dependencies.js.map +1 -1
  20. package/dist/infra/node-version.js +2 -5
  21. package/dist/infra/node-version.js.map +1 -1
  22. package/dist/monitor.d.ts +1 -1
  23. package/dist/monitor.js +2 -1
  24. package/dist/monitor.js.map +1 -1
  25. package/dist/onboarding/provider-status.d.ts +3 -5
  26. package/dist/onboarding/provider-status.js +5 -34
  27. package/dist/onboarding/provider-status.js.map +1 -1
  28. package/dist/pi/session.d.ts +0 -1
  29. package/dist/pi/session.js +2 -1
  30. package/dist/pi/session.js.map +1 -1
  31. package/dist/prompts/policy-cards.js +8 -4
  32. package/dist/prompts/policy-cards.js.map +1 -1
  33. package/dist/prompts/workflow-prompts.js +10 -6
  34. package/dist/prompts/workflow-prompts.js.map +1 -1
  35. package/dist/providers/external-tool-command.d.ts +17 -0
  36. package/dist/providers/external-tool-command.js +114 -0
  37. package/dist/providers/external-tool-command.js.map +1 -0
  38. package/dist/providers/reddit-cli.d.ts +2 -6
  39. package/dist/providers/reddit-cli.js +4 -33
  40. package/dist/providers/reddit-cli.js.map +1 -1
  41. package/dist/providers/twitter-cli.d.ts +2 -6
  42. package/dist/providers/twitter-cli.js +4 -33
  43. package/dist/providers/twitter-cli.js.map +1 -1
  44. package/dist/routing/classify-intent.js +7 -3
  45. package/dist/routing/classify-intent.js.map +1 -1
  46. package/dist/routing/planning.js +9 -6
  47. package/dist/routing/planning.js.map +1 -1
  48. package/dist/routing/router.js +7 -3
  49. package/dist/routing/router.js.map +1 -1
  50. package/gui/server/http-routes.ts +419 -0
  51. package/gui/server/server.ts +29 -292
  52. package/gui/web/dist/assets/CatalogOverlay-CCVKwBUB.js +1 -0
  53. package/gui/web/dist/assets/index-Dm4Aom2_.js +69 -0
  54. package/gui/web/dist/assets/{index-hwbx24a5.css → index-UzZUg3dx.css} +1 -1
  55. package/gui/web/dist/index.html +2 -2
  56. package/package.json +13 -9
  57. package/src/cli-main.ts +233 -0
  58. package/src/cli.ts +14 -267
  59. package/src/doctor/cli-command.ts +83 -0
  60. package/src/doctor/render.ts +37 -0
  61. package/src/doctor/report.ts +638 -0
  62. package/src/infra/native-dependencies.ts +67 -0
  63. package/src/infra/node-version.ts +2 -5
  64. package/src/monitor.ts +3 -1
  65. package/src/onboarding/provider-status.ts +10 -38
  66. package/src/pi/session.ts +2 -1
  67. package/src/prompts/policy-cards.ts +12 -4
  68. package/src/prompts/workflow-prompts.ts +13 -6
  69. package/src/providers/external-tool-command.ts +164 -0
  70. package/src/providers/reddit-cli.ts +10 -41
  71. package/src/providers/twitter-cli.ts +10 -41
  72. package/src/routing/classify-intent.ts +11 -6
  73. package/src/routing/planning.ts +15 -10
  74. package/src/routing/router.ts +11 -6
  75. package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +0 -1
  76. package/gui/web/dist/assets/index-C6W_2eAn.js +0 -69
@@ -1,12 +1,11 @@
1
- const SUPPORTED_NODE_RANGE = "20.19+, 22.12+, or 24.x-26.x";
1
+ const SUPPORTED_NODE_RANGE = "22.19+ or 24.x-26.x";
2
2
 
3
3
  function isSupportedNodeVersion(version: string): boolean {
4
4
  const [majorRaw, minorRaw] = version.split(".");
5
5
  const major = Number(majorRaw);
6
6
  const minor = Number(minorRaw);
7
7
 
8
- if (major === 20) return minor >= 19;
9
- if (major === 22) return minor >= 12;
8
+ if (major === 22) return minor >= 19;
10
9
  return major >= 24 && major < 27;
11
10
  }
12
11
 
@@ -22,5 +21,3 @@ export function assertSupportedNodeVersion(version?: string): void {
22
21
  const message = getUnsupportedNodeVersionMessage(version);
23
22
  if (message) throw new Error(message);
24
23
  }
25
-
26
- assertSupportedNodeVersion();
package/src/monitor.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import "./infra/node-version.js";
3
2
  import { parseArgs } from "node:util";
4
3
  import { loadEnv } from "./config.js";
4
+ import { assertSupportedNodeVersion } from "./infra/node-version.js";
5
5
  import { runLocalAutomationHeartbeat } from "./market-state/local-automation-service.js";
6
6
  import { MarketStateService } from "./market-state/service.js";
7
7
  import { initDefaultDatabase } from "./memory/sqlite.js";
8
8
 
9
+ assertSupportedNodeVersion();
10
+
9
11
  const DEFAULT_MONITOR_INTERVAL_MS = 60_000;
10
12
  const MIN_MONITOR_INTERVAL_MS = 5_000;
11
13
  const MONITOR_OWNER_ID = `monitor:${process.pid}`;
@@ -1,4 +1,7 @@
1
- import { spawn } from "node:child_process";
1
+ import {
2
+ type ExternalToolCommandResult,
3
+ runExternalToolCommand,
4
+ } from "../providers/external-tool-command.js";
2
5
  import type { ProviderId } from "./providers.js";
3
6
  import {
4
7
  getCredentialSource,
@@ -14,11 +17,7 @@ const COMMAND_TIMEOUT_MS = 5_000;
14
17
  const PUBLIC_HTTP_TIMEOUT_MS = 3_000;
15
18
  const MAX_OUTPUT_CHARS = 32_000;
16
19
 
17
- export interface CommandResult {
18
- readonly code: number | null;
19
- readonly stdout: string;
20
- readonly stderr: string;
21
- }
20
+ export type CommandResult = ExternalToolCommandResult;
22
21
 
23
22
  export type CommandRunner = (
24
23
  command: string,
@@ -69,6 +68,7 @@ export type ProviderStatus =
69
68
  export interface ProbeProviderStatusOptions {
70
69
  readonly mode?: "install" | "session";
71
70
  readonly force?: boolean;
71
+ readonly respectSkipped?: boolean;
72
72
  readonly commandRunner?: CommandRunner;
73
73
  readonly fetchImpl?: typeof fetch;
74
74
  readonly now?: Date;
@@ -97,7 +97,7 @@ export async function probeProviderStatus(
97
97
  isExternalToolProvider(provider) &&
98
98
  loadOnboardingState().providers[providerId]?.status === "never_ask";
99
99
 
100
- if (skippedByPreference && !options.force) {
100
+ if (skippedByPreference && (!options.force || options.respectSkipped)) {
101
101
  return {
102
102
  providerId,
103
103
  kind: "external-tool",
@@ -376,35 +376,7 @@ export function redactSensitiveOutput(input: string): string {
376
376
  }
377
377
 
378
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
- });
379
+ runExternalToolCommand(command, args, {
380
+ timeoutMs: options?.timeoutMs ?? COMMAND_TIMEOUT_MS,
381
+ maxOutputChars: MAX_OUTPUT_CHARS,
410
382
  });
package/src/pi/session.ts CHANGED
@@ -1,4 +1,3 @@
1
- import "../infra/node-version.js";
2
1
  import {
3
2
  type AuthStorage,
4
3
  type CreateAgentSessionResult,
@@ -10,6 +9,7 @@ import {
10
9
  type SettingsManager,
11
10
  } from "@earendil-works/pi-coding-agent";
12
11
  import { loadEnv } from "../config.js";
12
+ import { assertSupportedNodeVersion } from "../infra/node-version.js";
13
13
  import type { AskUserHandler } from "../types/index.js";
14
14
  import openCandleExtension from "./opencandle-extension.js";
15
15
 
@@ -28,6 +28,7 @@ export interface CreateOpenCandleSessionOptions {
28
28
  export async function createOpenCandleSession(
29
29
  options: CreateOpenCandleSessionOptions = {},
30
30
  ): Promise<CreateAgentSessionResult> {
31
+ assertSupportedNodeVersion();
31
32
  loadEnv();
32
33
 
33
34
  const cwd = options.cwd ?? process.cwd();
@@ -37,6 +37,14 @@ export interface PolicyCard {
37
37
  content: string;
38
38
  }
39
39
 
40
+ const HIGH_RISK_SPECULATION_SECTION_LIST =
41
+ "Why the win story is misleading, Main risks, Why limited capital changes the answer, If you insist, and Better uses for the money";
42
+ const VALUATION_METRIC_EDUCATION_SECTION_LIST =
43
+ "Bottom line, then a one-sentence Core mental model, then Practical workflow, Where it misleads, Cross-checks, and Quick checklist";
44
+ const VALUATION_METRIC_START = "Bottom line and a one-sentence Core mental model";
45
+ const OPTIONS_CURRENT_FACTS_GUARDRAIL =
46
+ "Do not name strikes, expirations, premiums, Greeks, or implied volatility as current facts without fetched evidence.";
47
+
40
48
  const POLICY_CARDS: Record<PromptPolicyCardId, PolicyCard> = {
41
49
  ticker_disambiguation: {
42
50
  id: "ticker_disambiguation",
@@ -141,7 +149,7 @@ For watchlist, portfolio tracking, alert management, daily-report, prediction re
141
149
  status: "implemented",
142
150
  capabilityGapIds: ["brokerage_comparison", "cash_yield_products", "fund_tax_efficiency"],
143
151
  content: `## Retail Finance Tradeoff Policy
144
- For brokerage, account, cash-parking, mortgage-vs-investing, robo-advisor, debt payoff, high-risk speculation, or retail financial-product prompts, use the retail tradeoff answer shape. Do not punt just because no dedicated live-data provider exists. Answer from durable public finance knowledge, disclose unavailable provider coverage, and label provider-site facts or current yield facts as facts the user should verify instead of fabricating them. For brokerage, robo-advisor, and product comparisons, start with a direct comparison table, cover fees, expense ratios, advisory fees, cash drag, cash sweep yields, fractional shares, fund minimums, tax-loss-harvesting support, transfer/account fees, mutual-fund versus ETF availability, support quality, recurring investment ease, ETF tax efficiency, and asset-location caveats for taxable accounts; explicitly tell the user to verify current fees, minimums, promotions, and cash allocations on provider sites. For cash parking, compare liquidity, FDIC/SIPC/Treasury risk, rate risk, taxes, minimums, access timing, and default 6-12 month cash hierarchy without inventing live yields. Debt payoff prompts should lead with avalanche when rates differ materially, compare snowball only as a behavioral alternative, show approximate monthly interest cost, mention emergency-fund floor, balance-transfer/refinance options, minimum payments, prepayment penalties, and the missing extra-payment amount needed for a precise timeline. High-risk speculation prompts such as penny stocks should answer suitability directly, especially with limited capital; lead with downside, survivorship bias, opportunity cost, liquidity/manipulation/dilution risk, and safer alternatives such as diversified ETFs or fractional shares. Do not use Practical workflow, Cross-checks, or Quick checklist for high-risk speculation; use direct sections such as Why the win story is misleading, Main risks, Why limited capital changes the answer, If you insist, and Better uses for the money. If the user insists, frame any workflow as harm reduction with a tiny play-money bucket, a 100% loss assumption, no averaging down, and a pre-set exit rule. For mortgage-vs-investing prompts that compare against index funds, fetch broad-market history when available, then compare the guaranteed after-tax return from the supplied debt rate against uncertain market returns, liquidity, emergency fund, taxes, risk tolerance, time horizon, hybrid payoff/investing splits, and the practical implications of the user's stated rate.`,
152
+ For brokerage, account, cash-parking, mortgage-vs-investing, robo-advisor, debt payoff, high-risk speculation, or retail financial-product prompts, use the retail tradeoff answer shape. Do not punt just because no dedicated live-data provider exists. Answer from durable public finance knowledge, disclose unavailable provider coverage, and label provider-site facts or current yield facts as facts the user should verify instead of fabricating them. For brokerage, robo-advisor, and product comparisons, start with a direct comparison table, cover fees, expense ratios, advisory fees, cash drag, cash sweep yields, fractional shares, fund minimums, tax-loss-harvesting support, transfer/account fees, mutual-fund versus ETF availability, support quality, recurring investment ease, ETF tax efficiency, and asset-location caveats for taxable accounts; explicitly tell the user to verify current fees, minimums, promotions, and cash allocations on provider sites. For cash parking, compare liquidity, FDIC/SIPC/Treasury risk, rate risk, taxes, minimums, access timing, and default 6-12 month cash hierarchy without inventing live yields. Debt payoff prompts should lead with avalanche when rates differ materially, compare snowball only as a behavioral alternative, show approximate monthly interest cost, mention emergency-fund floor, balance-transfer/refinance options, minimum payments, prepayment penalties, and the missing extra-payment amount needed for a precise timeline. High-risk speculation prompts such as penny stocks should answer suitability directly, especially with limited capital; lead with downside, survivorship bias, opportunity cost, liquidity/manipulation/dilution risk, and safer alternatives such as diversified ETFs or fractional shares. Do not use Practical workflow, Cross-checks, or Quick checklist for high-risk speculation; use direct sections such as ${HIGH_RISK_SPECULATION_SECTION_LIST}. If the user insists, frame any workflow as harm reduction with a tiny play-money bucket, a 100% loss assumption, no averaging down, and a pre-set exit rule. For mortgage-vs-investing prompts that compare against index funds, fetch broad-market history when available, then compare the guaranteed after-tax return from the supplied debt rate against uncertain market returns, liquidity, emergency fund, taxes, risk tolerance, time horizon, hybrid payoff/investing splits, and the practical implications of the user's stated rate.`,
145
153
  },
146
154
  concept_explainer: {
147
155
  id: "concept_explainer",
@@ -149,7 +157,7 @@ For brokerage, account, cash-parking, mortgage-vs-investing, robo-advisor, debt
149
157
  status: "implemented",
150
158
  capabilityGapIds: [],
151
159
  content: `## Concept Explainer Policy
152
- For conceptual or educational finance prompts, use a decision-framework shape instead of a stock-analysis shape. Do not fetch live data unless the user asks for current examples, named securities, or live comparisons. Do not mention OpenCandle tool names unless the user asks how to apply the concept with OpenCandle. Do not append Analyst View, Commitment, Reasoning Chain, Confidence Band, or Invalidation Level sections. Lead with Bottom line and a plain-language Core mental model, then add a simple numerical example early when the concept has mechanics. For pure definition or interpretation prompts, explain the concept directly and do not force the Practical workflow, Cross-checks, or Quick checklist shape unless the user asks how to apply it. Adapt the sections to the concept instead of forcing every topic into a valuation-metric template. Include common misconception coverage, typical ranges or rules of thumb when stable, and a concise practical takeaway. For high-risk speculation education, directly address suitability, survivorship bias, opportunity cost for limited capital, safer alternatives such as diversified ETFs or fractional shares, and a tiny play-money bucket with a 100% loss assumption if the user insists; do not use Practical workflow, Cross-checks, or Quick checklist, and use direct sections such as Why the win story is misleading, Main risks, Why limited capital changes the answer, If you insist, and Better uses for the money. For volatility or risk-indicator education, explain options-implied annualized volatility as magnitude, not direction; include the daily expected-move rule VIX / sqrt(252); include a small range table for low, normal, elevated, and crisis-like readings; Use direct Q&A headings such as Key things to keep straight, What a high reading means, What a low reading means, and Does a spike mean a crash is coming; state that spikes often accompany rather than reliably precede selloffs, that very high readings can become contrarian on average, and that no single indicator predicts crashes; frame the indicator as a thermometer, not a forecast; mention term structure as a fuller volatility cross-check; end with Practical takeaways. For inflation, cash, savings, or purchasing-power education, explain nominal returns versus real returns, how inflation affects cash, bonds, stocks, and real assets, common protection tools such as TIPS or shorter-duration bonds, and the tax/time-horizon tradeoffs. For options education, use a simple analogy before mechanics, define jargon immediately, and include an explicit Main risks section covering capped upside, assignment risk, premium decay or limited protection, tax consequences, and behavioral risk. For valuation-metric education, start with Bottom line, then a one-sentence Core mental model, then Practical workflow, Where it misleads, Cross-checks, and Quick checklist. Frame metrics as screening tools or question generators, not verdicts; cover earnings-quality distortions, variants such as trailing, forward, normalized, or cyclically adjusted, and cross-checks such as cash flow or enterprise-value lenses.`,
160
+ For conceptual or educational finance prompts, use a decision-framework shape instead of a stock-analysis shape. Do not fetch live data unless the user asks for current examples, named securities, or live comparisons. Do not mention OpenCandle tool names unless the user asks how to apply the concept with OpenCandle. Do not append Analyst View, Commitment, Reasoning Chain, Confidence Band, or Invalidation Level sections. Lead with Bottom line and a plain-language Core mental model, then add a simple numerical example early when the concept has mechanics. For pure definition or interpretation prompts, explain the concept directly and do not force the Practical workflow, Cross-checks, or Quick checklist shape unless the user asks how to apply it. Adapt the sections to the concept instead of forcing every topic into a valuation-metric template. Include common misconception coverage, typical ranges or rules of thumb when stable, and a concise practical takeaway. For high-risk speculation education, directly address suitability, survivorship bias, opportunity cost for limited capital, safer alternatives such as diversified ETFs or fractional shares, and a tiny play-money bucket with a 100% loss assumption if the user insists; do not use Practical workflow, Cross-checks, or Quick checklist, and use direct sections such as ${HIGH_RISK_SPECULATION_SECTION_LIST}. For volatility or risk-indicator education, explain options-implied annualized volatility as magnitude, not direction; include the daily expected-move rule VIX / sqrt(252); include a small range table for low, normal, elevated, and crisis-like readings; Use direct Q&A headings such as Key things to keep straight, What a high reading means, What a low reading means, and Does a spike mean a crash is coming; state that spikes often accompany rather than reliably precede selloffs, that very high readings can become contrarian on average, and that no single indicator predicts crashes; frame the indicator as a thermometer, not a forecast; mention term structure as a fuller volatility cross-check; end with Practical takeaways. For inflation, cash, savings, or purchasing-power education, explain nominal returns versus real returns, how inflation affects cash, bonds, stocks, and real assets, common protection tools such as TIPS or shorter-duration bonds, and the tax/time-horizon tradeoffs. For options education, use a simple analogy before mechanics, define jargon immediately, and include an explicit Main risks section covering capped upside, assignment risk, premium decay or limited protection, tax consequences, and behavioral risk. For valuation-metric education, start with ${VALUATION_METRIC_EDUCATION_SECTION_LIST}. Frame metrics as screening tools or question generators, not verdicts; cover earnings-quality distortions, variants such as trailing, forward, normalized, or cyclically adjusted, and cross-checks such as cash flow or enterprise-value lenses.`,
153
161
  },
154
162
  concept_options_education: {
155
163
  id: "concept_options_education",
@@ -157,7 +165,7 @@ For conceptual or educational finance prompts, use a decision-framework shape in
157
165
  status: "implemented",
158
166
  capabilityGapIds: [],
159
167
  content: `## Options Education Policy
160
- For no-symbol options education prompts, teach the concept without fetching live option chains unless the user asks for current tradable examples. Start with Bottom line and a simple analogy before mechanics, then define jargon immediately. Use distinct beginner-friendly headings or a simple table for How it works, What you give up, Main risks, and When it is not ideal. Explain the payoff shape, why the strategy exists, and the tradeoffs in plain language, especially for long-term holdings. Include a Main risks section covering capped upside, assignment risk, share-price downside that premium does not protect, tax consequences including possible wash-sale or holding-period complications, liquidity or bid/ask caveats, behavioral risk, and tax treatment complexity. For volatility strategies such as long strangles or straddles, include a hypothetical stock price, strike prices, premiums, total debit, and break-even points; explain IV crush, IV rank or percentile, Vega, Theta, and why long-volatility trades can have a low win rate despite large payoff potential. Add practical trade-management guidance: profit target, stop loss, time-based exit, position sizing, and paper trading for beginners. Include a strangle versus straddle comparison and a pre-trade checklist covering catalyst, implied move, current IV environment, liquidity, max loss, tax caveat, and exit plan. Treat premium decay as a mechanics point for sellers rather than overstating it as a seller risk. Do not name strikes, expirations, premiums, Greeks, or implied volatility as current facts without fetched evidence.`,
168
+ For no-symbol options education prompts, teach the concept without fetching live option chains unless the user asks for current tradable examples. Start with Bottom line and a simple analogy before mechanics, then define jargon immediately. Use distinct beginner-friendly headings or a simple table for How it works, What you give up, Main risks, and When it is not ideal. Explain the payoff shape, why the strategy exists, and the tradeoffs in plain language, especially for long-term holdings. Include a Main risks section covering capped upside, assignment risk, share-price downside that premium does not protect, tax consequences including possible wash-sale or holding-period complications, liquidity or bid/ask caveats, behavioral risk, and tax treatment complexity. For volatility strategies such as long strangles or straddles, include a hypothetical stock price, strike prices, premiums, total debit, and break-even points; explain IV crush, IV rank or percentile, Vega, Theta, and why long-volatility trades can have a low win rate despite large payoff potential. Add practical trade-management guidance: profit target, stop loss, time-based exit, position sizing, and paper trading for beginners. Include a strangle versus straddle comparison and a pre-trade checklist covering catalyst, implied move, current IV environment, liquidity, max loss, tax caveat, and exit plan. Treat premium decay as a mechanics point for sellers rather than overstating it as a seller risk. ${OPTIONS_CURRENT_FACTS_GUARDRAIL}`,
161
169
  },
162
170
  concept_inflation_cash_education: {
163
171
  id: "concept_inflation_cash_education",
@@ -173,7 +181,7 @@ For no-symbol inflation, cash, savings, or purchasing-power education, explain n
173
181
  status: "implemented",
174
182
  capabilityGapIds: [],
175
183
  content: `## Valuation Metric Education Policy
176
- For valuation-metric education, start with Bottom line and a one-sentence Core mental model, then ground the idea with a simple analogy and numerical example. Explain how to use the metric as a set of considerations rather than a rigid step-by-step verdict: what the business earns, what investors pay for those earnings, and what growth or risk expectations are embedded. Include rules of thumb carefully, noting that growth companies, mature value companies, cyclicals, banks, and different industries can deserve very different ranges. Then explain Practical workflow, Where it misleads, Cross-checks, and a Quick checklist in beginner-friendly language. Frame P/E, P/S, EV/EBITDA, trailing, forward, normalized, or cyclically adjusted metrics as screening tools and question generators, not verdicts. Cover earnings-quality distortions, cyclicality, balance-sheet differences, capital intensity, growth quality, margin durability, accounting noise, and cross-checks such as cash flow, enterprise-value lenses, historical ranges, and peer context. Do not add entry levels, confidence bands, or invalidation boilerplate for pure education prompts.`,
184
+ For valuation-metric education, start with ${VALUATION_METRIC_START}, then ground the idea with a simple analogy and numerical example. Explain how to use the metric as a set of considerations rather than a rigid step-by-step verdict: what the business earns, what investors pay for those earnings, and what growth or risk expectations are embedded. Include rules of thumb carefully, noting that growth companies, mature value companies, cyclicals, banks, and different industries can deserve very different ranges. Then explain Practical workflow, Where it misleads, Cross-checks, and a Quick checklist in beginner-friendly language. Frame P/E, P/S, EV/EBITDA, trailing, forward, normalized, or cyclically adjusted metrics as screening tools and question generators, not verdicts. Cover earnings-quality distortions, cyclicality, balance-sheet differences, capital intensity, growth quality, margin durability, accounting noise, and cross-checks such as cash flow, enterprise-value lenses, historical ranges, and peer context. Do not add entry levels, confidence bands, or invalidation boilerplate for pure education prompts.`,
177
185
  },
178
186
  general_fallback: placeholder("general_fallback", "general_fallback", []),
179
187
  };
@@ -56,6 +56,13 @@ const DISPLAY_NAMES: Record<string, string> = {
56
56
  metrics: "metrics",
57
57
  };
58
58
 
59
+ const ASSUMPTIONS_RESPONSE_INSTRUCTION =
60
+ "- Start with the assumptions block above exactly as written. Do not relabel source attribution anywhere else in your response.";
61
+
62
+ function currentDateLine(date = todayStr()): string {
63
+ return `Current date: ${date}`;
64
+ }
65
+
59
66
  /**
60
67
  * Build a deterministic assumption disclosure block from resolution.sources.
61
68
  * This is the single authoritative provenance representation.
@@ -172,7 +179,7 @@ export function buildPortfolioPrompt(resolution: SlotResolution<PortfolioSlots>)
172
179
  4. Use analyze_risk on each candidate for volatility, Sharpe, and max drawdown.
173
180
  5. Use analyze_correlation across all candidates to check diversification.`;
174
181
 
175
- return `Current date: ${todayStr()}
182
+ return `${currentDateLine()}
176
183
 
177
184
  Build a draft portfolio under these parameters:
178
185
  - Budget: ${formatBudget(s.budget)}
@@ -194,7 +201,7 @@ Portfolio construction guardrails:
194
201
  ${disclosureBlock}
195
202
 
196
203
  Response format:
197
- - Start with the assumptions block above exactly as written. Do not relabel source attribution anywhere else in your response.
204
+ ${ASSUMPTIONS_RESPONSE_INSTRUCTION}
198
205
  - Then start the analysis with "Bottom line:" and directly say what portfolio you would build for the user.
199
206
  - Commit to the draft: give concrete percentages for each position, not ranges, and not "consider allocating X-Y%".
200
207
  - Present an allocation table: symbol, allocation %, dollar amount, current price used, estimated shares, role, and a one-line analyst rationale for each position (what the data showed and why it belongs in this portfolio).
@@ -315,7 +322,7 @@ Protective-put hedge guidance:
315
322
  ? `Explain why the top pick is ranked #1. For covered calls with a cost basis, include the effective assignment sale price (strike + premium collected) and compare it with the ${s.costBasis !== undefined ? formatBudget(s.costBasis) : "user's"} cost basis.`
316
323
  : "Explain why the top pick is ranked #1.";
317
324
 
318
- return `Current date: ${dateStr}
325
+ return `${currentDateLine(dateStr)}
319
326
  Do NOT invent or assume a different current date.${expirationSection}
320
327
 
321
328
  Screen and rank options contracts for ${s.symbol}:
@@ -349,7 +356,7 @@ ${rankingConstraints}
349
356
  ${disclosureBlock}
350
357
 
351
358
  Response format:
352
- - Start with the assumptions block above exactly as written. Do not relabel source attribution anywhere else in your response.
359
+ ${ASSUMPTIONS_RESPONSE_INSTRUCTION}
353
360
  - ${isCoveredCallContext ? `Start with an Interpretation line: "Interpretation: Treating ${s.symbol} as the held ticker because you phrased it as an existing position. If you meant ${s.symbol} as memory exposure or another ticker, clarify before trading."` : isProtectivePutContext ? `Start with an Interpretation line: "Interpretation: Treating this as buying protective puts on an existing long ${s.symbol} share position."` : "State the interpretation only if the user's requested underlying is ambiguous."}
354
361
  - Present top 3-5 ranked contracts in a table: strike, expiry, premium, delta, gamma, theta, vega, rho, IV, OI, bid-ask spread${isProtectivePutContext ? ", hedge floor, premium % of position" : ""}.
355
362
  - ${topPickExplanation}
@@ -460,7 +467,7 @@ macro hedge decision guidance:
460
467
  resolution.sources as Record<string, SlotSource | undefined>,
461
468
  );
462
469
 
463
- return `Current date: ${todayStr()}
470
+ return `${currentDateLine()}
464
471
 
465
472
  Compare these assets side by side: ${symbolList}${horizonLine}${budgetLine}
466
473
 
@@ -475,7 +482,7 @@ ${overlapGuidance}
475
482
  ${disclosureBlock}
476
483
 
477
484
  Response format:
478
- - Start with the assumptions block above exactly as written. Do not relabel source attribution anywhere else in your response.
485
+ ${ASSUMPTIONS_RESPONSE_INSTRUCTION}
479
486
  ${tableInstruction}
480
487
  - Provide a summary verdict: which is most attractive and why.
481
488
  - Note any caveats (different sectors, concentration, market cap disparity, unavailable fundamentals, unavailable forward-looking estimates, etc.).${horizonResponse}`;
@@ -0,0 +1,164 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { delimiter, isAbsolute, join, sep } from "node:path";
5
+
6
+ const DEFAULT_TIMEOUT_MS = 20_000;
7
+ const DEFAULT_MAX_OUTPUT_CHARS = 2_000_000;
8
+
9
+ export interface ExternalToolCommandResult {
10
+ readonly code: number | null;
11
+ readonly stdout: string;
12
+ readonly stderr: string;
13
+ }
14
+
15
+ export interface ExternalToolCommandOptions {
16
+ readonly timeoutMs?: number;
17
+ readonly maxOutputChars?: number;
18
+ }
19
+
20
+ interface CandidateOptions {
21
+ readonly env?: NodeJS.ProcessEnv;
22
+ readonly homeDir?: string;
23
+ readonly platform?: NodeJS.Platform;
24
+ }
25
+
26
+ export function getExternalToolCommandCandidates(
27
+ command: string,
28
+ options: CandidateOptions = {},
29
+ ): string[] {
30
+ if (isPathLikeCommand(command)) return [command];
31
+
32
+ const env = options.env ?? process.env;
33
+ const platform = options.platform ?? process.platform;
34
+ const home = options.homeDir ?? homedir();
35
+ const candidates = [command];
36
+ const binDirs = [
37
+ env.OPENCANDLE_EXTERNAL_TOOL_BIN_DIR,
38
+ env.UV_TOOL_BIN_DIR,
39
+ env.XDG_BIN_HOME,
40
+ home ? join(home, ".local", "bin") : undefined,
41
+ ];
42
+
43
+ for (const dir of binDirs) {
44
+ if (!dir) continue;
45
+ for (const executable of commandNames(command, platform)) {
46
+ candidates.push(join(dir, executable));
47
+ }
48
+ }
49
+
50
+ return [...new Set(candidates)];
51
+ }
52
+
53
+ export async function runExternalToolCommand(
54
+ command: string,
55
+ args: readonly string[],
56
+ options: ExternalToolCommandOptions = {},
57
+ ): Promise<ExternalToolCommandResult> {
58
+ const uvToolBinDirs = await resolveUvToolBinDirs(options);
59
+ const candidates = [
60
+ ...getExternalToolCommandCandidates(command),
61
+ ...uvToolBinDirs.flatMap((dir) =>
62
+ commandNames(command, process.platform).map((executable) => join(dir, executable)),
63
+ ),
64
+ ].filter((candidate, index, all) => all.indexOf(candidate) === index);
65
+ const existingCandidates = candidates.filter(
66
+ (candidate) => candidate === command || existsSync(candidate),
67
+ );
68
+ return runCandidate(
69
+ existingCandidates.length > 0 ? existingCandidates : [command],
70
+ args,
71
+ options,
72
+ );
73
+ }
74
+
75
+ async function resolveUvToolBinDirs(
76
+ options: ExternalToolCommandOptions,
77
+ ): Promise<readonly string[]> {
78
+ try {
79
+ const result = await spawnCommand("uv", ["tool", "dir", "--bin"], {
80
+ timeoutMs: Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, 2_000),
81
+ maxOutputChars: 4_000,
82
+ });
83
+ if (result.code !== 0) return [];
84
+ const binDir = result.stdout.trim();
85
+ return binDir ? [binDir] : [];
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ function runCandidate(
92
+ candidates: readonly string[],
93
+ args: readonly string[],
94
+ options: ExternalToolCommandOptions,
95
+ ): Promise<ExternalToolCommandResult> {
96
+ const [command, ...fallbacks] = candidates;
97
+ return spawnCommand(command, args, options).catch((err: NodeJS.ErrnoException) => {
98
+ if (err.code === "ENOENT" && fallbacks.length > 0) {
99
+ return runCandidate(fallbacks, args, options);
100
+ }
101
+ throw err;
102
+ });
103
+ }
104
+
105
+ function spawnCommand(
106
+ command: string,
107
+ args: readonly string[],
108
+ options: ExternalToolCommandOptions,
109
+ ): Promise<ExternalToolCommandResult> {
110
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
111
+ const maxOutputChars = options.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
112
+
113
+ return new Promise((resolve, reject) => {
114
+ const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
115
+ let stdout = "";
116
+ let stderr = "";
117
+ let settled = false;
118
+
119
+ const timeout = setTimeout(() => {
120
+ if (settled) return;
121
+ settled = true;
122
+ child.kill("SIGTERM");
123
+ reject(new Error(`${command} timed out after ${timeoutMs}ms`));
124
+ }, timeoutMs);
125
+
126
+ child.stdout.on("data", (chunk: Buffer) => {
127
+ stdout = (stdout + chunk.toString("utf8")).slice(0, maxOutputChars);
128
+ });
129
+ child.stderr.on("data", (chunk: Buffer) => {
130
+ stderr = (stderr + chunk.toString("utf8")).slice(0, maxOutputChars);
131
+ });
132
+ child.on("error", (err) => {
133
+ if (settled) return;
134
+ settled = true;
135
+ clearTimeout(timeout);
136
+ reject(err);
137
+ });
138
+ child.on("close", (code) => {
139
+ if (settled) return;
140
+ settled = true;
141
+ clearTimeout(timeout);
142
+ resolve({ code, stdout, stderr });
143
+ });
144
+ });
145
+ }
146
+
147
+ function isPathLikeCommand(command: string): boolean {
148
+ return (
149
+ isAbsolute(command) || command.includes("/") || command.includes("\\") || command.includes(sep)
150
+ );
151
+ }
152
+
153
+ function commandNames(command: string, platform: NodeJS.Platform): string[] {
154
+ if (platform !== "win32") return [command];
155
+
156
+ const names = [command];
157
+ const pathExt = process.env.PATHEXT?.split(delimiter) ?? [".EXE", ".CMD", ".BAT"];
158
+ for (const ext of pathExt) {
159
+ if (!command.toLowerCase().endsWith(ext.toLowerCase())) {
160
+ names.push(`${command}${ext.toLowerCase()}`);
161
+ }
162
+ }
163
+ return names;
164
+ }
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { type ExternalToolCommandResult, runExternalToolCommand } from "./external-tool-command.js";
2
2
  import { ExternalToolError, ExternalToolNotInstalled } from "./external-tool-error.js";
3
3
  import type { RedditComment } from "./reddit.js";
4
4
 
@@ -62,13 +62,10 @@ interface RdtListing<T> {
62
62
  };
63
63
  }
64
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>;
65
+ type RdtCommandRunner = (
66
+ command: string,
67
+ args: readonly string[],
68
+ ) => Promise<ExternalToolCommandResult>;
72
69
 
73
70
  let commandRunner: RdtCommandRunner = runCommand;
74
71
 
@@ -147,7 +144,7 @@ export async function readRedditPost(
147
144
  }
148
145
 
149
146
  async function runRdt<T>(args: readonly string[]): Promise<T> {
150
- let result: CommandResult;
147
+ let result: ExternalToolCommandResult;
151
148
  try {
152
149
  result = await commandRunner(RDT_BINARY, args);
153
150
  } catch (err) {
@@ -281,37 +278,9 @@ export function redactSensitiveOutput(input: string): string {
281
278
  );
282
279
  }
283
280
 
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
- });
281
+ function runCommand(command: string, args: readonly string[]): Promise<ExternalToolCommandResult> {
282
+ return runExternalToolCommand(command, args, {
283
+ timeoutMs: COMMAND_TIMEOUT_MS,
284
+ maxOutputChars: MAX_OUTPUT_CHARS,
316
285
  });
317
286
  }
@@ -1,5 +1,5 @@
1
- import { spawn } from "node:child_process";
2
1
  import type { TwitterTweet } from "../types/sentiment.js";
2
+ import { type ExternalToolCommandResult, runExternalToolCommand } from "./external-tool-command.js";
3
3
  import { ExternalToolError, ExternalToolNotInstalled } from "./external-tool-error.js";
4
4
 
5
5
  const TWITTER_CLI_BINARY = "twitter";
@@ -47,13 +47,10 @@ interface TwitterCliEnvelope<T> {
47
47
  };
48
48
  }
49
49
 
50
- interface CommandResult {
51
- readonly code: number | null;
52
- readonly stdout: string;
53
- readonly stderr: string;
54
- }
55
-
56
- type TwitterCliCommandRunner = (command: string, args: readonly string[]) => Promise<CommandResult>;
50
+ type TwitterCliCommandRunner = (
51
+ command: string,
52
+ args: readonly string[],
53
+ ) => Promise<ExternalToolCommandResult>;
57
54
 
58
55
  let commandRunner: TwitterCliCommandRunner = runCommand;
59
56
 
@@ -90,7 +87,7 @@ export async function searchTweets(query: string, max = 20): Promise<TwitterTwee
90
87
  }
91
88
 
92
89
  async function runTwitterCli<T>(args: readonly string[]): Promise<T> {
93
- let result: CommandResult;
90
+ let result: ExternalToolCommandResult;
94
91
  try {
95
92
  result = await commandRunner(TWITTER_CLI_BINARY, args);
96
93
  } catch (err) {
@@ -197,37 +194,9 @@ export function redactSensitiveOutput(input: string): string {
197
194
  .replace(new RegExp(`\\b(${xCookieNames})=([^;\\s,)]+)`, "gi"), "$1=[redacted]");
198
195
  }
199
196
 
200
- function runCommand(command: string, args: readonly string[]): Promise<CommandResult> {
201
- return new Promise((resolve, reject) => {
202
- const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"] });
203
- let stdout = "";
204
- let stderr = "";
205
- let settled = false;
206
-
207
- const timeout = setTimeout(() => {
208
- if (settled) return;
209
- settled = true;
210
- child.kill("SIGTERM");
211
- reject(new Error(`${command} timed out after ${COMMAND_TIMEOUT_MS}ms`));
212
- }, COMMAND_TIMEOUT_MS);
213
-
214
- child.stdout.on("data", (chunk: Buffer) => {
215
- stdout = (stdout + chunk.toString("utf8")).slice(0, MAX_OUTPUT_CHARS);
216
- });
217
- child.stderr.on("data", (chunk: Buffer) => {
218
- stderr = (stderr + chunk.toString("utf8")).slice(0, MAX_OUTPUT_CHARS);
219
- });
220
- child.on("error", (err) => {
221
- if (settled) return;
222
- settled = true;
223
- clearTimeout(timeout);
224
- reject(err);
225
- });
226
- child.on("close", (code) => {
227
- if (settled) return;
228
- settled = true;
229
- clearTimeout(timeout);
230
- resolve({ code, stdout, stderr });
231
- });
197
+ function runCommand(command: string, args: readonly string[]): Promise<ExternalToolCommandResult> {
198
+ return runExternalToolCommand(command, args, {
199
+ timeoutMs: COMMAND_TIMEOUT_MS,
200
+ maxOutputChars: MAX_OUTPUT_CHARS,
232
201
  });
233
202
  }
@@ -285,17 +285,22 @@ export function classifyIntent(input: string): ClassificationResult {
285
285
  function isPortfolioEvaluationRequest(input: string): boolean {
286
286
  const lower = input.toLowerCase();
287
287
  const hasEvaluationIntent =
288
- /\b(?:evaluat(?:e|ion)|review|assess|analy[sz]e|prospects?|risks?|opportunities?|mitigat(?:e|ion)|adjustment)\b/.test(
289
- lower,
290
- );
291
- const hasPortfolioObject =
292
- /\b(?:portfolio|allocation|asset\s+allocation|60\/40|equity|fixed\s+income|bonds?)\b/.test(
288
+ /\b(?:evaluat(?:e|ion)|review|assess|analy[sz]e|prospects?|risks?|risky|opportunities?|mitigat(?:e|ion)|adjust(?:ment)?|change|rebalance|diversify|concentration|overweight|underweight|expos(?:ed|ure)|target\s+bands?|drift|worried|crash|protect|protection|missing\s+out\s+on\s+growth)\b/.test(
293
289
  lower,
294
290
  );
291
+ const hasExplicitPortfolioSubject =
292
+ /\b(?:portfolio|allocation|asset\s+allocation|60\/40|holdings?|positions?)\b/.test(lower);
293
+ const hasOwnedAssetMix =
294
+ /\b(?:my|current|have|holding|hold|own|invested|positions?|\d+(?:\.\d+)?%)\b/.test(lower) &&
295
+ /\b(?:equity|fixed\s+income|bonds?|cash|stocks?|etfs?|funds?)\b/.test(lower);
295
296
  const hasConstructionIntent =
296
297
  /\b(?:build|create|construct|put\s+together|invest|allocate)\b/.test(lower) &&
297
298
  /\$\s*\d|\b\d+(?:\.\d+)?\s*k\b|\bbudget\b|\bcapital\b/.test(lower);
298
- return hasEvaluationIntent && hasPortfolioObject && !hasConstructionIntent;
299
+ return (
300
+ hasEvaluationIntent &&
301
+ (hasExplicitPortfolioSubject || hasOwnedAssetMix) &&
302
+ !hasConstructionIntent
303
+ );
299
304
  }
300
305
 
301
306
  function isStatefulTrackingRequest(input: string): boolean {