opencandle 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +43 -23
  2. package/dist/cli-main.d.ts +1 -0
  3. package/dist/cli-main.js +200 -0
  4. package/dist/cli-main.js.map +1 -0
  5. package/dist/cli.d.ts +1 -1
  6. package/dist/cli.js +14 -194
  7. package/dist/cli.js.map +1 -1
  8. package/dist/config.d.ts +10 -0
  9. package/dist/config.js +13 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/doctor/cli-command.d.ts +1 -0
  12. package/dist/doctor/cli-command.js +69 -0
  13. package/dist/doctor/cli-command.js.map +1 -0
  14. package/dist/doctor/render.d.ts +2 -0
  15. package/dist/doctor/render.js +32 -0
  16. package/dist/doctor/render.js.map +1 -0
  17. package/dist/doctor/report.d.ts +63 -0
  18. package/dist/doctor/report.js +500 -0
  19. package/dist/doctor/report.js.map +1 -0
  20. package/dist/infra/index.d.ts +0 -1
  21. package/dist/infra/index.js +0 -1
  22. package/dist/infra/index.js.map +1 -1
  23. package/dist/infra/native-dependencies.d.ts +8 -0
  24. package/dist/infra/native-dependencies.js +49 -0
  25. package/dist/infra/native-dependencies.js.map +1 -1
  26. package/dist/infra/node-version.js +2 -5
  27. package/dist/infra/node-version.js.map +1 -1
  28. package/dist/monitor.d.ts +1 -1
  29. package/dist/monitor.js +2 -1
  30. package/dist/monitor.js.map +1 -1
  31. package/dist/onboarding/connect.d.ts +2 -2
  32. package/dist/onboarding/connect.js +10 -3
  33. package/dist/onboarding/connect.js.map +1 -1
  34. package/dist/onboarding/provider-status.d.ts +46 -0
  35. package/dist/onboarding/provider-status.js +256 -0
  36. package/dist/onboarding/provider-status.js.map +1 -0
  37. package/dist/onboarding/providers.d.ts +85 -8
  38. package/dist/onboarding/providers.js +87 -9
  39. package/dist/onboarding/providers.js.map +1 -1
  40. package/dist/onboarding/state.d.ts +1 -0
  41. package/dist/onboarding/state.js +5 -0
  42. package/dist/onboarding/state.js.map +1 -1
  43. package/dist/onboarding/tool-tags.d.ts +12 -1
  44. package/dist/onboarding/tool-tags.js +31 -1
  45. package/dist/onboarding/tool-tags.js.map +1 -1
  46. package/dist/onboarding/validation.d.ts +2 -2
  47. package/dist/onboarding/validation.js.map +1 -1
  48. package/dist/pi/opencandle-extension.js +91 -15
  49. package/dist/pi/opencandle-extension.js.map +1 -1
  50. package/dist/pi/session.d.ts +0 -1
  51. package/dist/pi/session.js +2 -1
  52. package/dist/pi/session.js.map +1 -1
  53. package/dist/pi/tool-adapter.d.ts +4 -1
  54. package/dist/pi/tool-adapter.js +5 -4
  55. package/dist/pi/tool-adapter.js.map +1 -1
  56. package/dist/prompts/context-builder.js +1 -1
  57. package/dist/prompts/policy-cards.js +9 -5
  58. package/dist/prompts/policy-cards.js.map +1 -1
  59. package/dist/prompts/workflow-prompts.js +10 -6
  60. package/dist/prompts/workflow-prompts.js.map +1 -1
  61. package/dist/providers/external-tool-command.d.ts +17 -0
  62. package/dist/providers/external-tool-command.js +114 -0
  63. package/dist/providers/external-tool-command.js.map +1 -0
  64. package/dist/providers/external-tool-error.d.ts +10 -0
  65. package/dist/providers/external-tool-error.js +21 -0
  66. package/dist/providers/external-tool-error.js.map +1 -0
  67. package/dist/providers/reddit-cli.d.ts +32 -0
  68. package/dist/providers/reddit-cli.js +172 -0
  69. package/dist/providers/reddit-cli.js.map +1 -0
  70. package/dist/providers/reddit.d.ts +1 -1
  71. package/dist/providers/reddit.js +7 -35
  72. package/dist/providers/reddit.js.map +1 -1
  73. package/dist/providers/twitter-cli.d.ts +36 -0
  74. package/dist/providers/twitter-cli.js +124 -0
  75. package/dist/providers/twitter-cli.js.map +1 -0
  76. package/dist/providers/twitter.d.ts +0 -8
  77. package/dist/providers/twitter.js +4 -54
  78. package/dist/providers/twitter.js.map +1 -1
  79. package/dist/providers/wrap-provider.js +30 -0
  80. package/dist/providers/wrap-provider.js.map +1 -1
  81. package/dist/providers/yahoo-finance.js +53 -32
  82. package/dist/providers/yahoo-finance.js.map +1 -1
  83. package/dist/routing/classify-intent.js +7 -3
  84. package/dist/routing/classify-intent.js.map +1 -1
  85. package/dist/routing/planning.d.ts +1 -1
  86. package/dist/routing/planning.js +9 -6
  87. package/dist/routing/planning.js.map +1 -1
  88. package/dist/routing/router.js +7 -3
  89. package/dist/routing/router.js.map +1 -1
  90. package/dist/runtime/answer-contracts.d.ts +1 -1
  91. package/dist/runtime/answer-contracts.js +12 -1
  92. package/dist/runtime/answer-contracts.js.map +1 -1
  93. package/dist/runtime/tool-defaults-wrapper.js +6 -2
  94. package/dist/runtime/tool-defaults-wrapper.js.map +1 -1
  95. package/dist/sentiment/index.d.ts +1 -0
  96. package/dist/sentiment/index.js +1 -0
  97. package/dist/sentiment/index.js.map +1 -1
  98. package/dist/sentiment/insights.d.ts +17 -0
  99. package/dist/sentiment/insights.js +206 -0
  100. package/dist/sentiment/insights.js.map +1 -0
  101. package/dist/sentiment/pipeline.js +13 -1
  102. package/dist/sentiment/pipeline.js.map +1 -1
  103. package/dist/sentiment/scorer.d.ts +2 -0
  104. package/dist/sentiment/scorer.js +10 -1
  105. package/dist/sentiment/scorer.js.map +1 -1
  106. package/dist/sentiment/types.d.ts +2 -0
  107. package/dist/sentiment/types.js.map +1 -1
  108. package/dist/system-prompt.js +3 -7
  109. package/dist/system-prompt.js.map +1 -1
  110. package/dist/tools/index.d.ts +5 -2
  111. package/dist/tools/index.js +8 -8
  112. package/dist/tools/index.js.map +1 -1
  113. package/dist/tools/sentiment/insight-format.d.ts +2 -0
  114. package/dist/tools/sentiment/insight-format.js +36 -0
  115. package/dist/tools/sentiment/insight-format.js.map +1 -0
  116. package/dist/tools/sentiment/query-match.d.ts +3 -0
  117. package/dist/tools/sentiment/query-match.js +113 -0
  118. package/dist/tools/sentiment/query-match.js.map +1 -0
  119. package/dist/tools/sentiment/reddit-sentiment.d.ts +12 -1
  120. package/dist/tools/sentiment/reddit-sentiment.js +263 -117
  121. package/dist/tools/sentiment/reddit-sentiment.js.map +1 -1
  122. package/dist/tools/sentiment/sentiment-summary.d.ts +9 -1
  123. package/dist/tools/sentiment/sentiment-summary.js +217 -201
  124. package/dist/tools/sentiment/sentiment-summary.js.map +1 -1
  125. package/dist/tools/sentiment/twitter-sentiment.d.ts +11 -1
  126. package/dist/tools/sentiment/twitter-sentiment.js +187 -64
  127. package/dist/tools/sentiment/twitter-sentiment.js.map +1 -1
  128. package/dist/tools/sentiment/web-sentiment.js +4 -0
  129. package/dist/tools/sentiment/web-sentiment.js.map +1 -1
  130. package/dist/types/sentiment.d.ts +52 -0
  131. package/gui/server/http-routes.ts +419 -0
  132. package/gui/server/invoke-tool.ts +17 -3
  133. package/gui/server/model-setup.ts +10 -3
  134. package/gui/server/projector.ts +6 -2
  135. package/gui/server/server.ts +30 -275
  136. package/gui/server/tool-metadata.ts +80 -16
  137. package/gui/server/ws-hub.ts +19 -0
  138. package/gui/web/dist/assets/CatalogOverlay-CCVKwBUB.js +1 -0
  139. package/gui/web/dist/assets/index-Dm4Aom2_.js +69 -0
  140. package/gui/web/dist/assets/{index-2KZtKBmu.css → index-UzZUg3dx.css} +1 -1
  141. package/gui/web/dist/index.html +2 -2
  142. package/package.json +17 -14
  143. package/src/cli-main.ts +233 -0
  144. package/src/cli.ts +14 -226
  145. package/src/config.ts +27 -0
  146. package/src/doctor/cli-command.ts +83 -0
  147. package/src/doctor/render.ts +37 -0
  148. package/src/doctor/report.ts +638 -0
  149. package/src/infra/index.ts +0 -1
  150. package/src/infra/native-dependencies.ts +67 -0
  151. package/src/infra/node-version.ts +2 -5
  152. package/src/monitor.ts +3 -1
  153. package/src/onboarding/connect.ts +20 -4
  154. package/src/onboarding/provider-status.ts +382 -0
  155. package/src/onboarding/providers.ts +148 -18
  156. package/src/onboarding/state.ts +9 -0
  157. package/src/onboarding/tool-tags.ts +45 -2
  158. package/src/onboarding/validation.ts +2 -2
  159. package/src/pi/opencandle-extension.ts +115 -17
  160. package/src/pi/session.ts +2 -1
  161. package/src/pi/tool-adapter.ts +14 -4
  162. package/src/prompts/context-builder.ts +1 -1
  163. package/src/prompts/policy-cards.ts +13 -5
  164. package/src/prompts/workflow-prompts.ts +13 -6
  165. package/src/providers/external-tool-command.ts +164 -0
  166. package/src/providers/external-tool-error.ts +20 -0
  167. package/src/providers/reddit-cli.ts +286 -0
  168. package/src/providers/reddit.ts +7 -63
  169. package/src/providers/twitter-cli.ts +202 -0
  170. package/src/providers/twitter.ts +4 -73
  171. package/src/providers/wrap-provider.ts +34 -0
  172. package/src/providers/yahoo-finance.ts +65 -32
  173. package/src/routing/classify-intent.ts +11 -6
  174. package/src/routing/planning.ts +16 -10
  175. package/src/routing/router.ts +11 -6
  176. package/src/runtime/answer-contracts.ts +23 -2
  177. package/src/runtime/tool-defaults-wrapper.ts +12 -2
  178. package/src/sentiment/index.ts +1 -0
  179. package/src/sentiment/insights.ts +269 -0
  180. package/src/sentiment/pipeline.ts +13 -1
  181. package/src/sentiment/scorer.ts +12 -1
  182. package/src/sentiment/types.ts +3 -0
  183. package/src/system-prompt.ts +3 -7
  184. package/src/tools/index.ts +9 -8
  185. package/src/tools/sentiment/insight-format.ts +50 -0
  186. package/src/tools/sentiment/query-match.ts +117 -0
  187. package/src/tools/sentiment/reddit-sentiment.ts +354 -141
  188. package/src/tools/sentiment/sentiment-summary.ts +283 -237
  189. package/src/tools/sentiment/twitter-sentiment.ts +262 -78
  190. package/src/tools/sentiment/web-sentiment.ts +4 -0
  191. package/src/types/sentiment.ts +59 -0
  192. package/dist/infra/browser.d.ts +0 -35
  193. package/dist/infra/browser.js +0 -105
  194. package/dist/infra/browser.js.map +0 -1
  195. package/dist/tools/interaction/twitter-login.d.ts +0 -8
  196. package/dist/tools/interaction/twitter-login.js +0 -87
  197. package/dist/tools/interaction/twitter-login.js.map +0 -1
  198. package/gui/web/dist/assets/CatalogOverlay-eJ2cBk33.js +0 -1
  199. package/gui/web/dist/assets/index-CveNgtDg.js +0 -69
  200. package/src/infra/browser.ts +0 -113
  201. package/src/tools/interaction/twitter-login.ts +0 -105
@@ -0,0 +1,419 @@
1
+ import { createReadStream, existsSync } from "node:fs";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { extname, join, resolve } from "node:path";
4
+ import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import { buildDoctorReport } from "../../src/doctor/report.js";
6
+ import { probeProviderStatus } from "../../src/onboarding/provider-status.js";
7
+ import type { ChatEvent } from "../shared/chat-events.js";
8
+ import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
9
+ import { chatRunSessionConflict } from "./chat-run-session.js";
10
+ import { createLiveChatEventAdapter } from "./live-chat-event-adapter.js";
11
+ import { buildMarketStateSnapshot, searchInstrumentCandidates } from "./market-state-api.js";
12
+ import { buildModelSetupState, type ModelSetupController } from "./model-setup.js";
13
+ import { isTrustedPrivateApiRequest, privateApiCookieHeader } from "./private-api-access.js";
14
+ import { createPromptObservation, observePromptEvent } from "./prompt-observation.js";
15
+ import type { QuoteSnapshotStore } from "./quote-snapshot-store.js";
16
+ import { promptAndSettle, type SessionActionsController } from "./session-actions.js";
17
+ import { waitForNewEntryId } from "./session-entry-wait.js";
18
+ import { buildCatalog } from "./tool-metadata.js";
19
+ import type { WsHub } from "./ws-hub.js";
20
+
21
+ interface GuiHttpRouteOptions {
22
+ host: string;
23
+ port: number;
24
+ webDist: string;
25
+ role: string;
26
+ cwd: string;
27
+ agentDir: string;
28
+ sessionDir: string;
29
+ privateApiSessionToken: string;
30
+ allowRemotePrivateApi: boolean;
31
+ getSession: () => AgentSession;
32
+ getSessionManager: () => SessionManager;
33
+ wsHub: WsHub;
34
+ modelSetupController: ModelSetupController;
35
+ sessionActionsController: SessionActionsController;
36
+ quoteSnapshotStore: QuoteSnapshotStore;
37
+ }
38
+
39
+ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
40
+ return async function handleHttpRequest(
41
+ req: IncomingMessage,
42
+ res: ServerResponse,
43
+ ): Promise<void> {
44
+ const url = new URL(req.url ?? "/", `http://${options.host}:${options.port}`);
45
+ if (url.pathname === "/health") {
46
+ writeJson(res, { ok: true, role: options.role });
47
+ return;
48
+ }
49
+
50
+ if (url.pathname === "/api/bootstrap" && req.method === "GET") {
51
+ if (!allowTrustedGuiRequest(req, res, "Bootstrap API", options)) return;
52
+ writeJson(res, await options.wsHub.buildBootstrapPayload());
53
+ return;
54
+ }
55
+
56
+ if (url.pathname === "/api/session/new" && req.method === "POST") {
57
+ if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
58
+ if (options.role !== "writer") {
59
+ writeJson(res, { error: "Read-only follower mode" }, 409);
60
+ return;
61
+ }
62
+ await options.sessionActionsController.handleNewSession();
63
+ options.wsHub.broadcastState();
64
+ options.wsHub.broadcastSessions();
65
+ writeJson(res, await options.wsHub.buildBootstrapPayload());
66
+ return;
67
+ }
68
+
69
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
70
+ if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
71
+ writeJson(res, {
72
+ currentSessionId: options.getSessionManager().getSessionId(),
73
+ role: options.role,
74
+ sessions: await SessionManager.list(options.cwd, options.sessionDir),
75
+ });
76
+ return;
77
+ }
78
+
79
+ if (url.pathname === "/api/session/events" && req.method === "GET") {
80
+ if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
81
+ writeJson(res, {
82
+ sessionId: options.getSessionManager().getSessionId(),
83
+ role: options.role,
84
+ events: options.wsHub.currentChatEvents(),
85
+ });
86
+ return;
87
+ }
88
+
89
+ if (url.pathname === "/api/model-setup/refresh" && req.method === "POST") {
90
+ if (!allowTrustedGuiRequest(req, res, "Model setup API", options)) return;
91
+ options.getSession().modelRegistry.refresh();
92
+ options.wsHub.broadcastModelSetup();
93
+ writeJson(res, await options.wsHub.buildBootstrapPayload());
94
+ return;
95
+ }
96
+
97
+ if (url.pathname === "/api/model-setup/api-key" && req.method === "POST") {
98
+ if (!allowTrustedGuiRequest(req, res, "Model setup API", options)) return;
99
+ await handleTrustedGuiMutation(req, res, options, async (body) => {
100
+ await options.modelSetupController.handleSaveModelApiKey(
101
+ String(body.provider ?? ""),
102
+ String(body.apiKey ?? ""),
103
+ );
104
+ options.wsHub.broadcastModelSetup();
105
+ });
106
+ return;
107
+ }
108
+
109
+ if (url.pathname === "/api/model-setup/model" && req.method === "POST") {
110
+ if (!allowTrustedGuiRequest(req, res, "Model setup API", options)) return;
111
+ await handleTrustedGuiMutation(req, res, options, async (body) => {
112
+ await options.modelSetupController.handleSelectModel(
113
+ String(body.provider ?? ""),
114
+ String(body.modelId ?? ""),
115
+ );
116
+ options.wsHub.broadcastModelSetup();
117
+ });
118
+ return;
119
+ }
120
+
121
+ if (url.pathname === "/api/provider-setup/api-key" && req.method === "POST") {
122
+ if (!allowTrustedGuiRequest(req, res, "Provider setup API", options)) return;
123
+ await handleTrustedGuiMutation(req, res, options, async (body) => {
124
+ await options.modelSetupController.handleSaveProviderApiKey(
125
+ String(body.providerId ?? ""),
126
+ String(body.apiKey ?? ""),
127
+ );
128
+ options.wsHub.broadcast({ type: "catalog", catalog: buildCatalog() });
129
+ });
130
+ return;
131
+ }
132
+
133
+ if (url.pathname === "/api/market-state" && req.method === "GET") {
134
+ if (!allowTrustedGuiRequest(req, res, "Market-state API", options)) return;
135
+ writeJson(res, buildMarketStateSnapshot());
136
+ return;
137
+ }
138
+
139
+ if (url.pathname === "/api/market-state/quotes" && req.method === "GET") {
140
+ if (!allowTrustedGuiRequest(req, res, "Market-state API", options)) return;
141
+ writeJson(res, await options.quoteSnapshotStore.get());
142
+ return;
143
+ }
144
+
145
+ if (url.pathname === "/api/doctor" && req.method === "GET") {
146
+ if (!allowTrustedGuiRequest(req, res, "Diagnostics API", options)) return;
147
+ const session = options.getSession();
148
+ writeJson(
149
+ res,
150
+ await buildDoctorReport({
151
+ cwd: options.cwd,
152
+ agentDir: options.agentDir,
153
+ includeSessions: url.searchParams.get("sessions") === "1",
154
+ gui: {
155
+ host: options.host,
156
+ port: options.port,
157
+ role: options.role,
158
+ reachable: true,
159
+ healthEndpoint: `http://${options.host}:${options.port}/health`,
160
+ },
161
+ modelSetup: buildModelSetupState(session.modelRegistry, session.model),
162
+ }),
163
+ );
164
+ return;
165
+ }
166
+
167
+ if (url.pathname === "/api/instruments/search" && req.method === "GET") {
168
+ if (!allowTrustedGuiRequest(req, res, "Market-state API", options)) return;
169
+ writeJson(res, await searchInstrumentCandidates(url.searchParams.get("q") ?? ""));
170
+ return;
171
+ }
172
+
173
+ if (url.pathname === "/api/diagnostics/twitter-cli" && req.method === "GET") {
174
+ if (!allowTrustedGuiRequest(req, res, "Diagnostics API", options)) return;
175
+ const mode = url.searchParams.get("mode") === "session" ? "session" : "install";
176
+ const force = url.searchParams.get("force") === "1";
177
+ writeJson(res, await probeProviderStatus("twitter", { mode, force }));
178
+ return;
179
+ }
180
+
181
+ if (url.pathname === "/api/diagnostics/reddit-cli" && req.method === "GET") {
182
+ if (!allowTrustedGuiRequest(req, res, "Diagnostics API", options)) return;
183
+ const mode = url.searchParams.get("mode") === "session" ? "session" : "install";
184
+ const force = url.searchParams.get("force") === "1";
185
+ writeJson(res, await probeProviderStatus("reddit", { mode, force }));
186
+ return;
187
+ }
188
+
189
+ if (url.pathname === "/api/chat/run" && req.method === "POST") {
190
+ if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
191
+ await handleSseChatRun(req, res, options);
192
+ return;
193
+ }
194
+
195
+ serveStaticAsset(url.pathname, res, options);
196
+ };
197
+ }
198
+
199
+ async function handleSseChatRun(
200
+ req: IncomingMessage,
201
+ res: ServerResponse,
202
+ options: GuiHttpRouteOptions,
203
+ ): Promise<void> {
204
+ if (options.role !== "writer") {
205
+ writeJson(res, { error: "Read-only follower mode" }, 409);
206
+ return;
207
+ }
208
+
209
+ const body = await readJsonBody(req);
210
+ const prompt = String(asRecord(body).prompt ?? "").trim();
211
+ if (!prompt) {
212
+ writeJson(res, { error: "prompt is required" }, 400);
213
+ return;
214
+ }
215
+
216
+ const sessionManager = options.getSessionManager();
217
+ const sessionConflict = chatRunSessionConflict(
218
+ asRecord(body).sessionId,
219
+ sessionManager.getSessionId(),
220
+ );
221
+ if (sessionConflict) {
222
+ writeJson(res, sessionConflict, 409);
223
+ return;
224
+ }
225
+
226
+ res.writeHead(200, {
227
+ "content-type": "text/event-stream; charset=utf-8",
228
+ "cache-control": "no-cache, no-transform",
229
+ connection: "keep-alive",
230
+ });
231
+
232
+ let seq = 1;
233
+ const runId = `gui-run-${Date.now()}`;
234
+ const runSession = options.getSession();
235
+ const runSessionManager = options.getSessionManager();
236
+ const sessionId = runSessionManager.getSessionId();
237
+ if (!prompt.startsWith("/") && !runSessionManager.getSessionName()) {
238
+ runSessionManager.appendSessionInfo(prompt.length > 80 ? `${prompt.slice(0, 77)}...` : prompt);
239
+ }
240
+ const beforeEntries = runSessionManager.getEntries();
241
+ const beforeCount = beforeEntries.length;
242
+ const beforeIds = new Set(beforeEntries.map((entry) => entry.id));
243
+ writeSse(res, { type: "run.started", runId, sessionId, seq: seq++ });
244
+ res.flushHeaders?.();
245
+ const liveStartSeq = seq;
246
+ const liveAdapter = createLiveChatEventAdapter({
247
+ runId,
248
+ sessionId,
249
+ startSeq: seq,
250
+ emit: (event) => writeSse(res, event),
251
+ originalPrompt: prompt,
252
+ });
253
+ const observation = createPromptObservation();
254
+ const unsubscribeLive = runSession.subscribe((event) => {
255
+ liveAdapter.handle(event);
256
+ observePromptEvent(observation, event);
257
+ });
258
+
259
+ try {
260
+ const modelSetup = buildModelSetupState(runSession.modelRegistry, runSession.model);
261
+ if (!prompt.startsWith("/") && modelSetup.requirement !== "ready") {
262
+ runSessionManager.appendMessage({ role: "user", content: prompt, timestamp: Date.now() });
263
+ const message =
264
+ modelSetup.requirement === "select_model"
265
+ ? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
266
+ : "Connect an AI model before chat can run. Paste a Google Gemini, OpenAI, or Anthropic API key in the setup panel.";
267
+ runSessionManager.appendCustomMessageEntry("opencandle-model-setup", message, true, {
268
+ source: "gui",
269
+ requirement: modelSetup.requirement,
270
+ });
271
+ options.wsHub.broadcastState();
272
+ } else {
273
+ await promptAndSettle(runSession, prompt, beforeIds, observation);
274
+ options.wsHub.broadcastState();
275
+ }
276
+ seq = liveAdapter.nextSeq();
277
+ if (seq === liveStartSeq) {
278
+ await waitForNewEntryId(
279
+ () => runSessionManager.getEntries().map((entry) => entry.id),
280
+ beforeIds,
281
+ );
282
+ const newEntries = runSessionManager
283
+ .getEntries()
284
+ .slice(beforeCount)
285
+ .filter((entry) => !beforeIds.has(entry.id));
286
+ const events = sessionEntriesToChatEvents(newEntries, {
287
+ sessionId,
288
+ updatedAt: new Date().toISOString(),
289
+ startSeq: seq,
290
+ });
291
+ for (const event of events) {
292
+ writeSse(res, event);
293
+ seq = event.seq + 1;
294
+ }
295
+ }
296
+ writeSse(res, { type: "run.completed", runId, seq });
297
+ } catch (error) {
298
+ seq = liveAdapter.nextSeq();
299
+ const message = error instanceof Error ? error.message : String(error);
300
+ writeSse(res, { type: "run.failed", runId, error: { message }, seq });
301
+ } finally {
302
+ unsubscribeLive();
303
+ res.end();
304
+ }
305
+ }
306
+
307
+ async function handleTrustedGuiMutation(
308
+ req: IncomingMessage,
309
+ res: ServerResponse,
310
+ options: GuiHttpRouteOptions,
311
+ action: (body: Record<string, unknown>) => Promise<void>,
312
+ ): Promise<void> {
313
+ try {
314
+ await action(asRecord(await readJsonBody(req)));
315
+ writeJson(res, await options.wsHub.buildBootstrapPayload());
316
+ } catch (error) {
317
+ const message = error instanceof Error ? error.message : String(error);
318
+ writeJson(res, { error: message }, message === "Read-only follower mode" ? 409 : 400);
319
+ }
320
+ }
321
+
322
+ function serveStaticAsset(
323
+ pathname: string,
324
+ res: ServerResponse,
325
+ options: GuiHttpRouteOptions,
326
+ ): void {
327
+ const requested = pathname === "/" ? "/index.html" : pathname;
328
+ const path = resolve(join(options.webDist, requested));
329
+ if (!path.startsWith(options.webDist) || !existsSync(path)) {
330
+ const fallback = resolve(join(options.webDist, "index.html"));
331
+ if (!extname(requested) && fallback.startsWith(options.webDist) && existsSync(fallback)) {
332
+ res.writeHead(200, privateGuiHeaders("text/html; charset=utf-8", options));
333
+ createReadStream(fallback).pipe(res);
334
+ return;
335
+ }
336
+ res.writeHead(404).end("Not found");
337
+ return;
338
+ }
339
+
340
+ const type = contentType(path);
341
+ res.writeHead(200, privateGuiHeaders(type, options));
342
+ createReadStream(path).pipe(res);
343
+ }
344
+
345
+ function writeJson(res: ServerResponse, value: unknown, status = 200): void {
346
+ res.writeHead(status, { "content-type": "application/json" });
347
+ res.end(JSON.stringify(value));
348
+ }
349
+
350
+ function allowTrustedGuiRequest(
351
+ req: IncomingMessage,
352
+ res: ServerResponse,
353
+ label: string,
354
+ options: GuiHttpRouteOptions,
355
+ ): boolean {
356
+ if (
357
+ isTrustedPrivateApiRequest(
358
+ req.headers,
359
+ options.privateApiSessionToken,
360
+ req.socket.remoteAddress,
361
+ {
362
+ allowRemote: options.allowRemotePrivateApi,
363
+ },
364
+ )
365
+ )
366
+ return true;
367
+ res.writeHead(403, { "content-type": "application/json" });
368
+ res.end(
369
+ JSON.stringify({
370
+ error: `${label} is only available to trusted GUI browser sessions.`,
371
+ }),
372
+ );
373
+ return false;
374
+ }
375
+
376
+ function privateGuiHeaders(
377
+ contentTypeValue: string,
378
+ options: GuiHttpRouteOptions,
379
+ ): Record<string, string> {
380
+ const headers: Record<string, string> = { "content-type": contentTypeValue };
381
+ if (contentTypeValue.startsWith("text/html")) {
382
+ headers["set-cookie"] = privateApiCookieHeader(options.privateApiSessionToken);
383
+ }
384
+ return headers;
385
+ }
386
+
387
+ function writeSse(res: ServerResponse, event: ChatEvent): void {
388
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
389
+ }
390
+
391
+ async function readJsonBody(req: IncomingMessage): Promise<unknown> {
392
+ const chunks: Buffer[] = [];
393
+ for await (const chunk of req) {
394
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
395
+ }
396
+ if (chunks.length === 0) return {};
397
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
398
+ }
399
+
400
+ function contentType(path: string): string {
401
+ switch (extname(path)) {
402
+ case ".html":
403
+ return "text/html; charset=utf-8";
404
+ case ".css":
405
+ return "text/css; charset=utf-8";
406
+ case ".js":
407
+ return "text/javascript; charset=utf-8";
408
+ case ".svg":
409
+ return "image/svg+xml";
410
+ default:
411
+ return "application/octet-stream";
412
+ }
413
+ }
414
+
415
+ function asRecord(value: unknown): Record<string, unknown> {
416
+ return typeof value === "object" && value !== null && !Array.isArray(value)
417
+ ? (value as Record<string, unknown>)
418
+ : {};
419
+ }
@@ -6,6 +6,7 @@ import { Value } from "@sinclair/typebox/value";
6
6
  import { getDefaults } from "../../src/memory/tool-defaults.js";
7
7
  import { wrapWithDefaults } from "../../src/runtime/tool-defaults-wrapper.js";
8
8
  import { getAllTools } from "../../src/tools/index.js";
9
+ import type { AskUserHandler } from "../../src/types/index.js";
9
10
  import { buildToolInvokeAckMessage } from "./tool-invoke-ack.js";
10
11
 
11
12
  export interface InvokeToolResult {
@@ -30,6 +31,7 @@ export interface ToolInvokeControllerOptions {
30
31
  onMarketStateChanged?: () => void;
31
32
  getTools?: typeof getAllTools;
32
33
  invokeTool?: typeof invokeToolFromUi;
34
+ askUserHandler?: AskUserHandler;
33
35
  }
34
36
 
35
37
  export function createToolInvokeController({
@@ -39,6 +41,7 @@ export function createToolInvokeController({
39
41
  onMarketStateChanged,
40
42
  getTools = getAllTools,
41
43
  invokeTool = invokeToolFromUi,
44
+ askUserHandler,
42
45
  }: ToolInvokeControllerOptions): ToolInvokeController {
43
46
  async function handleToolInvoke(
44
47
  toolName: string,
@@ -47,7 +50,7 @@ export function createToolInvokeController({
47
50
  if (role !== "writer") throw new Error("Read-only follower mode");
48
51
  const tool = getTools().find((candidate) => candidate.name === toolName);
49
52
  if (!tool) throw new Error(`Unknown tool: ${toolName}`);
50
- const result = await invokeTool(getSessionManager(), tool, args, "ui");
53
+ const result = await invokeTool(getSessionManager(), tool, args, "ui", { askUserHandler });
51
54
  if (!result.isError && marketStateToolMapping(toolName) != null) {
52
55
  onMarketStateChanged?.();
53
56
  }
@@ -94,7 +97,7 @@ export async function invokeToolFromUi(
94
97
  tool: AgentTool<TSchema, unknown>,
95
98
  args: Record<string, unknown>,
96
99
  source: "ui" | "background" = "ui",
97
- options: { recordTranscript?: boolean } = {},
100
+ options: { askUserHandler?: AskUserHandler; recordTranscript?: boolean } = {},
98
101
  ): Promise<InvokeToolResult> {
99
102
  if (!Value.Check(tool.parameters, args)) {
100
103
  const errors = [...Value.Errors(tool.parameters, args)]
@@ -130,7 +133,18 @@ export async function invokeToolFromUi(
130
133
  let result: AgentToolResult<unknown>;
131
134
  let isError = false;
132
135
  try {
133
- result = await wrapped.execute(toolCallId, args as never);
136
+ result = await (
137
+ wrapped.execute as (
138
+ id: string,
139
+ params: never,
140
+ signal: AbortSignal | undefined,
141
+ onUpdate: undefined,
142
+ ctx: { askUserHandler?: AskUserHandler; hasUI: false },
143
+ ) => ReturnType<typeof wrapped.execute>
144
+ )(toolCallId, args as never, undefined, undefined, {
145
+ askUserHandler: options.askUserHandler,
146
+ hasUI: false,
147
+ });
134
148
  } catch (error) {
135
149
  isError = true;
136
150
  result = {
@@ -1,6 +1,10 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import { persistProviderCredential } from "../../src/onboarding/connect.js";
3
- import { getCredentialSource, PROVIDERS, type ProviderId } from "../../src/onboarding/providers.js";
3
+ import {
4
+ getCredentialSource,
5
+ isApiKeyProvider,
6
+ PROVIDERS,
7
+ } from "../../src/onboarding/providers.js";
4
8
  import { validateCredential } from "../../src/onboarding/validation.js";
5
9
 
6
10
  export type ModelSetupRequirement = "ready" | "select_model" | "connect_auth";
@@ -193,6 +197,9 @@ export function createModelSetupController({
193
197
 
194
198
  const descriptor = PROVIDERS.find((candidate) => candidate.id === providerId);
195
199
  if (!descriptor) throw new Error(`Unknown provider: ${providerId}`);
200
+ if (!isApiKeyProvider(descriptor)) {
201
+ throw new Error(`${descriptor.displayName} is not configured with an API key.`);
202
+ }
196
203
 
197
204
  if (getCredentialSource(descriptor.id) === "env") {
198
205
  throw new Error(
@@ -203,7 +210,7 @@ export function createModelSetupController({
203
210
  const trimmed = apiKey.trim();
204
211
  if (!trimmed) throw new Error(`Paste a ${descriptor.displayName} API key first.`);
205
212
 
206
- const validation = await validateCredential(descriptor.id as ProviderId, trimmed);
213
+ const validation = await validateCredential(descriptor.id, trimmed);
207
214
  if (validation.status === "invalid") {
208
215
  const statusHint =
209
216
  validation.httpStatus !== undefined ? ` (HTTP ${validation.httpStatus})` : "";
@@ -213,7 +220,7 @@ export function createModelSetupController({
213
220
  );
214
221
  }
215
222
 
216
- persistProviderCredential(descriptor.id as ProviderId, trimmed);
223
+ persistProviderCredential(descriptor.id, trimmed);
217
224
 
218
225
  const verifiedNote =
219
226
  validation.status === "transient"
@@ -36,6 +36,7 @@ const DIRECT_TOOL_GAP_PROVIDERS: Record<string, string> = {
36
36
  compare_companies: "alpha_vantage",
37
37
  get_economic_data: "fred",
38
38
  get_twitter_sentiment: "twitter",
39
+ get_reddit_sentiment: "reddit",
39
40
  };
40
41
 
41
42
  export function createEmptyDashboardState(): DashboardState {
@@ -213,7 +214,8 @@ function parseSkippedProviders(text: string): string[] {
213
214
 
214
215
  function parseSoftGapProviders(text: string): string[] {
215
216
  const providers: string[] = [];
216
- const re = /\[OPENCANDLE_(?:SKIPPED|SOFT_DEGRADED)[^\]]*provider=([a-z0-9_-]+)/gi;
217
+ const re =
218
+ /\[OPENCANDLE_(?:SKIPPED|SOFT_DEGRADED|EXTERNAL_TOOL_REQUIRED)[^\]]*provider=([a-z0-9_-]+)/gi;
217
219
  while (true) {
218
220
  const match = re.exec(text);
219
221
  if (!match) break;
@@ -226,7 +228,9 @@ function inferDirectToolGapProvider(
226
228
  toolName: string | undefined,
227
229
  text: string,
228
230
  ): string | undefined {
229
- if (!toolName || !/(?:⚠|unavailable|No .*data found|LOGIN_NEEDED)/i.test(text)) return undefined;
231
+ if (!toolName || !/(?:⚠|unavailable|No .*data found|EXTERNAL_TOOL_SETUP)/i.test(text)) {
232
+ return undefined;
233
+ }
230
234
  return DIRECT_TOOL_GAP_PROVIDERS[toolName];
231
235
  }
232
236