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.
- package/README.md +12 -9
- package/dist/analysts/orchestrator.d.ts +3 -4
- package/dist/analysts/orchestrator.js +34 -44
- package/dist/analysts/orchestrator.js.map +1 -1
- package/dist/cli-main.js +242 -9
- package/dist/cli-main.js.map +1 -1
- package/dist/config.d.ts +4 -9
- package/dist/config.js +7 -10
- package/dist/config.js.map +1 -1
- package/dist/doctor/report.js +1 -1
- package/dist/doctor/report.js.map +1 -1
- package/dist/infra/cache.d.ts +6 -0
- package/dist/infra/cache.js +16 -6
- package/dist/infra/cache.js.map +1 -1
- package/dist/infra/freshness.d.ts +21 -0
- package/dist/infra/freshness.js +119 -0
- package/dist/infra/freshness.js.map +1 -0
- package/dist/infra/market-calendar.d.ts +14 -0
- package/dist/infra/market-calendar.js +137 -0
- package/dist/infra/market-calendar.js.map +1 -0
- package/dist/infra/rate-limiter.js +1 -0
- package/dist/infra/rate-limiter.js.map +1 -1
- package/dist/market-state/daily-report.d.ts +1 -1
- package/dist/market-state/daily-report.js +5 -16
- package/dist/market-state/daily-report.js.map +1 -1
- package/dist/market-state/service.d.ts +0 -35
- package/dist/market-state/service.js +0 -63
- package/dist/market-state/service.js.map +1 -1
- package/dist/market-state/summaries.d.ts +8 -0
- package/dist/market-state/summaries.js +70 -0
- package/dist/market-state/summaries.js.map +1 -0
- package/dist/memory/sqlite.js +20 -19
- package/dist/memory/sqlite.js.map +1 -1
- package/dist/onboarding/providers.d.ts +25 -1
- package/dist/onboarding/providers.js +26 -0
- package/dist/onboarding/providers.js.map +1 -1
- package/dist/pi/opencandle-extension.js +57 -226
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/session-action-dedupe.d.ts +6 -0
- package/dist/pi/session-action-dedupe.js +126 -0
- package/dist/pi/session-action-dedupe.js.map +1 -0
- package/dist/pi/session-writer-lock.d.ts +26 -2
- package/dist/pi/session-writer-lock.js +230 -18
- package/dist/pi/session-writer-lock.js.map +1 -1
- package/dist/pi/setup.js +5 -5
- package/dist/pi/setup.js.map +1 -1
- package/dist/pi/tui-session-coordinator.d.ts +15 -0
- package/dist/pi/tui-session-coordinator.js +283 -0
- package/dist/pi/tui-session-coordinator.js.map +1 -0
- package/dist/prompts/context-builder.js +1 -1
- package/dist/prompts/policy-cards.js +1 -1
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/providers/alpha-vantage.js +6 -0
- package/dist/providers/alpha-vantage.js.map +1 -1
- package/dist/providers/coingecko.js +1 -0
- package/dist/providers/coingecko.js.map +1 -1
- package/dist/providers/polymarket.d.ts +2 -0
- package/dist/providers/polymarket.js +163 -0
- package/dist/providers/polymarket.js.map +1 -0
- package/dist/providers/wrap-provider.js +5 -3
- package/dist/providers/wrap-provider.js.map +1 -1
- package/dist/providers/yahoo-finance.d.ts +3 -0
- package/dist/providers/yahoo-finance.js +206 -10
- package/dist/providers/yahoo-finance.js.map +1 -1
- package/dist/routing/classify-intent.js +4 -5
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/entity-extractor.js +16 -0
- package/dist/routing/entity-extractor.js.map +1 -1
- package/dist/routing/route-manifest.js +1 -2
- package/dist/routing/route-manifest.js.map +1 -1
- package/dist/routing/router-llm-client.js +18 -3
- package/dist/routing/router-llm-client.js.map +1 -1
- package/dist/routing/router-prompt.js +1 -1
- package/dist/routing/router.d.ts +1 -1
- package/dist/routing/router.js +309 -9
- package/dist/routing/router.js.map +1 -1
- package/dist/routing/slot-resolver.d.ts +1 -0
- package/dist/routing/slot-resolver.js +1 -1
- package/dist/routing/slot-resolver.js.map +1 -1
- package/dist/runtime/evidence.d.ts +1 -0
- package/dist/runtime/evidence.js.map +1 -1
- package/dist/runtime/numeric-claims.d.ts +23 -0
- package/dist/runtime/numeric-claims.js +100 -0
- package/dist/runtime/numeric-claims.js.map +1 -0
- package/dist/runtime/planning-evidence.js +1 -76
- package/dist/runtime/planning-evidence.js.map +1 -1
- package/dist/runtime/prompt-step.d.ts +10 -1
- package/dist/runtime/prompt-step.js +116 -2
- package/dist/runtime/prompt-step.js.map +1 -1
- package/dist/runtime/session-coordinator.d.ts +11 -1
- package/dist/runtime/session-coordinator.js +354 -62
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/runtime/validation.d.ts +3 -1
- package/dist/runtime/workflow-events.d.ts +1 -1
- package/dist/runtime/workflow-events.js.map +1 -1
- package/dist/runtime/workflow-types.d.ts +3 -0
- package/dist/runtime/workflow-types.js.map +1 -1
- package/dist/system-prompt.js +1 -1
- package/dist/tools/fundamentals/comps.d.ts +1 -0
- package/dist/tools/fundamentals/comps.js +20 -9
- package/dist/tools/fundamentals/comps.js.map +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.js +190 -76
- package/dist/tools/fundamentals/dcf.js.map +1 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +3 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/macro/event-probabilities.d.ts +8 -0
- package/dist/tools/macro/event-probabilities.js +121 -0
- package/dist/tools/macro/event-probabilities.js.map +1 -0
- package/dist/tools/market/crypto-price.d.ts +4 -1
- package/dist/tools/market/crypto-price.js +10 -1
- package/dist/tools/market/crypto-price.js.map +1 -1
- package/dist/tools/market/screen-stocks.js +14 -3
- package/dist/tools/market/screen-stocks.js.map +1 -1
- package/dist/tools/market/stock-quote.d.ts +4 -1
- package/dist/tools/market/stock-quote.js +9 -4
- package/dist/tools/market/stock-quote.js.map +1 -1
- package/dist/tools/options/option-chain.d.ts +4 -1
- package/dist/tools/options/option-chain.js +12 -1
- package/dist/tools/options/option-chain.js.map +1 -1
- package/dist/tools/portfolio/daily-report.js +10 -4
- package/dist/tools/portfolio/daily-report.js.map +1 -1
- package/dist/tools/portfolio/risk-analysis.js +1 -1
- package/dist/tools/portfolio/risk-analysis.js.map +1 -1
- package/dist/tools/technical/backtest.d.ts +23 -5
- package/dist/tools/technical/backtest.js +131 -94
- package/dist/tools/technical/backtest.js.map +1 -1
- package/dist/tools/technical/indicators.js +1 -1
- package/dist/tools/technical/indicators.js.map +1 -1
- package/dist/types/fundamentals.d.ts +1 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/market.d.ts +2 -0
- package/dist/types/options.d.ts +1 -0
- package/dist/types/prediction-markets.d.ts +13 -0
- package/dist/types/prediction-markets.js +2 -0
- package/dist/types/prediction-markets.js.map +1 -0
- package/gui/server/chat-event-adapter.ts +89 -5
- package/gui/server/http-routes.ts +661 -37
- package/gui/server/invoke-tool.ts +165 -16
- package/gui/server/live-chat-event-adapter.ts +37 -2
- package/gui/server/local-session-coordinator.ts +97 -0
- package/gui/server/market-state-api.ts +64 -48
- package/gui/server/projector.ts +132 -3
- package/gui/server/server.ts +51 -6
- package/gui/server/session-actions.ts +146 -45
- package/gui/server/session-entry-wait.ts +22 -3
- package/gui/server/tool-metadata.ts +3 -1
- package/gui/server/ws-hub.ts +77 -10
- package/gui/shared/chat-events.ts +10 -1
- package/gui/shared/event-reducer.ts +1 -0
- package/gui/web/dist/assets/CatalogOverlay-DZ1niyQm.js +1 -0
- package/gui/web/dist/assets/index-D4F9AJnn.css +2 -0
- package/gui/web/dist/assets/index-DtqMMBTr.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +13 -15
- package/src/analysts/orchestrator.ts +35 -56
- package/src/cli-main.ts +253 -13
- package/src/config.ts +12 -20
- package/src/doctor/report.ts +1 -1
- package/src/infra/cache.ts +24 -11
- package/src/infra/freshness.ts +165 -0
- package/src/infra/market-calendar.ts +193 -0
- package/src/infra/rate-limiter.ts +1 -0
- package/src/market-state/daily-report.ts +6 -16
- package/src/market-state/service.ts +0 -136
- package/src/market-state/summaries.ts +75 -0
- package/src/memory/sqlite.ts +23 -19
- package/src/onboarding/providers.ts +29 -1
- package/src/pi/opencandle-extension.ts +67 -270
- package/src/pi/session-action-dedupe.ts +155 -0
- package/src/pi/session-writer-lock.ts +290 -20
- package/src/pi/setup.ts +6 -6
- package/src/pi/tui-session-coordinator.ts +351 -0
- package/src/prompts/context-builder.ts +1 -1
- package/src/prompts/policy-cards.ts +1 -1
- package/src/providers/alpha-vantage.ts +6 -0
- package/src/providers/coingecko.ts +2 -0
- package/src/providers/polymarket.ts +214 -0
- package/src/providers/wrap-provider.ts +5 -3
- package/src/providers/yahoo-finance.ts +339 -27
- package/src/routing/classify-intent.ts +4 -5
- package/src/routing/entity-extractor.ts +17 -0
- package/src/routing/route-manifest.ts +1 -2
- package/src/routing/router-llm-client.ts +31 -19
- package/src/routing/router-prompt.ts +1 -1
- package/src/routing/router.ts +379 -8
- package/src/routing/slot-resolver.ts +1 -1
- package/src/runtime/evidence.ts +1 -0
- package/src/runtime/numeric-claims.ts +108 -0
- package/src/runtime/planning-evidence.ts +6 -91
- package/src/runtime/prompt-step.ts +127 -2
- package/src/runtime/session-coordinator.ts +464 -74
- package/src/runtime/validation.ts +1 -1
- package/src/runtime/workflow-events.ts +2 -0
- package/src/runtime/workflow-types.ts +3 -0
- package/src/system-prompt.ts +1 -1
- package/src/tools/AGENTS.md +1 -1
- package/src/tools/fundamentals/comps.ts +25 -14
- package/src/tools/fundamentals/dcf.ts +217 -87
- package/src/tools/index.ts +3 -3
- package/src/tools/macro/event-probabilities.ts +141 -0
- package/src/tools/market/crypto-price.ts +14 -2
- package/src/tools/market/screen-stocks.ts +14 -3
- package/src/tools/market/stock-quote.ts +49 -43
- package/src/tools/options/option-chain.ts +16 -2
- package/src/tools/portfolio/daily-report.ts +10 -4
- package/src/tools/portfolio/risk-analysis.ts +1 -1
- package/src/tools/technical/backtest.ts +167 -108
- package/src/tools/technical/indicators.ts +1 -1
- package/src/types/fundamentals.ts +1 -0
- package/src/types/index.ts +1 -0
- package/src/types/market.ts +2 -0
- package/src/types/options.ts +1 -0
- package/src/types/prediction-markets.ts +13 -0
- package/dist/tools/portfolio/predictions.d.ts +0 -55
- package/dist/tools/portfolio/predictions.js +0 -422
- package/dist/tools/portfolio/predictions.js.map +0 -1
- package/gui/server/chat-run-session.ts +0 -16
- package/gui/web/dist/assets/CatalogOverlay-CYptsda-.js +0 -1
- package/gui/web/dist/assets/index-B7QAjY5g.js +0 -65
- package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
- package/src/tools/portfolio/predictions.ts +0 -553
|
@@ -3,12 +3,35 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
3
3
|
import { extname, join, resolve } from "node:path";
|
|
4
4
|
import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { buildDoctorReport } from "../../src/doctor/report.js";
|
|
6
|
+
import { MarketStateService } from "../../src/market-state/service.js";
|
|
7
|
+
import {
|
|
8
|
+
formatLatestReportSummary,
|
|
9
|
+
formatPortfolioSummary,
|
|
10
|
+
formatWatchlistSummary,
|
|
11
|
+
} from "../../src/market-state/summaries.js";
|
|
12
|
+
import { initDefaultDatabase } from "../../src/memory/sqlite.js";
|
|
6
13
|
import { probeProviderStatus } from "../../src/onboarding/provider-status.js";
|
|
14
|
+
import {
|
|
15
|
+
clearPendingSessionAction,
|
|
16
|
+
hasAcceptedSessionAction,
|
|
17
|
+
hasPendingSessionAction,
|
|
18
|
+
recordAcceptedSessionAction,
|
|
19
|
+
recordPendingSessionAction,
|
|
20
|
+
} from "../../src/pi/session-action-dedupe.js";
|
|
7
21
|
import type { ChatEvent } from "../shared/chat-events.js";
|
|
8
22
|
import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
|
|
9
|
-
import {
|
|
23
|
+
import type { ToolInvokeController } from "./invoke-tool.js";
|
|
10
24
|
import { createLiveChatEventAdapter } from "./live-chat-event-adapter.js";
|
|
11
|
-
import {
|
|
25
|
+
import type {
|
|
26
|
+
LocalSessionCoordinator,
|
|
27
|
+
SessionActionEnvelope,
|
|
28
|
+
SessionActionResult,
|
|
29
|
+
} from "./local-session-coordinator.js";
|
|
30
|
+
import {
|
|
31
|
+
buildMarketStateSnapshot,
|
|
32
|
+
getSavedMarketStateSymbols,
|
|
33
|
+
searchInstrumentCandidates,
|
|
34
|
+
} from "./market-state-api.js";
|
|
12
35
|
import { buildModelSetupState, type ModelSetupController } from "./model-setup.js";
|
|
13
36
|
import { isTrustedPrivateApiRequest, privateApiCookieHeader } from "./private-api-access.js";
|
|
14
37
|
import { projectDashboard } from "./projector.js";
|
|
@@ -19,9 +42,11 @@ import { waitForNewEntryId } from "./session-entry-wait.js";
|
|
|
19
42
|
import { buildCatalog } from "./tool-metadata.js";
|
|
20
43
|
import {
|
|
21
44
|
acquireWriterLock,
|
|
45
|
+
isCoordinatorOwnerAlive,
|
|
22
46
|
readWriterLock,
|
|
23
47
|
refreshWriterLock,
|
|
24
48
|
releaseWriterLock,
|
|
49
|
+
shouldBlockFailedCoordinatorAction as shouldBlockFailedCoordinatorLockAction,
|
|
25
50
|
writerLockScopeForSession,
|
|
26
51
|
} from "./writer-lock.js";
|
|
27
52
|
import type { WsHub } from "./ws-hub.js";
|
|
@@ -35,16 +60,43 @@ interface GuiHttpRouteOptions {
|
|
|
35
60
|
agentDir: string;
|
|
36
61
|
sessionDir: string;
|
|
37
62
|
privateApiSessionToken: string;
|
|
63
|
+
localCoordinatorEndpoint: string;
|
|
64
|
+
localCoordinatorSecret: string;
|
|
38
65
|
allowRemotePrivateApi: boolean;
|
|
66
|
+
syncCurrentWriterLockScope?: () => void;
|
|
39
67
|
getSession: () => AgentSession;
|
|
40
68
|
getSessionManager: () => SessionManager;
|
|
41
69
|
createSessionForManager: (sessionManager: SessionManager) => Promise<{ session: AgentSession }>;
|
|
42
70
|
wsHub: WsHub;
|
|
43
71
|
modelSetupController: ModelSetupController;
|
|
44
72
|
sessionActionsController: SessionActionsController;
|
|
73
|
+
toolInvokeController: ToolInvokeController;
|
|
45
74
|
quoteSnapshotStore: QuoteSnapshotStore;
|
|
75
|
+
localSessionCoordinator?: LocalSessionCoordinator;
|
|
46
76
|
}
|
|
47
77
|
|
|
78
|
+
export interface ChatRunImageInput {
|
|
79
|
+
data: string;
|
|
80
|
+
mimeType: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ChatRunAttachmentInput =
|
|
84
|
+
| { kind: "portfolio"; id?: string }
|
|
85
|
+
| { kind: "watchlist"; id: string }
|
|
86
|
+
| { kind: "report"; id: string };
|
|
87
|
+
|
|
88
|
+
export interface ParsedChatRunBody {
|
|
89
|
+
prompt: string;
|
|
90
|
+
images: ChatRunImageInput[];
|
|
91
|
+
attachments: ChatRunAttachmentInput[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type ChatRunParseResult = { ok: true; value: ParsedChatRunBody } | { ok: false; error: string };
|
|
95
|
+
|
|
96
|
+
const CHAT_RUN_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
97
|
+
const CHAT_RUN_MAX_IMAGE_COUNT = 4;
|
|
98
|
+
const CHAT_RUN_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
99
|
+
|
|
48
100
|
export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
|
|
49
101
|
const activeRunSessionIds = new Set<string>();
|
|
50
102
|
|
|
@@ -67,7 +119,11 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
|
|
|
67
119
|
if (url.pathname === "/api/session/new" && req.method === "POST") {
|
|
68
120
|
if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
|
|
69
121
|
if (options.role !== "writer") {
|
|
70
|
-
writeJson(
|
|
122
|
+
writeJson(
|
|
123
|
+
res,
|
|
124
|
+
{ error: "OpenCandle is reconnecting to this session.", code: "syncing" },
|
|
125
|
+
409,
|
|
126
|
+
);
|
|
71
127
|
return;
|
|
72
128
|
}
|
|
73
129
|
await options.sessionActionsController.handleNewSession();
|
|
@@ -211,19 +267,109 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
|
|
|
211
267
|
|
|
212
268
|
if (url.pathname === "/api/chat/run" && req.method === "POST") {
|
|
213
269
|
if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
|
|
214
|
-
|
|
270
|
+
writeJson(
|
|
271
|
+
res,
|
|
272
|
+
{
|
|
273
|
+
error: "Legacy active-session chat runs are no longer supported.",
|
|
274
|
+
code: "legacy_route_removed",
|
|
275
|
+
},
|
|
276
|
+
410,
|
|
277
|
+
);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (url.pathname === "/api/local-coordinator/chat-run" && req.method === "POST") {
|
|
282
|
+
if (!allowLocalCoordinatorRequest(req, res, options)) return;
|
|
283
|
+
const body = asRecord(await readJsonBody(req));
|
|
284
|
+
if (!requireSessionActionFields(res, body)) return;
|
|
285
|
+
const sessionManager = await resolveSessionManagerById(options, String(body.sessionId));
|
|
286
|
+
if (!sessionManager) {
|
|
287
|
+
writeJson(res, { error: "Unknown saved session" }, 404);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// allowProxy: false — this endpoint is the proxy target; re-proxying loops.
|
|
291
|
+
await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body, false);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (url.pathname === "/api/local-coordinator/tool-invoke" && req.method === "POST") {
|
|
296
|
+
if (!allowLocalCoordinatorRequest(req, res, options)) return;
|
|
297
|
+
const body = asRecord(await readJsonBody(req));
|
|
298
|
+
if (!requireSessionActionFields(res, body)) return;
|
|
299
|
+
let ack: unknown;
|
|
300
|
+
await options.toolInvokeController.handleToolInvokeMessage(
|
|
301
|
+
{ send: (message) => (ack = message) },
|
|
302
|
+
{
|
|
303
|
+
requestId: "local-coordinator-tool",
|
|
304
|
+
actionId: String(body.actionId ?? ""),
|
|
305
|
+
sessionId: String(body.sessionId ?? ""),
|
|
306
|
+
toolName: String(body.toolName ?? ""),
|
|
307
|
+
args: asRecord(body.args),
|
|
308
|
+
allowProxy: false,
|
|
309
|
+
},
|
|
310
|
+
);
|
|
311
|
+
const message = asRecord(ack);
|
|
312
|
+
const result = asRecord(message.result);
|
|
313
|
+
if (result.toolCallId) {
|
|
314
|
+
writeJson(res, { result });
|
|
315
|
+
} else {
|
|
316
|
+
writeJson(
|
|
317
|
+
res,
|
|
318
|
+
{ error: String(asRecord(message.error).message ?? "Tool invocation failed") },
|
|
319
|
+
409,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (url.pathname === "/api/local-coordinator/ask-user" && req.method === "POST") {
|
|
326
|
+
if (!allowLocalCoordinatorRequest(req, res, options)) return;
|
|
327
|
+
const body = asRecord(await readJsonBody(req));
|
|
328
|
+
if (!requireSessionActionFields(res, body)) return;
|
|
329
|
+
const payload = asRecord(body.payload);
|
|
330
|
+
try {
|
|
331
|
+
const action = {
|
|
332
|
+
actionId: String(body.actionId ?? ""),
|
|
333
|
+
sessionId: String(body.sessionId ?? ""),
|
|
334
|
+
source: "browser" as const,
|
|
335
|
+
allowProxy: false,
|
|
336
|
+
};
|
|
337
|
+
if (body.actionType === "ask_user.answer") {
|
|
338
|
+
await options.sessionActionsController.handleAskUserAnswer(
|
|
339
|
+
String(payload.id ?? ""),
|
|
340
|
+
payload.answer,
|
|
341
|
+
action,
|
|
342
|
+
);
|
|
343
|
+
} else if (body.actionType === "ask_user.cancel") {
|
|
344
|
+
await options.sessionActionsController.handleAskUserCancel(
|
|
345
|
+
String(payload.id ?? ""),
|
|
346
|
+
action,
|
|
347
|
+
);
|
|
348
|
+
} else {
|
|
349
|
+
writeJson(res, { error: "Unknown ask_user action" }, 400);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
writeJson(res, { ok: true });
|
|
353
|
+
} catch (error) {
|
|
354
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
355
|
+
writeJson(res, { error: message }, 409);
|
|
356
|
+
}
|
|
215
357
|
return;
|
|
216
358
|
}
|
|
217
359
|
|
|
218
360
|
const runSessionId = sessionIdFromRoute(url.pathname, "runs");
|
|
219
361
|
if (runSessionId && req.method === "POST") {
|
|
220
362
|
if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
|
|
363
|
+
const body = asRecord(await readJsonBody(req));
|
|
364
|
+
if (!requireSessionActionFields(res, { ...body, sessionId: runSessionId }, false)) return;
|
|
221
365
|
const sessionManager = await resolveSessionManagerById(options, runSessionId);
|
|
222
366
|
if (!sessionManager) {
|
|
223
367
|
writeJson(res, { error: "Unknown saved session" }, 404);
|
|
224
368
|
return;
|
|
225
369
|
}
|
|
226
|
-
|
|
370
|
+
// allowProxy: true — forward to a live coordinator owned by another
|
|
371
|
+
// process (0.11.0 authenticated local forwarding).
|
|
372
|
+
await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body, true);
|
|
227
373
|
return;
|
|
228
374
|
}
|
|
229
375
|
|
|
@@ -236,31 +382,26 @@ async function handleSseChatRun(
|
|
|
236
382
|
res: ServerResponse,
|
|
237
383
|
options: GuiHttpRouteOptions,
|
|
238
384
|
activeRunSessionIds: Set<string>,
|
|
239
|
-
targetSessionManager
|
|
385
|
+
targetSessionManager: SessionManager | undefined,
|
|
386
|
+
bodyOverride: Record<string, unknown> | undefined,
|
|
387
|
+
// Explicit because both remaining callers pass a body: the browser-facing
|
|
388
|
+
// runs route must forward to a live coordinator owned by another process,
|
|
389
|
+
// while the local-coordinator endpoint IS the proxy target and re-proxying
|
|
390
|
+
// there would loop.
|
|
391
|
+
allowProxy: boolean,
|
|
240
392
|
): Promise<void> {
|
|
241
|
-
|
|
242
|
-
|
|
393
|
+
const body = bodyOverride ?? asRecord(await readJsonBody(req));
|
|
394
|
+
const parsed = parseChatRunBody(body);
|
|
395
|
+
if (!parsed.ok) {
|
|
396
|
+
writeJson(res, { error: parsed.error }, 400);
|
|
243
397
|
return;
|
|
244
398
|
}
|
|
245
|
-
|
|
246
|
-
const body = await readJsonBody(req);
|
|
247
|
-
const prompt = String(asRecord(body).prompt ?? "").trim();
|
|
248
|
-
if (!prompt) {
|
|
249
|
-
writeJson(res, { error: "prompt is required" }, 400);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
399
|
const currentSessionManager = options.getSessionManager();
|
|
254
|
-
const
|
|
400
|
+
const bodyRecord = asRecord(body);
|
|
401
|
+
const requestedSessionId = String(bodyRecord.sessionId ?? "").trim();
|
|
255
402
|
const runSessionManager = targetSessionManager ?? currentSessionManager;
|
|
256
403
|
const sessionId = runSessionManager.getSessionId();
|
|
257
|
-
if (
|
|
258
|
-
const sessionConflict = chatRunSessionConflict(requestedSessionId, sessionId);
|
|
259
|
-
if (sessionConflict) {
|
|
260
|
-
writeJson(res, sessionConflict, 409);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
} else if (requestedSessionId && requestedSessionId !== sessionId) {
|
|
404
|
+
if (requestedSessionId && requestedSessionId !== sessionId) {
|
|
264
405
|
writeJson(
|
|
265
406
|
res,
|
|
266
407
|
{ error: "Session route and request body disagree", code: "session_changed" },
|
|
@@ -269,22 +410,212 @@ async function handleSseChatRun(
|
|
|
269
410
|
return;
|
|
270
411
|
}
|
|
271
412
|
|
|
413
|
+
const actionId = String(bodyRecord.actionId ?? "").trim();
|
|
414
|
+
const shouldProxyChatRun = allowProxy && canProxyChatRunToCoordinator(runSessionManager);
|
|
415
|
+
if (shouldProxyChatRun) {
|
|
416
|
+
if (await proxyChatRunToCoordinator(res, runSessionManager, bodyRecord)) {
|
|
417
|
+
const useCurrentSession =
|
|
418
|
+
!targetSessionManager ||
|
|
419
|
+
currentSessionManager.getSessionFile() === runSessionManager.getSessionFile();
|
|
420
|
+
await broadcastFreshRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (actionId && hasAcceptedSessionAction(runSessionManager, actionId)) {
|
|
424
|
+
writeJson(res, { ok: true, duplicate: true });
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (actionId && hasPendingSessionAction(runSessionManager, actionId)) {
|
|
428
|
+
writeJson(
|
|
429
|
+
res,
|
|
430
|
+
{ error: "OpenCandle is reconnecting to this session.", code: "syncing" },
|
|
431
|
+
409,
|
|
432
|
+
);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (shouldBlockFailedCoordinatorAction(runSessionManager, bodyRecord)) {
|
|
436
|
+
writeJson(
|
|
437
|
+
res,
|
|
438
|
+
{ error: "OpenCandle is reconnecting to this session.", code: "syncing" },
|
|
439
|
+
409,
|
|
440
|
+
);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (actionId && hasAcceptedSessionAction(runSessionManager, actionId)) {
|
|
445
|
+
writeJson(res, { ok: true, duplicate: true });
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (actionId && hasPendingSessionAction(runSessionManager, actionId)) {
|
|
449
|
+
writeJson(res, { error: "OpenCandle is reconnecting to this session.", code: "syncing" }, 409);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (options.localSessionCoordinator) {
|
|
454
|
+
const action = buildChatRunActionEnvelope(bodyRecord, sessionId);
|
|
455
|
+
let result: SessionActionResult<{ streamed: boolean }>;
|
|
456
|
+
try {
|
|
457
|
+
result = await options.localSessionCoordinator.runSessionAction(action, async () => {
|
|
458
|
+
const admitted = await streamAcceptedSseChatRun({
|
|
459
|
+
res,
|
|
460
|
+
options,
|
|
461
|
+
activeRunSessionIds,
|
|
462
|
+
targetSessionManager,
|
|
463
|
+
currentSessionManager,
|
|
464
|
+
runSessionManager,
|
|
465
|
+
sessionId,
|
|
466
|
+
parsedRun: parsed.value,
|
|
467
|
+
actionId: action.actionId,
|
|
468
|
+
});
|
|
469
|
+
if (!admitted) throw new SessionActionNotAdmitted();
|
|
470
|
+
return { streamed: true };
|
|
471
|
+
});
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (error instanceof SessionActionNotAdmitted) return;
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
if (!result.ok) {
|
|
477
|
+
writeJson(res, { error: result.message, code: result.code }, 409);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (result.duplicate && !res.headersSent) {
|
|
481
|
+
writeJson(res, { ok: true, duplicate: true });
|
|
482
|
+
}
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
await streamAcceptedSseChatRun({
|
|
487
|
+
res,
|
|
488
|
+
options,
|
|
489
|
+
activeRunSessionIds,
|
|
490
|
+
targetSessionManager,
|
|
491
|
+
currentSessionManager,
|
|
492
|
+
runSessionManager,
|
|
493
|
+
sessionId,
|
|
494
|
+
parsedRun: parsed.value,
|
|
495
|
+
actionId,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export function canProxyChatRunToCoordinator(runSessionManager: SessionManager): boolean {
|
|
500
|
+
const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
|
|
501
|
+
return hasLiveCoordinatorLock(lock);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function proxyChatRunToCoordinator(
|
|
505
|
+
res: ServerResponse,
|
|
506
|
+
runSessionManager: SessionManager,
|
|
507
|
+
body: Record<string, unknown>,
|
|
508
|
+
): Promise<boolean> {
|
|
509
|
+
const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
|
|
510
|
+
if (!hasLiveCoordinatorLock(lock)) return false;
|
|
511
|
+
const endpoint = new URL("/api/local-coordinator/chat-run", lock.coordinatorEndpoint);
|
|
512
|
+
let response: Response;
|
|
513
|
+
try {
|
|
514
|
+
response = await fetch(endpoint, {
|
|
515
|
+
method: "POST",
|
|
516
|
+
headers: {
|
|
517
|
+
"content-type": "application/json",
|
|
518
|
+
"x-opencandle-coordinator-secret": lock.coordinatorSecret,
|
|
519
|
+
},
|
|
520
|
+
body: JSON.stringify({ ...body, sessionId: runSessionManager.getSessionId() }),
|
|
521
|
+
});
|
|
522
|
+
} catch {
|
|
523
|
+
return false;
|
|
524
|
+
}
|
|
525
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
526
|
+
if (response.body) {
|
|
527
|
+
for await (const chunk of response.body) {
|
|
528
|
+
res.write(chunk);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
res.end();
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function hasLiveCoordinatorLock(
|
|
536
|
+
lock: ReturnType<typeof readWriterLock>,
|
|
537
|
+
staleGraceMs = 15_000,
|
|
538
|
+
): lock is NonNullable<ReturnType<typeof readWriterLock>> & {
|
|
539
|
+
coordinatorEndpoint: string;
|
|
540
|
+
coordinatorSecret: string;
|
|
541
|
+
} {
|
|
542
|
+
if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret || lock.pid === process.pid) {
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
const heartbeat = Date.parse(lock.lastHeartbeat);
|
|
546
|
+
const hasFreshHeartbeat = Number.isFinite(heartbeat) && Date.now() - heartbeat <= staleGraceMs;
|
|
547
|
+
return hasFreshHeartbeat && isCoordinatorOwnerAlive(lock.pid);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function streamAcceptedSseChatRun({
|
|
551
|
+
res,
|
|
552
|
+
options,
|
|
553
|
+
activeRunSessionIds,
|
|
554
|
+
targetSessionManager,
|
|
555
|
+
currentSessionManager,
|
|
556
|
+
runSessionManager,
|
|
557
|
+
sessionId,
|
|
558
|
+
parsedRun,
|
|
559
|
+
actionId,
|
|
560
|
+
}: {
|
|
561
|
+
res: ServerResponse;
|
|
562
|
+
options: GuiHttpRouteOptions;
|
|
563
|
+
activeRunSessionIds: Set<string>;
|
|
564
|
+
targetSessionManager?: SessionManager;
|
|
565
|
+
currentSessionManager: SessionManager;
|
|
566
|
+
runSessionManager: SessionManager;
|
|
567
|
+
sessionId: string;
|
|
568
|
+
parsedRun: ParsedChatRunBody;
|
|
569
|
+
actionId: string;
|
|
570
|
+
}): Promise<boolean> {
|
|
571
|
+
const prompt = parsedRun.prompt;
|
|
272
572
|
if (activeRunSessionIds.has(sessionId)) {
|
|
273
573
|
writeJson(res, { error: "Session already has an active run", code: "session_busy" }, 409);
|
|
274
|
-
return;
|
|
574
|
+
return false;
|
|
575
|
+
}
|
|
576
|
+
let dispatchedPrompt: string;
|
|
577
|
+
try {
|
|
578
|
+
dispatchedPrompt = await buildDispatchedPrompt(parsedRun);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
writeJson(res, { error: error instanceof Error ? error.message : String(error) }, 400);
|
|
581
|
+
return false;
|
|
275
582
|
}
|
|
583
|
+
const promptImages = parsedRun.images.map((image) => ({
|
|
584
|
+
type: "image" as const,
|
|
585
|
+
data: image.data,
|
|
586
|
+
mimeType: image.mimeType,
|
|
587
|
+
}));
|
|
588
|
+
const attachmentLabels = parsedRun.attachments.map((attachment) => ({
|
|
589
|
+
kind: attachment.kind,
|
|
590
|
+
label: attachmentLabel(attachment),
|
|
591
|
+
}));
|
|
592
|
+
const inputAttachmentLabels = [
|
|
593
|
+
...promptImages.map((image, index) => ({
|
|
594
|
+
kind: "image",
|
|
595
|
+
label: `${image.mimeType || "image"} #${index + 1}`,
|
|
596
|
+
})),
|
|
597
|
+
...attachmentLabels,
|
|
598
|
+
];
|
|
276
599
|
|
|
277
600
|
const currentSessionFile = currentSessionManager.getSessionFile();
|
|
278
601
|
const targetSessionFile = runSessionManager.getSessionFile();
|
|
279
602
|
const useCurrentSession = !targetSessionManager || currentSessionFile === targetSessionFile;
|
|
280
603
|
let acquiredLockScope = "";
|
|
281
604
|
let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
282
|
-
|
|
605
|
+
const needsWriterLock = !useCurrentSession || options.role !== "writer";
|
|
606
|
+
if (needsWriterLock) {
|
|
283
607
|
const lockScope = writerLockScopeForSession(runSessionManager);
|
|
284
|
-
const lockResult = await acquireWriterLock(lockScope, "gui"
|
|
608
|
+
const lockResult = await acquireWriterLock(lockScope, "gui", {
|
|
609
|
+
coordinatorEndpoint: options.localCoordinatorEndpoint,
|
|
610
|
+
coordinatorSecret: options.localCoordinatorSecret,
|
|
611
|
+
});
|
|
285
612
|
if (lockResult.role !== "writer") {
|
|
286
|
-
writeJson(
|
|
287
|
-
|
|
613
|
+
writeJson(
|
|
614
|
+
res,
|
|
615
|
+
{ error: "OpenCandle is reconnecting to this session.", code: "syncing" },
|
|
616
|
+
409,
|
|
617
|
+
);
|
|
618
|
+
return false;
|
|
288
619
|
}
|
|
289
620
|
acquiredLockScope = lockScope;
|
|
290
621
|
lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
|
|
@@ -302,7 +633,7 @@ async function handleSseChatRun(
|
|
|
302
633
|
if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
|
|
303
634
|
const message = error instanceof Error ? error.message : String(error);
|
|
304
635
|
writeJson(res, { error: message }, 500);
|
|
305
|
-
return;
|
|
636
|
+
return false;
|
|
306
637
|
}
|
|
307
638
|
|
|
308
639
|
res.writeHead(200, {
|
|
@@ -329,17 +660,45 @@ async function handleSseChatRun(
|
|
|
329
660
|
startSeq: seq,
|
|
330
661
|
emit: (event) => writeSse(res, event),
|
|
331
662
|
originalPrompt: prompt,
|
|
663
|
+
originalAttachments: inputAttachmentLabels,
|
|
332
664
|
});
|
|
333
665
|
const observation = createPromptObservation();
|
|
666
|
+
let originalInputMarkerAppended = false;
|
|
667
|
+
const appendOriginalInputMarker = () => {
|
|
668
|
+
if (inputAttachmentLabels.length === 0 || originalInputMarkerAppended) return;
|
|
669
|
+
runSessionManager.appendCustomEntry("opencandle-user-input", {
|
|
670
|
+
original: prompt,
|
|
671
|
+
attachments: inputAttachmentLabels,
|
|
672
|
+
});
|
|
673
|
+
originalInputMarkerAppended = true;
|
|
674
|
+
};
|
|
675
|
+
let actionAccepted = false;
|
|
676
|
+
const recordAcceptedAction = () => {
|
|
677
|
+
if (actionAccepted) return;
|
|
678
|
+
appendOriginalInputMarker();
|
|
679
|
+
recordAcceptedSessionAction(runSessionManager, actionId);
|
|
680
|
+
if (useCurrentSession) options.syncCurrentWriterLockScope?.();
|
|
681
|
+
actionAccepted = true;
|
|
682
|
+
};
|
|
683
|
+
|
|
334
684
|
const unsubscribeLive = runSession.subscribe((event) => {
|
|
335
685
|
liveAdapter.handle(event);
|
|
336
686
|
observePromptEvent(observation, event);
|
|
687
|
+
if (
|
|
688
|
+
!prompt.startsWith("/") &&
|
|
689
|
+
!actionAccepted &&
|
|
690
|
+
observation.userTexts.some((text) => text.trim() === dispatchedPrompt.trim())
|
|
691
|
+
) {
|
|
692
|
+
recordAcceptedAction();
|
|
693
|
+
}
|
|
337
694
|
});
|
|
338
695
|
|
|
339
696
|
try {
|
|
697
|
+
recordPendingSessionAction(runSessionManager, actionId);
|
|
340
698
|
const modelSetup = buildModelSetupState(runSession.modelRegistry, runSession.model);
|
|
341
699
|
if (!prompt.startsWith("/") && modelSetup.requirement !== "ready") {
|
|
342
700
|
runSessionManager.appendMessage({ role: "user", content: prompt, timestamp: Date.now() });
|
|
701
|
+
recordAcceptedAction();
|
|
343
702
|
const message =
|
|
344
703
|
modelSetup.requirement === "select_model"
|
|
345
704
|
? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
|
|
@@ -348,10 +707,17 @@ async function handleSseChatRun(
|
|
|
348
707
|
source: "gui",
|
|
349
708
|
requirement: modelSetup.requirement,
|
|
350
709
|
});
|
|
351
|
-
broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
710
|
+
await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
352
711
|
} else {
|
|
353
|
-
await promptAndSettle(
|
|
354
|
-
|
|
712
|
+
await promptAndSettle(
|
|
713
|
+
runSession,
|
|
714
|
+
dispatchedPrompt,
|
|
715
|
+
beforeIds,
|
|
716
|
+
observation,
|
|
717
|
+
promptImages.length > 0 ? { images: promptImages } : undefined,
|
|
718
|
+
);
|
|
719
|
+
recordAcceptedAction();
|
|
720
|
+
await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
355
721
|
}
|
|
356
722
|
seq = liveAdapter.nextSeq();
|
|
357
723
|
if (seq === liveStartSeq) {
|
|
@@ -375,6 +741,7 @@ async function handleSseChatRun(
|
|
|
375
741
|
}
|
|
376
742
|
writeSse(res, { type: "run.completed", runId, sessionId, seq });
|
|
377
743
|
} catch (error) {
|
|
744
|
+
if (!actionAccepted) clearPendingSessionAction(runSessionManager, actionId);
|
|
378
745
|
seq = liveAdapter.nextSeq();
|
|
379
746
|
const message = error instanceof Error ? error.message : String(error);
|
|
380
747
|
writeSse(res, { type: "run.failed", runId, sessionId, error: { message }, seq });
|
|
@@ -386,14 +753,18 @@ async function handleSseChatRun(
|
|
|
386
753
|
if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
|
|
387
754
|
res.end();
|
|
388
755
|
}
|
|
756
|
+
return actionAccepted;
|
|
389
757
|
}
|
|
390
758
|
|
|
391
|
-
|
|
759
|
+
class SessionActionNotAdmitted extends Error {}
|
|
760
|
+
|
|
761
|
+
async function broadcastRunSessionSnapshot(
|
|
392
762
|
options: GuiHttpRouteOptions,
|
|
393
763
|
sessionManager: SessionManager,
|
|
394
764
|
useCurrentSession: boolean,
|
|
395
|
-
): void {
|
|
765
|
+
): Promise<void> {
|
|
396
766
|
if (useCurrentSession) {
|
|
767
|
+
options.syncCurrentWriterLockScope?.();
|
|
397
768
|
options.wsHub.broadcastState();
|
|
398
769
|
} else {
|
|
399
770
|
options.wsHub.broadcastSessionSnapshot(sessionManager);
|
|
@@ -401,6 +772,51 @@ function broadcastRunSessionSnapshot(
|
|
|
401
772
|
}
|
|
402
773
|
}
|
|
403
774
|
|
|
775
|
+
async function broadcastFreshRunSessionSnapshot(
|
|
776
|
+
options: GuiHttpRouteOptions,
|
|
777
|
+
sessionManager: SessionManager,
|
|
778
|
+
useCurrentSession: boolean,
|
|
779
|
+
): Promise<void> {
|
|
780
|
+
const freshSessionManager = await reloadSessionManager(sessionManager, options);
|
|
781
|
+
if (useCurrentSession) {
|
|
782
|
+
options.syncCurrentWriterLockScope?.();
|
|
783
|
+
options.wsHub.broadcast({
|
|
784
|
+
type: "state.snapshot",
|
|
785
|
+
...buildSnapshotPayload(freshSessionManager),
|
|
786
|
+
});
|
|
787
|
+
} else {
|
|
788
|
+
options.wsHub.broadcast({
|
|
789
|
+
type: "session.snapshot",
|
|
790
|
+
...buildSnapshotPayload(freshSessionManager),
|
|
791
|
+
});
|
|
792
|
+
options.wsHub.broadcastSessions();
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async function reloadSessionManager(
|
|
797
|
+
sessionManager: SessionManager,
|
|
798
|
+
options: Pick<GuiHttpRouteOptions, "sessionDir" | "cwd">,
|
|
799
|
+
): Promise<SessionManager> {
|
|
800
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
801
|
+
return sessionFile
|
|
802
|
+
? SessionManager.open(sessionFile, options.sessionDir, options.cwd)
|
|
803
|
+
: sessionManager;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function buildSnapshotPayload(sessionManager: SessionManager): Record<string, unknown> {
|
|
807
|
+
const sessionId = sessionManager.getSessionId();
|
|
808
|
+
const entries = sessionManager.getEntries();
|
|
809
|
+
return {
|
|
810
|
+
sessionId,
|
|
811
|
+
state: projectDashboard(entries, sessionId, getSavedMarketStateSymbols()),
|
|
812
|
+
entries,
|
|
813
|
+
events: sessionEntriesToChatEvents(entries, {
|
|
814
|
+
sessionId,
|
|
815
|
+
title: sessionManager.getSessionName(),
|
|
816
|
+
}),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
|
|
404
820
|
export async function buildSessionBootstrapPayload(
|
|
405
821
|
options: Pick<
|
|
406
822
|
GuiHttpRouteOptions,
|
|
@@ -414,13 +830,18 @@ export async function buildSessionBootstrapPayload(
|
|
|
414
830
|
return {
|
|
415
831
|
role: roleForSessionBootstrap(options, sessionManager),
|
|
416
832
|
sessionId,
|
|
833
|
+
coordination: {
|
|
834
|
+
sessionId,
|
|
835
|
+
status: roleForSessionBootstrap(options, sessionManager) === "writer" ? "ready" : "syncing",
|
|
836
|
+
ownerKind: ownerKindForSessionBootstrap(options, sessionManager),
|
|
837
|
+
},
|
|
417
838
|
catalog: buildCatalog(),
|
|
418
839
|
modelSetup: options.modelSetupController.buildCurrentModelSetupState(),
|
|
419
840
|
askUserPrompts: Array.isArray(bootstrap.askUserPrompts) ? bootstrap.askUserPrompts : [],
|
|
420
841
|
sessions: await SessionManager.list(options.cwd, options.sessionDir),
|
|
421
842
|
snapshot: {
|
|
422
843
|
sessionId,
|
|
423
|
-
state: projectDashboard(entries, sessionId),
|
|
844
|
+
state: projectDashboard(entries, sessionId, getSavedMarketStateSymbols()),
|
|
424
845
|
entries,
|
|
425
846
|
events: sessionEntriesToChatEvents(entries, {
|
|
426
847
|
sessionId,
|
|
@@ -430,6 +851,14 @@ export async function buildSessionBootstrapPayload(
|
|
|
430
851
|
};
|
|
431
852
|
}
|
|
432
853
|
|
|
854
|
+
function ownerKindForSessionBootstrap(
|
|
855
|
+
options: Pick<GuiHttpRouteOptions, "getSessionManager" | "role">,
|
|
856
|
+
sessionManager: SessionManager,
|
|
857
|
+
): string | undefined {
|
|
858
|
+
if (roleForSessionBootstrap(options, sessionManager) === "writer") return "gui";
|
|
859
|
+
return readWriterLock(writerLockScopeForSession(sessionManager))?.processKind;
|
|
860
|
+
}
|
|
861
|
+
|
|
433
862
|
function roleForSessionBootstrap(
|
|
434
863
|
options: Pick<GuiHttpRouteOptions, "role" | "getSessionManager">,
|
|
435
864
|
sessionManager: SessionManager,
|
|
@@ -462,6 +891,180 @@ export function sessionIdFromRoute(pathname: string, action: "bootstrap" | "runs
|
|
|
462
891
|
}
|
|
463
892
|
}
|
|
464
893
|
|
|
894
|
+
export function buildChatRunActionEnvelope(
|
|
895
|
+
body: Record<string, unknown>,
|
|
896
|
+
sessionId: string,
|
|
897
|
+
): SessionActionEnvelope {
|
|
898
|
+
const actionId = String(body.actionId ?? "").trim();
|
|
899
|
+
const parsed = parseChatRunBody(body);
|
|
900
|
+
const attachments = parsed.ok ? parsed.value.attachments : [];
|
|
901
|
+
const images = parsed.ok ? parsed.value.images : [];
|
|
902
|
+
return {
|
|
903
|
+
sessionId,
|
|
904
|
+
actionId,
|
|
905
|
+
actionType: "chat.prompt",
|
|
906
|
+
payload: {
|
|
907
|
+
prompt: String(body.prompt ?? ""),
|
|
908
|
+
...(images.length > 0 ? { imageCount: images.length } : {}),
|
|
909
|
+
...(attachments.length > 0
|
|
910
|
+
? { attachments: attachments.map((attachment) => ({ kind: attachment.kind })) }
|
|
911
|
+
: {}),
|
|
912
|
+
},
|
|
913
|
+
source: "browser",
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
export function parseChatRunBody(body: unknown): ChatRunParseResult {
|
|
918
|
+
const record = asRecord(body);
|
|
919
|
+
const prompt = String(record.prompt ?? "").trim();
|
|
920
|
+
if (!prompt) return { ok: false, error: "prompt is required" };
|
|
921
|
+
|
|
922
|
+
const rawImages = record.images;
|
|
923
|
+
const images: ChatRunImageInput[] = [];
|
|
924
|
+
if (rawImages !== undefined) {
|
|
925
|
+
if (!Array.isArray(rawImages)) return { ok: false, error: "images must be an array" };
|
|
926
|
+
if (rawImages.length > CHAT_RUN_MAX_IMAGE_COUNT) {
|
|
927
|
+
return { ok: false, error: "Attach up to 4 images" };
|
|
928
|
+
}
|
|
929
|
+
for (const rawImage of rawImages) {
|
|
930
|
+
const image = asRecord(rawImage);
|
|
931
|
+
const mimeType = String(image.mimeType ?? "");
|
|
932
|
+
const data = String(image.data ?? "");
|
|
933
|
+
if (!CHAT_RUN_IMAGE_MIME_TYPES.has(mimeType)) {
|
|
934
|
+
return { ok: false, error: "Unsupported image mime type" };
|
|
935
|
+
}
|
|
936
|
+
if (data.length > Math.ceil(CHAT_RUN_MAX_IMAGE_BYTES / 3) * 4) {
|
|
937
|
+
return { ok: false, error: "Image attachment must be 5 MB or smaller" };
|
|
938
|
+
}
|
|
939
|
+
const decoded = decodeStrictBase64(data);
|
|
940
|
+
if (!decoded) {
|
|
941
|
+
return { ok: false, error: "Image attachment data must be valid base64" };
|
|
942
|
+
}
|
|
943
|
+
if (decoded.byteLength > CHAT_RUN_MAX_IMAGE_BYTES) {
|
|
944
|
+
return { ok: false, error: "Image attachment must be 5 MB or smaller" };
|
|
945
|
+
}
|
|
946
|
+
images.push({ data, mimeType });
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const rawAttachments = record.attachments;
|
|
951
|
+
const attachments: ChatRunAttachmentInput[] = [];
|
|
952
|
+
if (rawAttachments !== undefined) {
|
|
953
|
+
if (!Array.isArray(rawAttachments)) {
|
|
954
|
+
return { ok: false, error: "attachments must be an array" };
|
|
955
|
+
}
|
|
956
|
+
for (const rawAttachment of rawAttachments) {
|
|
957
|
+
const attachment = asRecord(rawAttachment);
|
|
958
|
+
const kind = String(attachment.kind ?? "");
|
|
959
|
+
const id = typeof attachment.id === "string" ? attachment.id.trim() : "";
|
|
960
|
+
if (kind === "portfolio") {
|
|
961
|
+
attachments.push(id ? { kind, id } : { kind });
|
|
962
|
+
} else if (kind === "watchlist" || kind === "report") {
|
|
963
|
+
if (!id) return { ok: false, error: `${kind} attachment id is required` };
|
|
964
|
+
attachments.push({ kind, id });
|
|
965
|
+
} else {
|
|
966
|
+
return { ok: false, error: "Unsupported attachment kind" };
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
if (prompt.startsWith("/") && (images.length > 0 || attachments.length > 0)) {
|
|
972
|
+
return { ok: false, error: "Attachments are not supported for slash commands" };
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return { ok: true, value: { prompt, images, attachments } };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function decodeStrictBase64(data: string): Buffer | null {
|
|
979
|
+
if (!data || data.trim() !== data) return null;
|
|
980
|
+
if (data.length % 4 !== 0) return null;
|
|
981
|
+
if (/[^A-Za-z0-9+/=]/.test(data)) return null;
|
|
982
|
+
const firstPadding = data.indexOf("=");
|
|
983
|
+
if (firstPadding !== -1) {
|
|
984
|
+
const padding = data.slice(firstPadding);
|
|
985
|
+
if (!/^={1,2}$/.test(padding)) return null;
|
|
986
|
+
}
|
|
987
|
+
const decoded = Buffer.from(data, "base64");
|
|
988
|
+
if (decoded.byteLength === 0) return null;
|
|
989
|
+
return decoded.toString("base64") === data ? decoded : null;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export async function buildDispatchedPrompt(parsed: ParsedChatRunBody): Promise<string> {
|
|
993
|
+
if (parsed.prompt.startsWith("/")) return parsed.prompt;
|
|
994
|
+
if (parsed.attachments.length === 0) return parsed.prompt;
|
|
995
|
+
const db = initDefaultDatabase();
|
|
996
|
+
try {
|
|
997
|
+
const service = new MarketStateService(db);
|
|
998
|
+
const blocks = parsed.attachments.map((attachment) => {
|
|
999
|
+
const lines = attachmentSummaryLines(service, attachment);
|
|
1000
|
+
if (lines.length === 0) throw new Error(`${attachment.kind} attachment was empty`);
|
|
1001
|
+
return `[Attached by user — ${attachment.kind}]\n${lines.join("\n")}`;
|
|
1002
|
+
});
|
|
1003
|
+
return [parsed.prompt, ...blocks].join("\n\n");
|
|
1004
|
+
} finally {
|
|
1005
|
+
db.close();
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
function attachmentSummaryLines(
|
|
1010
|
+
service: MarketStateService,
|
|
1011
|
+
attachment: ChatRunAttachmentInput,
|
|
1012
|
+
): string[] {
|
|
1013
|
+
if (attachment.kind === "portfolio") {
|
|
1014
|
+
const portfolioId = attachment.id ? Number(attachment.id) : undefined;
|
|
1015
|
+
if (attachment.id && !Number.isFinite(portfolioId)) {
|
|
1016
|
+
throw new Error("Unknown portfolio attachment");
|
|
1017
|
+
}
|
|
1018
|
+
return formatPortfolioSummary(service.listPortfolioLots(portfolioId));
|
|
1019
|
+
}
|
|
1020
|
+
if (attachment.kind === "watchlist") {
|
|
1021
|
+
const watchlistId =
|
|
1022
|
+
attachment.id === "default" ? service.getDefaultWatchlist().id : Number(attachment.id);
|
|
1023
|
+
if (!Number.isFinite(watchlistId)) throw new Error("Unknown watchlist attachment");
|
|
1024
|
+
return formatWatchlistSummary(service.listWatchlistItems(watchlistId));
|
|
1025
|
+
}
|
|
1026
|
+
const reports = service.listReportRuns();
|
|
1027
|
+
const report =
|
|
1028
|
+
attachment.id === "latest"
|
|
1029
|
+
? reports[0]
|
|
1030
|
+
: reports.find((candidate) => candidate.id === Number(attachment.id));
|
|
1031
|
+
if (!report) throw new Error("Unknown report attachment");
|
|
1032
|
+
return formatLatestReportSummary(report);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function attachmentLabel(attachment: ChatRunAttachmentInput): string {
|
|
1036
|
+
if (attachment.kind === "portfolio") return "Portfolio";
|
|
1037
|
+
if (attachment.kind === "watchlist") return "Watchlist";
|
|
1038
|
+
return "Latest report";
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
function hasClientActionId(body: Record<string, unknown>): boolean {
|
|
1042
|
+
return typeof body.actionId === "string" && body.actionId.trim().length > 0;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function requireSessionActionFields(
|
|
1046
|
+
res: ServerResponse,
|
|
1047
|
+
body: Record<string, unknown>,
|
|
1048
|
+
requireSessionId = true,
|
|
1049
|
+
): boolean {
|
|
1050
|
+
if (requireSessionId && !String(body.sessionId ?? "").trim()) {
|
|
1051
|
+
writeJson(res, { error: "sessionId is required" }, 400);
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
1054
|
+
if (!String(body.actionId ?? "").trim()) {
|
|
1055
|
+
writeJson(res, { error: "actionId is required" }, 400);
|
|
1056
|
+
return false;
|
|
1057
|
+
}
|
|
1058
|
+
return true;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function shouldBlockFailedCoordinatorAction(
|
|
1062
|
+
runSessionManager: SessionManager,
|
|
1063
|
+
body: Record<string, unknown>,
|
|
1064
|
+
): boolean {
|
|
1065
|
+
return hasClientActionId(body) && shouldBlockFailedCoordinatorLockAction(runSessionManager);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
465
1068
|
async function handleTrustedGuiMutation(
|
|
466
1069
|
req: IncomingMessage,
|
|
467
1070
|
res: ServerResponse,
|
|
@@ -473,7 +1076,15 @@ async function handleTrustedGuiMutation(
|
|
|
473
1076
|
writeJson(res, await options.wsHub.buildBootstrapPayload());
|
|
474
1077
|
} catch (error) {
|
|
475
1078
|
const message = error instanceof Error ? error.message : String(error);
|
|
476
|
-
|
|
1079
|
+
const coordinationError = message === "Read-only follower mode";
|
|
1080
|
+
writeJson(
|
|
1081
|
+
res,
|
|
1082
|
+
{
|
|
1083
|
+
error: coordinationError ? "OpenCandle is reconnecting to this session." : message,
|
|
1084
|
+
...(coordinationError ? { code: "syncing" } : {}),
|
|
1085
|
+
},
|
|
1086
|
+
coordinationError ? 409 : 400,
|
|
1087
|
+
);
|
|
477
1088
|
}
|
|
478
1089
|
}
|
|
479
1090
|
|
|
@@ -531,6 +1142,19 @@ function allowTrustedGuiRequest(
|
|
|
531
1142
|
return false;
|
|
532
1143
|
}
|
|
533
1144
|
|
|
1145
|
+
function allowLocalCoordinatorRequest(
|
|
1146
|
+
req: IncomingMessage,
|
|
1147
|
+
res: ServerResponse,
|
|
1148
|
+
options: GuiHttpRouteOptions,
|
|
1149
|
+
): boolean {
|
|
1150
|
+
if (req.headers["x-opencandle-coordinator-secret"] === options.localCoordinatorSecret) {
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
1154
|
+
res.end(JSON.stringify({ error: "Local coordinator request was not authorized." }));
|
|
1155
|
+
return false;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
534
1158
|
function privateGuiHeaders(
|
|
535
1159
|
contentTypeValue: string,
|
|
536
1160
|
options: GuiHttpRouteOptions,
|