opencandle 0.7.0 → 0.9.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 (101) hide show
  1. package/README.md +34 -21
  2. package/dist/cli-main.d.ts +1 -0
  3. package/dist/cli-main.js +224 -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-writer-lock.d.ts +30 -0
  29. package/dist/pi/session-writer-lock.js +111 -0
  30. package/dist/pi/session-writer-lock.js.map +1 -0
  31. package/dist/pi/session.d.ts +0 -1
  32. package/dist/pi/session.js +2 -1
  33. package/dist/pi/session.js.map +1 -1
  34. package/dist/prompts/policy-cards.js +8 -4
  35. package/dist/prompts/policy-cards.js.map +1 -1
  36. package/dist/prompts/workflow-prompts.js +25 -6
  37. package/dist/prompts/workflow-prompts.js.map +1 -1
  38. package/dist/providers/external-tool-command.d.ts +17 -0
  39. package/dist/providers/external-tool-command.js +114 -0
  40. package/dist/providers/external-tool-command.js.map +1 -0
  41. package/dist/providers/reddit-cli.d.ts +2 -6
  42. package/dist/providers/reddit-cli.js +4 -33
  43. package/dist/providers/reddit-cli.js.map +1 -1
  44. package/dist/providers/twitter-cli.d.ts +2 -6
  45. package/dist/providers/twitter-cli.js +4 -33
  46. package/dist/providers/twitter-cli.js.map +1 -1
  47. package/dist/routing/classify-intent.js +15 -12
  48. package/dist/routing/classify-intent.js.map +1 -1
  49. package/dist/routing/entity-extractor.js +1 -1
  50. package/dist/routing/entity-extractor.js.map +1 -1
  51. package/dist/routing/planning.js +9 -6
  52. package/dist/routing/planning.js.map +1 -1
  53. package/dist/routing/router-llm-client.d.ts +1 -1
  54. package/dist/routing/router-llm-client.js +1 -1
  55. package/dist/routing/router-llm-client.js.map +1 -1
  56. package/dist/routing/router.js +44 -3
  57. package/dist/routing/router.js.map +1 -1
  58. package/dist/workflows/compare-assets.js +4 -1
  59. package/dist/workflows/compare-assets.js.map +1 -1
  60. package/gui/server/ask-user-bridge.ts +26 -19
  61. package/gui/server/chat-event-adapter.ts +49 -3
  62. package/gui/server/http-routes.ts +577 -0
  63. package/gui/server/invoke-tool.ts +102 -7
  64. package/gui/server/live-chat-event-adapter.ts +16 -4
  65. package/gui/server/server.ts +70 -296
  66. package/gui/server/session-actions.ts +7 -1
  67. package/gui/server/session-entry-wait.ts +79 -0
  68. package/gui/server/writer-lock.ts +1 -122
  69. package/gui/server/ws-hub.ts +25 -0
  70. package/gui/shared/chat-events.ts +42 -19
  71. package/gui/shared/event-reducer.ts +41 -26
  72. package/gui/web/dist/assets/CatalogOverlay-DcTXLc7u.js +1 -0
  73. package/gui/web/dist/assets/index-DdMnIvZ7.css +2 -0
  74. package/gui/web/dist/assets/index-YlMaahAS.js +65 -0
  75. package/gui/web/dist/index.html +2 -2
  76. package/package.json +23 -19
  77. package/src/cli-main.ts +270 -0
  78. package/src/cli.ts +14 -267
  79. package/src/doctor/cli-command.ts +83 -0
  80. package/src/doctor/render.ts +37 -0
  81. package/src/doctor/report.ts +638 -0
  82. package/src/infra/native-dependencies.ts +67 -0
  83. package/src/infra/node-version.ts +2 -5
  84. package/src/monitor.ts +3 -1
  85. package/src/onboarding/provider-status.ts +10 -38
  86. package/src/pi/session-writer-lock.ts +156 -0
  87. package/src/pi/session.ts +2 -1
  88. package/src/prompts/policy-cards.ts +12 -4
  89. package/src/prompts/workflow-prompts.ts +28 -6
  90. package/src/providers/external-tool-command.ts +164 -0
  91. package/src/providers/reddit-cli.ts +10 -41
  92. package/src/providers/twitter-cli.ts +10 -41
  93. package/src/routing/classify-intent.ts +22 -17
  94. package/src/routing/entity-extractor.ts +1 -1
  95. package/src/routing/planning.ts +15 -10
  96. package/src/routing/router-llm-client.ts +2 -1
  97. package/src/routing/router.ts +57 -6
  98. package/src/workflows/compare-assets.ts +4 -2
  99. package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +0 -1
  100. package/gui/web/dist/assets/index-C6W_2eAn.js +0 -69
  101. package/gui/web/dist/assets/index-hwbx24a5.css +0 -1
@@ -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
  });
@@ -0,0 +1,156 @@
1
+ import {
2
+ closeSync,
3
+ mkdirSync,
4
+ openSync,
5
+ readFileSync,
6
+ statSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+
12
+ export type ProcessKind = "tui" | "gui";
13
+
14
+ export interface WriterLock {
15
+ pid: number;
16
+ processKind: ProcessKind;
17
+ acquiredAt: string;
18
+ lastHeartbeat: string;
19
+ }
20
+
21
+ export interface AcquireOptions {
22
+ pid?: number;
23
+ staleGraceMs?: number;
24
+ }
25
+
26
+ export type AcquireResult =
27
+ | { role: "writer"; lock: WriterLock }
28
+ | { role: "follower"; lock: WriterLock };
29
+
30
+ export interface SessionLockScopeSource {
31
+ getSessionFile(): string | undefined;
32
+ getSessionDir(): string;
33
+ }
34
+
35
+ const DEFAULT_STALE_GRACE_MS = 15_000;
36
+
37
+ export async function acquireWriterLock(
38
+ scopePath: string,
39
+ processKind: ProcessKind,
40
+ options: AcquireOptions = {},
41
+ ): Promise<AcquireResult> {
42
+ mkdirSync(dirname(lockPath(scopePath)), { recursive: true });
43
+ const pid = options.pid ?? process.pid;
44
+ const staleGraceMs = options.staleGraceMs ?? DEFAULT_STALE_GRACE_MS;
45
+
46
+ const created = tryCreate(scopePath, processKind, pid);
47
+ if (created) return { role: "writer", lock: created };
48
+
49
+ const existing = readWriterLock(scopePath);
50
+ if (existing && isLockCurrent(existing, staleGraceMs)) {
51
+ return { role: "follower", lock: existing };
52
+ }
53
+
54
+ await sleep(staleGraceMs);
55
+ const afterGrace = readWriterLock(scopePath);
56
+ if (afterGrace && isLockCurrent(afterGrace, staleGraceMs)) {
57
+ return { role: "follower", lock: afterGrace };
58
+ }
59
+
60
+ try {
61
+ unlinkSync(lockPath(scopePath));
62
+ } catch {
63
+ // Missing or concurrently removed is fine; the next create decides ownership.
64
+ }
65
+
66
+ const recovered = tryCreate(scopePath, processKind, pid);
67
+ if (recovered) return { role: "writer", lock: recovered };
68
+
69
+ const current = readWriterLock(scopePath) ?? afterGrace ?? existing;
70
+ if (!current) throw new Error("Unable to determine active writer lock");
71
+ return { role: "follower", lock: current };
72
+ }
73
+
74
+ export function readWriterLock(scopePath: string): WriterLock | null {
75
+ try {
76
+ return JSON.parse(readFileSync(lockPath(scopePath), "utf8")) as WriterLock;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export function refreshWriterLock(scopePath: string, pid = process.pid): void {
83
+ const lock = readWriterLock(scopePath);
84
+ if (!lock || lock.pid !== pid) return;
85
+ writeFileSync(
86
+ lockPath(scopePath),
87
+ JSON.stringify({ ...lock, lastHeartbeat: new Date().toISOString() }, null, 2),
88
+ );
89
+ }
90
+
91
+ export function releaseWriterLock(scopePath: string, pid = process.pid): void {
92
+ const lock = readWriterLock(scopePath);
93
+ if (!lock || lock.pid !== pid) return;
94
+ try {
95
+ unlinkSync(lockPath(scopePath));
96
+ } catch {
97
+ // Best effort shutdown cleanup.
98
+ }
99
+ }
100
+
101
+ export const acquireSessionWriterLock = acquireWriterLock;
102
+ export const refreshSessionWriterLock = refreshWriterLock;
103
+ export const releaseSessionWriterLock = releaseWriterLock;
104
+
105
+ export function writerLockScopeForSession(sessionManager: SessionLockScopeSource): string {
106
+ return sessionManager.getSessionFile() ?? sessionManager.getSessionDir();
107
+ }
108
+
109
+ function tryCreate(scopePath: string, processKind: ProcessKind, pid: number): WriterLock | null {
110
+ const now = new Date().toISOString();
111
+ const lock: WriterLock = { pid, processKind, acquiredAt: now, lastHeartbeat: now };
112
+ try {
113
+ const fd = openSync(lockPath(scopePath), "wx");
114
+ try {
115
+ writeFileSync(fd, JSON.stringify(lock, null, 2));
116
+ } finally {
117
+ closeSync(fd);
118
+ }
119
+ return lock;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ function isPidAlive(pid: number): boolean {
126
+ try {
127
+ process.kill(pid, 0);
128
+ return true;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ function isLockCurrent(lock: WriterLock, staleGraceMs: number): boolean {
135
+ const heartbeat = Date.parse(lock.lastHeartbeat);
136
+ return (
137
+ isPidAlive(lock.pid) && Number.isFinite(heartbeat) && Date.now() - heartbeat <= staleGraceMs
138
+ );
139
+ }
140
+
141
+ function lockPath(scopePath: string): string {
142
+ return isFileScope(scopePath) ? `${scopePath}.writer.lock` : join(scopePath, "writer.lock");
143
+ }
144
+
145
+ function isFileScope(scopePath: string): boolean {
146
+ if (scopePath.endsWith(".jsonl")) return true;
147
+ try {
148
+ return statSync(scopePath).isFile();
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ function sleep(ms: number): Promise<void> {
155
+ return new Promise((resolve) => setTimeout(resolve, ms));
156
+ }
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}
@@ -448,6 +455,19 @@ macro hedge decision guidance:
448
455
  - Prioritize the evidence that matters most over ${timeHorizon}: near-term catalysts, earnings/guidance, forward-looking valuation/estimates, sentiment, macro sensitivity, and company-specific risks.
449
456
  - Call out evidence that is missing or unavailable, especially forward-looking estimates or company-specific catalysts.`
450
457
  : "";
458
+ const actionPlanResponse = timeHorizon
459
+ ? `
460
+ - REQUIRED ACTION SECTION: Immediately after the summary verdict and before caveats, include a section exactly titled "### Cautious action plan". Use bullets for preferred tilt, entry conditions, position-sizing caution, downside risk/invalidation trigger, and the missing data that would change the plan for the ${timeHorizon} horizon. If this section is missing, the answer is incomplete.`
461
+ : "";
462
+ const responseHeadingOrder = timeHorizon
463
+ ? `
464
+ Use these response headings in this order:
465
+ 1. Assumptions
466
+ 2. Comparison table
467
+ 3. Summary Verdict
468
+ 4. Cautious action plan
469
+ 5. Caveats`
470
+ : "";
451
471
 
452
472
  const disclosureBlock = buildDisclosureBlock(
453
473
  {
@@ -460,7 +480,7 @@ macro hedge decision guidance:
460
480
  resolution.sources as Record<string, SlotSource | undefined>,
461
481
  );
462
482
 
463
- return `Current date: ${todayStr()}
483
+ return `${currentDateLine()}
464
484
 
465
485
  Compare these assets side by side: ${symbolList}${horizonLine}${budgetLine}
466
486
 
@@ -475,8 +495,10 @@ ${overlapGuidance}
475
495
  ${disclosureBlock}
476
496
 
477
497
  Response format:
478
- - Start with the assumptions block above exactly as written. Do not relabel source attribution anywhere else in your response.
498
+ ${responseHeadingOrder}
499
+ ${ASSUMPTIONS_RESPONSE_INSTRUCTION}
479
500
  ${tableInstruction}
480
501
  - Provide a summary verdict: which is most attractive and why.
502
+ ${actionPlanResponse}
481
503
  - Note any caveats (different sectors, concentration, market cap disparity, unavailable fundamentals, unavailable forward-looking estimates, etc.).${horizonResponse}`;
482
504
  }
@@ -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
+ }