opencandle 0.11.0 → 0.11.1

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 (162) hide show
  1. package/README.md +5 -4
  2. package/dist/analysts/orchestrator.d.ts +2 -0
  3. package/dist/analysts/orchestrator.js +19 -0
  4. package/dist/analysts/orchestrator.js.map +1 -1
  5. package/dist/doctor/report.js +1 -1
  6. package/dist/doctor/report.js.map +1 -1
  7. package/dist/infra/cache.d.ts +6 -0
  8. package/dist/infra/cache.js +16 -6
  9. package/dist/infra/cache.js.map +1 -1
  10. package/dist/infra/freshness.d.ts +21 -0
  11. package/dist/infra/freshness.js +119 -0
  12. package/dist/infra/freshness.js.map +1 -0
  13. package/dist/infra/market-calendar.d.ts +14 -0
  14. package/dist/infra/market-calendar.js +137 -0
  15. package/dist/infra/market-calendar.js.map +1 -0
  16. package/dist/infra/rate-limiter.js +1 -0
  17. package/dist/infra/rate-limiter.js.map +1 -1
  18. package/dist/market-state/summaries.d.ts +8 -0
  19. package/dist/market-state/summaries.js +70 -0
  20. package/dist/market-state/summaries.js.map +1 -0
  21. package/dist/onboarding/providers.d.ts +25 -1
  22. package/dist/onboarding/providers.js +26 -0
  23. package/dist/onboarding/providers.js.map +1 -1
  24. package/dist/pi/opencandle-extension.js +44 -5
  25. package/dist/pi/opencandle-extension.js.map +1 -1
  26. package/dist/providers/alpha-vantage.js +6 -0
  27. package/dist/providers/alpha-vantage.js.map +1 -1
  28. package/dist/providers/coingecko.js +1 -0
  29. package/dist/providers/coingecko.js.map +1 -1
  30. package/dist/providers/polymarket.d.ts +2 -0
  31. package/dist/providers/polymarket.js +163 -0
  32. package/dist/providers/polymarket.js.map +1 -0
  33. package/dist/providers/wrap-provider.js +5 -3
  34. package/dist/providers/wrap-provider.js.map +1 -1
  35. package/dist/providers/yahoo-finance.d.ts +3 -0
  36. package/dist/providers/yahoo-finance.js +206 -10
  37. package/dist/providers/yahoo-finance.js.map +1 -1
  38. package/dist/routing/entity-extractor.js +16 -0
  39. package/dist/routing/entity-extractor.js.map +1 -1
  40. package/dist/routing/route-manifest.js +1 -1
  41. package/dist/routing/route-manifest.js.map +1 -1
  42. package/dist/routing/router-llm-client.js +18 -3
  43. package/dist/routing/router-llm-client.js.map +1 -1
  44. package/dist/routing/router.d.ts +1 -1
  45. package/dist/routing/router.js +274 -5
  46. package/dist/routing/router.js.map +1 -1
  47. package/dist/routing/slot-resolver.d.ts +1 -0
  48. package/dist/routing/slot-resolver.js +1 -1
  49. package/dist/routing/slot-resolver.js.map +1 -1
  50. package/dist/runtime/evidence.d.ts +1 -0
  51. package/dist/runtime/evidence.js.map +1 -1
  52. package/dist/runtime/numeric-claims.d.ts +23 -0
  53. package/dist/runtime/numeric-claims.js +100 -0
  54. package/dist/runtime/numeric-claims.js.map +1 -0
  55. package/dist/runtime/planning-evidence.js +1 -76
  56. package/dist/runtime/planning-evidence.js.map +1 -1
  57. package/dist/runtime/prompt-step.d.ts +10 -1
  58. package/dist/runtime/prompt-step.js +116 -2
  59. package/dist/runtime/prompt-step.js.map +1 -1
  60. package/dist/runtime/session-coordinator.d.ts +11 -1
  61. package/dist/runtime/session-coordinator.js +352 -49
  62. package/dist/runtime/session-coordinator.js.map +1 -1
  63. package/dist/runtime/validation.d.ts +3 -1
  64. package/dist/runtime/workflow-events.d.ts +1 -1
  65. package/dist/runtime/workflow-events.js.map +1 -1
  66. package/dist/runtime/workflow-types.d.ts +3 -0
  67. package/dist/runtime/workflow-types.js.map +1 -1
  68. package/dist/tools/fundamentals/comps.d.ts +1 -0
  69. package/dist/tools/fundamentals/comps.js +20 -9
  70. package/dist/tools/fundamentals/comps.js.map +1 -1
  71. package/dist/tools/fundamentals/dcf.js +183 -112
  72. package/dist/tools/fundamentals/dcf.js.map +1 -1
  73. package/dist/tools/index.d.ts +1 -0
  74. package/dist/tools/index.js +3 -0
  75. package/dist/tools/index.js.map +1 -1
  76. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  77. package/dist/tools/macro/event-probabilities.js +121 -0
  78. package/dist/tools/macro/event-probabilities.js.map +1 -0
  79. package/dist/tools/market/crypto-price.d.ts +4 -1
  80. package/dist/tools/market/crypto-price.js +10 -1
  81. package/dist/tools/market/crypto-price.js.map +1 -1
  82. package/dist/tools/market/screen-stocks.js +14 -3
  83. package/dist/tools/market/screen-stocks.js.map +1 -1
  84. package/dist/tools/market/stock-quote.d.ts +4 -1
  85. package/dist/tools/market/stock-quote.js +9 -4
  86. package/dist/tools/market/stock-quote.js.map +1 -1
  87. package/dist/tools/options/option-chain.d.ts +4 -1
  88. package/dist/tools/options/option-chain.js +12 -1
  89. package/dist/tools/options/option-chain.js.map +1 -1
  90. package/dist/tools/portfolio/risk-analysis.js +1 -1
  91. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  92. package/dist/tools/technical/indicators.js +1 -1
  93. package/dist/tools/technical/indicators.js.map +1 -1
  94. package/dist/types/fundamentals.d.ts +1 -0
  95. package/dist/types/index.d.ts +1 -0
  96. package/dist/types/market.d.ts +2 -0
  97. package/dist/types/options.d.ts +1 -0
  98. package/dist/types/prediction-markets.d.ts +13 -0
  99. package/dist/types/prediction-markets.js +2 -0
  100. package/dist/types/prediction-markets.js.map +1 -0
  101. package/gui/server/chat-event-adapter.ts +89 -5
  102. package/gui/server/http-routes.ts +285 -37
  103. package/gui/server/invoke-tool.ts +14 -13
  104. package/gui/server/live-chat-event-adapter.ts +37 -2
  105. package/gui/server/market-state-api.ts +63 -5
  106. package/gui/server/projector.ts +132 -3
  107. package/gui/server/session-actions.ts +32 -40
  108. package/gui/server/session-entry-wait.ts +22 -3
  109. package/gui/server/ws-hub.ts +16 -4
  110. package/gui/shared/chat-events.ts +10 -1
  111. package/gui/shared/event-reducer.ts +1 -0
  112. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  113. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  114. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  115. package/gui/web/dist/index.html +2 -2
  116. package/package.json +8 -13
  117. package/src/analysts/orchestrator.ts +20 -0
  118. package/src/doctor/report.ts +1 -1
  119. package/src/infra/cache.ts +24 -11
  120. package/src/infra/freshness.ts +165 -0
  121. package/src/infra/market-calendar.ts +193 -0
  122. package/src/infra/rate-limiter.ts +1 -0
  123. package/src/market-state/summaries.ts +75 -0
  124. package/src/onboarding/providers.ts +29 -1
  125. package/src/pi/opencandle-extension.ts +55 -5
  126. package/src/providers/alpha-vantage.ts +6 -0
  127. package/src/providers/coingecko.ts +2 -0
  128. package/src/providers/polymarket.ts +214 -0
  129. package/src/providers/wrap-provider.ts +5 -3
  130. package/src/providers/yahoo-finance.ts +339 -27
  131. package/src/routing/entity-extractor.ts +17 -0
  132. package/src/routing/route-manifest.ts +1 -1
  133. package/src/routing/router-llm-client.ts +31 -19
  134. package/src/routing/router.ts +340 -5
  135. package/src/routing/slot-resolver.ts +1 -1
  136. package/src/runtime/evidence.ts +1 -0
  137. package/src/runtime/numeric-claims.ts +108 -0
  138. package/src/runtime/planning-evidence.ts +6 -91
  139. package/src/runtime/prompt-step.ts +127 -2
  140. package/src/runtime/session-coordinator.ts +462 -57
  141. package/src/runtime/validation.ts +1 -1
  142. package/src/runtime/workflow-events.ts +2 -0
  143. package/src/runtime/workflow-types.ts +3 -0
  144. package/src/tools/fundamentals/comps.ts +25 -14
  145. package/src/tools/fundamentals/dcf.ts +204 -124
  146. package/src/tools/index.ts +3 -0
  147. package/src/tools/macro/event-probabilities.ts +141 -0
  148. package/src/tools/market/crypto-price.ts +14 -2
  149. package/src/tools/market/screen-stocks.ts +14 -3
  150. package/src/tools/market/stock-quote.ts +49 -43
  151. package/src/tools/options/option-chain.ts +16 -2
  152. package/src/tools/portfolio/risk-analysis.ts +1 -1
  153. package/src/tools/technical/indicators.ts +1 -1
  154. package/src/types/fundamentals.ts +1 -0
  155. package/src/types/index.ts +1 -0
  156. package/src/types/market.ts +2 -0
  157. package/src/types/options.ts +1 -0
  158. package/src/types/prediction-markets.ts +13 -0
  159. package/gui/server/chat-run-session.ts +0 -16
  160. package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +0 -1
  161. package/gui/web/dist/assets/index-BzyqyVnd.css +0 -2
  162. package/gui/web/dist/assets/index-DvMjkOP9.js +0 -65
@@ -3,6 +3,13 @@ import type { IncomingMessage, ServerResponse } from "node:http";
3
3
  import { extname, join, resolve } from "node:path";
4
4
  import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
5
5
  import { buildDoctorReport } from "../../src/doctor/report.js";
6
+ import { MarketStateService } from "../../src/market-state/service.js";
7
+ import {
8
+ formatLatestReportSummary,
9
+ formatPortfolioSummary,
10
+ formatWatchlistSummary,
11
+ } from "../../src/market-state/summaries.js";
12
+ import { initDefaultDatabase } from "../../src/memory/sqlite.js";
6
13
  import { probeProviderStatus } from "../../src/onboarding/provider-status.js";
7
14
  import {
8
15
  clearPendingSessionAction,
@@ -13,7 +20,6 @@ import {
13
20
  } from "../../src/pi/session-action-dedupe.js";
14
21
  import type { ChatEvent } from "../shared/chat-events.js";
15
22
  import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
16
- import { chatRunSessionConflict } from "./chat-run-session.js";
17
23
  import type { ToolInvokeController } from "./invoke-tool.js";
18
24
  import { createLiveChatEventAdapter } from "./live-chat-event-adapter.js";
19
25
  import type {
@@ -21,7 +27,11 @@ import type {
21
27
  SessionActionEnvelope,
22
28
  SessionActionResult,
23
29
  } from "./local-session-coordinator.js";
24
- import { buildMarketStateSnapshot, searchInstrumentCandidates } from "./market-state-api.js";
30
+ import {
31
+ buildMarketStateSnapshot,
32
+ getSavedMarketStateSymbols,
33
+ searchInstrumentCandidates,
34
+ } from "./market-state-api.js";
25
35
  import { buildModelSetupState, type ModelSetupController } from "./model-setup.js";
26
36
  import { isTrustedPrivateApiRequest, privateApiCookieHeader } from "./private-api-access.js";
27
37
  import { projectDashboard } from "./projector.js";
@@ -32,6 +42,7 @@ import { waitForNewEntryId } from "./session-entry-wait.js";
32
42
  import { buildCatalog } from "./tool-metadata.js";
33
43
  import {
34
44
  acquireWriterLock,
45
+ isCoordinatorOwnerAlive,
35
46
  readWriterLock,
36
47
  refreshWriterLock,
37
48
  releaseWriterLock,
@@ -64,6 +75,28 @@ interface GuiHttpRouteOptions {
64
75
  localSessionCoordinator?: LocalSessionCoordinator;
65
76
  }
66
77
 
78
+ export interface ChatRunImageInput {
79
+ data: string;
80
+ mimeType: string;
81
+ }
82
+
83
+ export type ChatRunAttachmentInput =
84
+ | { kind: "portfolio"; id?: string }
85
+ | { kind: "watchlist"; id: string }
86
+ | { kind: "report"; id: string };
87
+
88
+ export interface ParsedChatRunBody {
89
+ prompt: string;
90
+ images: ChatRunImageInput[];
91
+ attachments: ChatRunAttachmentInput[];
92
+ }
93
+
94
+ type ChatRunParseResult = { ok: true; value: ParsedChatRunBody } | { ok: false; error: string };
95
+
96
+ const CHAT_RUN_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
97
+ const CHAT_RUN_MAX_IMAGE_COUNT = 4;
98
+ const CHAT_RUN_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
99
+
67
100
  export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
68
101
  const activeRunSessionIds = new Set<string>();
69
102
 
@@ -234,28 +267,35 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
234
267
 
235
268
  if (url.pathname === "/api/chat/run" && req.method === "POST") {
236
269
  if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
237
- await handleSseChatRun(req, res, options, activeRunSessionIds);
270
+ writeJson(
271
+ res,
272
+ {
273
+ error: "Legacy active-session chat runs are no longer supported.",
274
+ code: "legacy_route_removed",
275
+ },
276
+ 410,
277
+ );
238
278
  return;
239
279
  }
240
280
 
241
281
  if (url.pathname === "/api/local-coordinator/chat-run" && req.method === "POST") {
242
282
  if (!allowLocalCoordinatorRequest(req, res, options)) return;
243
283
  const body = asRecord(await readJsonBody(req));
244
- const requestedSessionId = String(body.sessionId ?? "").trim();
245
- const sessionManager = requestedSessionId
246
- ? await resolveSessionManagerById(options, requestedSessionId)
247
- : undefined;
248
- if (requestedSessionId && !sessionManager) {
284
+ if (!requireSessionActionFields(res, body)) return;
285
+ const sessionManager = await resolveSessionManagerById(options, String(body.sessionId));
286
+ if (!sessionManager) {
249
287
  writeJson(res, { error: "Unknown saved session" }, 404);
250
288
  return;
251
289
  }
252
- await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body);
290
+ // allowProxy: false this endpoint is the proxy target; re-proxying loops.
291
+ await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body, false);
253
292
  return;
254
293
  }
255
294
 
256
295
  if (url.pathname === "/api/local-coordinator/tool-invoke" && req.method === "POST") {
257
296
  if (!allowLocalCoordinatorRequest(req, res, options)) return;
258
297
  const body = asRecord(await readJsonBody(req));
298
+ if (!requireSessionActionFields(res, body)) return;
259
299
  let ack: unknown;
260
300
  await options.toolInvokeController.handleToolInvokeMessage(
261
301
  { send: (message) => (ack = message) },
@@ -285,6 +325,7 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
285
325
  if (url.pathname === "/api/local-coordinator/ask-user" && req.method === "POST") {
286
326
  if (!allowLocalCoordinatorRequest(req, res, options)) return;
287
327
  const body = asRecord(await readJsonBody(req));
328
+ if (!requireSessionActionFields(res, body)) return;
288
329
  const payload = asRecord(body.payload);
289
330
  try {
290
331
  const action = {
@@ -319,12 +360,16 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
319
360
  const runSessionId = sessionIdFromRoute(url.pathname, "runs");
320
361
  if (runSessionId && req.method === "POST") {
321
362
  if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
363
+ const body = asRecord(await readJsonBody(req));
364
+ if (!requireSessionActionFields(res, { ...body, sessionId: runSessionId }, false)) return;
322
365
  const sessionManager = await resolveSessionManagerById(options, runSessionId);
323
366
  if (!sessionManager) {
324
367
  writeJson(res, { error: "Unknown saved session" }, 404);
325
368
  return;
326
369
  }
327
- await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager);
370
+ // allowProxy: true forward to a live coordinator owned by another
371
+ // process (0.11.0 authenticated local forwarding).
372
+ await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body, true);
328
373
  return;
329
374
  }
330
375
 
@@ -337,28 +382,26 @@ async function handleSseChatRun(
337
382
  res: ServerResponse,
338
383
  options: GuiHttpRouteOptions,
339
384
  activeRunSessionIds: Set<string>,
340
- targetSessionManager?: SessionManager,
341
- bodyOverride?: Record<string, unknown>,
385
+ targetSessionManager: SessionManager | undefined,
386
+ bodyOverride: Record<string, unknown> | undefined,
387
+ // Explicit because both remaining callers pass a body: the browser-facing
388
+ // runs route must forward to a live coordinator owned by another process,
389
+ // while the local-coordinator endpoint IS the proxy target and re-proxying
390
+ // there would loop.
391
+ allowProxy: boolean,
342
392
  ): Promise<void> {
343
393
  const body = bodyOverride ?? asRecord(await readJsonBody(req));
344
- const prompt = String(asRecord(body).prompt ?? "").trim();
345
- if (!prompt) {
346
- writeJson(res, { error: "prompt is required" }, 400);
394
+ const parsed = parseChatRunBody(body);
395
+ if (!parsed.ok) {
396
+ writeJson(res, { error: parsed.error }, 400);
347
397
  return;
348
398
  }
349
-
350
399
  const currentSessionManager = options.getSessionManager();
351
400
  const bodyRecord = asRecord(body);
352
401
  const requestedSessionId = String(bodyRecord.sessionId ?? "").trim();
353
402
  const runSessionManager = targetSessionManager ?? currentSessionManager;
354
403
  const sessionId = runSessionManager.getSessionId();
355
- if (!targetSessionManager) {
356
- const sessionConflict = chatRunSessionConflict(requestedSessionId, sessionId);
357
- if (sessionConflict) {
358
- writeJson(res, sessionConflict, 409);
359
- return;
360
- }
361
- } else if (requestedSessionId && requestedSessionId !== sessionId) {
404
+ if (requestedSessionId && requestedSessionId !== sessionId) {
362
405
  writeJson(
363
406
  res,
364
407
  { error: "Session route and request body disagree", code: "session_changed" },
@@ -367,9 +410,8 @@ async function handleSseChatRun(
367
410
  return;
368
411
  }
369
412
 
370
- const proxyAllowed = bodyOverride === undefined;
371
413
  const actionId = String(bodyRecord.actionId ?? "").trim();
372
- const shouldProxyChatRun = proxyAllowed && canProxyChatRunToCoordinator(runSessionManager);
414
+ const shouldProxyChatRun = allowProxy && canProxyChatRunToCoordinator(runSessionManager);
373
415
  if (shouldProxyChatRun) {
374
416
  if (await proxyChatRunToCoordinator(res, runSessionManager, bodyRecord)) {
375
417
  const useCurrentSession =
@@ -421,7 +463,7 @@ async function handleSseChatRun(
421
463
  currentSessionManager,
422
464
  runSessionManager,
423
465
  sessionId,
424
- prompt,
466
+ parsedRun: parsed.value,
425
467
  actionId: action.actionId,
426
468
  });
427
469
  if (!admitted) throw new SessionActionNotAdmitted();
@@ -449,14 +491,14 @@ async function handleSseChatRun(
449
491
  currentSessionManager,
450
492
  runSessionManager,
451
493
  sessionId,
452
- prompt,
494
+ parsedRun: parsed.value,
453
495
  actionId,
454
496
  });
455
497
  }
456
498
 
457
499
  export function canProxyChatRunToCoordinator(runSessionManager: SessionManager): boolean {
458
500
  const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
459
- return Boolean(lock?.coordinatorEndpoint && lock.coordinatorSecret && lock.pid !== process.pid);
501
+ return hasLiveCoordinatorLock(lock);
460
502
  }
461
503
 
462
504
  async function proxyChatRunToCoordinator(
@@ -465,7 +507,7 @@ async function proxyChatRunToCoordinator(
465
507
  body: Record<string, unknown>,
466
508
  ): Promise<boolean> {
467
509
  const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
468
- if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret) return false;
510
+ if (!hasLiveCoordinatorLock(lock)) return false;
469
511
  const endpoint = new URL("/api/local-coordinator/chat-run", lock.coordinatorEndpoint);
470
512
  let response: Response;
471
513
  try {
@@ -490,6 +532,21 @@ async function proxyChatRunToCoordinator(
490
532
  return true;
491
533
  }
492
534
 
535
+ function hasLiveCoordinatorLock(
536
+ lock: ReturnType<typeof readWriterLock>,
537
+ staleGraceMs = 15_000,
538
+ ): lock is NonNullable<ReturnType<typeof readWriterLock>> & {
539
+ coordinatorEndpoint: string;
540
+ coordinatorSecret: string;
541
+ } {
542
+ if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret || lock.pid === process.pid) {
543
+ return false;
544
+ }
545
+ const heartbeat = Date.parse(lock.lastHeartbeat);
546
+ const hasFreshHeartbeat = Number.isFinite(heartbeat) && Date.now() - heartbeat <= staleGraceMs;
547
+ return hasFreshHeartbeat && isCoordinatorOwnerAlive(lock.pid);
548
+ }
549
+
493
550
  async function streamAcceptedSseChatRun({
494
551
  res,
495
552
  options,
@@ -498,7 +555,7 @@ async function streamAcceptedSseChatRun({
498
555
  currentSessionManager,
499
556
  runSessionManager,
500
557
  sessionId,
501
- prompt,
558
+ parsedRun,
502
559
  actionId,
503
560
  }: {
504
561
  res: ServerResponse;
@@ -508,13 +565,37 @@ async function streamAcceptedSseChatRun({
508
565
  currentSessionManager: SessionManager;
509
566
  runSessionManager: SessionManager;
510
567
  sessionId: string;
511
- prompt: string;
568
+ parsedRun: ParsedChatRunBody;
512
569
  actionId: string;
513
570
  }): Promise<boolean> {
571
+ const prompt = parsedRun.prompt;
514
572
  if (activeRunSessionIds.has(sessionId)) {
515
573
  writeJson(res, { error: "Session already has an active run", code: "session_busy" }, 409);
516
574
  return false;
517
575
  }
576
+ let dispatchedPrompt: string;
577
+ try {
578
+ dispatchedPrompt = await buildDispatchedPrompt(parsedRun);
579
+ } catch (error) {
580
+ writeJson(res, { error: error instanceof Error ? error.message : String(error) }, 400);
581
+ return false;
582
+ }
583
+ const promptImages = parsedRun.images.map((image) => ({
584
+ type: "image" as const,
585
+ data: image.data,
586
+ mimeType: image.mimeType,
587
+ }));
588
+ const attachmentLabels = parsedRun.attachments.map((attachment) => ({
589
+ kind: attachment.kind,
590
+ label: attachmentLabel(attachment),
591
+ }));
592
+ const inputAttachmentLabels = [
593
+ ...promptImages.map((image, index) => ({
594
+ kind: "image",
595
+ label: `${image.mimeType || "image"} #${index + 1}`,
596
+ })),
597
+ ...attachmentLabels,
598
+ ];
518
599
 
519
600
  const currentSessionFile = currentSessionManager.getSessionFile();
520
601
  const targetSessionFile = runSessionManager.getSessionFile();
@@ -579,22 +660,34 @@ async function streamAcceptedSseChatRun({
579
660
  startSeq: seq,
580
661
  emit: (event) => writeSse(res, event),
581
662
  originalPrompt: prompt,
663
+ originalAttachments: inputAttachmentLabels,
582
664
  });
583
665
  const observation = createPromptObservation();
666
+ let originalInputMarkerAppended = false;
667
+ const appendOriginalInputMarker = () => {
668
+ if (inputAttachmentLabels.length === 0 || originalInputMarkerAppended) return;
669
+ runSessionManager.appendCustomEntry("opencandle-user-input", {
670
+ original: prompt,
671
+ attachments: inputAttachmentLabels,
672
+ });
673
+ originalInputMarkerAppended = true;
674
+ };
584
675
  let actionAccepted = false;
585
676
  const recordAcceptedAction = () => {
586
677
  if (actionAccepted) return;
678
+ appendOriginalInputMarker();
587
679
  recordAcceptedSessionAction(runSessionManager, actionId);
588
680
  if (useCurrentSession) options.syncCurrentWriterLockScope?.();
589
681
  actionAccepted = true;
590
682
  };
683
+
591
684
  const unsubscribeLive = runSession.subscribe((event) => {
592
685
  liveAdapter.handle(event);
593
686
  observePromptEvent(observation, event);
594
687
  if (
595
688
  !prompt.startsWith("/") &&
596
689
  !actionAccepted &&
597
- observation.userTexts.some((text) => text.trim() === prompt.trim())
690
+ observation.userTexts.some((text) => text.trim() === dispatchedPrompt.trim())
598
691
  ) {
599
692
  recordAcceptedAction();
600
693
  }
@@ -616,7 +709,13 @@ async function streamAcceptedSseChatRun({
616
709
  });
617
710
  await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
618
711
  } else {
619
- await promptAndSettle(runSession, prompt, beforeIds, observation);
712
+ await promptAndSettle(
713
+ runSession,
714
+ dispatchedPrompt,
715
+ beforeIds,
716
+ observation,
717
+ promptImages.length > 0 ? { images: promptImages } : undefined,
718
+ );
620
719
  recordAcceptedAction();
621
720
  await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
622
721
  }
@@ -709,7 +808,7 @@ function buildSnapshotPayload(sessionManager: SessionManager): Record<string, un
709
808
  const entries = sessionManager.getEntries();
710
809
  return {
711
810
  sessionId,
712
- state: projectDashboard(entries, sessionId),
811
+ state: projectDashboard(entries, sessionId, getSavedMarketStateSymbols()),
713
812
  entries,
714
813
  events: sessionEntriesToChatEvents(entries, {
715
814
  sessionId,
@@ -742,7 +841,7 @@ export async function buildSessionBootstrapPayload(
742
841
  sessions: await SessionManager.list(options.cwd, options.sessionDir),
743
842
  snapshot: {
744
843
  sessionId,
745
- state: projectDashboard(entries, sessionId),
844
+ state: projectDashboard(entries, sessionId, getSavedMarketStateSymbols()),
746
845
  entries,
747
846
  events: sessionEntriesToChatEvents(entries, {
748
847
  sessionId,
@@ -796,20 +895,169 @@ export function buildChatRunActionEnvelope(
796
895
  body: Record<string, unknown>,
797
896
  sessionId: string,
798
897
  ): SessionActionEnvelope {
799
- const actionId = String(body.actionId ?? "").trim() || `legacy-chat-${Date.now()}`;
898
+ const actionId = String(body.actionId ?? "").trim();
899
+ const parsed = parseChatRunBody(body);
900
+ const attachments = parsed.ok ? parsed.value.attachments : [];
901
+ const images = parsed.ok ? parsed.value.images : [];
800
902
  return {
801
903
  sessionId,
802
904
  actionId,
803
905
  actionType: "chat.prompt",
804
- payload: { prompt: String(body.prompt ?? "") },
906
+ payload: {
907
+ prompt: String(body.prompt ?? ""),
908
+ ...(images.length > 0 ? { imageCount: images.length } : {}),
909
+ ...(attachments.length > 0
910
+ ? { attachments: attachments.map((attachment) => ({ kind: attachment.kind })) }
911
+ : {}),
912
+ },
805
913
  source: "browser",
806
914
  };
807
915
  }
808
916
 
917
+ export function parseChatRunBody(body: unknown): ChatRunParseResult {
918
+ const record = asRecord(body);
919
+ const prompt = String(record.prompt ?? "").trim();
920
+ if (!prompt) return { ok: false, error: "prompt is required" };
921
+
922
+ const rawImages = record.images;
923
+ const images: ChatRunImageInput[] = [];
924
+ if (rawImages !== undefined) {
925
+ if (!Array.isArray(rawImages)) return { ok: false, error: "images must be an array" };
926
+ if (rawImages.length > CHAT_RUN_MAX_IMAGE_COUNT) {
927
+ return { ok: false, error: "Attach up to 4 images" };
928
+ }
929
+ for (const rawImage of rawImages) {
930
+ const image = asRecord(rawImage);
931
+ const mimeType = String(image.mimeType ?? "");
932
+ const data = String(image.data ?? "");
933
+ if (!CHAT_RUN_IMAGE_MIME_TYPES.has(mimeType)) {
934
+ return { ok: false, error: "Unsupported image mime type" };
935
+ }
936
+ if (data.length > Math.ceil(CHAT_RUN_MAX_IMAGE_BYTES / 3) * 4) {
937
+ return { ok: false, error: "Image attachment must be 5 MB or smaller" };
938
+ }
939
+ const decoded = decodeStrictBase64(data);
940
+ if (!decoded) {
941
+ return { ok: false, error: "Image attachment data must be valid base64" };
942
+ }
943
+ if (decoded.byteLength > CHAT_RUN_MAX_IMAGE_BYTES) {
944
+ return { ok: false, error: "Image attachment must be 5 MB or smaller" };
945
+ }
946
+ images.push({ data, mimeType });
947
+ }
948
+ }
949
+
950
+ const rawAttachments = record.attachments;
951
+ const attachments: ChatRunAttachmentInput[] = [];
952
+ if (rawAttachments !== undefined) {
953
+ if (!Array.isArray(rawAttachments)) {
954
+ return { ok: false, error: "attachments must be an array" };
955
+ }
956
+ for (const rawAttachment of rawAttachments) {
957
+ const attachment = asRecord(rawAttachment);
958
+ const kind = String(attachment.kind ?? "");
959
+ const id = typeof attachment.id === "string" ? attachment.id.trim() : "";
960
+ if (kind === "portfolio") {
961
+ attachments.push(id ? { kind, id } : { kind });
962
+ } else if (kind === "watchlist" || kind === "report") {
963
+ if (!id) return { ok: false, error: `${kind} attachment id is required` };
964
+ attachments.push({ kind, id });
965
+ } else {
966
+ return { ok: false, error: "Unsupported attachment kind" };
967
+ }
968
+ }
969
+ }
970
+
971
+ if (prompt.startsWith("/") && (images.length > 0 || attachments.length > 0)) {
972
+ return { ok: false, error: "Attachments are not supported for slash commands" };
973
+ }
974
+
975
+ return { ok: true, value: { prompt, images, attachments } };
976
+ }
977
+
978
+ function decodeStrictBase64(data: string): Buffer | null {
979
+ if (!data || data.trim() !== data) return null;
980
+ if (data.length % 4 !== 0) return null;
981
+ if (/[^A-Za-z0-9+/=]/.test(data)) return null;
982
+ const firstPadding = data.indexOf("=");
983
+ if (firstPadding !== -1) {
984
+ const padding = data.slice(firstPadding);
985
+ if (!/^={1,2}$/.test(padding)) return null;
986
+ }
987
+ const decoded = Buffer.from(data, "base64");
988
+ if (decoded.byteLength === 0) return null;
989
+ return decoded.toString("base64") === data ? decoded : null;
990
+ }
991
+
992
+ export async function buildDispatchedPrompt(parsed: ParsedChatRunBody): Promise<string> {
993
+ if (parsed.prompt.startsWith("/")) return parsed.prompt;
994
+ if (parsed.attachments.length === 0) return parsed.prompt;
995
+ const db = initDefaultDatabase();
996
+ try {
997
+ const service = new MarketStateService(db);
998
+ const blocks = parsed.attachments.map((attachment) => {
999
+ const lines = attachmentSummaryLines(service, attachment);
1000
+ if (lines.length === 0) throw new Error(`${attachment.kind} attachment was empty`);
1001
+ return `[Attached by user — ${attachment.kind}]\n${lines.join("\n")}`;
1002
+ });
1003
+ return [parsed.prompt, ...blocks].join("\n\n");
1004
+ } finally {
1005
+ db.close();
1006
+ }
1007
+ }
1008
+
1009
+ function attachmentSummaryLines(
1010
+ service: MarketStateService,
1011
+ attachment: ChatRunAttachmentInput,
1012
+ ): string[] {
1013
+ if (attachment.kind === "portfolio") {
1014
+ const portfolioId = attachment.id ? Number(attachment.id) : undefined;
1015
+ if (attachment.id && !Number.isFinite(portfolioId)) {
1016
+ throw new Error("Unknown portfolio attachment");
1017
+ }
1018
+ return formatPortfolioSummary(service.listPortfolioLots(portfolioId));
1019
+ }
1020
+ if (attachment.kind === "watchlist") {
1021
+ const watchlistId =
1022
+ attachment.id === "default" ? service.getDefaultWatchlist().id : Number(attachment.id);
1023
+ if (!Number.isFinite(watchlistId)) throw new Error("Unknown watchlist attachment");
1024
+ return formatWatchlistSummary(service.listWatchlistItems(watchlistId));
1025
+ }
1026
+ const reports = service.listReportRuns();
1027
+ const report =
1028
+ attachment.id === "latest"
1029
+ ? reports[0]
1030
+ : reports.find((candidate) => candidate.id === Number(attachment.id));
1031
+ if (!report) throw new Error("Unknown report attachment");
1032
+ return formatLatestReportSummary(report);
1033
+ }
1034
+
1035
+ function attachmentLabel(attachment: ChatRunAttachmentInput): string {
1036
+ if (attachment.kind === "portfolio") return "Portfolio";
1037
+ if (attachment.kind === "watchlist") return "Watchlist";
1038
+ return "Latest report";
1039
+ }
1040
+
809
1041
  function hasClientActionId(body: Record<string, unknown>): boolean {
810
1042
  return typeof body.actionId === "string" && body.actionId.trim().length > 0;
811
1043
  }
812
1044
 
1045
+ function requireSessionActionFields(
1046
+ res: ServerResponse,
1047
+ body: Record<string, unknown>,
1048
+ requireSessionId = true,
1049
+ ): boolean {
1050
+ if (requireSessionId && !String(body.sessionId ?? "").trim()) {
1051
+ writeJson(res, { error: "sessionId is required" }, 400);
1052
+ return false;
1053
+ }
1054
+ if (!String(body.actionId ?? "").trim()) {
1055
+ writeJson(res, { error: "actionId is required" }, 400);
1056
+ return false;
1057
+ }
1058
+ return true;
1059
+ }
1060
+
813
1061
  function shouldBlockFailedCoordinatorAction(
814
1062
  runSessionManager: SessionManager,
815
1063
  body: Record<string, unknown>,
@@ -194,21 +194,22 @@ export function createToolInvokeController({
194
194
  const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
195
195
  const allowProxy = data.allowProxy !== false;
196
196
  try {
197
+ if (!sessionId.trim()) throw new Error("sessionId is required");
198
+ if (!actionId.trim()) throw new Error("actionId is required");
197
199
  const invoke = () =>
198
200
  handleToolInvoke(toolName, requestArgs(data.args), sessionId, { actionId, allowProxy });
199
- const actionResult =
200
- localSessionCoordinator && actionId
201
- ? await localSessionCoordinator.runSessionAction(
202
- {
203
- sessionId: sessionId || safeSessionId(getSessionManager()),
204
- actionId,
205
- actionType: "tool.invoke",
206
- payload: { toolName, args: requestArgs(data.args) },
207
- source: "browser",
208
- },
209
- invoke,
210
- )
211
- : await invoke().then((result) => ({ ok: true as const, duplicate: false, result }));
201
+ const actionResult = localSessionCoordinator
202
+ ? await localSessionCoordinator.runSessionAction(
203
+ {
204
+ sessionId,
205
+ actionId,
206
+ actionType: "tool.invoke",
207
+ payload: { toolName, args: requestArgs(data.args) },
208
+ source: "browser",
209
+ },
210
+ invoke,
211
+ )
212
+ : await invoke().then((result) => ({ ok: true as const, duplicate: false, result }));
212
213
  if (!actionResult.ok) throw new Error(actionResult.message);
213
214
  const result = actionResult.result;
214
215
  if (requestId) {
@@ -1,6 +1,11 @@
1
1
  import type { AssistantMessage, Message } from "@earendil-works/pi-ai";
2
2
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
3
- import type { ChatEvent, MessageContent, ToolOutput } from "../shared/chat-events.js";
3
+ import type {
4
+ ChatEvent,
5
+ MessageAttachmentChip,
6
+ MessageContent,
7
+ ToolOutput,
8
+ } from "../shared/chat-events.js";
4
9
 
5
10
  export interface LiveChatEventAdapterOptions {
6
11
  runId: string;
@@ -12,6 +17,7 @@ export interface LiveChatEventAdapterOptions {
12
17
  * message with an expanded prompt; the live view renders this instead.
13
18
  */
14
19
  originalPrompt?: string;
20
+ originalAttachments?: MessageAttachmentChip[];
15
21
  }
16
22
 
17
23
  export interface LiveChatEventAdapter {
@@ -77,7 +83,12 @@ export function createLiveChatEventAdapter(
77
83
  type: "message.completed",
78
84
  runId: options.runId,
79
85
  messageId,
80
- content: [{ type: "text", text }],
86
+ content: userMessageContent(message.content, text),
87
+ ...(userCount === 1 &&
88
+ options.originalAttachments &&
89
+ options.originalAttachments.length > 0
90
+ ? { attachments: options.originalAttachments }
91
+ : {}),
81
92
  });
82
93
  return;
83
94
  }
@@ -192,6 +203,30 @@ function messageText(content: unknown): string {
192
203
  .join("");
193
204
  }
194
205
 
206
+ function userMessageContent(content: unknown, text: string): MessageContent[] {
207
+ const parts: MessageContent[] = [{ type: "text", text }];
208
+ if (!Array.isArray(content)) return parts;
209
+ for (const part of content) {
210
+ const record = asRecord(part);
211
+ if (record.type !== "image") continue;
212
+ const alt = typeof record.alt === "string" ? record.alt : undefined;
213
+ if (typeof record.url === "string") {
214
+ parts.push({ type: "image", url: record.url, alt });
215
+ continue;
216
+ }
217
+ if (typeof record.data === "string" && typeof record.mimeType === "string") {
218
+ parts.push({
219
+ type: "image",
220
+ url: `data:${record.mimeType};base64,${record.data}`,
221
+ data: record.data,
222
+ mimeType: record.mimeType,
223
+ alt,
224
+ });
225
+ }
226
+ }
227
+ return parts;
228
+ }
229
+
195
230
  function toolOutput(result: unknown, isError: boolean): ToolOutput {
196
231
  const record = asRecord(result);
197
232
  return {