opencandle 0.9.0 → 0.11.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.
- package/README.md +7 -5
- package/dist/analysts/orchestrator.d.ts +1 -4
- package/dist/analysts/orchestrator.js +15 -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/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/memory/sqlite.js +20 -19
- package/dist/memory/sqlite.js.map +1 -1
- package/dist/pi/opencandle-extension.js +13 -221
- 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/routing/classify-intent.js +4 -5
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/route-manifest.js +0 -1
- package/dist/routing/route-manifest.js.map +1 -1
- package/dist/routing/router-prompt.js +1 -1
- package/dist/routing/router.js +35 -4
- package/dist/routing/router.js.map +1 -1
- package/dist/runtime/session-coordinator.js +2 -13
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/system-prompt.js +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.js +49 -6
- package/dist/tools/fundamentals/dcf.js.map +1 -1
- package/dist/tools/index.d.ts +0 -1
- package/dist/tools/index.js +0 -3
- package/dist/tools/index.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/technical/backtest.d.ts +23 -5
- package/dist/tools/technical/backtest.js +131 -94
- package/dist/tools/technical/backtest.js.map +1 -1
- package/gui/server/http-routes.ts +395 -19
- package/gui/server/invoke-tool.ts +164 -16
- package/gui/server/local-session-coordinator.ts +97 -0
- package/gui/server/market-state-api.ts +1 -43
- package/gui/server/server.ts +51 -6
- package/gui/server/session-actions.ts +117 -8
- package/gui/server/tool-metadata.ts +3 -1
- package/gui/server/ws-hub.ts +61 -6
- package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +1 -0
- package/gui/web/dist/assets/index-BzyqyVnd.css +2 -0
- package/gui/web/dist/assets/index-DvMjkOP9.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/analysts/orchestrator.ts +15 -56
- package/src/cli-main.ts +253 -13
- package/src/config.ts +12 -20
- package/src/market-state/daily-report.ts +6 -16
- package/src/market-state/service.ts +0 -136
- package/src/memory/sqlite.ts +23 -19
- package/src/pi/opencandle-extension.ts +12 -265
- 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/routing/classify-intent.ts +4 -5
- package/src/routing/route-manifest.ts +0 -1
- package/src/routing/router-prompt.ts +1 -1
- package/src/routing/router.ts +39 -3
- package/src/runtime/session-coordinator.ts +2 -17
- package/src/system-prompt.ts +1 -1
- package/src/tools/AGENTS.md +1 -1
- package/src/tools/fundamentals/dcf.ts +57 -7
- package/src/tools/index.ts +0 -3
- package/src/tools/portfolio/daily-report.ts +10 -4
- package/src/tools/technical/backtest.ts +167 -108
- 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/web/dist/assets/CatalogOverlay-DcTXLc7u.js +0 -1
- package/gui/web/dist/assets/index-DdMnIvZ7.css +0 -2
- package/gui/web/dist/assets/index-YlMaahAS.js +0 -65
- 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,
|
|
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
|
|
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",
|
|
@@ -136,9 +136,9 @@ const RULES: Rule[] = [
|
|
|
136
136
|
return hasOptionKeywordsInText(lower) && entities.symbols.length >= 1;
|
|
137
137
|
},
|
|
138
138
|
},
|
|
139
|
-
// Stateful portfolio/watchlist/alert
|
|
140
|
-
//
|
|
141
|
-
//
|
|
139
|
+
// Stateful portfolio/watchlist/alert mutations must not be mistaken for
|
|
140
|
+
// compare or portfolio-construction workflows just because a cost basis,
|
|
141
|
+
// target, or currency token is present.
|
|
142
142
|
{
|
|
143
143
|
workflow: "watchlist_or_tracking",
|
|
144
144
|
confidence: 0.95,
|
|
@@ -184,7 +184,6 @@ const RULES: Rule[] = [
|
|
|
184
184
|
const lower = input.toLowerCase();
|
|
185
185
|
return (
|
|
186
186
|
/\bwatchlist\b/.test(lower) ||
|
|
187
|
-
/\bprediction/i.test(lower) ||
|
|
188
187
|
/\bshow\s+my\s+portfolio\b/.test(lower) ||
|
|
189
188
|
/\bmy\s+portfolio\b/.test(lower) ||
|
|
190
189
|
/\btrack\b/.test(lower)
|
|
@@ -314,7 +313,7 @@ function isStatefulTrackingRequest(input: string): boolean {
|
|
|
314
313
|
lower,
|
|
315
314
|
);
|
|
316
315
|
const hasStateObject =
|
|
317
|
-
/\b(?:watchlist|portfolio|holding|holdings|position|positions|
|
|
316
|
+
/\b(?:watchlist|portfolio|holding|holdings|position|positions|alert|alerts|daily\s+report|watchlist\s+report|report\s+history)\b/.test(
|
|
318
317
|
lower,
|
|
319
318
|
);
|
|
320
319
|
const hasPortfolioLotShape =
|
|
@@ -19,7 +19,7 @@ function renderCatalog(): string {
|
|
|
19
19
|
compare_assets: "user asks to compare two or more symbols (vs / versus / which is better)",
|
|
20
20
|
single_asset_analysis:
|
|
21
21
|
"user asks for a full analysis / deep dive / 'is X attractive' on ONE symbol",
|
|
22
|
-
watchlist_or_tracking: "user manages or asks about their saved watchlist
|
|
22
|
+
watchlist_or_tracking: "user manages or asks about their saved watchlist or tracked positions",
|
|
23
23
|
general_finance_qa:
|
|
24
24
|
"definitional / conceptual questions plus broad market structure, sector, industry, monetary policy, and emerging markets research",
|
|
25
25
|
};
|
package/src/routing/router.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
legacyRouteForRouteKind,
|
|
13
13
|
routeKindFromLegacyRoute,
|
|
14
14
|
selectToolBundles,
|
|
15
|
+
workflowRequiredSlots,
|
|
15
16
|
} from "./route-manifest.js";
|
|
16
17
|
import { buildRouterPrompt } from "./router-prompt.js";
|
|
17
18
|
import type {
|
|
@@ -127,7 +128,7 @@ export function validateRouterOutput(raw: string): RouterOutput {
|
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
const entities = validateEntities(obj.entities);
|
|
130
|
-
const slots = validateSlots(obj.slots);
|
|
131
|
+
const slots = canonicalizeSymbolSlots(workflow, validateSlots(obj.slots));
|
|
131
132
|
const preference_updates = validatePreferenceUpdates(obj.preference_updates);
|
|
132
133
|
const missing_required = rawMissingRequired;
|
|
133
134
|
const tool_bundles = validateToolBundles(obj.tool_bundles);
|
|
@@ -659,7 +660,7 @@ function isStatefulTrackingRequest(text: string): boolean {
|
|
|
659
660
|
lower,
|
|
660
661
|
);
|
|
661
662
|
const hasStateObject =
|
|
662
|
-
/\b(?:watchlist|portfolio|holding|holdings|position|positions|
|
|
663
|
+
/\b(?:watchlist|portfolio|holding|holdings|position|positions|alert|alerts|daily\s+report|watchlist\s+report|report\s+history)\b/.test(
|
|
663
664
|
lower,
|
|
664
665
|
);
|
|
665
666
|
const hasPortfolioLotShape =
|
|
@@ -816,7 +817,8 @@ function validateSlots(raw: unknown): Record<string, RouterSlot> {
|
|
|
816
817
|
if (!VALID_CONFIDENCE.has(s.confidence as string)) {
|
|
817
818
|
throw new Error(`slot ${key} has invalid confidence: ${JSON.stringify(s.confidence)}`);
|
|
818
819
|
}
|
|
819
|
-
|
|
820
|
+
const canonicalKey = canonicalSlotKey(key);
|
|
821
|
+
out[canonicalKey] = {
|
|
820
822
|
value: s.value,
|
|
821
823
|
source: s.source as RouterSlot["source"],
|
|
822
824
|
confidence: s.confidence as RouterSlot["confidence"],
|
|
@@ -825,6 +827,40 @@ function validateSlots(raw: unknown): Record<string, RouterSlot> {
|
|
|
825
827
|
return out;
|
|
826
828
|
}
|
|
827
829
|
|
|
830
|
+
// The slot contract uses snake_case keys; non-Claude router models sometimes
|
|
831
|
+
// emit camelCase (timeHorizon, riskProfile). Canonicalize deterministically so
|
|
832
|
+
// downstream slot resolution and missing-required checks match.
|
|
833
|
+
function canonicalSlotKey(key: string): string {
|
|
834
|
+
return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Models also drift between scalar `symbol` and one-element `symbols` slot
|
|
838
|
+
// shapes. Canonicalize toward the workflow's manifest-declared required slot
|
|
839
|
+
// name when the conversion is unambiguous.
|
|
840
|
+
function canonicalizeSymbolSlots(
|
|
841
|
+
workflow: RouterOutput["workflow"],
|
|
842
|
+
slots: Record<string, RouterSlot>,
|
|
843
|
+
): Record<string, RouterSlot> {
|
|
844
|
+
if (!workflow) return slots;
|
|
845
|
+
const required = workflowRequiredSlots(workflow);
|
|
846
|
+
const next = { ...slots };
|
|
847
|
+
if (required.includes("symbol") && next.symbol == null && next.symbols != null) {
|
|
848
|
+
const value = next.symbols.value;
|
|
849
|
+
if (Array.isArray(value) && value.length === 1) {
|
|
850
|
+
next.symbol = { ...next.symbols, value: value[0] };
|
|
851
|
+
delete next.symbols;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (required.includes("symbols") && next.symbols == null && next.symbol != null) {
|
|
855
|
+
const value = next.symbol.value;
|
|
856
|
+
if (typeof value === "string" && value.length > 0) {
|
|
857
|
+
next.symbols = { ...next.symbol, value: [value] };
|
|
858
|
+
delete next.symbol;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return next;
|
|
862
|
+
}
|
|
863
|
+
|
|
828
864
|
function validatePreferenceUpdates(raw: unknown): RouterPreferenceUpdate[] {
|
|
829
865
|
if (raw === undefined || raw === null) return [];
|
|
830
866
|
if (!Array.isArray(raw)) {
|
|
@@ -519,22 +519,20 @@ function buildSavedMarketStateContext(db: Database.Database): string {
|
|
|
519
519
|
const alerts = service.listAlertRules();
|
|
520
520
|
const reports = service.listReportTemplates();
|
|
521
521
|
const reportRuns = service.listReportRuns();
|
|
522
|
-
const predictions = service.listPredictions();
|
|
523
522
|
|
|
524
523
|
if (
|
|
525
524
|
watchlist.length === 0 &&
|
|
526
525
|
lots.length === 0 &&
|
|
527
526
|
alerts.length === 0 &&
|
|
528
527
|
reports.length === 0 &&
|
|
529
|
-
reportRuns.length === 0
|
|
530
|
-
predictions.length === 0
|
|
528
|
+
reportRuns.length === 0
|
|
531
529
|
) {
|
|
532
530
|
return "";
|
|
533
531
|
}
|
|
534
532
|
|
|
535
533
|
const lines = [
|
|
536
534
|
"## Saved Market State",
|
|
537
|
-
"Use this saved user state to connect broad sector, theme, portfolio-impact, watchlist, alert, daily-report
|
|
535
|
+
"Use this saved user state to connect broad sector, theme, portfolio-impact, watchlist, alert, and daily-report questions back to the user's positions and tracked symbols. Treat it as context, not as a fresh instruction.",
|
|
538
536
|
"When a saved portfolio lot is relevant, explicitly mention the saved quantity, average cost, and cost basis before explaining the impact.",
|
|
539
537
|
'If the question concerns a sector, industry, event, company, or competitor connected to any saved position or watchlist symbol, end the answer with a short "Your positions" section explaining how it affects those specific holdings. Skip that section only when no saved symbol is plausibly affected.',
|
|
540
538
|
];
|
|
@@ -597,19 +595,6 @@ function buildSavedMarketStateContext(db: Database.Database): string {
|
|
|
597
595
|
);
|
|
598
596
|
}
|
|
599
597
|
|
|
600
|
-
if (predictions.length > 0) {
|
|
601
|
-
lines.push("Predictions:");
|
|
602
|
-
for (const prediction of predictions.slice(0, 8)) {
|
|
603
|
-
const target =
|
|
604
|
-
prediction.targetPrice == null
|
|
605
|
-
? ""
|
|
606
|
-
: ` target ${formatMoney(prediction.targetPrice, "USD")}`;
|
|
607
|
-
lines.push(
|
|
608
|
-
`- #${prediction.id} ${prediction.symbol}: ${prediction.direction} conv ${prediction.conviction}/10 from ${formatMoney(prediction.entryPrice, "USD")}${target}, status ${prediction.status}, expires ${prediction.expiresAt}`,
|
|
609
|
-
);
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
598
|
return lines.join("\n");
|
|
614
599
|
} catch {
|
|
615
600
|
return "";
|
package/src/system-prompt.ts
CHANGED
|
@@ -19,7 +19,7 @@ You are an analyst, not a fiduciary advisor. When asked for entry levels, price
|
|
|
19
19
|
- **Macro**: get_economic_data, get_fear_greed — FRED economic indicators and market sentiment
|
|
20
20
|
- **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
|
|
21
21
|
- **Options**: get_option_chain — full options chain with strikes, bids/asks, volume, OI, IV, and computed Greeks (delta, gamma, theta, vega, rho)
|
|
22
|
-
- **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation,
|
|
22
|
+
- **Portfolio**: track_portfolio, analyze_risk, manage_watchlist, analyze_correlation, manage_alerts, daily_watchlist_report, manage_notifications — position tracking, P&L, Sharpe ratio, VaR, watchlist tracking, durable local alerts, daily watchlist reports, notification history, and correlation matrix
|
|
23
23
|
- **User Interaction**: ask_user — ask clarification questions and setup confirmations
|
|
24
24
|
|
|
25
25
|
## Analytical Framework
|
package/src/tools/AGENTS.md
CHANGED
|
@@ -10,7 +10,7 @@ src/tools/
|
|
|
10
10
|
├── options/ # Options chains, Greeks computation
|
|
11
11
|
├── macro/ # FRED economic data, fear & greed index
|
|
12
12
|
├── sentiment/ # Reddit sentiment, news sentiment
|
|
13
|
-
├── portfolio/ # Tracker, risk analysis, watchlist, correlation
|
|
13
|
+
├── portfolio/ # Tracker, risk analysis, watchlist, correlation
|
|
14
14
|
├── market/ # Stock quotes, history, crypto, ticker search
|
|
15
15
|
└── index.ts # getAllTools() registry — add new tools here
|
|
16
16
|
```
|
|
@@ -45,6 +45,14 @@ export function computeDCF(params: DCFParams): DCFResult {
|
|
|
45
45
|
sharesOutstanding,
|
|
46
46
|
} = params;
|
|
47
47
|
|
|
48
|
+
// Gordon Growth requires discount > terminal growth; a non-positive spread
|
|
49
|
+
// divides by zero or flips the terminal value's sign.
|
|
50
|
+
if (discountRate <= terminalGrowth) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Gordon Growth constraint violated: terminal growth (${(terminalGrowth * 100).toFixed(1)}%) must be below the discount rate (${(discountRate * 100).toFixed(1)}%).`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
// Project future cash flows (mid-year convention: discount at year-0.5)
|
|
49
57
|
const projectedCashFlows: Array<{ year: number; fcf: number; presentValue: number }> = [];
|
|
50
58
|
for (let y = 1; y <= years; y++) {
|
|
@@ -152,12 +160,11 @@ function computeDCFSimple(
|
|
|
152
160
|
return (sumPV + pvTV - debt) / shares;
|
|
153
161
|
}
|
|
154
162
|
|
|
155
|
-
export function computeNetDebt(f: FinancialStatement): number {
|
|
163
|
+
export function computeNetDebt(f: FinancialStatement): number | null {
|
|
156
164
|
if (f.totalDebt != null && f.cashAndEquivalents != null) {
|
|
157
165
|
return f.totalDebt - f.cashAndEquivalents;
|
|
158
166
|
}
|
|
159
|
-
|
|
160
|
-
return f.totalLiabilities - f.totalAssets;
|
|
167
|
+
return null;
|
|
161
168
|
}
|
|
162
169
|
|
|
163
170
|
const params = Type.Object({
|
|
@@ -243,10 +250,45 @@ export const dcfTool: AgentTool<typeof params> = {
|
|
|
243
250
|
|
|
244
251
|
const discountRate = args.discount_rate ?? 0.1;
|
|
245
252
|
const terminalGrowth = args.terminal_growth ?? 0.03;
|
|
253
|
+
if (discountRate <= terminalGrowth) {
|
|
254
|
+
return {
|
|
255
|
+
content: [
|
|
256
|
+
{
|
|
257
|
+
type: "text",
|
|
258
|
+
text: `⚠ Invalid DCF assumptions for ${symbol}: terminal growth (${(terminalGrowth * 100).toFixed(1)}%) must be below the discount rate (${(discountRate * 100).toFixed(1)}%) for the Gordon Growth terminal value to be meaningful. Lower terminal_growth or raise discount_rate.`,
|
|
259
|
+
},
|
|
260
|
+
],
|
|
261
|
+
details: null,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
246
265
|
const years = args.projection_years ?? 5;
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
266
|
+
// Prefer the overview market cap; fall back to the quote's market cap.
|
|
267
|
+
// Never substitute a placeholder share count — a fabricated
|
|
268
|
+
// per-share value is worse than an honest refusal.
|
|
269
|
+
const marketCap =
|
|
270
|
+
overview?.marketCap && overview.marketCap > 0
|
|
271
|
+
? overview.marketCap
|
|
272
|
+
: quote.marketCap > 0
|
|
273
|
+
? quote.marketCap
|
|
274
|
+
: 0;
|
|
275
|
+
if (quote.price <= 0 || marketCap <= 0) {
|
|
276
|
+
return {
|
|
277
|
+
content: [
|
|
278
|
+
{
|
|
279
|
+
type: "text",
|
|
280
|
+
text: `⚠ Cannot compute a per-share DCF for ${symbol}: shares outstanding cannot be derived because ${quote.price <= 0 ? "the current quote price is unavailable" : "market capitalization is unavailable from the overview and quote providers"}. Per-share intrinsic value requires a real share count.`,
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
details: null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const sharesOutstanding = marketCap / quote.price;
|
|
287
|
+
// Signed on purpose: net cash (negative net debt) adds to equity value.
|
|
288
|
+
// Omit the adjustment if debt/cash fields are missing; assets minus
|
|
289
|
+
// liabilities is book equity, not a net-cash proxy.
|
|
290
|
+
const computedNetDebt = financials[0] ? computeNetDebt(financials[0]) : null;
|
|
291
|
+
const netDebt = computedNetDebt ?? 0;
|
|
250
292
|
|
|
251
293
|
const result = computeDCF({
|
|
252
294
|
freeCashFlow: latestFCF,
|
|
@@ -254,9 +296,14 @@ export const dcfTool: AgentTool<typeof params> = {
|
|
|
254
296
|
discountRate,
|
|
255
297
|
terminalGrowth,
|
|
256
298
|
years,
|
|
257
|
-
netDebt
|
|
299
|
+
netDebt,
|
|
258
300
|
sharesOutstanding,
|
|
259
301
|
});
|
|
302
|
+
if (computedNetDebt == null) {
|
|
303
|
+
result.warnings.push(
|
|
304
|
+
"Net debt adjustment omitted because total debt and cash equivalents were unavailable.",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
260
307
|
|
|
261
308
|
const marginOfSafety = (result.intrinsicValue - quote.price) / result.intrinsicValue;
|
|
262
309
|
const upside = (result.intrinsicValue - quote.price) / quote.price;
|
|
@@ -286,6 +333,9 @@ export const dcfTool: AgentTool<typeof params> = {
|
|
|
286
333
|
``,
|
|
287
334
|
`**Sensitivity Table** (Intrinsic Value at different Growth/Discount rates)`,
|
|
288
335
|
...formatSensitivityTable(result.sensitivityTable),
|
|
336
|
+
...(result.warnings.length > 0
|
|
337
|
+
? ["", "**Warnings**", ...result.warnings.map((warning) => `- ${warning}`)]
|
|
338
|
+
: []),
|
|
289
339
|
];
|
|
290
340
|
|
|
291
341
|
return {
|