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
@@ -0,0 +1,351 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import type { AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ clearPendingSessionAction,
7
+ hasAcceptedSessionAction,
8
+ hasPendingSessionAction,
9
+ recordAcceptedSessionAction,
10
+ recordPendingSessionAction,
11
+ } from "./session-action-dedupe.js";
12
+
13
+ interface TuiSessionCoordinatorOptions {
14
+ getSession: () => AgentSession;
15
+ getSessionManager: () => SessionManager;
16
+ getModelUnavailableMessage: () => string | null;
17
+ syncWriterLockScope?: () => void;
18
+ now?: () => number;
19
+ }
20
+
21
+ export interface TuiSessionCoordinatorServer {
22
+ endpoint: string;
23
+ secret: string;
24
+ close(): Promise<void>;
25
+ }
26
+
27
+ interface AcceptedAction {
28
+ expiresAt: number;
29
+ }
30
+
31
+ const DEDUPE_RETENTION_MS = 10 * 60 * 1000;
32
+
33
+ export async function startTuiSessionCoordinatorServer(
34
+ options: TuiSessionCoordinatorOptions,
35
+ ): Promise<TuiSessionCoordinatorServer> {
36
+ const now = options.now ?? Date.now;
37
+ const secret = randomBytes(32).toString("base64url");
38
+ const acceptedActions = new Map<string, AcceptedAction>();
39
+ const activeRunSessions = new Set<string>();
40
+
41
+ const server = createServer((req, res) => {
42
+ void handleRequest(req, res).catch((error) => {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ writeJson(res, { error: message }, 500);
45
+ });
46
+ });
47
+
48
+ await new Promise<void>((resolve, reject) => {
49
+ server.once("error", reject);
50
+ server.listen(0, "127.0.0.1", () => {
51
+ server.off("error", reject);
52
+ resolve();
53
+ });
54
+ });
55
+
56
+ const address = server.address() as AddressInfo;
57
+ const endpoint = `http://127.0.0.1:${address.port}`;
58
+
59
+ return {
60
+ endpoint,
61
+ secret,
62
+ close: () =>
63
+ new Promise<void>((resolve, reject) => {
64
+ server.close((error) => (error ? reject(error) : resolve()));
65
+ }),
66
+ };
67
+
68
+ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
69
+ const url = new URL(req.url ?? "/", endpoint);
70
+ if (url.pathname !== "/api/local-coordinator/chat-run" || req.method !== "POST") {
71
+ writeJson(res, { error: "Not found" }, 404);
72
+ return;
73
+ }
74
+
75
+ if (
76
+ !isLoopbackAddress(req.socket.remoteAddress) ||
77
+ req.headers["x-opencandle-coordinator-secret"] !== secret
78
+ ) {
79
+ writeJson(res, { error: "Local coordinator request was not authorized." }, 403);
80
+ return;
81
+ }
82
+
83
+ const body = asRecord(await readJsonBody(req));
84
+ const prompt = String(body.prompt ?? "").trim();
85
+ const requestedSessionId = String(body.sessionId ?? "").trim();
86
+ const actionId = String(body.actionId ?? "").trim() || `legacy-tui-chat-${now()}`;
87
+ const sessionManager = options.getSessionManager();
88
+ const sessionId = sessionManager.getSessionId();
89
+ if (!prompt) {
90
+ writeJson(res, { error: "prompt is required" }, 400);
91
+ return;
92
+ }
93
+ if (requestedSessionId && requestedSessionId !== sessionId) {
94
+ writeJson(
95
+ res,
96
+ { error: "Session route and request body disagree", code: "session_changed" },
97
+ 409,
98
+ );
99
+ return;
100
+ }
101
+ try {
102
+ options.syncWriterLockScope?.();
103
+ } catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ writeJson(res, { error: message, code: "syncing" }, 409);
106
+ return;
107
+ }
108
+
109
+ pruneExpiredActions();
110
+ const actionKey = `${sessionId}:${actionId}`;
111
+ if (acceptedActions.has(actionKey) || hasAcceptedSessionAction(sessionManager, actionId)) {
112
+ writeJson(res, { ok: true, duplicate: true });
113
+ return;
114
+ }
115
+ if (hasPendingSessionAction(sessionManager, actionId)) {
116
+ writeJson(
117
+ res,
118
+ { error: "OpenCandle is reconnecting to this session.", code: "syncing" },
119
+ 409,
120
+ );
121
+ return;
122
+ }
123
+ let session: AgentSession;
124
+ try {
125
+ session = options.getSession();
126
+ } catch (error) {
127
+ const message = error instanceof Error ? error.message : String(error);
128
+ writeJson(res, { error: message, code: "session_starting" }, 409);
129
+ return;
130
+ }
131
+ if (
132
+ activeRunSessions.has(sessionId) ||
133
+ session.isStreaming ||
134
+ session.pendingMessageCount > 0
135
+ ) {
136
+ writeJson(
137
+ res,
138
+ {
139
+ error: "OpenCandle is still working in this session. Try again when it finishes.",
140
+ code: "session_busy",
141
+ },
142
+ 409,
143
+ );
144
+ return;
145
+ }
146
+
147
+ activeRunSessions.add(sessionId);
148
+ try {
149
+ recordPendingSessionAction(sessionManager, actionId);
150
+ let actionAccepted = false;
151
+ const recordAcceptedAction = () => {
152
+ if (actionAccepted) return;
153
+ recordAcceptedSessionAction(sessionManager, actionId);
154
+ options.syncWriterLockScope?.();
155
+ acceptedActions.set(actionKey, { expiresAt: now() + DEDUPE_RETENTION_MS });
156
+ actionAccepted = true;
157
+ };
158
+ let completed: boolean;
159
+ try {
160
+ completed = await streamPromptRun(res, prompt, sessionId, recordAcceptedAction);
161
+ } catch (error) {
162
+ if (!actionAccepted) clearPendingSessionAction(sessionManager, actionId);
163
+ throw error;
164
+ }
165
+ if (completed) {
166
+ recordAcceptedAction();
167
+ } else if (!actionAccepted) {
168
+ clearPendingSessionAction(sessionManager, actionId);
169
+ }
170
+ } finally {
171
+ activeRunSessions.delete(sessionId);
172
+ }
173
+ }
174
+
175
+ async function streamPromptRun(
176
+ res: ServerResponse,
177
+ prompt: string,
178
+ sessionId: string,
179
+ onPromptAdmitted: () => void,
180
+ ): Promise<boolean> {
181
+ options.syncWriterLockScope?.();
182
+ const session = options.getSession();
183
+ const sessionManager = options.getSessionManager();
184
+ const beforeEntries = sessionManager.getEntries();
185
+ const beforeCount = beforeEntries.length;
186
+ let seq = 1;
187
+ const runId = `tui-run-${now()}`;
188
+
189
+ res.writeHead(200, {
190
+ "content-type": "text/event-stream; charset=utf-8",
191
+ "cache-control": "no-cache, no-transform",
192
+ connection: "keep-alive",
193
+ });
194
+ writeSse(res, { type: "run.started", runId, sessionId, seq: seq++ });
195
+ res.flushHeaders?.();
196
+
197
+ try {
198
+ const modelUnavailableMessage = options.getModelUnavailableMessage();
199
+ if (!prompt.startsWith("/") && modelUnavailableMessage) {
200
+ sessionManager.appendMessage({ role: "user", content: prompt, timestamp: now() });
201
+ onPromptAdmitted();
202
+ options.syncWriterLockScope?.();
203
+ sessionManager.appendCustomMessageEntry(
204
+ "opencandle-model-setup",
205
+ modelUnavailableMessage,
206
+ true,
207
+ {
208
+ source: "tui",
209
+ },
210
+ );
211
+ } else {
212
+ const unsubscribe = subscribeToPromptAdmission(session, prompt, onPromptAdmitted);
213
+ try {
214
+ await session.prompt(prompt);
215
+ options.syncWriterLockScope?.();
216
+ await waitForTurnSettlement(session);
217
+ } finally {
218
+ unsubscribe?.();
219
+ }
220
+ }
221
+ const newEntries = sessionManager.getEntries().slice(beforeCount);
222
+ for (const event of entriesToChatEvents(newEntries, sessionId, seq)) {
223
+ writeSse(res, event);
224
+ seq = event.seq + 1;
225
+ }
226
+ writeSse(res, { type: "run.completed", runId, sessionId, seq });
227
+ return true;
228
+ } catch (error) {
229
+ const message = error instanceof Error ? error.message : String(error);
230
+ writeSse(res, { type: "run.failed", runId, sessionId, error: { message }, seq });
231
+ return false;
232
+ } finally {
233
+ res.end();
234
+ }
235
+ }
236
+
237
+ function pruneExpiredActions(): void {
238
+ const currentTime = now();
239
+ for (const [actionKey, accepted] of acceptedActions) {
240
+ if (accepted.expiresAt <= currentTime) acceptedActions.delete(actionKey);
241
+ }
242
+ }
243
+
244
+ function subscribeToPromptAdmission(
245
+ session: AgentSession,
246
+ prompt: string,
247
+ onPromptAdmitted: () => void,
248
+ ): (() => void) | undefined {
249
+ if (prompt.startsWith("/")) return undefined;
250
+ const maybeSession = session as AgentSession & {
251
+ subscribe?: (handler: (event: unknown) => void) => () => void;
252
+ };
253
+ if (typeof maybeSession.subscribe !== "function") return undefined;
254
+ return maybeSession.subscribe((event) => {
255
+ const record = asRecord(event);
256
+ if (record.type !== "message_start") return;
257
+ const message = asRecord(record.message);
258
+ if (message.role !== "user") return;
259
+ if (messageTextFromContent(message.content).trim() === prompt.trim()) onPromptAdmitted();
260
+ });
261
+ }
262
+ }
263
+
264
+ async function waitForTurnSettlement(session: AgentSession): Promise<void> {
265
+ const startedAt = Date.now();
266
+ while (session.isStreaming || session.pendingMessageCount > 0) {
267
+ if (Date.now() - startedAt > 120_000) {
268
+ throw new Error("Timed out waiting for session turn to settle");
269
+ }
270
+ await new Promise((resolve) => setTimeout(resolve, 50));
271
+ }
272
+ }
273
+
274
+ function entriesToChatEvents(
275
+ entries: unknown[],
276
+ sessionId: string,
277
+ startSeq: number,
278
+ ): Array<Record<string, unknown> & { seq: number }> {
279
+ let seq = startSeq;
280
+ const events: Array<Record<string, unknown> & { seq: number }> = [];
281
+ for (const entry of entries) {
282
+ const record = asRecord(entry);
283
+ const message = asRecord(record.message);
284
+ const source = Object.keys(message).length > 0 ? message : record;
285
+ const role =
286
+ typeof source.role === "string"
287
+ ? source.role
288
+ : record.type === "custom_message"
289
+ ? "assistant"
290
+ : undefined;
291
+ const content = normalizeContent(source.content);
292
+ if (!role || content.length === 0) continue;
293
+ events.push({
294
+ type: "message.completed",
295
+ sessionId,
296
+ messageId: String(record.id ?? `tui-entry-${seq}`),
297
+ role,
298
+ content,
299
+ seq: seq++,
300
+ });
301
+ }
302
+ return events;
303
+ }
304
+
305
+ function normalizeContent(content: unknown): Array<Record<string, unknown>> {
306
+ if (typeof content === "string") return [{ type: "text", text: content }];
307
+ if (Array.isArray(content)) return content.map(asRecord).filter((item) => item.type);
308
+ const record = asRecord(content);
309
+ if (typeof record.text === "string") return [{ type: "text", text: record.text }];
310
+ return [];
311
+ }
312
+
313
+ function messageTextFromContent(content: unknown): string {
314
+ if (typeof content === "string") return content;
315
+ if (!Array.isArray(content)) return "";
316
+ return content
317
+ .map((part) =>
318
+ typeof part === "object" && part !== null && "text" in part && typeof part.text === "string"
319
+ ? part.text
320
+ : "",
321
+ )
322
+ .join("");
323
+ }
324
+
325
+ function writeJson(res: ServerResponse, value: unknown, status = 200): void {
326
+ res.writeHead(status, { "content-type": "application/json" });
327
+ res.end(JSON.stringify(value));
328
+ }
329
+
330
+ function writeSse(res: ServerResponse, event: unknown): void {
331
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
332
+ }
333
+
334
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
335
+ const chunks: Buffer[] = [];
336
+ for await (const chunk of req) {
337
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
338
+ }
339
+ if (chunks.length === 0) return {};
340
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
341
+ }
342
+
343
+ function isLoopbackAddress(address: string | undefined): boolean {
344
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
345
+ }
346
+
347
+ function asRecord(value: unknown): Record<string, unknown> {
348
+ return typeof value === "object" && value !== null && !Array.isArray(value)
349
+ ? (value as Record<string, unknown>)
350
+ : {};
351
+ }
@@ -291,7 +291,7 @@ const TOOL_CATALOG = `## Available Tools
291
291
  - **Sentiment**: get_reddit_sentiment, get_twitter_sentiment, get_web_sentiment, get_sentiment_trend, get_sentiment_summary — retail and news sentiment from Reddit, Twitter/X, and web sources with historical trends and cross-source divergence detection
292
292
  - **Web Search**: search_web — breaking news, earnings context, company events, regulatory developments. Supported freshness values are hours, day, week, and month; use category general with freshness month for broad industry context; never pass unsupported values such as all, year, 3mo, quarter, or custom date ranges. When a dedicated tool can answer the question (quotes, fundamentals, earnings, macro, SEC filings, sentiment), use that tool instead — do not add search_web as a supplementary source for data available through dedicated tools
293
293
  - **Options**: get_option_chain — full options chain with strikes, bids/asks, volume, OI, IV, and computed Greeks (delta, gamma, theta, vega, rho)
294
- - **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, analyze_holdings_overlap, track_prediction, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, correlation matrix, ETF/fund holdings overlap, and prediction tracking with accuracy scoring
294
+ - **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, analyze_holdings_overlap, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, correlation matrix, and ETF/fund holdings overlap
295
295
  - **User Interaction**: ask_user — ask the user a clarification question when their request is ambiguous or missing key details`;
296
296
 
297
297
  function buildToolCatalog(addonDescriptions?: string[]): string {
@@ -141,7 +141,7 @@ For strategy backtest prompts, use backtest_strategy evidence before judging whe
141
141
  status: "implemented",
142
142
  capabilityGapIds: [],
143
143
  content: `## Stateful Tracking Update Policy
144
- For watchlist, portfolio tracking, alert management, daily-report, prediction recording, and prediction-check prompts, use the state tool that owns the change or lookup: manage_watchlist, track_prediction, track_portfolio, manage_alerts, or daily_watchlist_report. Do not confirm a saved change from prose alone. Confirm the persisted state update with the symbol, action, direction, entry price, target, stop, conviction, timeframe, or check result that the tool accepted. If the user asks to create an alert and check it now in the same request, call manage_alerts with the matching create action and check_after_create=true. If required fields for a state mutation are missing, ask the smallest clarification question instead of inventing values. For check or list operations, summarize the saved state and say clearly when no records exist. Do not turn a state update into a buy/sell recommendation unless the user separately asks for market analysis.`,
144
+ For watchlist, portfolio tracking, alert management, and daily-report prompts, use the state tool that owns the change or lookup: manage_watchlist, track_portfolio, manage_alerts, or daily_watchlist_report. Do not confirm a saved change from prose alone. Confirm the persisted state update with the symbol, action, quantity, cost, target, stop, or check result that the tool accepted. If the user asks to create an alert and check it now in the same request, call manage_alerts with the matching create action and check_after_create=true. If required fields for a state mutation are missing, ask the smallest clarification question instead of inventing values. For check or list operations, summarize the saved state and say clearly when no records exist. Do not turn a state update into a buy/sell recommendation unless the user separately asks for market analysis.`,
145
145
  },
146
146
  retail_finance_tradeoff: {
147
147
  id: "retail_finance_tradeoff",
@@ -234,6 +234,7 @@ export async function getGlobalQuote(symbol: string, apiKey: string): Promise<St
234
234
  week52High: 0, // Not available from GLOBAL_QUOTE
235
235
  week52Low: 0, // Not available from GLOBAL_QUOTE
236
236
  timestamp: Date.now(),
237
+ asOf: dateOnlyValue(gq["07. latest trading day"]),
237
238
  };
238
239
 
239
240
  cache.set(cacheKey, result, TTL.QUOTE);
@@ -246,6 +247,11 @@ export async function getGlobalQuote(symbol: string, apiKey: string): Promise<St
246
247
  }
247
248
  }
248
249
 
250
+ function dateOnlyValue(value: string | undefined): string | undefined {
251
+ if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
252
+ return value;
253
+ }
254
+
249
255
  export async function getDailyHistory(
250
256
  symbol: string,
251
257
  apiKey: string,
@@ -19,6 +19,7 @@ interface CoinGeckoDetailResponse {
19
19
  low_24h: { usd: number };
20
20
  ath: { usd: number };
21
21
  ath_date: { usd: string };
22
+ last_updated?: string;
22
23
  circulating_supply: number;
23
24
  total_supply: number | null;
24
25
  };
@@ -52,6 +53,7 @@ export async function getCryptoPrice(id: string): Promise<CryptoPrice> {
52
53
  circulatingSupply: md.circulating_supply,
53
54
  totalSupply: md.total_supply,
54
55
  timestamp: Date.now(),
56
+ asOf: md.last_updated,
55
57
  };
56
58
 
57
59
  cache.set(cacheKey, result, TTL.QUOTE);
@@ -0,0 +1,214 @@
1
+ import { cache, STALE_LIMIT, TTL } from "../infra/cache.js";
2
+ import { httpGet } from "../infra/http-client.js";
3
+ import { rateLimiter } from "../infra/rate-limiter.js";
4
+ import type { PredictionMarketQuote } from "../types/prediction-markets.js";
5
+
6
+ const BASE_URL = "https://gamma-api.polymarket.com";
7
+ const POLYMARKET_URL = "https://polymarket.com";
8
+
9
+ interface GammaSearchResponse {
10
+ events?: GammaEvent[];
11
+ markets?: GammaMarket[];
12
+ }
13
+
14
+ interface GammaEvent {
15
+ id?: string | number;
16
+ slug?: string;
17
+ title?: string;
18
+ description?: string;
19
+ resolutionSource?: string;
20
+ endDate?: string;
21
+ volume?: unknown;
22
+ liquidity?: unknown;
23
+ updatedAt?: string;
24
+ markets?: GammaMarket[];
25
+ }
26
+
27
+ interface GammaMarket {
28
+ id?: string | number;
29
+ question?: string;
30
+ slug?: string;
31
+ description?: string;
32
+ resolutionSource?: string;
33
+ outcomes?: unknown;
34
+ outcomePrices?: unknown;
35
+ volume?: unknown;
36
+ volumeNum?: unknown;
37
+ liquidity?: unknown;
38
+ liquidityNum?: unknown;
39
+ endDate?: string;
40
+ endDateIso?: string;
41
+ updatedAt?: string;
42
+ active?: unknown;
43
+ closed?: unknown;
44
+ }
45
+
46
+ interface MarketWithEvent {
47
+ event?: GammaEvent;
48
+ market: GammaMarket;
49
+ }
50
+
51
+ export async function searchPredictionMarkets(
52
+ query: string,
53
+ limit: number = 8,
54
+ ): Promise<PredictionMarketQuote[]> {
55
+ const normalizedQuery = query.trim();
56
+ const normalizedLimit = normalizeLimit(limit);
57
+ if (!normalizedQuery) return [];
58
+
59
+ const cacheKey = `polymarket:search:${normalizedQuery.toLowerCase()}:${normalizedLimit}`;
60
+ const cached = cache.get<PredictionMarketQuote[]>(cacheKey);
61
+ if (cached) return cached;
62
+
63
+ try {
64
+ await rateLimiter.acquire("polymarket");
65
+ const url = `${BASE_URL}/public-search?q=${encodeURIComponent(normalizedQuery)}&limit=${normalizedLimit}`;
66
+ const response = await httpGet<GammaSearchResponse>(url, {
67
+ headers: { Accept: "application/json" },
68
+ maxRetries: 0,
69
+ });
70
+ const quotes = decodeSearchResponse(response).slice(0, normalizedLimit);
71
+ cache.set(cacheKey, quotes, TTL.PREDICTION_MARKETS);
72
+ return quotes;
73
+ } catch (error) {
74
+ const stale = cache.getStale<PredictionMarketQuote[]>(cacheKey, STALE_LIMIT.PREDICTION_MARKETS);
75
+ if (stale) return stale.value;
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ function decodeSearchResponse(response: GammaSearchResponse): PredictionMarketQuote[] {
81
+ const markets: MarketWithEvent[] = [
82
+ ...(response.events ?? []).flatMap((event) =>
83
+ (event.markets ?? []).map((market) => ({ event, market })),
84
+ ),
85
+ ...(response.markets ?? []).map((market) => ({ market })),
86
+ ];
87
+ return markets.flatMap(({ event, market }) =>
88
+ isOpenMarket(market) ? marketToQuotes(market, event) : [],
89
+ );
90
+ }
91
+
92
+ function marketToQuotes(market: GammaMarket, event?: GammaEvent): PredictionMarketQuote[] {
93
+ const marketId = market.id === undefined ? undefined : String(market.id);
94
+ const title = stringValue(market.question) ?? stringValue(event?.title);
95
+ if (!marketId || !title) return [];
96
+
97
+ const outcomes = parseStringArray(market.outcomes);
98
+ const prices = parseNumberArray(market.outcomePrices);
99
+ if (outcomes.length === 0 || prices.length === 0) return [];
100
+
101
+ const resolutionCriteria = firstNonEmpty(market.description, market.resolutionSource);
102
+ const volumeUsd =
103
+ numberValue(market.volumeNum) ?? numberValue(market.volume) ?? numberValue(event?.volume);
104
+ const liquidityUsd =
105
+ numberValue(market.liquidityNum) ??
106
+ numberValue(market.liquidity) ??
107
+ numberValue(event?.liquidity);
108
+ const closeDate =
109
+ stringValue(market.endDate) ?? normalizeDateOnly(market.endDateIso ?? event?.endDate);
110
+ const url = marketUrl(market, event);
111
+ const asOf = stringValue(market.updatedAt) ?? stringValue(event?.updatedAt);
112
+
113
+ return outcomes.flatMap((outcome, index) => {
114
+ const probability = prices[index];
115
+ if (probability === undefined || probability < 0 || probability > 1) return [];
116
+ return [
117
+ {
118
+ source: "polymarket" as const,
119
+ marketId,
120
+ title,
121
+ outcome,
122
+ probability,
123
+ ...(volumeUsd !== undefined && { volumeUsd }),
124
+ ...(liquidityUsd !== undefined && { liquidityUsd }),
125
+ ...(closeDate && { closeDate }),
126
+ ...(resolutionCriteria && { resolutionCriteria }),
127
+ url,
128
+ ...(asOf && { asOf }),
129
+ },
130
+ ];
131
+ });
132
+ }
133
+
134
+ function isOpenMarket(market: GammaMarket): boolean {
135
+ if (booleanValue(market.closed) === true) return false;
136
+ if (booleanValue(market.active) === false) return false;
137
+ const endDate = stringValue(market.endDate) ?? stringValue(market.endDateIso);
138
+ if (!endDate) return true;
139
+ const parsed = new Date(endDate);
140
+ return Number.isNaN(parsed.getTime()) || parsed.getTime() > Date.now();
141
+ }
142
+
143
+ function normalizeLimit(limit: number): number {
144
+ if (!Number.isFinite(limit)) return 8;
145
+ return Math.min(50, Math.max(1, Math.floor(limit)));
146
+ }
147
+
148
+ function parseStringArray(value: unknown): string[] {
149
+ const parsed = parseJsonArray(value);
150
+ return parsed.flatMap((item) => {
151
+ const text = stringValue(item);
152
+ return text ? [text] : [];
153
+ });
154
+ }
155
+
156
+ function parseNumberArray(value: unknown): Array<number | undefined> {
157
+ const parsed = parseJsonArray(value);
158
+ return parsed.map((item) => numberValue(item));
159
+ }
160
+
161
+ function parseJsonArray(value: unknown): unknown[] {
162
+ if (Array.isArray(value)) return value;
163
+ if (typeof value !== "string") return [];
164
+ try {
165
+ const parsed = JSON.parse(value);
166
+ return Array.isArray(parsed) ? parsed : [];
167
+ } catch {
168
+ return [];
169
+ }
170
+ }
171
+
172
+ function numberValue(value: unknown): number | undefined {
173
+ const number =
174
+ typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
175
+ return Number.isFinite(number) ? number : undefined;
176
+ }
177
+
178
+ function stringValue(value: unknown): string | undefined {
179
+ if (typeof value !== "string") return undefined;
180
+ const trimmed = value.trim();
181
+ return trimmed.length > 0 ? trimmed : undefined;
182
+ }
183
+
184
+ function booleanValue(value: unknown): boolean | undefined {
185
+ if (typeof value === "boolean") return value;
186
+ if (typeof value !== "string") return undefined;
187
+ const normalized = value.trim().toLowerCase();
188
+ if (normalized === "true") return true;
189
+ if (normalized === "false") return false;
190
+ return undefined;
191
+ }
192
+
193
+ function firstNonEmpty(...values: unknown[]): string | undefined {
194
+ for (const value of values) {
195
+ const text = stringValue(value);
196
+ if (text) return text;
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ function normalizeDateOnly(value: string | undefined): string | undefined {
202
+ if (!value) return undefined;
203
+ return /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T00:00:00Z` : value;
204
+ }
205
+
206
+ function marketUrl(market: GammaMarket, event?: GammaEvent): string {
207
+ const marketSlug = stringValue(market.slug);
208
+ const eventSlug = stringValue(event?.slug);
209
+ if (eventSlug && marketSlug) {
210
+ return `${POLYMARKET_URL}/event/${encodeURIComponent(eventSlug)}/${encodeURIComponent(marketSlug)}`;
211
+ }
212
+ if (marketSlug) return `${POLYMARKET_URL}/event/${encodeURIComponent(marketSlug)}`;
213
+ return POLYMARKET_URL;
214
+ }
@@ -37,12 +37,14 @@ export async function wrapProvider<T>(
37
37
  }
38
38
 
39
39
  try {
40
- const { value: data, stale } = await runWithStaleMetadata(fn);
40
+ const { value: data, cache } = await runWithStaleMetadata(fn);
41
+ const timestamp = cache ? new Date(cache.cachedAt).toISOString() : new Date().toISOString();
41
42
  return {
42
43
  status: "ok",
43
44
  data,
44
- timestamp: stale ? new Date(stale.cachedAt).toISOString() : new Date().toISOString(),
45
- stale: stale ? true : undefined,
45
+ timestamp,
46
+ cached: cache?.status === "cached" ? true : undefined,
47
+ stale: cache?.status === "stale" ? true : undefined,
46
48
  };
47
49
  } catch (error) {
48
50
  // Credential errors are re-thrown so the tool-layer `withCredentialCheck`