echopai 2.0.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 +386 -0
- package/dist/_generated/commands.js +274 -0
- package/dist/_generated/help.js +190 -0
- package/dist/_generated/operations.js +1306 -0
- package/dist/bin.js +170 -0
- package/dist/runtime/auth.js +95 -0
- package/dist/runtime/envelope.js +52 -0
- package/dist/runtime/errors.js +186 -0
- package/dist/runtime/filters.js +153 -0
- package/dist/runtime/format.js +143 -0
- package/dist/runtime/http.js +65 -0
- package/dist/runtime/idempotency.js +18 -0
- package/dist/runtime/invoker.js +387 -0
- package/dist/runtime/io.js +16 -0
- package/dist/runtime/paginator.js +146 -0
- package/dist/runtime/trace.js +99 -0
- package/dist/runtime/tty.js +51 -0
- package/dist/runtime/verb_cmd.js +70 -0
- package/dist/runtime/verb_runner.js +152 -0
- package/dist/runtime/whoami_cache.js +109 -0
- package/dist/tools/api.js +81 -0
- package/dist/tools/completion.js +116 -0
- package/dist/tools/config.js +123 -0
- package/dist/tools/doctor.js +183 -0
- package/dist/tools/login.js +99 -0
- package/dist/tools/mcp.js +141 -0
- package/dist/tools/raw.js +96 -0
- package/dist/tools/schema.js +58 -0
- package/dist/tools/trace.js +54 -0
- package/dist/tools/whoami.js +132 -0
- package/dist/verbs/_spec.js +15 -0
- package/dist/verbs/bars_batch.js +66 -0
- package/dist/verbs/chart.js +110 -0
- package/dist/verbs/digest.js +342 -0
- package/dist/verbs/hot.js +29 -0
- package/dist/verbs/index.js +49 -0
- package/dist/verbs/lookup.js +72 -0
- package/dist/verbs/news.js +67 -0
- package/dist/verbs/quote.js +53 -0
- package/dist/verbs/research.js +44 -0
- package/dist/verbs/scan.js +42 -0
- package/dist/verbs/sentiment.js +46 -0
- package/dist/verbs/views.js +83 -0
- package/dist/version.js +5 -0
- package/package.json +58 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `echopai digest --code <canonical_code>` + MCP tool `digest`.
|
|
3
|
+
*
|
|
4
|
+
* Killer "one-shot research" verb:
|
|
5
|
+
*
|
|
6
|
+
* one canonical_code → views + research + quote + sentiment + news
|
|
7
|
+
*
|
|
8
|
+
* Plan-doc §3.3 makes this the "agent's opening move": one call returns the
|
|
9
|
+
* full picture of a security. Buckets stay separated (no ranking imposed) so
|
|
10
|
+
* the model can weigh each itself; the description already encodes the
|
|
11
|
+
* product call that views > news.
|
|
12
|
+
*
|
|
13
|
+
* Phase 5.2 strategy — server-first with client-side fallback:
|
|
14
|
+
* 1. Try `GET /v1/digest/{code}` (single HTTP call, server-side fan-out;
|
|
15
|
+
* response shape already matches the CLI envelope below).
|
|
16
|
+
* 2. If that returns 404 (endpoint not yet deployed) or `not_found`, fall
|
|
17
|
+
* back to client-side Promise.allSettled fan-out (Phase 5.1).
|
|
18
|
+
* 3. Other errors (4xx auth, 5xx server, network) propagate normally — we
|
|
19
|
+
* don't double-call.
|
|
20
|
+
*
|
|
21
|
+
* Either path produces the same envelope shape, so MCP / agent code is
|
|
22
|
+
* oblivious. Behavior parity is enforced by tests asserting the bucket order
|
|
23
|
+
* (views first, news last) is identical in both modes.
|
|
24
|
+
*
|
|
25
|
+
* Why these 5 ops in the fallback:
|
|
26
|
+
* - views.recent : PRIMARY research signal (analyst opinions on this code)
|
|
27
|
+
* - research.entity-performance-list : quality layer for view authors
|
|
28
|
+
* - quote : current price / change %
|
|
29
|
+
* - sentiment.overview : market regime context (not per-code; intentional —
|
|
30
|
+
* no per-code sentiment op exists)
|
|
31
|
+
* - news.search : SUPPLEMENTARY breadth (event stream filter on code)
|
|
32
|
+
*
|
|
33
|
+
* Windows differ on purpose: views=168h (7d), news=24h. Encodes the rule
|
|
34
|
+
* that research time-horizon > news time-horizon.
|
|
35
|
+
*/
|
|
36
|
+
import { Command, Option } from "commander";
|
|
37
|
+
import { z } from "zod";
|
|
38
|
+
import { OPERATIONS } from "../_generated/operations.js";
|
|
39
|
+
import { CallApiError } from "../runtime/errors.js";
|
|
40
|
+
import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
|
|
41
|
+
import { callOp } from "../runtime/verb_runner.js";
|
|
42
|
+
/** Buckets in the order the description establishes (views first, news last). */
|
|
43
|
+
const BUCKETS = ["views", "research", "quote", "sentiment", "news"];
|
|
44
|
+
const CODE_REGEX = /^(SSE|SZSE|BSE|SH|SZ|BJ):[0-9]{6}$/;
|
|
45
|
+
export const digestSpec = {
|
|
46
|
+
name: "digest",
|
|
47
|
+
description: "One-shot research digest for a single security: fan-out across views (PRIMARY), research-entity performance, quote, market sentiment, and supplementary news. Returns separated buckets — does NOT rank views vs news for the agent. Partial failures surface in meta.partial_failures[] without poisoning successful buckets. Use as the agent's first call when asked 'what's going on with <stock>'.",
|
|
48
|
+
inputSchema: {
|
|
49
|
+
code: z
|
|
50
|
+
.string()
|
|
51
|
+
.regex(CODE_REGEX)
|
|
52
|
+
.describe("Canonical code, e.g. SSE:600519. Use `lookup` first if you only have a name."),
|
|
53
|
+
views_since_days: z
|
|
54
|
+
.number()
|
|
55
|
+
.int()
|
|
56
|
+
.min(1)
|
|
57
|
+
.max(90)
|
|
58
|
+
.default(7)
|
|
59
|
+
.describe("Lookback for views bucket (days, default 7 — research horizon)"),
|
|
60
|
+
news_hours: z
|
|
61
|
+
.number()
|
|
62
|
+
.int()
|
|
63
|
+
.min(1)
|
|
64
|
+
.max(168)
|
|
65
|
+
.default(24)
|
|
66
|
+
.describe("Lookback for news bucket (hours, default 24 — event horizon)"),
|
|
67
|
+
limit_per_bucket: z
|
|
68
|
+
.number()
|
|
69
|
+
.int()
|
|
70
|
+
.min(1)
|
|
71
|
+
.max(50)
|
|
72
|
+
.default(10)
|
|
73
|
+
.describe("Per-bucket item cap (views/news). Quote/sentiment/research aren't paginated here."),
|
|
74
|
+
},
|
|
75
|
+
handler: async (args, ctx) => runDigest(args, ctx),
|
|
76
|
+
// Backing ops are listed in two groups: digest.get is the primary
|
|
77
|
+
// server-side endpoint we try first. The 5 fan-out ops are listed too
|
|
78
|
+
// because MCP `tools/list` should still expose `digest` for tokens that
|
|
79
|
+
// have *any* of these scopes — server-side `digest.get` exists for
|
|
80
|
+
// routing, but on tokens lacking the relevant per-bucket scopes the
|
|
81
|
+
// fan-out fallback (or server-side partial_failures) is what populates
|
|
82
|
+
// the response. Including all backing ops keeps the scope-derivation
|
|
83
|
+
// permissive; verbAvailable() returns true if any one is callable.
|
|
84
|
+
backingOps: [
|
|
85
|
+
"digest.get",
|
|
86
|
+
"views.recent",
|
|
87
|
+
"research.entity-performance-list",
|
|
88
|
+
"quote",
|
|
89
|
+
"sentiment.overview",
|
|
90
|
+
"news.search",
|
|
91
|
+
],
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Phase 5.2 entry: try server-side `/v1/digest/{code}` first; on 404
|
|
95
|
+
* (endpoint not yet deployed) fall back to client-side fan-out.
|
|
96
|
+
*
|
|
97
|
+
* Exported for tests.
|
|
98
|
+
*/
|
|
99
|
+
export async function runDigest(argsIn, ctx) {
|
|
100
|
+
const op = OPERATIONS["digest.get"];
|
|
101
|
+
if (op) {
|
|
102
|
+
try {
|
|
103
|
+
const args = argsIn;
|
|
104
|
+
const env = await callOp(op, {
|
|
105
|
+
code: args.code,
|
|
106
|
+
views_since_days: args.views_since_days,
|
|
107
|
+
news_hours: args.news_hours,
|
|
108
|
+
limit_per_bucket: args.limit_per_bucket,
|
|
109
|
+
}, ctx);
|
|
110
|
+
// Stamp meta so callers can tell which path served the response. Useful
|
|
111
|
+
// when debugging fallback decisions and when comparing latency.
|
|
112
|
+
env.meta = { ...env.meta, digest_path: "server" };
|
|
113
|
+
return env;
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
if (e instanceof CallApiError && shouldFallbackToFanout(e)) {
|
|
117
|
+
// Server endpoint not yet rolled out on this gateway. Quietly drop
|
|
118
|
+
// to fan-out and stamp meta so observability can track this.
|
|
119
|
+
const env = await runDigestFanout(argsIn, ctx);
|
|
120
|
+
env.meta = {
|
|
121
|
+
...env.meta,
|
|
122
|
+
digest_path: "fanout_fallback",
|
|
123
|
+
fallback_reason: e.code,
|
|
124
|
+
};
|
|
125
|
+
return env;
|
|
126
|
+
}
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// Codegen pre-Phase 5.2: no server op available at all → fan-out only.
|
|
131
|
+
const env = await runDigestFanout(argsIn, ctx);
|
|
132
|
+
env.meta = { ...env.meta, digest_path: "fanout_only" };
|
|
133
|
+
return env;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Heuristic: when should we fall back to client-side fan-out instead of
|
|
137
|
+
* propagating the server-side error?
|
|
138
|
+
*
|
|
139
|
+
* - 404 / not_found: endpoint isn't deployed yet (rolling release window).
|
|
140
|
+
* - http_error with 404 status: same situation, no envelope returned.
|
|
141
|
+
*
|
|
142
|
+
* Auth / scope / rate-limit / 5xx upstream errors MUST propagate — those
|
|
143
|
+
* indicate problems the fan-out path will hit anyway, and silently retrying
|
|
144
|
+
* would mask them while spending 5× the upstream cost.
|
|
145
|
+
*/
|
|
146
|
+
function shouldFallbackToFanout(e) {
|
|
147
|
+
if (e.httpStatus === 404)
|
|
148
|
+
return true;
|
|
149
|
+
if (e.code === "not_found")
|
|
150
|
+
return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function buildBucketCalls(args, ctx) {
|
|
154
|
+
const opsRequired = {
|
|
155
|
+
views: "views.recent",
|
|
156
|
+
research: "research.entity-performance-list",
|
|
157
|
+
quote: "quote",
|
|
158
|
+
sentiment: "sentiment.overview",
|
|
159
|
+
news: "news.search",
|
|
160
|
+
};
|
|
161
|
+
const calls = [];
|
|
162
|
+
for (const bucket of BUCKETS) {
|
|
163
|
+
const opKey = opsRequired[bucket];
|
|
164
|
+
const op = OPERATIONS[opKey];
|
|
165
|
+
if (!op) {
|
|
166
|
+
// Codegen drift — surface as a failure on that bucket rather than
|
|
167
|
+
// exploding the whole digest; agent sees the gap in partial_failures.
|
|
168
|
+
calls.push({
|
|
169
|
+
bucket,
|
|
170
|
+
endpoint: opKey,
|
|
171
|
+
run: () => Promise.reject(new CallApiError({
|
|
172
|
+
code: "internal_error",
|
|
173
|
+
message: `Operation ${opKey} missing from codegen`,
|
|
174
|
+
})),
|
|
175
|
+
});
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
let opArgs;
|
|
179
|
+
switch (bucket) {
|
|
180
|
+
case "views":
|
|
181
|
+
opArgs = {
|
|
182
|
+
security: args.code,
|
|
183
|
+
since_days: args.views_since_days,
|
|
184
|
+
limit: args.limit_per_bucket,
|
|
185
|
+
};
|
|
186
|
+
break;
|
|
187
|
+
case "research":
|
|
188
|
+
opArgs = {};
|
|
189
|
+
break;
|
|
190
|
+
case "quote":
|
|
191
|
+
opArgs = { codes: [args.code] };
|
|
192
|
+
break;
|
|
193
|
+
case "sentiment":
|
|
194
|
+
opArgs = { scope: "all_a_ex_st" };
|
|
195
|
+
break;
|
|
196
|
+
case "news":
|
|
197
|
+
opArgs = {
|
|
198
|
+
query: args.code,
|
|
199
|
+
since_hours: args.news_hours,
|
|
200
|
+
limit: args.limit_per_bucket,
|
|
201
|
+
};
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
calls.push({
|
|
205
|
+
bucket,
|
|
206
|
+
endpoint: op.path,
|
|
207
|
+
run: () => callOp(op, opArgs, ctx),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return calls;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Run the fan-out. Exported for tests; CLI/MCP enter via `digestSpec.handler`.
|
|
214
|
+
*/
|
|
215
|
+
export async function runDigestFanout(argsIn, ctx) {
|
|
216
|
+
const args = argsIn;
|
|
217
|
+
const calls = buildBucketCalls(args, ctx);
|
|
218
|
+
const results = await Promise.allSettled(calls.map((c) => c.run()));
|
|
219
|
+
const data = { code: args.code };
|
|
220
|
+
const partialFailures = [];
|
|
221
|
+
let succeeded = 0;
|
|
222
|
+
let totalDurationMs = 0;
|
|
223
|
+
let earliestRequestId;
|
|
224
|
+
results.forEach((result, idx) => {
|
|
225
|
+
const { bucket, endpoint } = calls[idx];
|
|
226
|
+
if (result.status === "fulfilled") {
|
|
227
|
+
// Strip CLI-wrapper meta and keep just data + relevant server meta.
|
|
228
|
+
// The outer digest envelope carries its own meta; per-bucket meta would
|
|
229
|
+
// be noisy. Preserve server-provided pagination/total on the bucket.
|
|
230
|
+
const env = result.value;
|
|
231
|
+
const bucketServerMeta = stripCliMeta(env.meta);
|
|
232
|
+
data[bucket] =
|
|
233
|
+
bucketServerMeta && Object.keys(bucketServerMeta).length > 0
|
|
234
|
+
? { data: env.data, meta: bucketServerMeta }
|
|
235
|
+
: env.data;
|
|
236
|
+
succeeded++;
|
|
237
|
+
const d = env.meta.duration_ms;
|
|
238
|
+
if (typeof d === "number")
|
|
239
|
+
totalDurationMs = Math.max(totalDurationMs, d);
|
|
240
|
+
if (!earliestRequestId && typeof env.meta.request_id === "string") {
|
|
241
|
+
earliestRequestId = env.meta.request_id;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
data[bucket] = null;
|
|
246
|
+
const err = result.reason;
|
|
247
|
+
if (err instanceof CallApiError) {
|
|
248
|
+
partialFailures.push({
|
|
249
|
+
bucket,
|
|
250
|
+
code: err.code,
|
|
251
|
+
message: err.message,
|
|
252
|
+
endpoint,
|
|
253
|
+
...(err.requestId ? { request_id: err.requestId } : {}),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
partialFailures.push({
|
|
258
|
+
bucket,
|
|
259
|
+
code: "internal_error",
|
|
260
|
+
message: err instanceof Error ? err.message : String(err),
|
|
261
|
+
endpoint,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
// If literally every bucket failed, that's not a partial-fail situation —
|
|
267
|
+
// surface it as a hard error so the agent doesn't act on an empty digest.
|
|
268
|
+
if (succeeded === 0) {
|
|
269
|
+
const first = partialFailures[0];
|
|
270
|
+
throw new CallApiError({
|
|
271
|
+
code: first?.code ?? "upstream_unavailable",
|
|
272
|
+
message: `All ${calls.length} digest sub-ops failed; first: ${first?.message ?? "unknown"}`,
|
|
273
|
+
...(first?.request_id ? { requestId: first.request_id } : {}),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
data,
|
|
278
|
+
meta: {
|
|
279
|
+
endpoint: "digest (fanout)",
|
|
280
|
+
method: "FANOUT",
|
|
281
|
+
cli_version: ctx.cliVersion,
|
|
282
|
+
duration_ms: totalDurationMs,
|
|
283
|
+
truncated: false,
|
|
284
|
+
fanout_total: calls.length,
|
|
285
|
+
fanout_succeeded: succeeded,
|
|
286
|
+
partial_failures: partialFailures,
|
|
287
|
+
...(earliestRequestId ? { request_id: earliestRequestId } : {}),
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** Drop CLI-injected meta fields; keep only server-originating keys. */
|
|
292
|
+
function stripCliMeta(meta) {
|
|
293
|
+
if (!meta)
|
|
294
|
+
return undefined;
|
|
295
|
+
const drop = new Set([
|
|
296
|
+
"request_id",
|
|
297
|
+
"endpoint",
|
|
298
|
+
"method",
|
|
299
|
+
"cli_version",
|
|
300
|
+
"api_version",
|
|
301
|
+
"duration_ms",
|
|
302
|
+
"truncated",
|
|
303
|
+
]);
|
|
304
|
+
const out = {};
|
|
305
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
306
|
+
if (drop.has(k))
|
|
307
|
+
continue;
|
|
308
|
+
out[k] = v;
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
function clamp(raw, min, max, fallback) {
|
|
313
|
+
const n = Math.floor(Number(raw));
|
|
314
|
+
if (!Number.isFinite(n))
|
|
315
|
+
return fallback;
|
|
316
|
+
if (n < min)
|
|
317
|
+
return min;
|
|
318
|
+
if (n > max)
|
|
319
|
+
return max;
|
|
320
|
+
return n;
|
|
321
|
+
}
|
|
322
|
+
export function buildDigestCommand() {
|
|
323
|
+
const cmd = new Command(digestSpec.name).description(digestSpec.description);
|
|
324
|
+
cmd.addOption(new Option("--code <canonical_code>", "Canonical code, e.g. SSE:600519")
|
|
325
|
+
.makeOptionMandatory(true));
|
|
326
|
+
cmd.addOption(new Option("--views-since-days <n>", "Views lookback in days (1-90)").default("7"));
|
|
327
|
+
cmd.addOption(new Option("--news-hours <n>", "News lookback in hours (1-168)").default("24"));
|
|
328
|
+
cmd.addOption(new Option("--limit-per-bucket <n>", "Items per bucket (1-50)").default("10"));
|
|
329
|
+
cmd.action(async (opts) => {
|
|
330
|
+
if (!CODE_REGEX.test(opts.code)) {
|
|
331
|
+
emitVerbError("invalid_args", `code ${JSON.stringify(opts.code)} is not a canonical_code (expected SSE|SZSE|BSE:NNNNNN)`, "Use `echopai lookup --text <name>` to resolve to a canonical_code first.", 1);
|
|
332
|
+
}
|
|
333
|
+
const args = {
|
|
334
|
+
code: opts.code,
|
|
335
|
+
views_since_days: clamp(opts.viewsSinceDays, 1, 90, 7),
|
|
336
|
+
news_hours: clamp(opts.newsHours, 1, 168, 24),
|
|
337
|
+
limit_per_bucket: clamp(opts.limitPerBucket, 1, 50, 10),
|
|
338
|
+
};
|
|
339
|
+
await executeVerb(async (ctx) => digestSpec.handler(args, ctx));
|
|
340
|
+
});
|
|
341
|
+
return cmd;
|
|
342
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `echopai hot` + MCP tool `hot`.
|
|
3
|
+
*/
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import { OPERATIONS } from "../_generated/operations.js";
|
|
6
|
+
import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
|
|
7
|
+
import { callOp } from "../runtime/verb_runner.js";
|
|
8
|
+
export const hotSpec = {
|
|
9
|
+
name: "hot",
|
|
10
|
+
description: "Today's hot-stock leaderboard (East-Money composite rank: search / follow / comment).",
|
|
11
|
+
inputSchema: {},
|
|
12
|
+
handler: async (_args, ctx) => {
|
|
13
|
+
const op = OPERATIONS["stocks.hot"];
|
|
14
|
+
if (!op)
|
|
15
|
+
throw new Error("stocks.hot op missing");
|
|
16
|
+
return callOp(op, {}, ctx);
|
|
17
|
+
},
|
|
18
|
+
backingOps: ["stocks.hot"],
|
|
19
|
+
};
|
|
20
|
+
export function buildHotCommand() {
|
|
21
|
+
const cmd = new Command(hotSpec.name).description(hotSpec.description);
|
|
22
|
+
cmd.action(async () => {
|
|
23
|
+
if (!OPERATIONS["stocks.hot"]) {
|
|
24
|
+
emitVerbError("internal_error", "stocks.hot missing", undefined, 2);
|
|
25
|
+
}
|
|
26
|
+
await executeVerb(async (ctx) => hotSpec.handler({}, ctx));
|
|
27
|
+
});
|
|
28
|
+
return cmd;
|
|
29
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Curated verb registry.
|
|
3
|
+
*
|
|
4
|
+
* Importing this module gives both:
|
|
5
|
+
* - ALL_VERB_SPECS: 共用声明,MCP serve 枚举出 tools/list
|
|
6
|
+
* - build*Command(): commander 端入口工厂
|
|
7
|
+
*/
|
|
8
|
+
export { barsBatchSpec, buildBarsBatchCommand } from "./bars_batch.js";
|
|
9
|
+
export { chartSpec, buildChartCommand } from "./chart.js";
|
|
10
|
+
export { digestSpec, buildDigestCommand } from "./digest.js";
|
|
11
|
+
export { hotSpec, buildHotCommand } from "./hot.js";
|
|
12
|
+
export { lookupSpec, buildLookupCommand } from "./lookup.js";
|
|
13
|
+
export { newsSpec, buildNewsCommand } from "./news.js";
|
|
14
|
+
export { quoteSpec, buildQuoteCommand } from "./quote.js";
|
|
15
|
+
export { researchSpec, buildResearchCommand } from "./research.js";
|
|
16
|
+
export { scanSpec, buildScanCommand } from "./scan.js";
|
|
17
|
+
export { sentimentSpec, buildSentimentCommand } from "./sentiment.js";
|
|
18
|
+
export { viewsSpec, buildViewsCommand } from "./views.js";
|
|
19
|
+
import { barsBatchSpec } from "./bars_batch.js";
|
|
20
|
+
import { chartSpec } from "./chart.js";
|
|
21
|
+
import { digestSpec } from "./digest.js";
|
|
22
|
+
import { hotSpec } from "./hot.js";
|
|
23
|
+
import { lookupSpec } from "./lookup.js";
|
|
24
|
+
import { newsSpec } from "./news.js";
|
|
25
|
+
import { quoteSpec } from "./quote.js";
|
|
26
|
+
import { researchSpec } from "./research.js";
|
|
27
|
+
import { scanSpec } from "./scan.js";
|
|
28
|
+
import { sentimentSpec } from "./sentiment.js";
|
|
29
|
+
import { viewsSpec } from "./views.js";
|
|
30
|
+
/**
|
|
31
|
+
* Ordering follows product priority (feedback_views_over_news.md):
|
|
32
|
+
* lookup (universal first step) → digest (one-shot fan-out, agent's opening
|
|
33
|
+
* move once it has a canonical_code) → quote → views/research (PRIMARY
|
|
34
|
+
* research) → news (supplementary) → sentiment → hot → chart → bars_batch →
|
|
35
|
+
* scan.
|
|
36
|
+
*/
|
|
37
|
+
export const ALL_VERB_SPECS = [
|
|
38
|
+
lookupSpec,
|
|
39
|
+
digestSpec,
|
|
40
|
+
quoteSpec,
|
|
41
|
+
viewsSpec,
|
|
42
|
+
researchSpec,
|
|
43
|
+
newsSpec,
|
|
44
|
+
sentimentSpec,
|
|
45
|
+
hotSpec,
|
|
46
|
+
chartSpec,
|
|
47
|
+
barsBatchSpec,
|
|
48
|
+
scanSpec,
|
|
49
|
+
];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `echopai lookup --text <q> [--limit N]` + MCP tool `lookup`.
|
|
3
|
+
*
|
|
4
|
+
* Curated verb: 解析中文名 / 代码 / 拼音 → canonical_code 列表。Agent 几乎
|
|
5
|
+
* 每个会话的第一步。底层调 raw OpenAPI op `semantic.find`。
|
|
6
|
+
*/
|
|
7
|
+
import { Command, Option } from "commander";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { OPERATIONS } from "../_generated/operations.js";
|
|
10
|
+
import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
|
|
11
|
+
import { callOp } from "../runtime/verb_runner.js";
|
|
12
|
+
export function mapLookupResponse(raw) {
|
|
13
|
+
if (!Array.isArray(raw))
|
|
14
|
+
return [];
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const item of raw) {
|
|
17
|
+
if (!item || typeof item !== "object")
|
|
18
|
+
continue;
|
|
19
|
+
const r = item;
|
|
20
|
+
if (typeof r.canonical_code !== "string")
|
|
21
|
+
continue;
|
|
22
|
+
out.push({
|
|
23
|
+
canonical_code: r.canonical_code,
|
|
24
|
+
name_cn: typeof r.name_cn === "string" ? r.name_cn : "",
|
|
25
|
+
ticker: typeof r.ticker === "string" ? r.ticker : "",
|
|
26
|
+
exchange: typeof r.exchange === "string" ? r.exchange : "",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
export const lookupSpec = {
|
|
32
|
+
name: "lookup",
|
|
33
|
+
description: "Resolve a Chinese name / A-share code / pinyin initials to canonical codes (e.g. SSE:600519). Use this first whenever the agent has a description but needs a canonical_code for downstream calls.",
|
|
34
|
+
inputSchema: {
|
|
35
|
+
text: z
|
|
36
|
+
.string()
|
|
37
|
+
.min(1)
|
|
38
|
+
.max(50)
|
|
39
|
+
.describe("Search text: Chinese name (贵州茅台), code (600519), or pinyin (gzmt)"),
|
|
40
|
+
limit: z.number().int().min(1).max(30).default(10).describe("Max matches (1-30)"),
|
|
41
|
+
},
|
|
42
|
+
handler: async (args, ctx) => {
|
|
43
|
+
const op = OPERATIONS["semantic.find"];
|
|
44
|
+
if (!op)
|
|
45
|
+
throw new Error("semantic.find op missing from codegen");
|
|
46
|
+
const env = await callOp(op, { query: args.text, limit: args.limit }, ctx);
|
|
47
|
+
const matches = mapLookupResponse(env.data);
|
|
48
|
+
return { data: { matches, count: matches.length }, meta: env.meta };
|
|
49
|
+
},
|
|
50
|
+
backingOps: ["semantic.find"],
|
|
51
|
+
};
|
|
52
|
+
function clampLimit(raw, min = 1, max = 30) {
|
|
53
|
+
const n = Math.floor(Number(raw));
|
|
54
|
+
if (!Number.isFinite(n) || n < min)
|
|
55
|
+
return min;
|
|
56
|
+
if (n > max)
|
|
57
|
+
return max;
|
|
58
|
+
return n;
|
|
59
|
+
}
|
|
60
|
+
export function buildLookupCommand() {
|
|
61
|
+
const cmd = new Command(lookupSpec.name).description(lookupSpec.description);
|
|
62
|
+
cmd.addOption(new Option("--text <text>", "Search text (Chinese name / code / pinyin)")
|
|
63
|
+
.makeOptionMandatory(true));
|
|
64
|
+
cmd.addOption(new Option("--limit <n>", "Max matches (1-30)").default("10"));
|
|
65
|
+
cmd.action(async (opts) => {
|
|
66
|
+
if (!OPERATIONS["semantic.find"]) {
|
|
67
|
+
emitVerbError("internal_error", "semantic.find missing from codegen", undefined, 2);
|
|
68
|
+
}
|
|
69
|
+
await executeVerb(async (ctx) => lookupSpec.handler({ text: opts.text, limit: clampLimit(opts.limit) }, ctx));
|
|
70
|
+
});
|
|
71
|
+
return cmd;
|
|
72
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `echopai news ...` + MCP tool `news`.
|
|
3
|
+
*
|
|
4
|
+
* SUPPLEMENTARY breadth source (feedback_views_over_news.md).
|
|
5
|
+
*/
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { OPERATIONS } from "../_generated/operations.js";
|
|
9
|
+
import { executeVerb } from "../runtime/verb_cmd.js";
|
|
10
|
+
import { callOp } from "../runtime/verb_runner.js";
|
|
11
|
+
export const newsSpec = {
|
|
12
|
+
name: "news",
|
|
13
|
+
description: "SUPPLEMENTARY news / market briefs (short time-horizon event stream). Use ONLY to fill gaps not covered by `views`; prefer `views` as the primary research source. With `query` → full-text search; without `query` → time-window feed.",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
query: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Free-text query (Chinese or English); omit for time-window feed"),
|
|
19
|
+
hours: z
|
|
20
|
+
.number()
|
|
21
|
+
.int()
|
|
22
|
+
.min(1)
|
|
23
|
+
.max(168)
|
|
24
|
+
.default(24)
|
|
25
|
+
.describe("Lookback window in hours (default 24 — much narrower than views)"),
|
|
26
|
+
limit: z.number().int().min(1).max(100).default(20).describe("Max items"),
|
|
27
|
+
},
|
|
28
|
+
handler: async (args, ctx) => {
|
|
29
|
+
if (args.query) {
|
|
30
|
+
const op = OPERATIONS["news.search"];
|
|
31
|
+
if (!op)
|
|
32
|
+
throw new Error("news.search op missing");
|
|
33
|
+
return callOp(op, { query: args.query, since_hours: args.hours, limit: args.limit }, ctx);
|
|
34
|
+
}
|
|
35
|
+
const op = OPERATIONS["news.feed"];
|
|
36
|
+
if (!op)
|
|
37
|
+
throw new Error("news.feed op missing");
|
|
38
|
+
return callOp(op, {}, ctx);
|
|
39
|
+
},
|
|
40
|
+
backingOps: ["news.search", "news.feed"],
|
|
41
|
+
};
|
|
42
|
+
function clamp(raw, min, max, fallback) {
|
|
43
|
+
const n = Math.floor(Number(raw));
|
|
44
|
+
if (!Number.isFinite(n))
|
|
45
|
+
return fallback;
|
|
46
|
+
if (n < min)
|
|
47
|
+
return min;
|
|
48
|
+
if (n > max)
|
|
49
|
+
return max;
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
export function buildNewsCommand() {
|
|
53
|
+
const cmd = new Command(newsSpec.name).description(newsSpec.description);
|
|
54
|
+
cmd.addOption(new Option("--query <text>", "Free-text query; omit for time-window feed"));
|
|
55
|
+
cmd.addOption(new Option("--hours <n>", "Lookback window (1-168 hours)").default("24"));
|
|
56
|
+
cmd.addOption(new Option("--limit <n>", "Max items").default("20"));
|
|
57
|
+
cmd.action(async (opts) => {
|
|
58
|
+
const args = {
|
|
59
|
+
hours: clamp(opts.hours, 1, 168, 24),
|
|
60
|
+
limit: clamp(opts.limit, 1, 100, 20),
|
|
61
|
+
};
|
|
62
|
+
if (opts.query)
|
|
63
|
+
args.query = opts.query;
|
|
64
|
+
await executeVerb(async (ctx) => newsSpec.handler(args, ctx));
|
|
65
|
+
});
|
|
66
|
+
return cmd;
|
|
67
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `echopai quote --codes A,B,C` + MCP tool `quote`.
|
|
3
|
+
*/
|
|
4
|
+
import { Command, Option } from "commander";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { OPERATIONS } from "../_generated/operations.js";
|
|
7
|
+
import { CallApiError } from "../runtime/errors.js";
|
|
8
|
+
import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
|
|
9
|
+
import { callOp } from "../runtime/verb_runner.js";
|
|
10
|
+
export const quoteSpec = {
|
|
11
|
+
name: "quote",
|
|
12
|
+
description: "Real-time quote for 1-200 A-share securities (last price, volume, change %, bid/ask). For >200 codes use `scan` instead.",
|
|
13
|
+
inputSchema: {
|
|
14
|
+
codes: z
|
|
15
|
+
.array(z.string().regex(/^(SSE|SZSE|BSE|SH|SZ|BJ):[0-9]{6}$/))
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(200)
|
|
18
|
+
.describe("Array of canonical codes (e.g. ['SSE:600519', 'SZSE:000001'])"),
|
|
19
|
+
include_l2: z
|
|
20
|
+
.boolean()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Include L2 5-level order book (requires quote:l2 scope)"),
|
|
23
|
+
},
|
|
24
|
+
handler: async (args, ctx) => {
|
|
25
|
+
const op = OPERATIONS["quote"];
|
|
26
|
+
if (!op)
|
|
27
|
+
throw new Error("quote op missing from codegen");
|
|
28
|
+
return callOp(op, args, ctx);
|
|
29
|
+
},
|
|
30
|
+
backingOps: ["quote"],
|
|
31
|
+
};
|
|
32
|
+
export function buildQuoteCommand() {
|
|
33
|
+
const cmd = new Command(quoteSpec.name).description(quoteSpec.description);
|
|
34
|
+
cmd.addOption(new Option("--codes <csv>", "Canonical codes, comma-separated (e.g. SSE:600519,SZSE:000001)")
|
|
35
|
+
.makeOptionMandatory(true));
|
|
36
|
+
cmd.addOption(new Option("--include-l2", "Include L2 order book (requires quote:l2 scope)"));
|
|
37
|
+
cmd.action(async (opts) => {
|
|
38
|
+
const codes = opts.codes.split(",").map((s) => s.trim()).filter(Boolean);
|
|
39
|
+
if (codes.length === 0 || codes.length > 200) {
|
|
40
|
+
emitVerbError("invalid_args", `codes count ${codes.length} out of range; expected 1-200`, "Use `echopai scan` for full-market snapshot (>200 codes).", 1);
|
|
41
|
+
}
|
|
42
|
+
if (!OPERATIONS["quote"]) {
|
|
43
|
+
emitVerbError("internal_error", "quote op missing from codegen", undefined, 2);
|
|
44
|
+
}
|
|
45
|
+
const args = { codes };
|
|
46
|
+
if (opts.includeL2)
|
|
47
|
+
args.include_l2 = true;
|
|
48
|
+
await executeVerb(async (ctx) => quoteSpec.handler(args, ctx));
|
|
49
|
+
});
|
|
50
|
+
return cmd;
|
|
51
|
+
}
|
|
52
|
+
// Re-export CallApiError so handler call sites have a typed throw signature.
|
|
53
|
+
export { CallApiError };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `echopai research [--entity <id>]` + MCP tool `research`.
|
|
3
|
+
*
|
|
4
|
+
* Views quality signal layer.
|
|
5
|
+
*/
|
|
6
|
+
import { Command, Option } from "commander";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { OPERATIONS } from "../_generated/operations.js";
|
|
9
|
+
import { executeVerb } from "../runtime/verb_cmd.js";
|
|
10
|
+
import { callOp } from "../runtime/verb_runner.js";
|
|
11
|
+
export const researchSpec = {
|
|
12
|
+
name: "research",
|
|
13
|
+
description: "Research-entity performance — hit rate / coverage / avg excess return. Pair with `views` (which returns research_entity_id per row) to assess analyst quality. Omit entity_id for aggregate top-performers list.",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
entity_id: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Specific research_entity_id; omit for aggregate top-performers list"),
|
|
19
|
+
},
|
|
20
|
+
handler: async (args, ctx) => {
|
|
21
|
+
if (args.entity_id) {
|
|
22
|
+
const op = OPERATIONS["research.entity-performance"];
|
|
23
|
+
if (!op)
|
|
24
|
+
throw new Error("research.entity-performance op missing");
|
|
25
|
+
return callOp(op, { entity_id: args.entity_id }, ctx);
|
|
26
|
+
}
|
|
27
|
+
const op = OPERATIONS["research.entity-performance-list"];
|
|
28
|
+
if (!op)
|
|
29
|
+
throw new Error("research.entity-performance-list op missing");
|
|
30
|
+
return callOp(op, {}, ctx);
|
|
31
|
+
},
|
|
32
|
+
backingOps: ["research.entity-performance-list", "research.entity-performance"],
|
|
33
|
+
};
|
|
34
|
+
export function buildResearchCommand() {
|
|
35
|
+
const cmd = new Command(researchSpec.name).description(researchSpec.description);
|
|
36
|
+
cmd.addOption(new Option("--entity <id>", "Specific research_entity_id; omit for aggregate list"));
|
|
37
|
+
cmd.action(async (opts) => {
|
|
38
|
+
const args = {};
|
|
39
|
+
if (opts.entity)
|
|
40
|
+
args.entity_id = opts.entity;
|
|
41
|
+
await executeVerb(async (ctx) => researchSpec.handler(args, ctx));
|
|
42
|
+
});
|
|
43
|
+
return cmd;
|
|
44
|
+
}
|