echopai 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +63 -348
  2. package/dist/bin.js +8298 -190
  3. package/package.json +11 -13
  4. package/dist/_generated/commands.js +0 -378
  5. package/dist/_generated/help.js +0 -295
  6. package/dist/_generated/operations.js +0 -2385
  7. package/dist/runtime/auth.js +0 -95
  8. package/dist/runtime/envelope.js +0 -52
  9. package/dist/runtime/errors.js +0 -186
  10. package/dist/runtime/filters.js +0 -153
  11. package/dist/runtime/format.js +0 -143
  12. package/dist/runtime/http.js +0 -65
  13. package/dist/runtime/idempotency.js +0 -18
  14. package/dist/runtime/invoker.js +0 -391
  15. package/dist/runtime/io.js +0 -16
  16. package/dist/runtime/paginator.js +0 -146
  17. package/dist/runtime/trace.js +0 -99
  18. package/dist/runtime/tty.js +0 -51
  19. package/dist/runtime/update_check.js +0 -120
  20. package/dist/runtime/update_worker.js +0 -63
  21. package/dist/runtime/verb_cmd.js +0 -72
  22. package/dist/runtime/verb_runner.js +0 -152
  23. package/dist/runtime/whoami_cache.js +0 -109
  24. package/dist/tools/api.js +0 -81
  25. package/dist/tools/completion.js +0 -116
  26. package/dist/tools/config.js +0 -123
  27. package/dist/tools/doctor.js +0 -183
  28. package/dist/tools/login.js +0 -99
  29. package/dist/tools/mcp.js +0 -141
  30. package/dist/tools/raw.js +0 -96
  31. package/dist/tools/schema.js +0 -58
  32. package/dist/tools/trace.js +0 -54
  33. package/dist/tools/upgrade.js +0 -103
  34. package/dist/tools/welcome.js +0 -225
  35. package/dist/tools/whoami.js +0 -132
  36. package/dist/verbs/_spec.js +0 -15
  37. package/dist/verbs/announcements.js +0 -195
  38. package/dist/verbs/bars_batch.js +0 -66
  39. package/dist/verbs/chart.js +0 -110
  40. package/dist/verbs/concepts.js +0 -393
  41. package/dist/verbs/digest.js +0 -351
  42. package/dist/verbs/financials.js +0 -212
  43. package/dist/verbs/hot.js +0 -29
  44. package/dist/verbs/index.js +0 -88
  45. package/dist/verbs/limit_up.js +0 -156
  46. package/dist/verbs/lookup.js +0 -72
  47. package/dist/verbs/market.js +0 -185
  48. package/dist/verbs/news.js +0 -81
  49. package/dist/verbs/quote.js +0 -53
  50. package/dist/verbs/scan.js +0 -42
  51. package/dist/verbs/search.js +0 -105
  52. package/dist/verbs/sentiment.js +0 -231
  53. package/dist/verbs/views.js +0 -85
  54. package/dist/version.js +0 -5
@@ -1,18 +0,0 @@
1
- /**
2
- * Idempotency-Key 自动生成。
3
- *
4
- * 端点 op.idempotencyRequired === true 时,invoker 自动 gen UUIDv4 并:
5
- * - 加 Idempotency-Key request header
6
- * - echo 到 stderr 让用户 / AI agent 能记下来重试时复用同一 key
7
- *
8
- * 用户也可显式 --idempotency-key <uuid> 覆盖(debug 重放用)。
9
- */
10
- import * as crypto from "node:crypto";
11
- export function generateIdempotencyKey() {
12
- return crypto.randomUUID();
13
- }
14
- export function announceKey(key, op) {
15
- // stderr 不污染 stdout JSON envelope
16
- process.stderr.write(`[idempotency-key] ${op}: ${key}\n` +
17
- `[idempotency-key] retry: pass --idempotency-key ${key} to dedupe.\n`);
18
- }
@@ -1,391 +0,0 @@
1
- /**
2
- * 端点调用器:Ajv pre-flight → undici fetch → envelope 解析 → render → exit。
3
- *
4
- * 由 _generated/commands.ts 的 dispatch 入口调用。
5
- *
6
- * 支持参数:
7
- * --output <fmt> 覆盖默认 outputDefault
8
- * --query <jmespath> Phase 3 加,本 PR 不接
9
- * --debug stderr 打 HTTP wire trace(脱敏 Bearer)
10
- * --raw 跳 envelope 解析,输出原始 body
11
- */
12
- import AjvPkg from "ajv";
13
- import addFormatsPkg from "ajv-formats";
14
- import { fetch } from "undici";
15
- import { resolveCredentials, AuthMissingError } from "./auth.js";
16
- import { buildResponseEnvelope } from "./envelope.js";
17
- import { exitCodeForStatus, isRetryableByCode, resolveRecoveryHint, tryParseErrorEnvelope, CallApiError, } from "./errors.js";
18
- import { applyFilters, parseFieldsFlag, parseMaxBytesFlag, FilterError, } from "./filters.js";
19
- import { isOutputFormat, render } from "./format.js";
20
- import { buildHttpHeaders, resolveRequestId } from "./http.js";
21
- import { appendTrace } from "./trace.js";
22
- import { generateIdempotencyKey, announceKey } from "./idempotency.js";
23
- import { writeStdout, writeStderr } from "./io.js";
24
- import { paginate, exitCodeForPaginateError } from "./paginator.js";
25
- import { renderError, isTtyHuman } from "./tty.js";
26
- import { maybeEmitUpdateBanner } from "./update_check.js";
27
- const Ajv = (AjvPkg.default ??
28
- AjvPkg);
29
- const addFormats = (addFormatsPkg.default ??
30
- addFormatsPkg);
31
- // strict: false 让 Ajv 接受 OpenAPI 3.0 的 `example` / `examples` 关键字(不是
32
- // 标准 JSON Schema draft 7,但 OpenAPI 普遍用)。
33
- // coerceTypes: 'array' 允许 --codes SSE:000001 自动包成 ["SSE:000001"],
34
- // 也允许 --codes "A,B,C" 字符串入 array schema(但用户更常用单值)。
35
- const ajv = new Ajv({
36
- allErrors: true,
37
- useDefaults: true,
38
- coerceTypes: "array",
39
- strict: false,
40
- });
41
- addFormats(ajv);
42
- const SYSTEM_FLAGS = new Set([
43
- "output",
44
- "all",
45
- "key",
46
- "profile",
47
- "debug",
48
- "raw",
49
- "idempotency_key",
50
- "max_pages",
51
- "max_items",
52
- // PR 1.4 — output filter pipeline (renamed root --query → --jq in Phase 6
53
- // smoke-fix because subcommand specs declare their own --query as a body
54
- // param, e.g. news.search. Stripping "query" here would eat that param.)
55
- "jq",
56
- "fields",
57
- "max_bytes",
58
- // Phase 6 — write safety + dry-run
59
- "yes",
60
- "dry_run",
61
- ]);
62
- export async function invoke(op, args, ctx) {
63
- const isTty = process.stdout.isTTY === true && !process.env.CI;
64
- // 1. resolve --output
65
- let outputFmt = op.outputDefault;
66
- if (typeof args.output === "string" && isOutputFormat(args.output)) {
67
- outputFmt = args.output;
68
- }
69
- const debug = Boolean(args.debug);
70
- const raw = Boolean(args.raw);
71
- // 2. resolve credentials
72
- const resolveOpts = {};
73
- if (typeof args.key === "string")
74
- resolveOpts.key = args.key;
75
- if (typeof args.profile === "string")
76
- resolveOpts.profile = args.profile;
77
- let creds;
78
- try {
79
- creds = resolveCredentials(resolveOpts);
80
- }
81
- catch (e) {
82
- if (e instanceof AuthMissingError) {
83
- trace(op, { exit_code: 1, error_code: "auth_missing" });
84
- writeError("auth_missing", e.message, 1, e.recovery_hint ? { recovery_hint: e.recovery_hint } : undefined);
85
- }
86
- throw e;
87
- }
88
- // 3. strip system flags + undefined to get actual API params
89
- const apiParams = {};
90
- for (const [k, v] of Object.entries(args)) {
91
- if (SYSTEM_FLAGS.has(k))
92
- continue;
93
- if (v === undefined || v === "")
94
- continue;
95
- apiParams[k] = v;
96
- }
97
- // 4a. coerce CSV → array for params declared as array in schema (Ajv coerce
98
- // wraps single-string as 1-element array, but for "A,B,C" we want split-on-comma).
99
- for (const [pname, pschema] of Object.entries(op.inputSchema.properties)) {
100
- if (typeof pschema === "object" &&
101
- pschema !== null &&
102
- pschema.type === "array" &&
103
- typeof apiParams[pname] === "string" &&
104
- apiParams[pname].includes(",")) {
105
- apiParams[pname] = apiParams[pname].split(",").map((s) => s.trim());
106
- }
107
- }
108
- // 4b. Ajv pre-flight schema validation
109
- const validate = ajv.compile(op.inputSchema);
110
- if (!validate(apiParams)) {
111
- const errs = (validate.errors || [])
112
- .map((e) => `${e.instancePath || "(root)"}: ${e.message ?? "invalid"}`)
113
- .join("; ");
114
- trace(op, { exit_code: 1, error_code: "invalid_args" });
115
- writeError("invalid_args", `Input validation failed: ${errs}`, 1);
116
- }
117
- // 4c. Phase 6 — write safety + dry-run gates
118
- //
119
- // Two CLI-side preflight checks, both fail-closed:
120
- //
121
- // a) non-TTY write without --yes → confirmation_required
122
- // Agents/scripts can never accidentally trigger a side-effect; they
123
- // must opt in explicitly. TTY users get the usual interactive flow
124
- // (commander itself prompts? no — we keep this simple: TTY skips the
125
- // gate and assumes the user typed the command knowingly).
126
- //
127
- // b) --dry-run on op where dryRunSupported=false → dry_run_unsupported
128
- // Refuse to silently noop. The agent gets a structured error and
129
- // knows to retry without --dry-run (or pick a different op).
130
- //
131
- // When --dry-run is honored we set X-Dry-Run:1 in the outgoing headers
132
- // further down (step 5c). Read ops with --dry-run are a noop client-side
133
- // (they don't mutate anyway) — we treat them as a soft success but stamp
134
- // meta.dry_run=true so callers know nothing happened.
135
- const yes = Boolean(args.yes);
136
- const dryRun = Boolean(args.dry_run);
137
- if (op.sideEffect === "write" && !isTty && !yes) {
138
- trace(op, { exit_code: 1, error_code: "confirmation_required" });
139
- writeError("confirmation_required", `Operation ${op.cliKey} is a write (side-effect=write) and stdout is not a TTY. Pass --yes to confirm.`, 1);
140
- }
141
- if (dryRun && !op.dryRunSupported && op.sideEffect === "write") {
142
- trace(op, { exit_code: 1, error_code: "dry_run_unsupported" });
143
- writeError("dry_run_unsupported", `Operation ${op.cliKey} does not support server-side dry-run (X-Dry-Run).`, 1);
144
- }
145
- // 5. build URL + query string
146
- let url = creds.baseUrl + op.path;
147
- // path templating: {id} → URL-encoded value, removed from query
148
- const pathParamRegex = /\{([^}]+)\}/g;
149
- url = url.replace(pathParamRegex, (_full, name) => {
150
- const v = apiParams[name];
151
- if (v === undefined) {
152
- trace(op, { exit_code: 1, error_code: "invalid_args" });
153
- writeError("invalid_args", `Path parameter '${name}' is required.`, 1);
154
- }
155
- delete apiParams[name];
156
- return encodeURIComponent(String(v));
157
- });
158
- // 5a. WS streaming endpoint — REMOVED. CLI no longer ships /v1/ws/{news,views}
159
- // as commands. The OpenAPI ops still exist for partner SDKs but the
160
- // codegen drops x-cli-key, so this branch is unreachable today and
161
- // should stay deleted unless the partner-side WS auth model is reworked
162
- // (see WS removal commit + plan §3 follow-ups).
163
- // 5b. paginate `--all`
164
- if (args.all && op.pagination !== "none") {
165
- try {
166
- const result = await paginate(op, apiParams, {
167
- baseUrl: creds.baseUrl,
168
- bearer: creds.key,
169
- cliVersion: ctx.cliVersion,
170
- debug,
171
- ...(typeof args.max_pages === "string"
172
- ? { maxPages: Number(args.max_pages) }
173
- : typeof args.max_pages === "number"
174
- ? { maxPages: args.max_pages }
175
- : {}),
176
- ...(typeof args.max_items === "string"
177
- ? { maxItems: Number(args.max_items) }
178
- : typeof args.max_items === "number"
179
- ? { maxItems: args.max_items }
180
- : {}),
181
- });
182
- await writeStderr(`[paginate] ${result.pages} pages, ${result.items} items\n`);
183
- trace(op, { exit_code: 0 });
184
- maybeEmitUpdateBanner();
185
- process.exit(0);
186
- }
187
- catch (e) {
188
- if (e instanceof CallApiError) {
189
- const exitCode = exitCodeForPaginateError(e);
190
- trace(op, {
191
- request_id: e.requestId,
192
- status: e.httpStatus,
193
- exit_code: exitCode,
194
- error_code: e.code,
195
- });
196
- writeError(e.code, e.message, exitCode, {
197
- retryable: e.retryable,
198
- ...(e.recovery_hint ? { recovery_hint: e.recovery_hint } : {}),
199
- ...(e.requestId ? { request_id: e.requestId } : {}),
200
- });
201
- }
202
- trace(op, { exit_code: 2, error_code: "internal_error" });
203
- writeError("internal_error", e instanceof Error ? e.message : String(e), 2);
204
- }
205
- }
206
- // 5c. single-shot HTTP request
207
- const queryString = buildQueryString(apiParams);
208
- if (op.method === "GET" && queryString)
209
- url += "?" + queryString;
210
- const { headers, requestId: clientRequestId } = buildHttpHeaders({
211
- bearer: creds.key,
212
- cliVersion: ctx.cliVersion,
213
- });
214
- // Idempotency-Key for endpoints that require it
215
- if (op.idempotencyRequired) {
216
- const userKey = typeof args.idempotency_key === "string" ? args.idempotency_key : null;
217
- const idemKey = userKey || generateIdempotencyKey();
218
- headers.set("Idempotency-Key", idemKey);
219
- if (!userKey)
220
- announceKey(idemKey, op.cliKey);
221
- }
222
- // X-Dry-Run: only set on writes where the server explicitly advertises
223
- // support (op.dryRunSupported). Read ops with --dry-run flow through
224
- // unchanged — they're a noop server-side, so there's nothing to dry-run.
225
- if (dryRun && op.dryRunSupported) {
226
- headers.set("X-Dry-Run", "1");
227
- }
228
- const init = {
229
- method: op.method,
230
- headers,
231
- };
232
- if (op.method === "POST") {
233
- headers.set("Content-Type", "application/json");
234
- init.body = JSON.stringify(apiParams);
235
- }
236
- if (debug) {
237
- process.stderr.write(`> ${op.method} ${url}\n` +
238
- `> Authorization: Bearer eps_live_***_***\n` +
239
- Array.from(headers.entries())
240
- .filter(([k]) => k.toLowerCase() !== "authorization")
241
- .map(([k, v]) => `> ${k}: ${v}`)
242
- .join("\n") +
243
- "\n");
244
- }
245
- // 6. fire request
246
- const startedAt = Date.now();
247
- let res;
248
- try {
249
- res = await fetch(url, init);
250
- }
251
- catch (e) {
252
- trace(op, { exit_code: 2, error_code: "network_error" });
253
- writeError("network_error", e instanceof Error ? e.message : String(e), 2);
254
- }
255
- const requestId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
256
- const apiVersion = res.headers.get("x-api-version") || undefined;
257
- const ct = res.headers.get("content-type") || "";
258
- const bodyText = await res.text();
259
- let bodyJson = null;
260
- if (ct.includes("application/json") && bodyText) {
261
- try {
262
- bodyJson = JSON.parse(bodyText);
263
- }
264
- catch {
265
- // body 不是 JSON
266
- }
267
- }
268
- const durationMs = Date.now() - startedAt;
269
- if (debug) {
270
- process.stderr.write(`< ${res.status} ${res.statusText}\n`);
271
- if (requestId)
272
- process.stderr.write(`< x-request-id: ${requestId}\n`);
273
- }
274
- // 7. error path
275
- if (res.status >= 400) {
276
- const apiErr = tryParseErrorEnvelope(bodyJson, res.status, requestId);
277
- const exitCode = exitCodeForStatus(res.status);
278
- const code = apiErr?.code ?? "http_error";
279
- trace(op, {
280
- request_id: requestId,
281
- status: res.status,
282
- duration_ms: durationMs,
283
- exit_code: exitCode,
284
- error_code: code,
285
- });
286
- if (apiErr) {
287
- writeError(apiErr.code, apiErr.message, exitCode, {
288
- retryable: apiErr.retryable,
289
- ...(apiErr.recovery_hint ? { recovery_hint: apiErr.recovery_hint } : {}),
290
- request_id: requestId,
291
- });
292
- }
293
- writeError("http_error", `HTTP ${res.status} ${res.statusText}: ${bodyText.slice(0, 200)}`, exitCode, { request_id: requestId });
294
- }
295
- // 8. success path — render
296
- if (raw) {
297
- await writeStdout(bodyText + "\n");
298
- maybeEmitUpdateBanner();
299
- process.exit(0);
300
- }
301
- const rawEnvelope = buildResponseEnvelope(bodyJson ?? bodyText, {
302
- requestId,
303
- endpoint: op.path,
304
- method: op.method,
305
- cliVersion: ctx.cliVersion,
306
- durationMs,
307
- ...(apiVersion ? { apiVersion } : {}),
308
- });
309
- let envelope = rawEnvelope;
310
- try {
311
- // Global JMESPath filter is `--jq` (renamed from --query in Phase 6
312
- // smoke-fix to avoid clashing with subcommand `--query` body params,
313
- // e.g. news.search). Do NOT fall back to args.query here — that's the
314
- // subcommand body field, not a JMESPath expression.
315
- const jq = typeof args.jq === "string" ? args.jq : undefined;
316
- envelope = applyFilters(rawEnvelope, {
317
- ...(jq ? { query: jq } : {}),
318
- ...(parseFieldsFlag(args.fields) ? { fields: parseFieldsFlag(args.fields) } : {}),
319
- ...(parseMaxBytesFlag(args.max_bytes) ? { maxBytes: parseMaxBytesFlag(args.max_bytes) } : {}),
320
- });
321
- }
322
- catch (e) {
323
- if (e instanceof FilterError) {
324
- trace(op, {
325
- request_id: requestId,
326
- status: res.status,
327
- duration_ms: durationMs,
328
- exit_code: 1,
329
- error_code: "invalid_args",
330
- });
331
- writeError("invalid_args", e.message, 1);
332
- }
333
- throw e;
334
- }
335
- await writeStdout(render(envelope, outputFmt, isTty) + "\n");
336
- trace(op, {
337
- request_id: requestId,
338
- status: res.status,
339
- duration_ms: durationMs,
340
- exit_code: 0,
341
- truncated: envelope.meta.truncated === true,
342
- });
343
- maybeEmitUpdateBanner();
344
- process.exit(0);
345
- }
346
- function buildQueryString(params) {
347
- const parts = [];
348
- for (const [k, v] of Object.entries(params)) {
349
- if (v === undefined || v === null)
350
- continue;
351
- if (Array.isArray(v)) {
352
- // edge-gateway expects ?codes=A,B not ?codes=A&codes=B
353
- parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v.join(","))}`);
354
- }
355
- else {
356
- parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
357
- }
358
- }
359
- return parts.join("&");
360
- }
361
- function trace(op, t) {
362
- appendTrace({
363
- ts: new Date().toISOString(),
364
- request_id: t.request_id ?? null,
365
- cmd: op.cliKey,
366
- status: t.status ?? null,
367
- duration_ms: t.duration_ms ?? null,
368
- exit_code: t.exit_code,
369
- ...(t.truncated ? { truncated: true } : {}),
370
- ...(t.error_code ? { error_code: t.error_code } : {}),
371
- });
372
- }
373
- function writeError(code, message, exitCode, extras) {
374
- const retryable = extras?.retryable ?? isRetryableByCode(code);
375
- const hint = resolveRecoveryHint(code, extras?.recovery_hint);
376
- const env = {
377
- error: { code, message, retryable },
378
- };
379
- if (hint)
380
- env.error.recovery_hint = hint;
381
- if (extras?.request_id)
382
- env.error.request_id = extras.request_id;
383
- // TTY: 多行人性化输出 (color + recovery_hint 高亮);非 TTY 输出 single-line JSON。
384
- if (isTtyHuman) {
385
- process.stderr.write(renderError(env) + "\n");
386
- }
387
- else {
388
- process.stderr.write(JSON.stringify(env) + "\n");
389
- }
390
- process.exit(exitCode);
391
- }
@@ -1,16 +0,0 @@
1
- function writeStream(stream, text) {
2
- return new Promise((resolve, reject) => {
3
- stream.write(text, (err) => {
4
- if (err)
5
- reject(err);
6
- else
7
- resolve();
8
- });
9
- });
10
- }
11
- export function writeStdout(text) {
12
- return writeStream(process.stdout, text);
13
- }
14
- export function writeStderr(text) {
15
- return writeStream(process.stderr, text);
16
- }
@@ -1,146 +0,0 @@
1
- /**
2
- * Pagination 策略:自动 follow next_cursor / next_offset 把全部 page NDJSON 输出。
3
- *
4
- * 触发:用户加 `--all` flag。`x-cli-pagination` 决定策略:
5
- * - `cursor`:response.meta.next_cursor 非空 → 透传到下一次请求 ?cursor=...
6
- * - `offset`:response.data.has_more=true → 用 ?offset = sum(已收) 拉下一页
7
- * - `none`:不该走 pagination 路径
8
- *
9
- * 输出:NDJSON,每行一条 item(剥去 envelope,便于 `| jq` `| while read` 等管道)。
10
- *
11
- * 上限:默认 1_000 页或 100_000 items 防失控;可 --max-pages / --max-items 覆盖。
12
- */
13
- import { fetch as undiciFetch } from "undici";
14
- import { tryParseErrorEnvelope, exitCodeForStatus, CallApiError } from "./errors.js";
15
- import { buildHttpHeaders, resolveRequestId } from "./http.js";
16
- import { writeStdout } from "./io.js";
17
- const DEFAULT_MAX_PAGES = 1000;
18
- const DEFAULT_MAX_ITEMS = 100_000;
19
- /**
20
- * 调用并 NDJSON-stream 所有 page。失败抛 CallApiError。
21
- *
22
- * write 由调用方注入(默认走 process.stdout.write 且等待 flush,便于测试 mock)。
23
- */
24
- export async function paginate(op, initialParams, ctx, write = writeStdout) {
25
- if (op.pagination === "none") {
26
- throw new Error(`${op.cliKey}: --all not supported (x-cli-pagination=none)`);
27
- }
28
- const maxPages = ctx.maxPages ?? DEFAULT_MAX_PAGES;
29
- const maxItems = ctx.maxItems ?? DEFAULT_MAX_ITEMS;
30
- const fetchFn = ctx.fetchImpl ?? undiciFetch;
31
- const params = { ...initialParams };
32
- let pages = 0;
33
- let items = 0;
34
- while (true) {
35
- const url = ctx.baseUrl + op.path + "?" + buildQueryString(params);
36
- // Rebuild headers per page — each page is a distinct request and needs
37
- // its own X-Request-Id for server-side correlation.
38
- const { headers, requestId: clientRequestId } = buildHttpHeaders({
39
- bearer: ctx.bearer,
40
- cliVersion: ctx.cliVersion,
41
- });
42
- if (ctx.debug) {
43
- process.stderr.write(`> [page ${pages + 1}] ${op.method} ${url}\n`);
44
- }
45
- const res = await fetchFn(url, { method: op.method, headers });
46
- const body = await res.text();
47
- let json = null;
48
- try {
49
- json = body ? JSON.parse(body) : null;
50
- }
51
- catch {
52
- // ignore
53
- }
54
- if (res.status >= 400) {
55
- const reqId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
56
- const apiErr = tryParseErrorEnvelope(json, res.status, reqId);
57
- throw apiErr ??
58
- new CallApiError({
59
- code: "http_error",
60
- message: `HTTP ${res.status}`,
61
- httpStatus: res.status,
62
- requestId: reqId,
63
- });
64
- }
65
- pages += 1;
66
- const { itemsArr, nextParams, hasMore } = extractPage(op, json, params);
67
- for (const item of itemsArr) {
68
- await write(JSON.stringify(item) + "\n");
69
- items += 1;
70
- if (items >= maxItems) {
71
- if (ctx.debug)
72
- process.stderr.write(`[paginate] hit max-items ${maxItems}, stopping\n`);
73
- return { pages, items };
74
- }
75
- }
76
- if (!hasMore)
77
- return { pages, items };
78
- if (pages >= maxPages) {
79
- if (ctx.debug)
80
- process.stderr.write(`[paginate] hit max-pages ${maxPages}, stopping\n`);
81
- return { pages, items };
82
- }
83
- Object.assign(params, nextParams);
84
- }
85
- }
86
- function extractPage(op, body, currentParams) {
87
- // 公认 envelope 形态:
88
- // { data: { items: [...], total?, has_more?, next_cursor? }, meta?: { next_cursor? } }
89
- // 或 { data: [...], meta: { next_cursor? } } —— 直接 array
90
- let itemsArr = [];
91
- let dataObj = null;
92
- let metaObj = null;
93
- if (typeof body === "object" && body !== null) {
94
- const top = body;
95
- if (Array.isArray(top.data)) {
96
- itemsArr = top.data;
97
- }
98
- else if (typeof top.data === "object" && top.data !== null) {
99
- dataObj = top.data;
100
- if (Array.isArray(dataObj.items))
101
- itemsArr = dataObj.items;
102
- }
103
- else if (Array.isArray(top.items)) {
104
- dataObj = top;
105
- itemsArr = top.items;
106
- }
107
- if (typeof top.meta === "object" && top.meta !== null) {
108
- metaObj = top.meta;
109
- }
110
- }
111
- if (op.pagination === "cursor") {
112
- const nextCursor = metaObj?.next_cursor ??
113
- dataObj?.next_cursor;
114
- if (typeof nextCursor === "string" && nextCursor) {
115
- return { itemsArr, nextParams: { cursor: nextCursor }, hasMore: true };
116
- }
117
- return { itemsArr, nextParams: {}, hasMore: false };
118
- }
119
- if (op.pagination === "offset") {
120
- const hasMore = Boolean(dataObj?.has_more);
121
- if (!hasMore)
122
- return { itemsArr, nextParams: {}, hasMore: false };
123
- const currentOffset = Number(currentParams.offset ?? 0) + itemsArr.length;
124
- return { itemsArr, nextParams: { offset: currentOffset }, hasMore: true };
125
- }
126
- return { itemsArr, nextParams: {}, hasMore: false };
127
- }
128
- function buildQueryString(params) {
129
- const parts = [];
130
- for (const [k, v] of Object.entries(params)) {
131
- if (v === undefined || v === null)
132
- continue;
133
- if (Array.isArray(v)) {
134
- parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v.join(","))}`);
135
- }
136
- else {
137
- parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
138
- }
139
- }
140
- return parts.join("&");
141
- }
142
- export function exitCodeForPaginateError(e) {
143
- if (e instanceof CallApiError && e.httpStatus)
144
- return exitCodeForStatus(e.httpStatus);
145
- return 2;
146
- }
@@ -1,99 +0,0 @@
1
- /**
2
- * Local CLI invocation trace (ring buffer).
3
- *
4
- * 写入 `~/.echopai/trace.ndjson`,每行一条 NDJSON。文件达 RING_MAX_BYTES (50MB)
5
- * 时旋转为 `trace.ndjson.1` (覆盖前一代)。
6
- *
7
- * 用途:事后追溯 request_id / 看最近一次 401 是什么命令打的 / 排查 agent
8
- * 调用频率。完全本地、不外发、对 prod 零影响。
9
- *
10
- * 关闭:`ECHOPAI_NO_TRACE=1`。
11
- * 重定向:`ECHOPAI_TRACE_FILE=/path/to/file` (主要给测试用)。
12
- *
13
- * 失败容错:trace 是 best-effort,任何 fs 错误吞掉,绝不让用户命令因 trace
14
- * 失败而 crash。
15
- *
16
- * 同步写入:CLI 退出时序 (process.exit) 不等 async flush,所以全用 sync API。
17
- * 一次 appendFileSync ≈ μs 级,对 CLI 启停 latency 可忽略。
18
- */
19
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, } from "node:fs";
20
- import { homedir } from "node:os";
21
- import { dirname, join } from "node:path";
22
- const DEFAULT_TRACE_FILE = join(homedir(), ".echopai", "trace.ndjson");
23
- const RING_MAX_BYTES = 50 * 1024 * 1024;
24
- export function isTraceDisabled() {
25
- return process.env.ECHOPAI_NO_TRACE === "1";
26
- }
27
- export function resolveTraceFile(opts) {
28
- return (opts?.filePath ?? process.env.ECHOPAI_TRACE_FILE ?? DEFAULT_TRACE_FILE);
29
- }
30
- export function appendTrace(record, opts) {
31
- if (isTraceDisabled())
32
- return;
33
- const file = resolveTraceFile(opts);
34
- const maxBytes = opts?.ringMaxBytes ?? RING_MAX_BYTES;
35
- try {
36
- mkdirSync(dirname(file), { recursive: true });
37
- if (existsSync(file)) {
38
- const size = statSync(file).size;
39
- if (size >= maxBytes) {
40
- renameSync(file, file + ".1");
41
- }
42
- }
43
- appendFileSync(file, JSON.stringify(record) + "\n");
44
- }
45
- catch {
46
- // best-effort
47
- }
48
- }
49
- function readNdjsonLines(file) {
50
- try {
51
- if (!existsSync(file))
52
- return [];
53
- const raw = readFileSync(file, "utf-8");
54
- const out = [];
55
- for (const line of raw.split("\n")) {
56
- if (line.length === 0)
57
- continue;
58
- try {
59
- out.push(JSON.parse(line));
60
- }
61
- catch {
62
- // skip malformed line
63
- }
64
- }
65
- return out;
66
- }
67
- catch {
68
- return [];
69
- }
70
- }
71
- /**
72
- * Last N records in chronological order (oldest → newest).
73
- * Reads current ring file; falls back to .1 to fill if needed.
74
- */
75
- export function tailTraces(n, opts) {
76
- if (n <= 0)
77
- return [];
78
- const file = resolveTraceFile(opts);
79
- const current = readNdjsonLines(file);
80
- if (current.length >= n)
81
- return current.slice(-n);
82
- const previous = readNdjsonLines(file + ".1");
83
- return previous.concat(current).slice(-n);
84
- }
85
- /**
86
- * Find a single record by request_id. Scans current ring then .1; newest first
87
- * (so retried request_ids return the latest occurrence).
88
- */
89
- export function getTraceByRequestId(requestId, opts) {
90
- const file = resolveTraceFile(opts);
91
- for (const candidate of [file, file + ".1"]) {
92
- const records = readNdjsonLines(candidate);
93
- for (let i = records.length - 1; i >= 0; i--) {
94
- if (records[i].request_id === requestId)
95
- return records[i];
96
- }
97
- }
98
- return null;
99
- }