opencandle 0.10.0 → 0.11.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 (97) hide show
  1. package/README.md +7 -5
  2. package/dist/analysts/orchestrator.d.ts +1 -4
  3. package/dist/analysts/orchestrator.js +15 -44
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/cli-main.js +242 -9
  6. package/dist/cli-main.js.map +1 -1
  7. package/dist/config.d.ts +4 -9
  8. package/dist/config.js +7 -10
  9. package/dist/config.js.map +1 -1
  10. package/dist/market-state/daily-report.d.ts +1 -1
  11. package/dist/market-state/daily-report.js +5 -16
  12. package/dist/market-state/daily-report.js.map +1 -1
  13. package/dist/market-state/service.d.ts +0 -35
  14. package/dist/market-state/service.js +0 -63
  15. package/dist/market-state/service.js.map +1 -1
  16. package/dist/memory/sqlite.js +20 -19
  17. package/dist/memory/sqlite.js.map +1 -1
  18. package/dist/pi/opencandle-extension.js +13 -221
  19. package/dist/pi/opencandle-extension.js.map +1 -1
  20. package/dist/pi/session-action-dedupe.d.ts +6 -0
  21. package/dist/pi/session-action-dedupe.js +126 -0
  22. package/dist/pi/session-action-dedupe.js.map +1 -0
  23. package/dist/pi/session-writer-lock.d.ts +26 -2
  24. package/dist/pi/session-writer-lock.js +230 -18
  25. package/dist/pi/session-writer-lock.js.map +1 -1
  26. package/dist/pi/setup.js +5 -5
  27. package/dist/pi/setup.js.map +1 -1
  28. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  29. package/dist/pi/tui-session-coordinator.js +283 -0
  30. package/dist/pi/tui-session-coordinator.js.map +1 -0
  31. package/dist/prompts/context-builder.js +1 -1
  32. package/dist/prompts/policy-cards.js +1 -1
  33. package/dist/prompts/policy-cards.js.map +1 -1
  34. package/dist/routing/classify-intent.js +4 -5
  35. package/dist/routing/classify-intent.js.map +1 -1
  36. package/dist/routing/route-manifest.js +0 -1
  37. package/dist/routing/route-manifest.js.map +1 -1
  38. package/dist/routing/router-prompt.js +1 -1
  39. package/dist/routing/router.js +35 -4
  40. package/dist/routing/router.js.map +1 -1
  41. package/dist/runtime/session-coordinator.js +2 -13
  42. package/dist/runtime/session-coordinator.js.map +1 -1
  43. package/dist/system-prompt.js +1 -1
  44. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  45. package/dist/tools/fundamentals/dcf.js +49 -6
  46. package/dist/tools/fundamentals/dcf.js.map +1 -1
  47. package/dist/tools/index.d.ts +0 -1
  48. package/dist/tools/index.js +0 -3
  49. package/dist/tools/index.js.map +1 -1
  50. package/dist/tools/portfolio/daily-report.js +10 -4
  51. package/dist/tools/portfolio/daily-report.js.map +1 -1
  52. package/dist/tools/technical/backtest.d.ts +23 -5
  53. package/dist/tools/technical/backtest.js +131 -94
  54. package/dist/tools/technical/backtest.js.map +1 -1
  55. package/gui/server/http-routes.ts +395 -19
  56. package/gui/server/invoke-tool.ts +164 -16
  57. package/gui/server/local-session-coordinator.ts +97 -0
  58. package/gui/server/market-state-api.ts +1 -43
  59. package/gui/server/server.ts +51 -6
  60. package/gui/server/session-actions.ts +117 -8
  61. package/gui/server/tool-metadata.ts +3 -1
  62. package/gui/server/ws-hub.ts +61 -6
  63. package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +1 -0
  64. package/gui/web/dist/assets/index-BzyqyVnd.css +2 -0
  65. package/gui/web/dist/assets/{index-B7QAjY5g.js → index-DvMjkOP9.js} +9 -9
  66. package/gui/web/dist/index.html +2 -2
  67. package/package.json +6 -3
  68. package/src/analysts/orchestrator.ts +15 -56
  69. package/src/cli-main.ts +253 -13
  70. package/src/config.ts +12 -20
  71. package/src/market-state/daily-report.ts +6 -16
  72. package/src/market-state/service.ts +0 -136
  73. package/src/memory/sqlite.ts +23 -19
  74. package/src/pi/opencandle-extension.ts +12 -265
  75. package/src/pi/session-action-dedupe.ts +155 -0
  76. package/src/pi/session-writer-lock.ts +290 -20
  77. package/src/pi/setup.ts +6 -6
  78. package/src/pi/tui-session-coordinator.ts +351 -0
  79. package/src/prompts/context-builder.ts +1 -1
  80. package/src/prompts/policy-cards.ts +1 -1
  81. package/src/routing/classify-intent.ts +4 -5
  82. package/src/routing/route-manifest.ts +0 -1
  83. package/src/routing/router-prompt.ts +1 -1
  84. package/src/routing/router.ts +39 -3
  85. package/src/runtime/session-coordinator.ts +2 -17
  86. package/src/system-prompt.ts +1 -1
  87. package/src/tools/AGENTS.md +1 -1
  88. package/src/tools/fundamentals/dcf.ts +57 -7
  89. package/src/tools/index.ts +0 -3
  90. package/src/tools/portfolio/daily-report.ts +10 -4
  91. package/src/tools/technical/backtest.ts +167 -108
  92. package/dist/tools/portfolio/predictions.d.ts +0 -55
  93. package/dist/tools/portfolio/predictions.js +0 -422
  94. package/dist/tools/portfolio/predictions.js.map +0 -1
  95. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
  96. package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
  97. package/src/tools/portfolio/predictions.ts +0 -553
@@ -8,8 +8,8 @@
8
8
  <link rel="preconnect" href="https://fonts.googleapis.com" />
9
9
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
10
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
11
- <script type="module" crossorigin src="/assets/index-B7QAjY5g.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-D5dbWPfM.css">
11
+ <script type="module" crossorigin src="/assets/index-DvMjkOP9.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-BzyqyVnd.css">
13
13
  </head>
14
14
  <body>
15
15
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencandle",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Financial trading & investing agent",
5
5
  "license": "MIT",
6
6
  "homepage": "https://opencandle.app",
@@ -14,7 +14,9 @@
14
14
  "type": "module",
15
15
  "workspaces": [
16
16
  "gui/server",
17
- "gui/web"
17
+ "gui/web",
18
+ "packages/ui",
19
+ "website"
18
20
  ],
19
21
  "bin": {
20
22
  "opencandle": "dist/cli.js"
@@ -86,7 +88,7 @@
86
88
  "prestart": "npm run check:node",
87
89
  "start": "tsx src/cli.ts",
88
90
  "gui:web:build": "npm --workspace @opencandle/gui-web run build",
89
- "docs:site:build": "node website/build.mjs",
91
+ "docs:site:build": "npm --workspace @opencandle/website run build",
90
92
  "docs:site:serve": "node website/serve.mjs",
91
93
  "docs:links:check": "node scripts/check-public-doc-links.mjs",
92
94
  "gui": "tsx gui/server/server.ts",
@@ -104,6 +106,7 @@
104
106
  "test:e2e:credential-per-workflow-cap": "tsx tests/e2e/credential-per-workflow-cap.test.ts",
105
107
  "test:gui:browser": "OPENCANDLE_GUI_BROWSER=1 vitest run --config vitest.config.gui.ts",
106
108
  "test:e2e:providers": "tsx tests/e2e/providers.test.ts",
109
+ "test:e2e:harness-dcf": "tsx tests/e2e/harness-dcf.test.ts",
107
110
  "test:evals": "vitest run --config vitest.config.evals.ts",
108
111
  "eval:router-live": "tsx tests/scripts/run-live-router-eval.ts",
109
112
  "test:evals:usually": "EVAL_TIER=usually vitest run --config vitest.config.evals.ts",
@@ -148,34 +148,11 @@ Additionally check:
148
148
 
149
149
  Output: VALIDATED if all checks pass, or list specific corrections needed.`;
150
150
 
151
- const SYNTHESIS_PROMPT_NO_DEBATE = (symbol: string) =>
152
- `**[Synthesis]** You have received five analyst signals above for ${symbol}. Tally the SIGNAL votes (BUY/HOLD/SELL) and weight them by CONVICTION scores. Then provide:
153
- 1. **Vote Tally**: X BUY, Y HOLD, Z SELL — weighted average conviction
154
- 2. **Verdict**: Buy, Hold, or Sell — based on the signal consensus
155
- 3. **Key thesis** in 2-3 sentences
156
- 4. **Bull case** — what could go right
157
- 5. **Bear case** — what could go wrong
158
- 6. **Key levels** — entry, stop-loss, and target prices
159
- 7. **Position sizing recommendation** based on risk profile
160
-
161
- Be direct and actionable. This is your final word on ${symbol}.`;
162
-
163
- const VALIDATION_PROMPT_NO_DEBATE = (symbol: string) =>
164
- `**[Validation Check]** Review your complete analysis of ${symbol} above. For each specific number you cited (price, P/E, revenue, RSI, intrinsic value, etc.), verify it matches the tool output data you received. Flag any inconsistencies. If you stated a number without fetching it first, call that out as UNVERIFIED. Output: VALIDATED if all numbers check out, or list specific corrections needed.`;
165
-
166
151
  export function getInitialAnalysisPrompt(symbol: string): string {
167
152
  return `Begin comprehensive analysis of ${symbol}. Start by getting the current stock quote.`;
168
153
  }
169
154
 
170
- export interface ComprehensiveAnalysisOptions {
171
- debate?: boolean;
172
- }
173
-
174
- export function buildComprehensiveAnalysisDefinition(
175
- symbol: string,
176
- options?: ComprehensiveAnalysisOptions,
177
- ): WorkflowDefinition {
178
- const debate = options?.debate ?? true;
155
+ export function buildComprehensiveAnalysisDefinition(symbol: string): WorkflowDefinition {
179
156
  const roles: AnalystRole[] = ["valuation", "momentum", "options", "contrarian", "risk"];
180
157
 
181
158
  const analystOutputs = roles.map((r) => `${r}_signal`);
@@ -193,45 +170,27 @@ export function buildComprehensiveAnalysisDefinition(
193
170
  ),
194
171
  ];
195
172
 
196
- if (debate) {
197
- return {
198
- workflowType: "comprehensive_analysis",
199
- steps: [
200
- ...analystSteps,
201
- promptStep("debate_bull", "Bull researcher case", buildBullPrompt(symbol), {
202
- requiredInputs: analystOutputs,
203
- expectedOutputs: ["bull_thesis"],
204
- }),
205
- promptStep("debate_bear", "Bear researcher case", buildBearPrompt(symbol), {
206
- requiredInputs: [...analystOutputs, "bull_thesis"],
207
- expectedOutputs: ["bear_thesis"],
208
- }),
209
- promptStep("debate_rebuttal", "Bull rebuttal (self-gating)", buildRebuttalPrompt(symbol), {
210
- requiredInputs: [...analystOutputs, "bull_thesis", "bear_thesis"],
211
- expectedOutputs: ["rebuttal"],
212
- }),
213
- promptStep("synthesis", "Resolve the debate", buildSynthesisPrompt(symbol), {
214
- requiredInputs: [...analystOutputs, "bull_thesis", "bear_thesis", "rebuttal"],
215
- expectedOutputs: ["verdict"],
216
- }),
217
- promptStep("validation", "Validate cited numbers", VALIDATION_PROMPT_DEBATE(symbol), {
218
- skippable: true,
219
- requiredInputs: ["verdict"],
220
- expectedOutputs: ["validation_result"],
221
- }),
222
- ],
223
- };
224
- }
225
-
226
173
  return {
227
174
  workflowType: "comprehensive_analysis",
228
175
  steps: [
229
176
  ...analystSteps,
230
- promptStep("synthesis", "Synthesize analyst signals", SYNTHESIS_PROMPT_NO_DEBATE(symbol), {
177
+ promptStep("debate_bull", "Bull researcher case", buildBullPrompt(symbol), {
231
178
  requiredInputs: analystOutputs,
179
+ expectedOutputs: ["bull_thesis"],
180
+ }),
181
+ promptStep("debate_bear", "Bear researcher case", buildBearPrompt(symbol), {
182
+ requiredInputs: [...analystOutputs, "bull_thesis"],
183
+ expectedOutputs: ["bear_thesis"],
184
+ }),
185
+ promptStep("debate_rebuttal", "Bull rebuttal (self-gating)", buildRebuttalPrompt(symbol), {
186
+ requiredInputs: [...analystOutputs, "bull_thesis", "bear_thesis"],
187
+ expectedOutputs: ["rebuttal"],
188
+ }),
189
+ promptStep("synthesis", "Resolve the debate", buildSynthesisPrompt(symbol), {
190
+ requiredInputs: [...analystOutputs, "bull_thesis", "bear_thesis", "rebuttal"],
232
191
  expectedOutputs: ["verdict"],
233
192
  }),
234
- promptStep("validation", "Validate cited numbers", VALIDATION_PROMPT_NO_DEBATE(symbol), {
193
+ promptStep("validation", "Validate cited numbers", VALIDATION_PROMPT_DEBATE(symbol), {
235
194
  skippable: true,
236
195
  requiredInputs: ["verdict"],
237
196
  expectedOutputs: ["validation_result"],
package/src/cli-main.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { createRequire } from "node:module";
3
4
  import { dirname, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
4
6
  import { fileURLToPath } from "node:url";
5
7
  import {
6
8
  AuthStorage,
@@ -11,6 +13,7 @@ import {
11
13
  InteractiveMode,
12
14
  initTheme,
13
15
  ModelRegistry,
16
+ SessionManager,
14
17
  SettingsManager,
15
18
  } from "@earendil-works/pi-coding-agent";
16
19
  import { loadEnv } from "./config.js";
@@ -19,10 +22,13 @@ import { createOpenCandleSession } from "./pi/session.js";
19
22
  import { continueOpenCandleSession } from "./pi/session-storage.js";
20
23
  import {
21
24
  acquireSessionWriterLock,
25
+ migrateWriterLockScope,
22
26
  refreshSessionWriterLock,
23
27
  releaseSessionWriterLock,
28
+ type WriterLock,
24
29
  writerLockScopeForSession,
25
30
  } from "./pi/session-writer-lock.js";
31
+ import { startTuiSessionCoordinatorServer } from "./pi/tui-session-coordinator.js";
26
32
 
27
33
  const require = createRequire(import.meta.url);
28
34
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -196,22 +202,62 @@ async function main(): Promise<void> {
196
202
  initTheme(settingsManager.getTheme(), true);
197
203
 
198
204
  const sessionManager = continueOpenCandleSession(cwd);
205
+ let activeSessionManager = sessionManager;
199
206
  const sessionWriterLockScope = writerLockScopeForSession(sessionManager);
200
- const sessionWriterLock = await acquireSessionWriterLock(sessionWriterLockScope, "tui");
207
+ let runtime: Awaited<ReturnType<typeof createAgentSessionRuntime>> | undefined;
208
+ const tuiCoordinator = await startTuiSessionCoordinatorServer({
209
+ getSession: () => {
210
+ if (!runtime) throw new Error("OpenCandle is still starting this session.");
211
+ return runtime.session;
212
+ },
213
+ getSessionManager: () => activeSessionManager,
214
+ getModelUnavailableMessage: () => {
215
+ if (!runtime) return "OpenCandle is still starting this session.";
216
+ const session = runtime.session;
217
+ const model = session.model;
218
+ if (!model) {
219
+ return "Connect an AI model before chat can run. Paste a Google Gemini, OpenAI, or Anthropic API key in the setup panel.";
220
+ }
221
+ if (!session.modelRegistry.hasConfiguredAuth(model)) {
222
+ return "Connect an AI model before chat can run. Paste a Google Gemini, OpenAI, or Anthropic API key in the setup panel.";
223
+ }
224
+ return null;
225
+ },
226
+ syncWriterLockScope: () => syncActiveSessionWriterLockScope(),
227
+ });
228
+ const sessionWriterLock = await acquireSessionWriterLock(sessionWriterLockScope, "tui", {
229
+ coordinatorEndpoint: tuiCoordinator.endpoint,
230
+ coordinatorSecret: tuiCoordinator.secret,
231
+ });
201
232
  if (sessionWriterLock.role !== "writer") {
202
- console.error(
203
- `Session is currently being written by ${sessionWriterLock.lock.processKind} (pid ${sessionWriterLock.lock.pid}).`,
204
- );
233
+ await tuiCoordinator.close();
234
+ if (await runFollowerTuiProxy(sessionWriterLock.lock, sessionManager, cwd)) return;
235
+ console.error("OpenCandle is syncing this session in another window. Try again shortly.");
205
236
  process.exitCode = 1;
206
237
  return;
207
238
  }
208
239
  let activeSessionWriterLockScope = sessionWriterLockScope;
209
- const writerLockHeartbeat = setInterval(
210
- () => refreshSessionWriterLock(activeSessionWriterLockScope),
211
- 5000,
212
- );
240
+ let activeSessionWriterLockLost = false;
241
+ function syncActiveSessionWriterLockScope(): void {
242
+ if (activeSessionWriterLockLost) throw new Error("OpenCandle is reconnecting to this session.");
243
+ const nextScope = writerLockScopeForSession(activeSessionManager);
244
+ if (nextScope === activeSessionWriterLockScope) return;
245
+ if (migrateWriterLockScope(activeSessionWriterLockScope, nextScope)) {
246
+ activeSessionWriterLockScope = nextScope;
247
+ } else {
248
+ activeSessionWriterLockLost = true;
249
+ throw new Error("OpenCandle is reconnecting to this session.");
250
+ }
251
+ }
252
+ const writerLockHeartbeat = setInterval(() => {
253
+ try {
254
+ syncActiveSessionWriterLockScope();
255
+ refreshSessionWriterLock(activeSessionWriterLockScope);
256
+ } catch {
257
+ clearInterval(writerLockHeartbeat);
258
+ }
259
+ }, 5000);
213
260
 
214
- let runtime: Awaited<ReturnType<typeof createAgentSessionRuntime>> | undefined;
215
261
  try {
216
262
  runtime = await createAgentSessionRuntime(
217
263
  async (opts) => {
@@ -239,20 +285,28 @@ async function main(): Promise<void> {
239
285
  },
240
286
  { cwd, agentDir, sessionManager },
241
287
  );
288
+ syncActiveSessionWriterLockScope();
242
289
  runtime.setRebindSession(async (nextSession) => {
243
290
  const nextSessionWriterLockScope = writerLockScopeForSession(nextSession.sessionManager);
244
- if (nextSessionWriterLockScope === activeSessionWriterLockScope) return;
291
+ if (nextSessionWriterLockScope === activeSessionWriterLockScope) {
292
+ activeSessionManager = nextSession.sessionManager;
293
+ syncActiveSessionWriterLockScope();
294
+ return;
295
+ }
245
296
  const nextSessionWriterLock = await acquireSessionWriterLock(
246
297
  nextSessionWriterLockScope,
247
298
  "tui",
299
+ {
300
+ coordinatorEndpoint: tuiCoordinator.endpoint,
301
+ coordinatorSecret: tuiCoordinator.secret,
302
+ },
248
303
  );
249
304
  if (nextSessionWriterLock.role !== "writer") {
250
- throw new Error(
251
- `Session is currently being written by ${nextSessionWriterLock.lock.processKind} (pid ${nextSessionWriterLock.lock.pid}).`,
252
- );
305
+ throw new Error("OpenCandle is syncing this session in another window. Try again shortly.");
253
306
  }
254
307
  releaseSessionWriterLock(activeSessionWriterLockScope);
255
308
  activeSessionWriterLockScope = nextSessionWriterLockScope;
309
+ activeSessionManager = nextSession.sessionManager;
256
310
  });
257
311
  const interactiveMode = new InteractiveMode(runtime, {
258
312
  modelFallbackMessage: shouldSuppressFallbackMessage
@@ -263,8 +317,194 @@ async function main(): Promise<void> {
263
317
  } finally {
264
318
  clearInterval(writerLockHeartbeat);
265
319
  releaseSessionWriterLock(activeSessionWriterLockScope);
320
+ await tuiCoordinator.close();
266
321
  await runtime?.dispose();
267
322
  }
268
323
  }
269
324
 
325
+ async function runFollowerTuiProxy(
326
+ lock: WriterLock,
327
+ sessionManager: ReturnType<typeof continueOpenCandleSession>,
328
+ cwd: string,
329
+ ): Promise<boolean> {
330
+ if (!lock.coordinatorEndpoint || !lock.coordinatorSecret) return false;
331
+ if (!process.stdin.isTTY) return false;
332
+
333
+ const input = createInterface({ input: process.stdin, output: process.stdout });
334
+ const follower = startFollowerTranscriptPrinter(sessionManager, cwd);
335
+ try {
336
+ console.log("Connected to the active OpenCandle session. Type /exit to close.");
337
+ while (true) {
338
+ const prompt = (await input.question("> ")).trim();
339
+ if (!prompt) continue;
340
+ if (prompt === "/exit" || prompt === "/quit") break;
341
+ await forwardTuiPrompt(lock, sessionManager.getSessionId(), prompt);
342
+ follower.markSeen();
343
+ }
344
+ } finally {
345
+ follower.stop();
346
+ input.close();
347
+ }
348
+ return true;
349
+ }
350
+
351
+ function startFollowerTranscriptPrinter(
352
+ sessionManager: ReturnType<typeof continueOpenCandleSession>,
353
+ cwd: string,
354
+ ): { stop: () => void; markSeen: () => void } {
355
+ const sessionFile = sessionManager.getSessionFile();
356
+ if (!sessionFile) return { stop: () => {}, markSeen: () => {} };
357
+ const seenEntryIds = new Set(
358
+ sessionManager
359
+ .getEntries()
360
+ .map((entry) => entryId(entry))
361
+ .filter((id): id is string => Boolean(id)),
362
+ );
363
+ const openFreshSession = () =>
364
+ SessionManager.open(sessionFile, sessionManager.getSessionDir(), cwd);
365
+ const markSeen = () => {
366
+ try {
367
+ for (const entry of openFreshSession().getEntries()) {
368
+ const id = entryId(entry);
369
+ if (id) seenEntryIds.add(id);
370
+ }
371
+ } catch {
372
+ // The owner may be rotating the session file; the next poll will catch up.
373
+ }
374
+ };
375
+ let polling = false;
376
+ const poll = () => {
377
+ if (polling) return;
378
+ polling = true;
379
+ try {
380
+ for (const entry of openFreshSession().getEntries()) {
381
+ const id = entryId(entry);
382
+ if (!id || seenEntryIds.has(id)) continue;
383
+ seenEntryIds.add(id);
384
+ const text = followerEntryText(entry);
385
+ if (text) process.stdout.write(`\n${text}\n`);
386
+ }
387
+ } catch {
388
+ // The owner may be rotating the session file; the next poll will catch up.
389
+ } finally {
390
+ polling = false;
391
+ }
392
+ };
393
+ const interval = setInterval(poll, 1000);
394
+ return { stop: () => clearInterval(interval), markSeen };
395
+ }
396
+
397
+ function entryId(entry: unknown): string | null {
398
+ const record = asRecord(entry);
399
+ return typeof record.id === "string" ? record.id : null;
400
+ }
401
+
402
+ function followerEntryText(entry: unknown): string {
403
+ const record = asRecord(entry);
404
+ if (record.type === "message") {
405
+ const message = asRecord(record.message);
406
+ const role = typeof message.role === "string" ? message.role : "message";
407
+ const text = contentText(message.content);
408
+ return text ? `${role}: ${text}` : "";
409
+ }
410
+ if (record.type === "custom") {
411
+ const text = contentText(record.message ?? record.text ?? record.content);
412
+ return text ? `system: ${text}` : "";
413
+ }
414
+ return "";
415
+ }
416
+
417
+ async function forwardTuiPrompt(
418
+ lock: WriterLock,
419
+ sessionId: string,
420
+ prompt: string,
421
+ ): Promise<void> {
422
+ if (!lock.coordinatorEndpoint || !lock.coordinatorSecret) return;
423
+ const response = await fetch(
424
+ new URL("/api/local-coordinator/chat-run", lock.coordinatorEndpoint),
425
+ {
426
+ method: "POST",
427
+ headers: {
428
+ "content-type": "application/json",
429
+ "x-opencandle-coordinator-secret": lock.coordinatorSecret,
430
+ },
431
+ body: JSON.stringify({
432
+ prompt,
433
+ sessionId,
434
+ actionId: `tui-proxy-${randomUUID()}`,
435
+ }),
436
+ },
437
+ );
438
+ if (!response.ok) {
439
+ const body = (await response.json().catch(() => ({}))) as { error?: string };
440
+ console.error(body.error || response.statusText);
441
+ return;
442
+ }
443
+ await printSseResponse(response);
444
+ }
445
+
446
+ async function printSseResponse(response: Response): Promise<void> {
447
+ const reader = response.body?.getReader();
448
+ if (!reader) return;
449
+ const decoder = new TextDecoder();
450
+ let buffer = "";
451
+ while (true) {
452
+ const { value, done } = await reader.read();
453
+ if (done) break;
454
+ buffer += decoder.decode(value, { stream: true });
455
+ let index = buffer.indexOf("\n\n");
456
+ while (index !== -1) {
457
+ const block = buffer.slice(0, index);
458
+ buffer = buffer.slice(index + 2);
459
+ printSseBlock(block);
460
+ index = buffer.indexOf("\n\n");
461
+ }
462
+ }
463
+ }
464
+
465
+ function printSseBlock(block: string): void {
466
+ const data = block
467
+ .split("\n")
468
+ .filter((line) => line.startsWith("data:"))
469
+ .map((line) => line.slice(5).trim())
470
+ .join("\n");
471
+ if (!data) return;
472
+ const event = JSON.parse(data) as {
473
+ type?: string;
474
+ role?: string;
475
+ text?: string;
476
+ content?: Array<{ type?: string; text?: string }>;
477
+ error?: { message?: string };
478
+ };
479
+ const text = sseEventText(event);
480
+ if (event.type === "message.completed" && text) {
481
+ process.stdout.write(`${text}\n`);
482
+ } else if (event.type === "run.failed") {
483
+ process.stderr.write(`${event.error?.message || "Run failed"}\n`);
484
+ }
485
+ }
486
+
487
+ function sseEventText(event: {
488
+ text?: string;
489
+ content?: Array<{ type?: string; text?: string }>;
490
+ }): string {
491
+ if (event.text) return event.text;
492
+ return contentText(event.content);
493
+ }
494
+
495
+ function contentText(content: unknown): string {
496
+ if (typeof content === "string") return content;
497
+ if (!Array.isArray(content)) return "";
498
+ return content
499
+ .filter((part) => part.type === "text" && part.text)
500
+ .map((part) => part.text)
501
+ .join("");
502
+ }
503
+
504
+ function asRecord(value: unknown): Record<string, unknown> {
505
+ return typeof value === "object" && value !== null && !Array.isArray(value)
506
+ ? (value as Record<string, unknown>)
507
+ : {};
508
+ }
509
+
270
510
  await main();
package/src/config.ts CHANGED
@@ -14,7 +14,7 @@ export interface SentimentConfig {
14
14
  maxNotableClaims?: number;
15
15
  }
16
16
 
17
- export type RouterMode = "rules" | "llm";
17
+ export type RouterMode = "llm";
18
18
  export type ToolScopeMode = "observe" | "enforce";
19
19
  export type PlanningMigrationStatuses = Partial<Record<TaskFamily, PlanningBehaviorMode>>;
20
20
 
@@ -24,13 +24,10 @@ export interface Config {
24
24
  braveApiKey?: string;
25
25
  exaApiKey?: string;
26
26
  finnhubApiKey?: string;
27
- /** Enable adversarial bull/bear debate in comprehensive analysis. Default: true. */
28
- debate?: boolean;
29
27
  /**
30
- * Intent-router mode. `"rules"` (default) uses the deterministic rule
31
- * router (`classifyIntent` + `extractPreferences`). `"llm"` opts into the
32
- * LLM router ahead of prompt assembly. Controlled by
33
- * `OPENCANDLE_ROUTER_MODE`.
28
+ * Intent-router mode. The LLM router is the only production routing path;
29
+ * `OPENCANDLE_ROUTER_MODE` accepts only `"llm"` (or unset). The removed
30
+ * `"rules"` value fails startup with migration guidance.
34
31
  */
35
32
  routerMode: RouterMode;
36
33
  /**
@@ -66,8 +63,6 @@ export interface OpenCandleFileConfig {
66
63
  apiKey?: string;
67
64
  };
68
65
  };
69
- /** Enable adversarial bull/bear debate in comprehensive analysis. Default: true. */
70
- debate?: boolean;
71
66
  sentiment?: {
72
67
  retentionDays?: number;
73
68
  defaultSubreddits?: string[];
@@ -95,7 +90,7 @@ export function loadEnv(path = ".env"): void {
95
90
  if (eqIndex === -1) continue;
96
91
  const key = trimmed.slice(0, eqIndex).trim();
97
92
  const value = trimmed.slice(eqIndex + 1).trim();
98
- if (key && value) {
93
+ if (key && value && process.env[key] === undefined) {
99
94
  process.env[key] = value;
100
95
  }
101
96
  }
@@ -141,11 +136,13 @@ const PLANNING_BEHAVIOR_MODES = [
141
136
 
142
137
  function resolveRouterMode(): RouterMode {
143
138
  const raw = process.env.OPENCANDLE_ROUTER_MODE;
144
- if (raw === undefined || raw === "") return "rules";
145
- if (raw === "rules" || raw === "llm") return raw;
146
- throw new Error(
147
- `Invalid OPENCANDLE_ROUTER_MODE="${raw}". Allowed values: "rules" (default) or "llm".`,
148
- );
139
+ if (raw === undefined || raw === "" || raw === "llm") return "llm";
140
+ if (raw === "rules") {
141
+ throw new Error(
142
+ 'OPENCANDLE_ROUTER_MODE="rules" was removed: the deterministic rules router is no longer a production routing path. Unset OPENCANDLE_ROUTER_MODE to use the LLM router.',
143
+ );
144
+ }
145
+ throw new Error(`Invalid OPENCANDLE_ROUTER_MODE="${raw}". Allowed value: "llm" (default).`);
149
146
  }
150
147
 
151
148
  function resolveToolScopeMode(): ToolScopeMode {
@@ -191,7 +188,6 @@ function isPlanningBehaviorMode(value: string | undefined): value is PlanningBeh
191
188
  }
192
189
 
193
190
  function resolveConfig(fileConfig: OpenCandleFileConfig): Config {
194
- const debateEnv = process.env.OPENCANDLE_DEBATE;
195
191
  const fileSentiment = fileConfig.sentiment;
196
192
  return {
197
193
  alphaVantageApiKey:
@@ -200,10 +196,6 @@ function resolveConfig(fileConfig: OpenCandleFileConfig): Config {
200
196
  braveApiKey: process.env.BRAVE_API_KEY ?? fileConfig.providers?.brave?.apiKey,
201
197
  exaApiKey: process.env.EXA_API_KEY ?? fileConfig.providers?.exa?.apiKey,
202
198
  finnhubApiKey: process.env.FINNHUB_API_KEY ?? fileConfig.providers?.finnhub?.apiKey,
203
- debate:
204
- debateEnv !== undefined
205
- ? debateEnv !== "false" && debateEnv !== "0"
206
- : (fileConfig.debate ?? true),
207
199
  routerMode: resolveRouterMode(),
208
200
  toolScopeMode: resolveToolScopeMode(),
209
201
  planningMigrationStatuses: resolvePlanningMigrationStatuses(),
@@ -17,25 +17,18 @@ export interface DailyWatchlistReport {
17
17
  dataGaps: string[];
18
18
  }
19
19
 
20
- export function getOrCreateDefaultWatchlistReportTemplate(
20
+ // Returns the configured default-watchlist morning template if one exists.
21
+ // Manual report runs must never create a schedule template as a side effect;
22
+ // only the explicit configure flow stores schedule intent.
23
+ export function findDefaultWatchlistReportTemplate(
21
24
  service: MarketStateService,
22
- ): ReportTemplateRecord {
23
- const existing = service
25
+ ): ReportTemplateRecord | undefined {
26
+ return service
24
27
  .listReportTemplates()
25
28
  .find(
26
29
  (template) =>
27
30
  template.reportType === "watchlist_daily" && targetsDefaultWatchlist(template.configJson),
28
31
  );
29
- if (existing) return existing;
30
- return service.createReportTemplate({
31
- name: "Morning watchlist",
32
- reportType: "watchlist_daily",
33
- cadence: "daily",
34
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
35
- localTime: "08:00",
36
- config: { targets: { default_watchlist: true } },
37
- enabled: true,
38
- });
39
32
  }
40
33
 
41
34
  export async function recordDailyWatchlistReportRun(
@@ -129,9 +122,6 @@ export async function generateDailyWatchlistReport(
129
122
  `Recent alerts`,
130
123
  ` ${service.listAlertEvents().length} recorded alert event(s).`,
131
124
  ``,
132
- `Technical snapshot`,
133
- ` Deferred unless quote/history data is available through a later section builder.`,
134
- ``,
135
125
  `Data gaps`,
136
126
  ...dataGapLines,
137
127
  ];