opencandle 0.10.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 (223) hide show
  1. package/README.md +12 -9
  2. package/dist/analysts/orchestrator.d.ts +3 -4
  3. package/dist/analysts/orchestrator.js +34 -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/doctor/report.js +1 -1
  11. package/dist/doctor/report.js.map +1 -1
  12. package/dist/infra/cache.d.ts +6 -0
  13. package/dist/infra/cache.js +16 -6
  14. package/dist/infra/cache.js.map +1 -1
  15. package/dist/infra/freshness.d.ts +21 -0
  16. package/dist/infra/freshness.js +119 -0
  17. package/dist/infra/freshness.js.map +1 -0
  18. package/dist/infra/market-calendar.d.ts +14 -0
  19. package/dist/infra/market-calendar.js +137 -0
  20. package/dist/infra/market-calendar.js.map +1 -0
  21. package/dist/infra/rate-limiter.js +1 -0
  22. package/dist/infra/rate-limiter.js.map +1 -1
  23. package/dist/market-state/daily-report.d.ts +1 -1
  24. package/dist/market-state/daily-report.js +5 -16
  25. package/dist/market-state/daily-report.js.map +1 -1
  26. package/dist/market-state/service.d.ts +0 -35
  27. package/dist/market-state/service.js +0 -63
  28. package/dist/market-state/service.js.map +1 -1
  29. package/dist/market-state/summaries.d.ts +8 -0
  30. package/dist/market-state/summaries.js +70 -0
  31. package/dist/market-state/summaries.js.map +1 -0
  32. package/dist/memory/sqlite.js +20 -19
  33. package/dist/memory/sqlite.js.map +1 -1
  34. package/dist/onboarding/providers.d.ts +25 -1
  35. package/dist/onboarding/providers.js +26 -0
  36. package/dist/onboarding/providers.js.map +1 -1
  37. package/dist/pi/opencandle-extension.js +57 -226
  38. package/dist/pi/opencandle-extension.js.map +1 -1
  39. package/dist/pi/session-action-dedupe.d.ts +6 -0
  40. package/dist/pi/session-action-dedupe.js +126 -0
  41. package/dist/pi/session-action-dedupe.js.map +1 -0
  42. package/dist/pi/session-writer-lock.d.ts +26 -2
  43. package/dist/pi/session-writer-lock.js +230 -18
  44. package/dist/pi/session-writer-lock.js.map +1 -1
  45. package/dist/pi/setup.js +5 -5
  46. package/dist/pi/setup.js.map +1 -1
  47. package/dist/pi/tui-session-coordinator.d.ts +15 -0
  48. package/dist/pi/tui-session-coordinator.js +283 -0
  49. package/dist/pi/tui-session-coordinator.js.map +1 -0
  50. package/dist/prompts/context-builder.js +1 -1
  51. package/dist/prompts/policy-cards.js +1 -1
  52. package/dist/prompts/policy-cards.js.map +1 -1
  53. package/dist/providers/alpha-vantage.js +6 -0
  54. package/dist/providers/alpha-vantage.js.map +1 -1
  55. package/dist/providers/coingecko.js +1 -0
  56. package/dist/providers/coingecko.js.map +1 -1
  57. package/dist/providers/polymarket.d.ts +2 -0
  58. package/dist/providers/polymarket.js +163 -0
  59. package/dist/providers/polymarket.js.map +1 -0
  60. package/dist/providers/wrap-provider.js +5 -3
  61. package/dist/providers/wrap-provider.js.map +1 -1
  62. package/dist/providers/yahoo-finance.d.ts +3 -0
  63. package/dist/providers/yahoo-finance.js +206 -10
  64. package/dist/providers/yahoo-finance.js.map +1 -1
  65. package/dist/routing/classify-intent.js +4 -5
  66. package/dist/routing/classify-intent.js.map +1 -1
  67. package/dist/routing/entity-extractor.js +16 -0
  68. package/dist/routing/entity-extractor.js.map +1 -1
  69. package/dist/routing/route-manifest.js +1 -2
  70. package/dist/routing/route-manifest.js.map +1 -1
  71. package/dist/routing/router-llm-client.js +18 -3
  72. package/dist/routing/router-llm-client.js.map +1 -1
  73. package/dist/routing/router-prompt.js +1 -1
  74. package/dist/routing/router.d.ts +1 -1
  75. package/dist/routing/router.js +309 -9
  76. package/dist/routing/router.js.map +1 -1
  77. package/dist/routing/slot-resolver.d.ts +1 -0
  78. package/dist/routing/slot-resolver.js +1 -1
  79. package/dist/routing/slot-resolver.js.map +1 -1
  80. package/dist/runtime/evidence.d.ts +1 -0
  81. package/dist/runtime/evidence.js.map +1 -1
  82. package/dist/runtime/numeric-claims.d.ts +23 -0
  83. package/dist/runtime/numeric-claims.js +100 -0
  84. package/dist/runtime/numeric-claims.js.map +1 -0
  85. package/dist/runtime/planning-evidence.js +1 -76
  86. package/dist/runtime/planning-evidence.js.map +1 -1
  87. package/dist/runtime/prompt-step.d.ts +10 -1
  88. package/dist/runtime/prompt-step.js +116 -2
  89. package/dist/runtime/prompt-step.js.map +1 -1
  90. package/dist/runtime/session-coordinator.d.ts +11 -1
  91. package/dist/runtime/session-coordinator.js +354 -62
  92. package/dist/runtime/session-coordinator.js.map +1 -1
  93. package/dist/runtime/validation.d.ts +3 -1
  94. package/dist/runtime/workflow-events.d.ts +1 -1
  95. package/dist/runtime/workflow-events.js.map +1 -1
  96. package/dist/runtime/workflow-types.d.ts +3 -0
  97. package/dist/runtime/workflow-types.js.map +1 -1
  98. package/dist/system-prompt.js +1 -1
  99. package/dist/tools/fundamentals/comps.d.ts +1 -0
  100. package/dist/tools/fundamentals/comps.js +20 -9
  101. package/dist/tools/fundamentals/comps.js.map +1 -1
  102. package/dist/tools/fundamentals/dcf.d.ts +1 -1
  103. package/dist/tools/fundamentals/dcf.js +190 -76
  104. package/dist/tools/fundamentals/dcf.js.map +1 -1
  105. package/dist/tools/index.d.ts +1 -1
  106. package/dist/tools/index.js +3 -3
  107. package/dist/tools/index.js.map +1 -1
  108. package/dist/tools/macro/event-probabilities.d.ts +8 -0
  109. package/dist/tools/macro/event-probabilities.js +121 -0
  110. package/dist/tools/macro/event-probabilities.js.map +1 -0
  111. package/dist/tools/market/crypto-price.d.ts +4 -1
  112. package/dist/tools/market/crypto-price.js +10 -1
  113. package/dist/tools/market/crypto-price.js.map +1 -1
  114. package/dist/tools/market/screen-stocks.js +14 -3
  115. package/dist/tools/market/screen-stocks.js.map +1 -1
  116. package/dist/tools/market/stock-quote.d.ts +4 -1
  117. package/dist/tools/market/stock-quote.js +9 -4
  118. package/dist/tools/market/stock-quote.js.map +1 -1
  119. package/dist/tools/options/option-chain.d.ts +4 -1
  120. package/dist/tools/options/option-chain.js +12 -1
  121. package/dist/tools/options/option-chain.js.map +1 -1
  122. package/dist/tools/portfolio/daily-report.js +10 -4
  123. package/dist/tools/portfolio/daily-report.js.map +1 -1
  124. package/dist/tools/portfolio/risk-analysis.js +1 -1
  125. package/dist/tools/portfolio/risk-analysis.js.map +1 -1
  126. package/dist/tools/technical/backtest.d.ts +23 -5
  127. package/dist/tools/technical/backtest.js +131 -94
  128. package/dist/tools/technical/backtest.js.map +1 -1
  129. package/dist/tools/technical/indicators.js +1 -1
  130. package/dist/tools/technical/indicators.js.map +1 -1
  131. package/dist/types/fundamentals.d.ts +1 -0
  132. package/dist/types/index.d.ts +1 -0
  133. package/dist/types/market.d.ts +2 -0
  134. package/dist/types/options.d.ts +1 -0
  135. package/dist/types/prediction-markets.d.ts +13 -0
  136. package/dist/types/prediction-markets.js +2 -0
  137. package/dist/types/prediction-markets.js.map +1 -0
  138. package/gui/server/chat-event-adapter.ts +89 -5
  139. package/gui/server/http-routes.ts +661 -37
  140. package/gui/server/invoke-tool.ts +165 -16
  141. package/gui/server/live-chat-event-adapter.ts +37 -2
  142. package/gui/server/local-session-coordinator.ts +97 -0
  143. package/gui/server/market-state-api.ts +64 -48
  144. package/gui/server/projector.ts +132 -3
  145. package/gui/server/server.ts +51 -6
  146. package/gui/server/session-actions.ts +146 -45
  147. package/gui/server/session-entry-wait.ts +22 -3
  148. package/gui/server/tool-metadata.ts +3 -1
  149. package/gui/server/ws-hub.ts +77 -10
  150. package/gui/shared/chat-events.ts +10 -1
  151. package/gui/shared/event-reducer.ts +1 -0
  152. package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
  153. package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
  154. package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
  155. package/gui/web/dist/index.html +2 -2
  156. package/package.json +13 -15
  157. package/src/analysts/orchestrator.ts +35 -56
  158. package/src/cli-main.ts +253 -13
  159. package/src/config.ts +12 -20
  160. package/src/doctor/report.ts +1 -1
  161. package/src/infra/cache.ts +24 -11
  162. package/src/infra/freshness.ts +165 -0
  163. package/src/infra/market-calendar.ts +193 -0
  164. package/src/infra/rate-limiter.ts +1 -0
  165. package/src/market-state/daily-report.ts +6 -16
  166. package/src/market-state/service.ts +0 -136
  167. package/src/market-state/summaries.ts +75 -0
  168. package/src/memory/sqlite.ts +23 -19
  169. package/src/onboarding/providers.ts +29 -1
  170. package/src/pi/opencandle-extension.ts +67 -270
  171. package/src/pi/session-action-dedupe.ts +155 -0
  172. package/src/pi/session-writer-lock.ts +290 -20
  173. package/src/pi/setup.ts +6 -6
  174. package/src/pi/tui-session-coordinator.ts +351 -0
  175. package/src/prompts/context-builder.ts +1 -1
  176. package/src/prompts/policy-cards.ts +1 -1
  177. package/src/providers/alpha-vantage.ts +6 -0
  178. package/src/providers/coingecko.ts +2 -0
  179. package/src/providers/polymarket.ts +214 -0
  180. package/src/providers/wrap-provider.ts +5 -3
  181. package/src/providers/yahoo-finance.ts +339 -27
  182. package/src/routing/classify-intent.ts +4 -5
  183. package/src/routing/entity-extractor.ts +17 -0
  184. package/src/routing/route-manifest.ts +1 -2
  185. package/src/routing/router-llm-client.ts +31 -19
  186. package/src/routing/router-prompt.ts +1 -1
  187. package/src/routing/router.ts +379 -8
  188. package/src/routing/slot-resolver.ts +1 -1
  189. package/src/runtime/evidence.ts +1 -0
  190. package/src/runtime/numeric-claims.ts +108 -0
  191. package/src/runtime/planning-evidence.ts +6 -91
  192. package/src/runtime/prompt-step.ts +127 -2
  193. package/src/runtime/session-coordinator.ts +464 -74
  194. package/src/runtime/validation.ts +1 -1
  195. package/src/runtime/workflow-events.ts +2 -0
  196. package/src/runtime/workflow-types.ts +3 -0
  197. package/src/system-prompt.ts +1 -1
  198. package/src/tools/AGENTS.md +1 -1
  199. package/src/tools/fundamentals/comps.ts +25 -14
  200. package/src/tools/fundamentals/dcf.ts +217 -87
  201. package/src/tools/index.ts +3 -3
  202. package/src/tools/macro/event-probabilities.ts +141 -0
  203. package/src/tools/market/crypto-price.ts +14 -2
  204. package/src/tools/market/screen-stocks.ts +14 -3
  205. package/src/tools/market/stock-quote.ts +49 -43
  206. package/src/tools/options/option-chain.ts +16 -2
  207. package/src/tools/portfolio/daily-report.ts +10 -4
  208. package/src/tools/portfolio/risk-analysis.ts +1 -1
  209. package/src/tools/technical/backtest.ts +167 -108
  210. package/src/tools/technical/indicators.ts +1 -1
  211. package/src/types/fundamentals.ts +1 -0
  212. package/src/types/index.ts +1 -0
  213. package/src/types/market.ts +2 -0
  214. package/src/types/options.ts +1 -0
  215. package/src/types/prediction-markets.ts +13 -0
  216. package/dist/tools/portfolio/predictions.d.ts +0 -55
  217. package/dist/tools/portfolio/predictions.js +0 -422
  218. package/dist/tools/portfolio/predictions.js.map +0 -1
  219. package/gui/server/chat-run-session.ts +0 -16
  220. package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
  221. package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
  222. package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
  223. package/src/tools/portfolio/predictions.ts +0 -553
@@ -4,14 +4,24 @@ import type { SessionManager } from "@earendil-works/pi-coding-agent";
4
4
  import type { TSchema } from "@sinclair/typebox";
5
5
  import { Value } from "@sinclair/typebox/value";
6
6
  import { getDefaults } from "../../src/memory/tool-defaults.js";
7
+ import {
8
+ clearPendingSessionAction,
9
+ hasAcceptedSessionAction,
10
+ hasPendingSessionAction,
11
+ recordAcceptedSessionAction,
12
+ recordPendingSessionAction,
13
+ } from "../../src/pi/session-action-dedupe.js";
7
14
  import { wrapWithDefaults } from "../../src/runtime/tool-defaults-wrapper.js";
8
15
  import { getAllTools } from "../../src/tools/index.js";
9
16
  import type { AskUserHandler } from "../../src/types/index.js";
17
+ import type { LocalSessionCoordinator } from "./local-session-coordinator.js";
10
18
  import { buildToolInvokeAckMessage } from "./tool-invoke-ack.js";
11
19
  import {
12
20
  acquireWriterLock,
21
+ readWriterLock,
13
22
  refreshWriterLock,
14
23
  releaseWriterLock,
24
+ shouldBlockFailedCoordinatorAction,
15
25
  writerLockScopeForSession,
16
26
  } from "./writer-lock.js";
17
27
 
@@ -30,6 +40,7 @@ export interface ToolInvokeController {
30
40
  toolName: string,
31
41
  args: Record<string, unknown>,
32
42
  sessionId?: string,
43
+ options?: { actionId?: string; allowProxy?: boolean },
33
44
  ): Promise<InvokeToolResult>;
34
45
  handleToolInvokeMessage(client: ToolInvokeClient, data: Record<string, unknown>): Promise<void>;
35
46
  }
@@ -46,6 +57,10 @@ export interface ToolInvokeControllerOptions {
46
57
  resolveSessionManager?: (sessionId: string) => Promise<SessionManager | null>;
47
58
  broadcastSessionSnapshot?: (sessionManager: SessionManager) => void;
48
59
  broadcastSessions?: () => void;
60
+ localSessionCoordinator?: LocalSessionCoordinator;
61
+ localCoordinatorEndpoint?: string;
62
+ localCoordinatorSecret?: string;
63
+ syncWriterLockScope?: () => void;
49
64
  }
50
65
 
51
66
  export function createToolInvokeController({
@@ -60,13 +75,17 @@ export function createToolInvokeController({
60
75
  resolveSessionManager,
61
76
  broadcastSessionSnapshot,
62
77
  broadcastSessions,
78
+ localSessionCoordinator,
79
+ localCoordinatorEndpoint,
80
+ localCoordinatorSecret,
81
+ syncWriterLockScope,
63
82
  }: ToolInvokeControllerOptions): ToolInvokeController {
64
83
  async function handleToolInvoke(
65
84
  toolName: string,
66
85
  args: Record<string, unknown>,
67
86
  sessionId = "",
87
+ options: { actionId?: string; allowProxy?: boolean } = {},
68
88
  ): Promise<InvokeToolResult> {
69
- if (role !== "writer") throw new Error("Read-only follower mode");
70
89
  const tool = getTools().find((candidate) => candidate.name === toolName);
71
90
  if (!tool) throw new Error(`Unknown tool: ${toolName}`);
72
91
 
@@ -77,15 +96,50 @@ export function createToolInvokeController({
77
96
  resolveSessionManager,
78
97
  );
79
98
  const useCurrentSession = sameSessionStorage(currentSessionManager, runSessionManager);
99
+ const allowProxy = options.allowProxy !== false;
100
+ const shouldProxyToolInvoke =
101
+ allowProxy &&
102
+ (role !== "writer" || !useCurrentSession) &&
103
+ canProxyToolInvokeToCoordinator(runSessionManager);
104
+ if (shouldProxyToolInvoke) {
105
+ const proxied = await proxyToolInvokeToCoordinator(
106
+ runSessionManager,
107
+ toolName,
108
+ args,
109
+ options.actionId,
110
+ );
111
+ if (proxied) return proxied;
112
+ if (options.actionId && hasAcceptedSessionAction(runSessionManager, options.actionId)) {
113
+ throw new Error("OpenCandle already accepted this action in the active session.");
114
+ }
115
+ if (options.actionId && hasPendingSessionAction(runSessionManager, options.actionId)) {
116
+ throw new Error("OpenCandle is reconnecting to this session.");
117
+ }
118
+ if (options.actionId && shouldBlockFailedCoordinatorAction(runSessionManager)) {
119
+ throw new Error("OpenCandle is reconnecting to this session.");
120
+ }
121
+ }
122
+ if (options.actionId && hasAcceptedSessionAction(runSessionManager, options.actionId)) {
123
+ throw new Error("OpenCandle already accepted this action in the active session.");
124
+ }
125
+ if (options.actionId && hasPendingSessionAction(runSessionManager, options.actionId)) {
126
+ throw new Error("OpenCandle is reconnecting to this session.");
127
+ }
80
128
  let acquiredLockScope = "";
81
129
  let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
82
- if (!useCurrentSession) {
130
+ const needsWriterLock = !useCurrentSession || role !== "writer";
131
+ // The writer fast path must fail closed like chat runs when this process
132
+ // lost its lock scope migration, or it can double-write to a session
133
+ // another process now owns.
134
+ if (!needsWriterLock) syncWriterLockScope?.();
135
+ if (needsWriterLock) {
83
136
  const lockScope = writerLockScopeForSession(runSessionManager);
84
- const lockResult = await acquireWriterLock(lockScope, "gui");
137
+ const lockResult = await acquireWriterLock(lockScope, "gui", {
138
+ coordinatorEndpoint: localCoordinatorEndpoint,
139
+ coordinatorSecret: localCoordinatorSecret,
140
+ });
85
141
  if (lockResult.role !== "writer") {
86
- throw new Error(
87
- `Session is currently being written by ${lockResult.lock.processKind} (pid ${lockResult.lock.pid}).`,
88
- );
142
+ throw new Error("OpenCandle is reconnecting to this session.");
89
143
  }
90
144
  acquiredLockScope = lockScope;
91
145
  lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
@@ -93,12 +147,27 @@ export function createToolInvokeController({
93
147
 
94
148
  try {
95
149
  const runSessionId = safeSessionId(runSessionManager);
96
- const result = await invokeTool(runSessionManager, tool, args, "ui", {
97
- askUserHandler:
98
- runSessionId && askUserHandlerForSessionId
99
- ? askUserHandlerForSessionId(runSessionId)
100
- : askUserHandler,
101
- });
150
+ recordPendingSessionAction(runSessionManager, options.actionId ?? "");
151
+ let actionAccepted = false;
152
+ const recordAcceptedAction = () => {
153
+ if (actionAccepted) return;
154
+ recordAcceptedSessionAction(runSessionManager, options.actionId ?? "");
155
+ actionAccepted = true;
156
+ };
157
+ let result: InvokeToolResult;
158
+ try {
159
+ result = await invokeTool(runSessionManager, tool, args, "ui", {
160
+ askUserHandler:
161
+ runSessionId && askUserHandlerForSessionId
162
+ ? askUserHandlerForSessionId(runSessionId)
163
+ : askUserHandler,
164
+ onTranscriptStarted: recordAcceptedAction,
165
+ });
166
+ } catch (error) {
167
+ if (!actionAccepted) clearPendingSessionAction(runSessionManager, options.actionId ?? "");
168
+ throw error;
169
+ }
170
+ recordAcceptedAction();
102
171
  if (!result.isError && marketStateToolMapping(toolName) != null) {
103
172
  onMarketStateChanged?.();
104
173
  }
@@ -120,10 +189,29 @@ export function createToolInvokeController({
120
189
  data: Record<string, unknown>,
121
190
  ): Promise<void> {
122
191
  const requestId = typeof data.requestId === "string" ? data.requestId : "";
192
+ const actionId = typeof data.actionId === "string" ? data.actionId : "";
123
193
  const toolName = String(data.toolName ?? "");
124
194
  const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
195
+ const allowProxy = data.allowProxy !== false;
125
196
  try {
126
- const result = await handleToolInvoke(toolName, requestArgs(data.args), sessionId);
197
+ if (!sessionId.trim()) throw new Error("sessionId is required");
198
+ if (!actionId.trim()) throw new Error("actionId is required");
199
+ const invoke = () =>
200
+ handleToolInvoke(toolName, requestArgs(data.args), sessionId, { actionId, allowProxy });
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 }));
213
+ if (!actionResult.ok) throw new Error(actionResult.message);
214
+ const result = actionResult.result;
127
215
  if (requestId) {
128
216
  client.send(buildToolInvokeAckMessage(requestId, toolName, result));
129
217
  }
@@ -146,6 +234,64 @@ export function createToolInvokeController({
146
234
  return { handleToolInvoke, handleToolInvokeMessage };
147
235
  }
148
236
 
237
+ export function canProxyToolInvokeToCoordinator(runSessionManager: SessionManager): boolean {
238
+ let lockScope: string;
239
+ try {
240
+ lockScope = writerLockScopeForSession(runSessionManager);
241
+ } catch {
242
+ return false;
243
+ }
244
+ const lock = readWriterLock(lockScope);
245
+ return Boolean(
246
+ lock?.processKind === "gui" &&
247
+ lock.coordinatorEndpoint &&
248
+ lock.coordinatorSecret &&
249
+ lock.pid !== process.pid,
250
+ );
251
+ }
252
+
253
+ async function proxyToolInvokeToCoordinator(
254
+ runSessionManager: SessionManager,
255
+ toolName: string,
256
+ args: Record<string, unknown>,
257
+ actionId = "",
258
+ ): Promise<InvokeToolResult | null> {
259
+ const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
260
+ if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret) return null;
261
+ const endpoint = new URL("/api/local-coordinator/tool-invoke", lock.coordinatorEndpoint);
262
+ let response: Response;
263
+ try {
264
+ response = await fetch(endpoint, {
265
+ method: "POST",
266
+ headers: {
267
+ "content-type": "application/json",
268
+ "x-opencandle-coordinator-secret": lock.coordinatorSecret,
269
+ },
270
+ body: JSON.stringify({
271
+ sessionId: safeSessionId(runSessionManager),
272
+ actionId,
273
+ toolName,
274
+ args,
275
+ }),
276
+ });
277
+ } catch {
278
+ return null;
279
+ }
280
+ const body = asRecord(await response.json());
281
+ const ackResult = asRecord(body.result);
282
+ if (!response.ok || !ackResult.toolCallId) {
283
+ throw new Error(String(body.error ?? "OpenCandle is reconnecting to this session."));
284
+ }
285
+ return {
286
+ toolCallId: String(ackResult.toolCallId),
287
+ result: {
288
+ content: Array.isArray(ackResult.content) ? ackResult.content : [],
289
+ details: ackResult.details,
290
+ },
291
+ isError: Boolean(ackResult.isError),
292
+ };
293
+ }
294
+
149
295
  function sameSessionStorage(current: SessionManager, target: SessionManager): boolean {
150
296
  if (current === target) return true;
151
297
  const currentFile = safeSessionFile(current);
@@ -192,7 +338,11 @@ export async function invokeToolFromUi(
192
338
  tool: AgentTool<TSchema, unknown>,
193
339
  args: Record<string, unknown>,
194
340
  source: "ui" | "background" = "ui",
195
- options: { askUserHandler?: AskUserHandler; recordTranscript?: boolean } = {},
341
+ options: {
342
+ askUserHandler?: AskUserHandler;
343
+ recordTranscript?: boolean;
344
+ onTranscriptStarted?: () => void;
345
+ } = {},
196
346
  ): Promise<InvokeToolResult> {
197
347
  if (!Value.Check(tool.parameters, args)) {
198
348
  const errors = [...Value.Errors(tool.parameters, args)]
@@ -222,6 +372,7 @@ export async function invokeToolFromUi(
222
372
  const recordTranscript = options.recordTranscript ?? true;
223
373
  if (recordTranscript) {
224
374
  sessionManager.appendMessage(assistant);
375
+ options.onTranscriptStarted?.();
225
376
  }
226
377
 
227
378
  const wrapped = wrapWithDefaults(tool, getDefaults(tool.name));
@@ -299,8 +450,6 @@ function marketStateToolMapping(toolName: string): { domain: string; targetType:
299
450
  return { domain: "watchlist", targetType: "watchlist_item" };
300
451
  case "track_portfolio":
301
452
  return { domain: "portfolio", targetType: "portfolio_lot" };
302
- case "track_prediction":
303
- return { domain: "predictions", targetType: "prediction" };
304
453
  case "manage_alerts":
305
454
  return { domain: "alerts", targetType: "alert_rule" };
306
455
  case "daily_watchlist_report":
@@ -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 {
@@ -0,0 +1,97 @@
1
+ export type SessionActionType =
2
+ | "chat.prompt"
3
+ | "tool.invoke"
4
+ | "ask_user.answer"
5
+ | "ask_user.cancel"
6
+ | "run.cancel";
7
+
8
+ export type SessionActionSource = "gui" | "tui" | "browser";
9
+
10
+ export interface SessionActionEnvelope {
11
+ sessionId: string;
12
+ actionId: string;
13
+ actionType: SessionActionType;
14
+ payload: Record<string, unknown>;
15
+ source: SessionActionSource;
16
+ }
17
+
18
+ export type SessionActionResult<T = unknown> =
19
+ | { ok: true; duplicate: boolean; result: T }
20
+ | { ok: false; code: "session_busy"; message: string };
21
+
22
+ export interface LocalSessionCoordinator {
23
+ runSessionAction<T>(
24
+ action: SessionActionEnvelope,
25
+ handler: (action: SessionActionEnvelope) => Promise<T>,
26
+ ): Promise<SessionActionResult<T>>;
27
+ }
28
+
29
+ export interface LocalSessionCoordinatorOptions {
30
+ dedupeRetentionMs?: number;
31
+ now?: () => number;
32
+ }
33
+
34
+ interface AcceptedAction {
35
+ expiresAt: number;
36
+ result: unknown;
37
+ }
38
+
39
+ const DEFAULT_DEDUPE_RETENTION_MS = 10 * 60 * 1000;
40
+ const BUSY_MESSAGE = "OpenCandle is still working in this session. Try again when it finishes.";
41
+
42
+ export function createLocalSessionCoordinator(
43
+ options: LocalSessionCoordinatorOptions = {},
44
+ ): LocalSessionCoordinator {
45
+ const now = options.now ?? Date.now;
46
+ const dedupeRetentionMs = options.dedupeRetentionMs ?? DEFAULT_DEDUPE_RETENTION_MS;
47
+ const acceptedActions = new Map<string, AcceptedAction>();
48
+ const activeActions = new Map<string, Promise<unknown>>();
49
+ const activeRunSessions = new Set<string>();
50
+
51
+ async function runSessionAction<T>(
52
+ action: SessionActionEnvelope,
53
+ handler: (action: SessionActionEnvelope) => Promise<T>,
54
+ ): Promise<SessionActionResult<T>> {
55
+ pruneExpiredActions();
56
+ const actionKey = `${action.sessionId}:${action.actionId}`;
57
+ const accepted = acceptedActions.get(actionKey);
58
+ if (accepted) return { ok: true, duplicate: true, result: accepted.result as T };
59
+ const active = activeActions.get(actionKey);
60
+ if (active) return { ok: true, duplicate: true, result: (await active) as T };
61
+
62
+ if (isRunAdmissionAction(action) && activeRunSessions.has(action.sessionId)) {
63
+ return { ok: false, code: "session_busy", message: BUSY_MESSAGE };
64
+ }
65
+
66
+ if (isRunAdmissionAction(action)) activeRunSessions.add(action.sessionId);
67
+ const runAction = (async () => {
68
+ const result = await handler(action);
69
+ acceptedActions.set(actionKey, {
70
+ expiresAt: now() + dedupeRetentionMs,
71
+ result,
72
+ });
73
+ return result;
74
+ })();
75
+ activeActions.set(actionKey, runAction);
76
+ try {
77
+ const result = await runAction;
78
+ return { ok: true, duplicate: false, result };
79
+ } finally {
80
+ activeActions.delete(actionKey);
81
+ if (isRunAdmissionAction(action)) activeRunSessions.delete(action.sessionId);
82
+ }
83
+ }
84
+
85
+ function pruneExpiredActions(): void {
86
+ const currentTime = now();
87
+ for (const [actionKey, accepted] of acceptedActions) {
88
+ if (accepted.expiresAt <= currentTime) acceptedActions.delete(actionKey);
89
+ }
90
+ }
91
+
92
+ return { runSessionAction };
93
+ }
94
+
95
+ function isRunAdmissionAction(action: SessionActionEnvelope): boolean {
96
+ return action.actionType === "chat.prompt" || action.actionType === "tool.invoke";
97
+ }
@@ -1,4 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
+ import { buildFreshnessStamp } from "../../src/infra/freshness.js";
2
3
  import { isZeroFilledQuote, searchYahooInstruments } from "../../src/market-state/resolve.js";
3
4
  import { MarketStateService } from "../../src/market-state/service.js";
4
5
  import { initDefaultDatabase } from "../../src/memory/sqlite.js";
@@ -9,7 +10,6 @@ export interface MarketStateSnapshot {
9
10
  instruments: Array<NonNullable<ReturnType<MarketStateService["getInstrument"]>>>;
10
11
  watchlist: ReturnType<MarketStateService["listWatchlistItems"]>;
11
12
  portfolio: ReturnType<MarketStateService["listPortfolioLots"]>;
12
- predictions: ReturnType<MarketStateService["listPredictions"]>;
13
13
  alerts: ReturnType<MarketStateService["listAlertRules"]>;
14
14
  alertEvents: ReturnType<MarketStateService["listAlertEvents"]>;
15
15
  alertCheckRuns: ReturnType<MarketStateService["listAlertCheckRuns"]>;
@@ -38,12 +38,12 @@ export interface MarketStateQuoteSnapshot {
38
38
  instrumentId: number;
39
39
  symbol: string;
40
40
  status: "ok" | "unavailable";
41
- currentPrice?: number;
41
+ currentPrice?: number | null;
42
42
  changePercent?: number;
43
- marketValue?: number;
43
+ marketValue?: number | null;
44
44
  totalCost: number;
45
- pnl?: number;
46
- pnlPercent?: number;
45
+ pnl?: number | null;
46
+ pnlPercent?: number | null;
47
47
  allocationPercent?: number;
48
48
  currency: string;
49
49
  includedInTotals: boolean;
@@ -51,17 +51,6 @@ export interface MarketStateQuoteSnapshot {
51
51
  stale?: boolean;
52
52
  reason?: string;
53
53
  }>;
54
- predictionQuotes: Array<{
55
- predictionId: number;
56
- instrumentId: number;
57
- symbol: string;
58
- status: "ok" | "unavailable";
59
- currentPrice?: number;
60
- changePercent?: number;
61
- fetchedAt?: string;
62
- stale?: boolean;
63
- reason?: string;
64
- }>;
65
54
  portfolioSummary: {
66
55
  baseCurrency: string;
67
56
  totalValue: number;
@@ -72,6 +61,30 @@ export interface MarketStateQuoteSnapshot {
72
61
  };
73
62
  }
74
63
 
64
+ interface SavedSymbolsMemoOptions {
65
+ ttlMs?: number;
66
+ now?: () => number;
67
+ }
68
+
69
+ export function createSavedSymbolsMemo(
70
+ load: () => string[],
71
+ options: SavedSymbolsMemoOptions = {},
72
+ ): () => string[] {
73
+ const ttlMs = options.ttlMs ?? 30_000;
74
+ const now = options.now ?? Date.now;
75
+ let cachedAt = 0;
76
+ let cached: string[] | null = null;
77
+ return () => {
78
+ const current = now();
79
+ if (cached && current - cachedAt < ttlMs) return cached;
80
+ cached = load();
81
+ cachedAt = current;
82
+ return cached;
83
+ };
84
+ }
85
+
86
+ export const getSavedMarketStateSymbols = createSavedSymbolsMemo(loadSavedMarketStateSymbols);
87
+
75
88
  export function buildMarketStateSnapshot(db?: Database.Database): MarketStateSnapshot {
76
89
  const ownedDb = db ?? initDefaultDatabase();
77
90
  const service = new MarketStateService(ownedDb);
@@ -86,7 +99,6 @@ export function buildMarketStateSnapshot(db?: Database.Database): MarketStateSna
86
99
  .filter((instrument) => instrument != null),
87
100
  watchlist: service.listWatchlistItems(),
88
101
  portfolio: service.listPortfolioLots(),
89
- predictions: service.listPredictions(),
90
102
  alerts,
91
103
  alertEvents: service.listAlertEvents(),
92
104
  alertCheckRuns: service.listAlertCheckRuns(),
@@ -101,6 +113,30 @@ export function buildMarketStateSnapshot(db?: Database.Database): MarketStateSna
101
113
  }
102
114
  }
103
115
 
116
+ function loadSavedMarketStateSymbols(): string[] {
117
+ const db = initDefaultDatabase();
118
+ const service = new MarketStateService(db);
119
+ try {
120
+ const symbols = [
121
+ ...service.listWatchlistItems().map((item) => item.symbol),
122
+ ...service.listPortfolioLots().map((lot) => lot.symbol),
123
+ ];
124
+ return normalizeSymbols(symbols);
125
+ } finally {
126
+ db.close();
127
+ }
128
+ }
129
+
130
+ function normalizeSymbols(symbols: string[]): string[] {
131
+ const normalized: string[] = [];
132
+ for (const symbol of symbols) {
133
+ const next = symbol.trim().toUpperCase();
134
+ if (!next || normalized.includes(next)) continue;
135
+ normalized.push(next);
136
+ }
137
+ return normalized;
138
+ }
139
+
104
140
  export async function buildMarketStateQuoteSnapshot(
105
141
  db?: Database.Database,
106
142
  ): Promise<MarketStateQuoteSnapshot> {
@@ -109,13 +145,8 @@ export async function buildMarketStateQuoteSnapshot(
109
145
  try {
110
146
  const watchlist = service.listWatchlistItems();
111
147
  const portfolio = service.listPortfolioLots();
112
- const predictions = service.listPredictions();
113
148
  const symbols = [
114
- ...new Set([
115
- ...watchlist.map((item) => item.symbol),
116
- ...portfolio.map((lot) => lot.symbol),
117
- ...predictions.map((prediction) => prediction.symbol),
118
- ]),
149
+ ...new Set([...watchlist.map((item) => item.symbol), ...portfolio.map((lot) => lot.symbol)]),
119
150
  ];
120
151
  const quoteMap = new Map<string, Awaited<ReturnType<typeof fetchQuoteSnapshot>>>();
121
152
  for (const symbol of symbols) {
@@ -224,34 +255,10 @@ export async function buildMarketStateQuoteSnapshot(
224
255
  currency: quote.currency,
225
256
  reason: quote.reason ?? "quote unavailable",
226
257
  }));
227
- const predictionQuotes = predictions.map((prediction) => {
228
- const quote = quoteMap.get(prediction.symbol);
229
- if (quote == null || quote.status === "unavailable") {
230
- return {
231
- predictionId: prediction.id,
232
- instrumentId: prediction.instrumentId,
233
- symbol: prediction.symbol,
234
- status: "unavailable" as const,
235
- reason: quote?.reason ?? "quote unavailable",
236
- };
237
- }
238
- return {
239
- predictionId: prediction.id,
240
- instrumentId: prediction.instrumentId,
241
- symbol: prediction.symbol,
242
- status: "ok" as const,
243
- currentPrice: quote.price,
244
- changePercent: quote.changePercent,
245
- fetchedAt: quote.fetchedAt,
246
- stale: quote.stale,
247
- };
248
- });
249
-
250
258
  return {
251
259
  generatedAt,
252
260
  watchlistQuotes,
253
261
  portfolioQuotes: portfolioQuotesWithAllocation,
254
- predictionQuotes,
255
262
  portfolioSummary: {
256
263
  baseCurrency,
257
264
  totalValue,
@@ -304,11 +311,20 @@ async function fetchQuoteSnapshot(symbol: string): Promise<
304
311
  if (isZeroFilledQuote(result.data)) {
305
312
  return { status: "unavailable", reason: "Yahoo returned no valid market data." };
306
313
  }
314
+ const freshness = buildFreshnessStamp({
315
+ asOf: result.data.asOf,
316
+ cached: result.cached,
317
+ stale: result.stale,
318
+ cachedAt: result.timestamp,
319
+ });
320
+ if (freshness.isStaleForSession) {
321
+ return { status: "unavailable", reason: "provider returned stale market data" };
322
+ }
307
323
  return {
308
324
  status: "ok",
309
325
  price: result.data.price,
310
326
  changePercent: result.data.changePercent,
311
- fetchedAt: result.timestamp,
327
+ fetchedAt: freshness.providerDataAt ?? result.timestamp,
312
328
  stale: result.stale,
313
329
  currency: result.data.currency ?? null,
314
330
  };