@signal4/s4 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +60 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +407 -196
  4. package/dist/cli.js.map +1 -1
  5. package/dist/cli_args.d.ts +24 -0
  6. package/dist/cli_args.d.ts.map +1 -0
  7. package/dist/cli_args.js +130 -0
  8. package/dist/cli_args.js.map +1 -0
  9. package/dist/client.d.ts +103 -3
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +195 -23
  12. package/dist/client.js.map +1 -1
  13. package/dist/format.d.ts +103 -4
  14. package/dist/format.d.ts.map +1 -1
  15. package/dist/format.js +299 -64
  16. package/dist/format.js.map +1 -1
  17. package/dist/handler_types.d.ts +10 -0
  18. package/dist/handler_types.d.ts.map +1 -1
  19. package/dist/handler_types.js +25 -0
  20. package/dist/handler_types.js.map +1 -1
  21. package/dist/handlers/ads.js +4 -4
  22. package/dist/handlers/ads.js.map +1 -1
  23. package/dist/handlers/analyze.d.ts.map +1 -1
  24. package/dist/handlers/analyze.js +22 -6
  25. package/dist/handlers/analyze.js.map +1 -1
  26. package/dist/handlers/bookmarks.js +2 -2
  27. package/dist/handlers/bookmarks.js.map +1 -1
  28. package/dist/handlers/claim_detail.d.ts.map +1 -1
  29. package/dist/handlers/claim_detail.js +18 -10
  30. package/dist/handlers/claim_detail.js.map +1 -1
  31. package/dist/handlers/claims.d.ts.map +1 -1
  32. package/dist/handlers/claims.js +72 -27
  33. package/dist/handlers/claims.js.map +1 -1
  34. package/dist/handlers/count.d.ts +14 -0
  35. package/dist/handlers/count.d.ts.map +1 -0
  36. package/dist/handlers/count.js +158 -0
  37. package/dist/handlers/count.js.map +1 -0
  38. package/dist/handlers/explore.d.ts.map +1 -1
  39. package/dist/handlers/explore.js +56 -14
  40. package/dist/handlers/explore.js.map +1 -1
  41. package/dist/handlers/registry.d.ts.map +1 -1
  42. package/dist/handlers/registry.js +5 -1
  43. package/dist/handlers/registry.js.map +1 -1
  44. package/dist/handlers/reports.d.ts +6 -0
  45. package/dist/handlers/reports.d.ts.map +1 -1
  46. package/dist/handlers/reports.js +140 -26
  47. package/dist/handlers/reports.js.map +1 -1
  48. package/dist/handlers/search.d.ts.map +1 -1
  49. package/dist/handlers/search.js +80 -23
  50. package/dist/handlers/search.js.map +1 -1
  51. package/dist/handlers/timeline.d.ts.map +1 -1
  52. package/dist/handlers/timeline.js +14 -8
  53. package/dist/handlers/timeline.js.map +1 -1
  54. package/dist/http.js +25 -3
  55. package/dist/http.js.map +1 -1
  56. package/dist/index.js +2 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/provenance.d.ts +29 -2
  59. package/dist/provenance.d.ts.map +1 -1
  60. package/dist/provenance.js +63 -4
  61. package/dist/provenance.js.map +1 -1
  62. package/dist/server_core.d.ts +8 -1
  63. package/dist/server_core.d.ts.map +1 -1
  64. package/dist/server_core.js +90 -8
  65. package/dist/server_core.js.map +1 -1
  66. package/dist/speed.d.ts +40 -0
  67. package/dist/speed.d.ts.map +1 -0
  68. package/dist/speed.js +214 -0
  69. package/dist/speed.js.map +1 -0
  70. package/dist/surface.d.ts +7 -0
  71. package/dist/surface.d.ts.map +1 -0
  72. package/dist/surface.js +19 -0
  73. package/dist/surface.js.map +1 -0
  74. package/dist/types.d.ts +249 -15
  75. package/dist/types.d.ts.map +1 -1
  76. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -1,106 +1,63 @@
1
1
  /** Signal4 CLI — terminal interface sharing handlers with the MCP server. */
2
2
  import { applyConfig } from "./config.js";
3
3
  import { HANDLERS } from "./handlers/registry.js";
4
+ import { descriptionTag, estimate, startNotice, REPORT_ELAPSED_OVER_S } from "./speed.js";
4
5
  import * as client from "./client.js";
5
6
  import { writeFileSync, mkdirSync, existsSync, chmodSync, readFileSync, statSync } from "node:fs";
6
7
  import { join } from "node:path";
7
- import { formatDossier } from "./format.js";
8
+ import { formatDossier, parsePoliticalLean } from "./format.js";
8
9
  import { homedir } from "node:os";
9
10
  import { createInterface } from "node:readline/promises";
11
+ import { parseArgs as parseArgsRaw, UsageError, didYouMean } from "./cli_args.js";
12
+ import { setSurface } from "./surface.js";
13
+ /** Parse or exit 1 with the usage error on stderr. */
10
14
  function parseArgs(argv, specs) {
11
- const flags = {};
12
- const positionals = [];
13
- // Apply defaults. Flags without a declared default stay undefined —
14
- // pre-filling numbers with 0 turns optional filters into active ones
15
- // (political-lean-min=0 + political-lean-max=0 matched zero claims).
16
- for (const s of specs) {
17
- if (s.default !== undefined)
18
- flags[s.name] = s.default;
19
- else if (s.type === "boolean")
20
- flags[s.name] = false;
21
- }
22
- // Build lookup: --name and -alias -> spec
23
- const lookup = new Map();
24
- for (const s of specs) {
25
- lookup.set(`--${s.name}`, s);
26
- if (s.alias)
27
- lookup.set(`-${s.alias}`, s);
28
- }
29
- let i = 0;
30
- while (i < argv.length) {
31
- const arg = argv[i];
32
- const spec = lookup.get(arg);
33
- if (spec) {
34
- if (spec.type === "boolean") {
35
- flags[spec.name] = true;
36
- }
37
- else {
38
- const val = argv[++i];
39
- if (spec.type === "number") {
40
- const num = Number(val);
41
- if (val === undefined || val === "" || Number.isNaN(num)) {
42
- process.stderr.write(`Error: --${spec.name} expects a number, got "${val ?? ""}".\n`);
43
- process.exit(1);
44
- }
45
- flags[spec.name] = num;
46
- }
47
- else {
48
- flags[spec.name] = val ?? "";
49
- }
50
- }
51
- }
52
- else if (arg.startsWith("-") && arg !== "-") {
53
- // Hard error, not a warning: a silently-dropped flag falls through to
54
- // default behavior, and the caller reads the wrong answer as the right one.
55
- const valid = specs.map((s) => `--${s.name}${s.alias ? ` (-${s.alias})` : ""}`).join(", ");
56
- process.stderr.write(`Error: unknown flag "${arg}" for this command.\nValid flags: ${valid}\nRun with --help for details.\n`);
57
- process.exit(1);
58
- }
59
- else {
60
- positionals.push(arg);
61
- }
62
- i++;
63
- }
64
- // Post-parse validation for shared flags — a silently NaN-coerced --days or
65
- // a raw backend error for a malformed date both read as broken tooling.
66
- const days = flags.days;
67
- if (typeof days === "number" && specs.some((s) => s.name === "days")) {
68
- if (!Number.isInteger(days) || days < 1) {
69
- process.stderr.write(`Error: --days must be a positive integer (got "${days}").\n`);
70
- process.exit(1);
71
- }
72
- if (days > 365) {
73
- process.stderr.write(`Error: --days is capped at 365. Use --start YYYY-MM-DD for older material (s4 search also supports --full-history).\n`);
74
- process.exit(1);
75
- }
15
+ try {
16
+ return parseArgsRaw(argv, specs);
76
17
  }
77
- for (const name of ["start", "end"]) {
78
- const v = flags[name];
79
- if (typeof v === "string" && v && !/^\d{4}-\d{2}-\d{2}$/.test(v)) {
80
- process.stderr.write(`Error: --${name} must be YYYY-MM-DD (e.g. 2026-07-13), got "${v}".\n`);
18
+ catch (err) {
19
+ if (err instanceof UsageError) {
20
+ process.stderr.write(`Error: ${err.message}\n`);
81
21
  process.exit(1);
82
22
  }
23
+ throw err;
83
24
  }
84
- return { positionals, flags };
85
25
  }
86
26
  // ---------------------------------------------------------------------------
87
27
  // Shared flag specs
88
28
  // ---------------------------------------------------------------------------
89
- const FLAG_DAYS = { name: "days", alias: "d", type: "number", default: 30, desc: "Time window in days (max 365 — use --start for older material)" };
90
- const FLAG_LIMIT = { name: "limit", alias: "n", type: "number", default: 20, desc: "Max results" };
91
- const FLAG_OFFSET = { name: "offset", alias: "o", type: "number", default: 0, desc: "Pagination offset" };
29
+ const FLAG_DAYS = { name: "days", alias: "d", type: "number", default: 30, desc: "Time window in days (report commands cap at 365; search/claims/timeline do not)" };
30
+ const FLAG_LIMIT = { name: "limit", alias: ["n", "k"], type: "number", default: 20, desc: "Number of results (-n, -k)" };
31
+ const FLAG_OFFSET = { name: "offset", type: "number", default: 0, desc: "Skip this many results (pagination)" };
32
+ const FLAG_PAGE = { name: "page", type: "number", desc: "Page number, 1-based, in pages of --limit (sets --offset)" };
33
+ const FLAG_ALL = { name: "all", type: "boolean", desc: `Follow every page (up to ${50} pages)` };
34
+ /** Paging flags for every list command (signal4#329). */
35
+ const PAGING = [FLAG_OFFSET, FLAG_PAGE, FLAG_ALL];
36
+ /** --all stops after this many pages; the last page then says how to continue. */
37
+ const MAX_ALL_PAGES = 50;
92
38
  const FLAG_SPEAKER = { name: "speaker", alias: "s", type: "string", desc: "Filter by speaker name (full name)" };
93
39
  const FLAG_CHANNEL = { name: "channel", alias: "c", type: "string", desc: "Filter by channel name (comma-separated for multiple)" };
94
- const FLAG_LANGUAGE = { name: "language", alias: "l", type: "string", desc: "Filter by language code (en, fr, ...)" };
40
+ const FLAG_LANGUAGE = { name: "language", type: "string", desc: "Filter by language code (en, fr, ...)" };
95
41
  const FLAG_COUNTRY = { name: "country", type: "string", desc: "Filter by channel country code" };
96
- const FLAG_PROJECT = { name: "project", type: "string", desc: "Restrict to a project (e.g. Big_Channels)" };
42
+ const FLAG_COLLECTION = { name: "collection", long: ["project"], type: "string", desc: "Restrict to one collection (see: s4 collections). Default: whole corpus" };
97
43
  const FLAG_MODE = { name: "mode", alias: "m", type: "string", default: "semantic", desc: "Search mode: semantic | keyword | hybrid" };
98
- const FLAG_START = { name: "start", type: "string", desc: "Start date YYYY-MM-DD (no cap — overrides --days)" };
99
- const FLAG_END = { name: "end", type: "string", desc: "End date YYYY-MM-DD (default today)" };
100
- const FLAG_KEYWORD = { name: "keyword", alias: "k", type: "string", desc: "Exact keyword search (implies --mode keyword)" };
44
+ const FLAG_START = { name: "start", long: ["since"], type: "string", desc: "Start date: YYYY, YYYY-MM or YYYY-MM-DD (overrides --days)" };
45
+ const FLAG_END = { name: "end", long: ["until"], type: "string", desc: "End date: YYYY, YYYY-MM or YYYY-MM-DD (default today)" };
46
+ const FLAG_KEYWORD = { name: "keyword", type: "string", desc: "Exact phrase search (implies --mode keyword)" };
101
47
  const FLAG_SKIP_CACHE = { name: "skip-cache", type: "boolean", desc: "Force fresh analysis (bypass cache)" };
102
- const FLAG_OUT = { name: "out", type: "string", desc: "Write the rendered output to a file (.md, or .csv on search)" };
48
+ const FLAG_OUT = { name: "out", alias: "o", long: ["output"], type: "string", desc: "Write the rendered output to a file (.md, or .csv on search)" };
103
49
  const FLAG_JSON = { name: "json", type: "boolean", desc: "Raw JSON output" };
50
+ const GRANULARITIES = ["day", "week", "month", "quarter", "year"];
51
+ const COUNT_GROUPS = ["none", ...GRANULARITIES, "channel", "episode", "speaker", "language", "country", "platform", "collection", "lean"];
52
+ /** --start/--end as tool params (already normalized to YYYY-MM-DD by parseArgs); absent when not given. */
53
+ function dateFlags(f) {
54
+ const out = {};
55
+ if (f.start)
56
+ out.start_date = f.start;
57
+ if (f.end)
58
+ out.end_date = f.end;
59
+ return out;
60
+ }
104
61
  // ---------------------------------------------------------------------------
105
62
  // Progress reporting for long-running analysis commands
106
63
  // ---------------------------------------------------------------------------
@@ -211,11 +168,15 @@ function parseSegmentId(raw) {
211
168
  // Accept all of: N, seg:N, seg_N, {seg_N}, {seg:N}.
212
169
  const id = Number(raw.replace(/^\{(.*)\}$/, "$1").replace(/^seg[:_]/, ""));
213
170
  if (!Number.isInteger(id)) {
214
- print(`Error: invalid segment ID "${raw}" (expected a number, seg:<number>, or seg_<number>).`);
171
+ printErr(`Error: invalid segment ID "${raw}" (expected a number, seg:<number>, or seg_<number>).`);
215
172
  process.exit(1);
216
173
  }
217
174
  return id;
218
175
  }
176
+ function printErr(text) {
177
+ activeProgress?.finish();
178
+ process.stderr.write(text + "\n");
179
+ }
219
180
  function print(text) {
220
181
  activeProgress?.finish();
221
182
  process.stdout.write(text + "\n");
@@ -239,23 +200,98 @@ function toCsv(rows, columns) {
239
200
  function printJson(data) {
240
201
  print(JSON.stringify(data, null, 2));
241
202
  }
203
+ /** The parsed --collection for the running command; injected into every handler call. */
204
+ let activeCollection;
242
205
  async function runHandler(tool, params, out) {
243
206
  const handler = HANDLERS[tool];
244
207
  if (!handler) {
245
- print(`Error: handler "${tool}" not found.`);
208
+ printErr(`Error: handler "${tool}" not found.`);
246
209
  process.exit(1);
247
210
  }
211
+ if (params.collection === undefined && activeCollection)
212
+ params = { ...params, collection: activeCollection };
213
+ // Slow calls say how long to expect before they start; stderr, so stdout stays the answer.
214
+ const expected = estimate(tool, params);
215
+ if (expected.tier === "slow")
216
+ process.stderr.write(`${startNotice(expected)}\n`);
217
+ const started = Date.now();
248
218
  const result = await handler(params);
219
+ const took = (Date.now() - started) / 1000;
220
+ if (took > REPORT_ELAPSED_OVER_S)
221
+ process.stderr.write(`Took ${took.toFixed(1)}s.\n`);
249
222
  if (result.isError) {
250
223
  process.stderr.write((result.content[0]?.text ?? "Unknown error") + "\n");
251
224
  process.exit(1);
252
225
  }
253
- const text = result.content[0]?.text ?? "";
226
+ let text = result.content[0]?.text ?? "";
227
+ // --all: follow the next-page hint every list answer ends with, until it stops.
228
+ let pages = 1;
229
+ let next = nextPage(text);
230
+ while (followAllPages && next && pages < MAX_ALL_PAGES) {
231
+ const current = Number(params[next.param] ?? 0);
232
+ if (next.offset <= current)
233
+ break;
234
+ params = { ...params, [next.param]: next.offset };
235
+ const more = await handler(params);
236
+ if (more.isError)
237
+ break;
238
+ const pageText = more.content[0]?.text ?? "";
239
+ text += `\n\n--- page ${++pages} (${next.param} ${next.offset}) ---\n` + pageText;
240
+ next = nextPage(pageText);
241
+ }
242
+ if (followAllPages && next && pages >= MAX_ALL_PAGES) {
243
+ text += `\n\n(--all stopped after ${MAX_ALL_PAGES} pages; continue with --offset ${next.offset})`;
244
+ }
254
245
  if (out)
255
246
  writeOut(out, text);
256
247
  else
257
248
  print(text);
258
249
  }
250
+ /** Set by main from --all. */
251
+ let followAllPages = false;
252
+ /** The next page a list answer offers ("--offset 40", "--obs-offset 40"), or null. */
253
+ function nextPage(text) {
254
+ const obs = [...text.matchAll(/--obs-offset (\d+)/g)].pop();
255
+ if (obs)
256
+ return { param: "observations_offset", offset: Number(obs[1]) };
257
+ const m = [...text.matchAll(/(?<![-\w])--offset (\d+)/g)].pop();
258
+ return m ? { param: "offset", offset: Number(m[1]) } : null;
259
+ }
260
+ // A `--channel` name that doesn't resolve to a real channel must fail loudly
261
+ // in keyword mode, not fall through as an unfiltered corpus-wide query
262
+ // (signal4#85). Keyword-mode search silently drops channel_names server-side
263
+ // (a server-side defect, out of scope here) so an unresolvable name there
264
+ // would otherwise read as a channel-scoped count that is actually
265
+ // corpus-wide. Semantic mode is NOT gated: the server already returns an
266
+ // empty result set for an unknown channel there, which is the correct UX —
267
+ // adding a second, client-side source of truth on that path risks disagreeing
268
+ // with the server and regressing it.
269
+ //
270
+ // Resolved against /entities/channels/by-name — the one client-reachable
271
+ // place this can be caught. Multiple names resolve concurrently. A non-404
272
+ // failure (network blip, 5xx, timeout) fails OPEN: warn on stderr and let the
273
+ // search proceed rather than blocking a query that might otherwise succeed.
274
+ async function assertChannelsExistForKeywordMode(channelArg, mode) {
275
+ if (!channelArg || mode !== "keyword")
276
+ return;
277
+ const names = channelArg.split(",").map((s) => s.trim()).filter(Boolean);
278
+ await Promise.all(names.map(async (name) => {
279
+ try {
280
+ await client.getChannelByName(name);
281
+ }
282
+ catch (err) {
283
+ const msg = err instanceof Error ? err.message : String(err);
284
+ if (/^HTTP 404\b/.test(msg)) {
285
+ process.stderr.write(`Error: channel "${name}" not found. This checks the channels registry ` +
286
+ `(display_name) only — a name that exists only as a content-level ` +
287
+ `channel_name variant may still be valid; verify against the exact ` +
288
+ `registry name (s4 channel "${name}" or s4 top-channels).\n`);
289
+ process.exit(1);
290
+ }
291
+ process.stderr.write(`Warning: could not verify channel "${name}" exists (${msg}) — proceeding without the check.\n`);
292
+ }
293
+ }));
294
+ }
259
295
  // ---------------------------------------------------------------------------
260
296
  // Batch dossiers (surrogate-roster sweeps): s4 dossier "<topic>" --speakers file.txt --out dir/
261
297
  // ---------------------------------------------------------------------------
@@ -409,21 +445,22 @@ const COMMANDS = {
409
445
  search: {
410
446
  tool: "search_quotes",
411
447
  description: "Search quotes by topic and/or speaker",
412
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, FLAG_LIMIT, FLAG_OFFSET, FLAG_LANGUAGE, FLAG_COUNTRY,
448
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, { ...FLAG_LIMIT, default: 10 }, ...PAGING, FLAG_LANGUAGE, FLAG_COUNTRY,
413
449
  { name: "sort", type: "string", default: "relevance", desc: "relevance | chronological" },
414
450
  { name: "count", type: "boolean", desc: "Return only the match count" },
415
451
  { name: "full-history", type: "boolean", desc: "Search the entire corpus (requires topic + speaker)" },
416
452
  { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0, default 0.40)" },
417
453
  FLAG_KEYWORD,
418
454
  { name: "speaker-id", type: "number", desc: "Speaker identity ID (from s4 speaker)" }, FLAG_OUT,
419
- FLAG_START, FLAG_END,
455
+ FLAG_START, FLAG_END, FLAG_COLLECTION,
420
456
  FLAG_JSON],
421
457
  run: async (args, json) => {
422
458
  const [positionalTopic] = args.positionals;
423
459
  const f = args.flags;
424
460
  const keyword = f.keyword || undefined;
425
- const topic = positionalTopic || keyword || undefined;
461
+ const topic = keyword || positionalTopic || undefined;
426
462
  const mode = keyword ? "keyword" : f.mode;
463
+ await assertChannelsExistForKeywordMode(f.channel || undefined, mode);
427
464
  const days = Number(f.days) || 30;
428
465
  const fullHistory = f["full-history"];
429
466
  // Full history means FULL history — only pass dates given explicitly,
@@ -442,6 +479,7 @@ const COMMANDS = {
442
479
  count_only: f.count, sort: f.sort,
443
480
  full_history: fullHistory,
444
481
  min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
482
+ project: f.collection || undefined,
445
483
  };
446
484
  const out = f.out;
447
485
  if (json || (out && out.endsWith(".csv"))) {
@@ -456,6 +494,7 @@ const COMMANDS = {
456
494
  full_history: fullHistory || undefined,
457
495
  count_only: params.count_only || undefined,
458
496
  min_similarity: params.min_similarity,
497
+ project: params.project,
459
498
  });
460
499
  if (out && out.endsWith(".csv")) {
461
500
  const rows = (data.quotes ?? []).map((q) => ({
@@ -485,7 +524,7 @@ const COMMANDS = {
485
524
  claims: {
486
525
  tool: "search_claims",
487
526
  description: "Search extracted claims",
488
- specs: [{ name: "entity", alias: "e", type: "string", desc: "Filter by entity name (person, org, place, subject)" }, FLAG_SPEAKER, FLAG_CHANNEL,
527
+ specs: [{ name: "entity", type: "string", desc: "Filter by entity name (person, org, place, subject)" }, FLAG_SPEAKER, FLAG_CHANNEL,
489
528
  { name: "category", type: "string", desc: "Topic category filter" },
490
529
  { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
491
530
  FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_MODE, FLAG_KEYWORD,
@@ -495,12 +534,12 @@ const COMMANDS = {
495
534
  { name: "political-lean-min", type: "number", desc: "Min channel political lean (0=left, 1=right)" },
496
535
  { name: "political-lean-max", type: "number", desc: "Max channel political lean (0=left, 1=right)" },
497
536
  { name: "source", type: "string", desc: "Claim source filter" },
498
- FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_START, FLAG_END, FLAG_OUT, FLAG_JSON],
537
+ FLAG_DAYS, { ...FLAG_LIMIT, default: 10 }, ...PAGING, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
499
538
  run: async (args, json) => {
500
539
  const [positionalTopic] = args.positionals;
501
540
  const f = args.flags;
502
541
  const keyword = f.keyword || undefined;
503
- const topic = positionalTopic || keyword || undefined;
542
+ const topic = keyword || positionalTopic || undefined;
504
543
  const mode = keyword ? "keyword" : f.mode;
505
544
  const days = Number(f.days) || 30;
506
545
  const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : undefined;
@@ -521,6 +560,7 @@ const COMMANDS = {
521
560
  days, mode,
522
561
  sort: f.sort, min_observations: Number(f["min-obs"]) || 1,
523
562
  limit: Number(f.limit), offset: Number(f.offset),
563
+ project: f.collection || undefined,
524
564
  ...dr,
525
565
  };
526
566
  const claimsDr = dr ?? daysRange(days);
@@ -536,6 +576,7 @@ const COMMANDS = {
536
576
  mode: params.mode, sort: params.sort,
537
577
  min_observations: params.min_observations > 1 ? params.min_observations : undefined,
538
578
  limit: params.limit, offset: params.offset,
579
+ project: params.project,
539
580
  }));
540
581
  await runHandler("search_claims", params, f.out);
541
582
  },
@@ -543,12 +584,12 @@ const COMMANDS = {
543
584
  "analyze-claims": {
544
585
  tool: "analyze_claims",
545
586
  description: "Analyze a topic over extracted claims — themes with observation stats (fast, no LLM)",
546
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_DAYS,
587
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_DAYS, FLAG_START, FLAG_END, FLAG_COLLECTION,
547
588
  { name: "min-obs", type: "number", default: 1, desc: "Minimum observation count per claim" }, FLAG_JSON],
548
589
  run: async (args, json) => {
549
590
  const [topic] = args.positionals;
550
591
  if (!topic) {
551
- print("Error: topic is required.");
592
+ printErr("Error: topic is required.");
552
593
  process.exit(1);
553
594
  }
554
595
  const f = args.flags;
@@ -560,11 +601,13 @@ const COMMANDS = {
560
601
  language: f.language || undefined,
561
602
  days: Number(f.days) || undefined,
562
603
  min_observations: Number(f["min-obs"]) || undefined,
604
+ ...dateFlags(f),
563
605
  };
564
606
  if (json)
565
607
  return printJson(await client.analyzeTopicClaims({
566
608
  topic, speaker_name: params.speaker, channel_names: params.channels,
567
609
  language: params.language, days: params.days, min_observations: params.min_observations,
610
+ project: f.collection || undefined, ...dateFlags(f),
568
611
  }));
569
612
  await runHandler("analyze_claims", params);
570
613
  },
@@ -574,11 +617,12 @@ const COMMANDS = {
574
617
  description: "Detect whether a topic is surging/fading by claim volume (fast)",
575
618
  specs: [{ name: "recent-days", type: "number", default: 7, desc: "Recent window in days" },
576
619
  { name: "baseline-days", type: "number", default: 30, desc: "Baseline window in days" },
577
- { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0)" }, FLAG_JSON],
620
+ { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0)" },
621
+ FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_JSON],
578
622
  run: async (args, json) => {
579
623
  const [topic] = args.positionals;
580
624
  if (!topic) {
581
- print("Error: topic is required.");
625
+ printErr("Error: topic is required.");
582
626
  process.exit(1);
583
627
  }
584
628
  const f = args.flags;
@@ -587,32 +631,34 @@ const COMMANDS = {
587
631
  recent_days: Number(f["recent-days"]) || undefined,
588
632
  baseline_days: Number(f["baseline-days"]) || undefined,
589
633
  min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
634
+ language: f.language || undefined,
590
635
  };
591
636
  if (json)
592
- return printJson(await client.detectTrendClaims(params));
637
+ return printJson(await client.detectTrendClaims({ ...params, project: f.collection || undefined }));
593
638
  await runHandler("trend_claims", params);
594
639
  },
595
640
  },
596
641
  "contradict-claims": {
597
642
  tool: "contradict_claims",
598
643
  description: "Find a speaker's contradictory claims on a topic via stance analysis (fast)",
599
- specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_OUT, FLAG_JSON],
644
+ specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
600
645
  run: async (args, json) => {
601
646
  const [topic] = args.positionals;
602
647
  if (!topic) {
603
- print("Error: topic is required.");
648
+ printErr("Error: topic is required.");
604
649
  process.exit(1);
605
650
  }
606
651
  const f = args.flags;
607
652
  const speaker = f.speaker || undefined;
608
653
  if (!speaker) {
609
- print("Error: --speaker is required.");
654
+ printErr("Error: --speaker is required.");
610
655
  process.exit(1);
611
656
  }
612
- const params = { topic, speaker, days: Number(f.days) || undefined };
657
+ const params = { topic, speaker, days: Number(f.days) || undefined, ...dateFlags(f) };
613
658
  if (json)
614
659
  return printJson(await client.checkContradictionsClaims({
615
660
  topic, speaker_name: speaker, days: params.days,
661
+ project: f.collection || undefined, ...dateFlags(f),
616
662
  }));
617
663
  await runHandler("contradict_claims", params, f.out);
618
664
  },
@@ -620,24 +666,26 @@ const COMMANDS = {
620
666
  "compare-claims": {
621
667
  tool: "compare_claims",
622
668
  description: "Compare 2-5 speakers' claims on a topic — side-by-side stance (fast)",
623
- specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 }, FLAG_OUT, FLAG_JSON],
669
+ specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 },
670
+ FLAG_START, FLAG_END, FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
624
671
  run: async (args, json) => {
625
672
  const [topic] = args.positionals;
626
673
  if (!topic) {
627
- print("Error: topic is required.");
674
+ printErr("Error: topic is required.");
628
675
  process.exit(1);
629
676
  }
630
677
  const f = args.flags;
631
678
  const speakersStr = f.speakers || f.speaker || "";
632
679
  const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
633
680
  if (speakers.length < 2) {
634
- print("Error: --speakers requires 2-5 comma-separated names.");
681
+ printErr("Error: --speakers requires 2-5 comma-separated names.");
635
682
  process.exit(1);
636
683
  }
637
- const params = { topic, speakers, days: Number(f.days) || undefined };
684
+ const params = { topic, speakers, days: Number(f.days) || undefined, language: f.language || undefined, ...dateFlags(f) };
638
685
  if (json)
639
686
  return printJson(await client.compareSpeakersClaims({
640
- topic, speaker_names: speakers, days: params.days,
687
+ topic, speaker_names: speakers, days: params.days, language: params.language,
688
+ project: f.collection || undefined, ...dateFlags(f),
641
689
  }));
642
690
  await runHandler("compare_claims", params, f.out);
643
691
  },
@@ -645,24 +693,24 @@ const COMMANDS = {
645
693
  "channel-dossier": {
646
694
  tool: "channel_dossier",
647
695
  description: "Claims dossier of what a channel (show) platformed on a topic (fast)",
648
- specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 180 }, FLAG_OUT, FLAG_JSON],
696
+ specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 180 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
649
697
  run: async (args, json) => {
650
698
  const [topic] = args.positionals;
651
699
  if (!topic) {
652
- print("Error: topic is required.");
700
+ printErr("Error: topic is required.");
653
701
  process.exit(1);
654
702
  }
655
703
  const f = args.flags;
656
704
  const channel = f.channel || undefined;
657
705
  if (!channel) {
658
- print("Error: --channel is required.");
706
+ printErr("Error: --channel is required.");
659
707
  process.exit(1);
660
708
  }
661
709
  const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
662
710
  const days = Number(f.days) || undefined;
663
711
  if (json)
664
712
  return printJson(await client.buildChannelDossier({
665
- topic, channel_names: channels, days,
713
+ topic, channel_names: channels, days, project: f.collection || undefined,
666
714
  }));
667
715
  await runHandler("channel_dossier", { topic, channels, days }, f.out);
668
716
  },
@@ -670,24 +718,24 @@ const COMMANDS = {
670
718
  "compare-channels": {
671
719
  tool: "compare_channels",
672
720
  description: "Compare 2-5 channels' claims on a topic — stance + speaker overlap (fast)",
673
- specs: [{ name: "channels", type: "string", desc: "2-5 comma-separated channel names" }, FLAG_CHANNEL, { ...FLAG_DAYS, default: 90 }, FLAG_OUT, FLAG_JSON],
721
+ specs: [{ name: "channels", type: "string", desc: "2-5 comma-separated channel names" }, FLAG_CHANNEL, { ...FLAG_DAYS, default: 90 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
674
722
  run: async (args, json) => {
675
723
  const [topic] = args.positionals;
676
724
  if (!topic) {
677
- print("Error: topic is required.");
725
+ printErr("Error: topic is required.");
678
726
  process.exit(1);
679
727
  }
680
728
  const f = args.flags;
681
729
  const channelsStr = f.channels || f.channel || "";
682
730
  const channels = channelsStr.split(",").map((s) => s.trim()).filter(Boolean);
683
731
  if (channels.length < 2) {
684
- print("Error: --channels requires 2-5 comma-separated names.");
732
+ printErr("Error: --channels requires 2-5 comma-separated names.");
685
733
  process.exit(1);
686
734
  }
687
735
  const days = Number(f.days) || undefined;
688
736
  if (json)
689
737
  return printJson(await client.compareChannelsClaims({
690
- topic, channel_names: channels, days,
738
+ topic, channel_names: channels, days, project: f.collection || undefined,
691
739
  }));
692
740
  await runHandler("compare_channels", { topic, channels, days }, f.out);
693
741
  },
@@ -695,56 +743,57 @@ const COMMANDS = {
695
743
  "channel-contradictions": {
696
744
  tool: "check_channel_contradictions",
697
745
  description: "Contradictory claims a channel platforms on a topic (fast)",
698
- specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 365 }, FLAG_OUT, FLAG_JSON],
746
+ specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 365 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
699
747
  run: async (args, json) => {
700
748
  const [topic] = args.positionals;
701
749
  if (!topic) {
702
- print("Error: topic is required.");
750
+ printErr("Error: topic is required.");
703
751
  process.exit(1);
704
752
  }
705
753
  const f = args.flags;
706
754
  const channel = f.channel || undefined;
707
755
  if (!channel) {
708
- print("Error: --channel is required.");
756
+ printErr("Error: --channel is required.");
709
757
  process.exit(1);
710
758
  }
711
759
  const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
712
760
  const days = Number(f.days) || undefined;
713
761
  if (json)
714
762
  return printJson(await client.checkChannelContradictionsClaims({
715
- topic, channel_names: channels, days,
763
+ topic, channel_names: channels, days, project: f.collection || undefined,
716
764
  }));
717
765
  await runHandler("check_channel_contradictions", { topic, channels, days }, f.out);
718
766
  },
719
767
  },
720
768
  "entities": {
721
769
  tool: "find_entities",
722
- description: "Fuzzy search canonical entities by name/alias (people, orgs, places, subjects)",
723
- specs: [{ name: "type", type: "string", desc: "Entity type filter: person | organization | place | subject" }, FLAG_LIMIT, FLAG_JSON],
770
+ description: "Find entities by name (people, orgs, places, subjects); the first row is what entity-claims uses",
771
+ specs: [{ name: "type", type: "string", desc: "Entity type filter: person | organization | place | subject" }, FLAG_LIMIT, ...PAGING, FLAG_JSON],
724
772
  run: async (args, json) => {
725
773
  const [query] = args.positionals;
726
774
  if (!query) {
727
- print("Error: query is required.");
775
+ printErr("Error: query is required.");
728
776
  process.exit(1);
729
777
  }
730
778
  const f = args.flags;
731
779
  const entityType = f.type || undefined;
732
780
  const limit = Number(f.limit) || undefined;
781
+ const offset = Number(f.offset) || undefined;
733
782
  if (json)
734
- return printJson(await client.searchEntities({ query, entity_type: entityType, limit }));
735
- await runHandler("find_entities", { query, entity_type: entityType, limit });
783
+ return printJson(await client.searchEntities({ query, entity_type: entityType, limit, offset }));
784
+ await runHandler("find_entities", { query, entity_type: entityType, limit, offset });
736
785
  },
737
786
  },
738
787
  "entity-claims": {
739
788
  tool: "entity_claims",
740
- description: "Claims about a resolved canonical entity, ranked, with stance distribution",
789
+ description: "Most-spread claims about an entity (every row of that name or alias, merges included)",
741
790
  specs: [{ name: "sort", type: "string", default: "spread", desc: "Sort: spread | observations | recent" },
742
791
  { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
743
- FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
792
+ FLAG_LIMIT, ...PAGING, FLAG_COLLECTION, FLAG_JSON],
744
793
  run: async (args, json) => {
745
794
  const [name] = args.positionals;
746
795
  if (!name) {
747
- print("Error: entity name is required.");
796
+ printErr("Error: entity name is required.");
748
797
  process.exit(1);
749
798
  }
750
799
  const f = args.flags;
@@ -755,6 +804,7 @@ const COMMANDS = {
755
804
  if (json)
756
805
  return printJson(await client.getEntityClaims({
757
806
  name, sort: params.sort, stance: params.stance, limit: params.limit, offset: params.offset,
807
+ project: f.collection || undefined,
758
808
  }));
759
809
  await runHandler("entity_claims", params);
760
810
  },
@@ -762,16 +812,16 @@ const COMMANDS = {
762
812
  "claim-graph": {
763
813
  tool: "claim_graph",
764
814
  description: "A claim's parent, children, and sibling (variant) claims",
765
- specs: [FLAG_JSON],
815
+ specs: [FLAG_COLLECTION, FLAG_JSON],
766
816
  run: async (args, json) => {
767
817
  const [id] = args.positionals;
768
818
  if (!id || !/^\d+$/.test(id)) {
769
- print("Error: claim_id (numeric) required.");
819
+ printErr("Error: claim_id (numeric) required.");
770
820
  process.exit(1);
771
821
  }
772
822
  const claimId = Number(id);
773
823
  if (json)
774
- return printJson(await client.getClaimGraph(claimId));
824
+ return printJson(await client.getClaimGraph(claimId, args.flags.collection || undefined));
775
825
  await runHandler("claim_graph", { claim_id: claimId });
776
826
  },
777
827
  },
@@ -780,42 +830,53 @@ const COMMANDS = {
780
830
  description: "Leaderboard of the most widely-spread or most-contested claims",
781
831
  specs: [{ name: "metric", type: "string", default: "spread", desc: "Ranking: spread (reach) | contested (assert vs deny balance)" },
782
832
  { name: "category", type: "string", desc: "Optional topic-category filter" },
783
- { name: "min-obs", type: "number", default: 2, desc: "Minimum observation count per claim" }, FLAG_DAYS, FLAG_LIMIT, FLAG_JSON],
833
+ { name: "min-obs", type: "number", default: 2, desc: "Minimum observation count per claim" }, FLAG_DAYS, FLAG_START, FLAG_END,
834
+ FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_LIMIT, ...PAGING, FLAG_JSON],
784
835
  run: async (args, json) => {
785
836
  const f = args.flags;
786
837
  const params = {
787
838
  metric: f.metric || undefined, category: f.category || undefined,
788
839
  days: Number(f.days) || undefined, min_observations: Number(f["min-obs"]) || undefined,
789
- limit: Number(f.limit) || undefined,
840
+ limit: Number(f.limit) || undefined, offset: Number(f.offset) || undefined,
841
+ language: f.language || undefined,
842
+ ...dateFlags(f),
790
843
  };
791
844
  if (json)
792
- return printJson(await client.getTopClaims(params));
845
+ return printJson(await client.getTopClaims({ ...params, project: f.collection || undefined }));
793
846
  await runHandler("top_claims", params);
794
847
  },
795
848
  },
796
849
  claim: {
797
850
  tool: "get_claim",
798
851
  description: "Get full claim detail with observations and related claims",
799
- specs: [{ name: "observations", type: "boolean", desc: "Include the full observation list" },
852
+ specs: [{ name: "observations", type: "boolean", desc: "Include the observation list (20 a page)" },
853
+ { name: "obs-offset", type: "number", desc: "Observations: skip this many (implies --observations)" },
854
+ { name: "obs-limit", type: "number", desc: "Observations per page (max 100)" },
855
+ FLAG_ALL,
800
856
  { name: "related", type: "boolean", desc: "Include semantically related claims" }, FLAG_JSON],
801
857
  run: async (args, json) => {
802
858
  const [id] = args.positionals;
803
859
  if (!id || !/^\d+$/.test(id)) {
804
- print("Error: claim_id (numeric) required.");
860
+ printErr("Error: claim_id (numeric) required.");
805
861
  process.exit(1);
806
862
  }
807
863
  const claimId = Number(id);
864
+ const obsOffset = args.flags["obs-offset"] != null ? Number(args.flags["obs-offset"]) : undefined;
865
+ const obsLimit = args.flags["obs-limit"] != null ? Number(args.flags["obs-limit"]) : undefined;
808
866
  if (json) {
809
867
  const detail = await client.getClaim(claimId);
810
- if (args.flags.observations)
811
- detail.observations = await client.getClaimObservations({ claim_id: claimId });
868
+ if (args.flags.observations || obsOffset != null || obsLimit != null) {
869
+ detail.observations = await client.getClaimObservations({ claim_id: claimId, limit: obsLimit, offset: obsOffset });
870
+ }
812
871
  if (args.flags.related)
813
872
  detail.related = await client.getRelatedClaims(claimId);
814
873
  return printJson(detail);
815
874
  }
816
875
  await runHandler("get_claim", {
817
876
  claim_id: claimId,
818
- include_observations: args.flags.observations,
877
+ include_observations: args.flags.observations || followAllPages,
878
+ observations_offset: obsOffset,
879
+ observations_limit: obsLimit,
819
880
  include_related: args.flags.related,
820
881
  });
821
882
  },
@@ -823,15 +884,17 @@ const COMMANDS = {
823
884
  trend: {
824
885
  tool: "detect_trend",
825
886
  description: "Detect topic volume trends",
826
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY,
887
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
827
888
  { name: "recent", type: "number", default: 7, desc: "Recent window in days" },
828
889
  { name: "baseline", type: "number", default: 30, desc: "Baseline window in days" },
829
890
  { name: "days", alias: "d", type: "number", desc: "Alias for --baseline" },
891
+ { ...FLAG_START, desc: "Baseline start: YYYY, YYYY-MM or YYYY-MM-DD (pins the baseline window)" },
892
+ { ...FLAG_END, desc: "Baseline end, inclusive (default today); recent = its last --recent days" },
830
893
  { name: "min-similarity", type: "number", desc: "Minimum similarity threshold" }, FLAG_JSON],
831
894
  run: async (args, json) => {
832
895
  const [topic] = args.positionals;
833
896
  if (!topic) {
834
- print("Error: topic is required.");
897
+ printErr("Error: topic is required.");
835
898
  process.exit(1);
836
899
  }
837
900
  const f = args.flags;
@@ -844,6 +907,8 @@ const COMMANDS = {
844
907
  recent_days: Number(f.recent) || 7,
845
908
  baseline_days: Number(f.days) || Number(f.baseline) || 30,
846
909
  min_similarity: Number(f["min-similarity"]) || undefined,
910
+ project: f.collection || undefined,
911
+ ...dateFlags(f),
847
912
  };
848
913
  if (json)
849
914
  return printJson(await client.detectTrend({
@@ -857,18 +922,28 @@ const COMMANDS = {
857
922
  tool: "get_timeline",
858
923
  description: "Topic mention volume over time",
859
924
  specs: [FLAG_DAYS, FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE,
860
- { name: "granularity", alias: "g", type: "string", default: "day", desc: "Bucket size: day | week" },
925
+ { name: "granularity", alias: "g", type: "string", default: "day", desc: "Bucket size: day | week | month | quarter | year (calendar buckets)" },
861
926
  { name: "min-similarity", type: "string", desc: "Relevance threshold 0-1 (default 0.30, same as quote search)" },
862
- FLAG_START, FLAG_END, FLAG_JSON],
927
+ FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_JSON],
863
928
  run: async (args, json) => {
864
929
  const [topic] = args.positionals;
865
930
  if (!topic) {
866
- print("Error: topic is required.");
931
+ printErr("Error: topic is required.");
867
932
  process.exit(1);
868
933
  }
869
934
  const f = args.flags;
935
+ if (!GRANULARITIES.includes(f.granularity)) {
936
+ printErr(`Error: --granularity must be one of ${GRANULARITIES.join(", ")} (got "${f.granularity}").`);
937
+ process.exit(1);
938
+ }
870
939
  const days = Number(f.days) || 30;
871
940
  const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : daysRange(days);
941
+ if (!f.start && f.end) {
942
+ // --end alone: the --days days ending there, not ending today.
943
+ const end = Date.parse(f.end);
944
+ dr.start_date = new Date(end - days * 86400000).toISOString().slice(0, 10);
945
+ dr.end_date = f.end;
946
+ }
872
947
  const tlChannel = f.channel || undefined;
873
948
  const tlParams = {
874
949
  topic,
@@ -879,21 +954,73 @@ const COMMANDS = {
879
954
  language: f.language || undefined,
880
955
  granularity: f.granularity || "day",
881
956
  min_similarity: f["min-similarity"] != null ? Number(f["min-similarity"]) : undefined,
957
+ project: f.collection || undefined,
882
958
  };
883
959
  if (json)
884
960
  return printJson(await client.getTimeline(tlParams));
885
- await runHandler("get_timeline", { ...tlParams, days, channel: tlChannel });
961
+ await runHandler("get_timeline", {
962
+ ...tlParams, days, channel: tlChannel,
963
+ // Explicit dates only, so the footer states them instead of "last N days".
964
+ start_date: f.start || f.end ? dr.start_date : undefined,
965
+ end_date: f.start || f.end ? dr.end_date : undefined,
966
+ });
967
+ },
968
+ },
969
+ count: {
970
+ tool: "count_mentions",
971
+ description: "Count segments, episodes and channels for a topic — by month, channel, episode, ...",
972
+ specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_SPEAKER, FLAG_CHANNEL,
973
+ { ...FLAG_LANGUAGE, desc: "Language code(s), comma-separated (default: every language)" },
974
+ FLAG_COUNTRY,
975
+ { name: "mode", alias: "m", type: "string", default: "semantic", desc: "semantic (estimate) | keyword (exact phrase, exact count)" },
976
+ { name: "by", long: ["group-by"], alias: "g", type: "string", default: "none", desc: `Break down by: ${COUNT_GROUPS.join(" | ")}` },
977
+ { name: "vs", type: "string", desc: "More topics to count side by side, comma-separated (up to 5 in all)" },
978
+ { name: "order", type: "string", default: "segments", desc: "Episode breakdown order: segments | date" },
979
+ { name: "min-similarity", type: "number", desc: "Semantic threshold (default 0.35)" },
980
+ { ...FLAG_LIMIT, desc: "Rows for channel/episode/... breakdowns (-n, -k; max 1000)" }, ...PAGING, FLAG_JSON],
981
+ run: async (args, json) => {
982
+ const f = args.flags;
983
+ const topic = args.positionals.join(" ").trim() || undefined;
984
+ const vs = f.vs?.split(",").map((s) => s.trim()).filter(Boolean);
985
+ if (!COUNT_GROUPS.includes(f.by)) {
986
+ printErr(`Error: --by must be one of ${COUNT_GROUPS.join(", ")} (got "${f.by}").`);
987
+ process.exit(1);
988
+ }
989
+ if (f.mode !== "semantic" && f.mode !== "keyword") {
990
+ printErr(`Error: --mode must be semantic or keyword for count (got "${f.mode}").`);
991
+ process.exit(1);
992
+ }
993
+ const params = {
994
+ topic,
995
+ topics: vs?.length ? [...(topic ? [topic] : []), ...vs] : undefined,
996
+ mode: f.mode,
997
+ group_by: f.by,
998
+ days: f.start || f.end ? undefined : Number(f.days) || 30,
999
+ ...dateFlags(f),
1000
+ collection: f.collection || undefined,
1001
+ speaker: f.speaker || undefined,
1002
+ channel: f.channel || undefined,
1003
+ language: f.language || undefined,
1004
+ country: f.country || undefined,
1005
+ min_similarity: f["min-similarity"] != null ? Number(f["min-similarity"]) : undefined,
1006
+ limit: Number(f.limit) || 20,
1007
+ offset: Number(f.offset) || 0,
1008
+ order: f.order,
1009
+ };
1010
+ if (json)
1011
+ params.response_format = "json";
1012
+ await runHandler("count_mentions", params);
886
1013
  },
887
1014
  },
888
1015
  speaker: {
889
1016
  tool: "find_speakers",
890
1017
  description: "Speaker search, profile, or discourse",
891
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_CHANNEL,
1018
+ specs: [FLAG_DAYS, FLAG_LIMIT, ...PAGING, FLAG_CHANNEL,
892
1019
  { name: "segments", type: "boolean", desc: "List the speaker's recent segments (chronological)" }, FLAG_JSON],
893
1020
  run: async (args, json) => {
894
1021
  const [identity, topic] = args.positionals;
895
1022
  if (!identity) {
896
- print("Error: speaker name or ID required.");
1023
+ printErr("Error: speaker name or ID required.");
897
1024
  process.exit(1);
898
1025
  }
899
1026
  const f = args.flags;
@@ -962,11 +1089,11 @@ const COMMANDS = {
962
1089
  channel: {
963
1090
  tool: "get_channel",
964
1091
  description: "Channel profile or episode listing",
965
- specs: [{ name: "episodes", type: "boolean", desc: "List the channel's recent episodes instead of the profile" }, FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
1092
+ specs: [{ name: "episodes", type: "boolean", desc: "List the channel's recent episodes instead of the profile" }, FLAG_DAYS, FLAG_LIMIT, ...PAGING, FLAG_JSON],
966
1093
  run: async (args, json) => {
967
1094
  const [id] = args.positionals;
968
1095
  if (!id) {
969
- print("Error: channel ID or name required.");
1096
+ printErr("Error: channel ID or name required.");
970
1097
  process.exit(1);
971
1098
  }
972
1099
  const isId = /^\d+$/.test(id);
@@ -1001,7 +1128,7 @@ const COMMANDS = {
1001
1128
  run: async (args, json) => {
1002
1129
  const [id] = args.positionals;
1003
1130
  if (!id) {
1004
- print("Error: content ID required.");
1131
+ printErr("Error: content ID required.");
1005
1132
  process.exit(1);
1006
1133
  }
1007
1134
  if (json) {
@@ -1020,7 +1147,7 @@ const COMMANDS = {
1020
1147
  run: async (args, json) => {
1021
1148
  const [id] = args.positionals;
1022
1149
  if (!id) {
1023
- print("Error: segment ID required.");
1150
+ printErr("Error: segment ID required.");
1024
1151
  process.exit(1);
1025
1152
  }
1026
1153
  const segId = parseSegmentId(id);
@@ -1032,15 +1159,15 @@ const COMMANDS = {
1032
1159
  thread: {
1033
1160
  tool: "get_thread",
1034
1161
  description: "Conversation context around a quote",
1035
- specs: [{ name: "n", type: "number", default: 5, desc: "Segments of context before and after the quote" }, FLAG_JSON],
1162
+ specs: [{ name: "context", alias: "C", long: ["n"], type: "number", default: 5, desc: "Segments of context before and after the quote" }, FLAG_JSON],
1036
1163
  run: async (args, json) => {
1037
1164
  const [id] = args.positionals;
1038
1165
  if (!id) {
1039
- print("Error: segment ID required.");
1166
+ printErr("Error: segment ID required.");
1040
1167
  process.exit(1);
1041
1168
  }
1042
1169
  const segId = parseSegmentId(id);
1043
- const n = Number(args.flags.n) || 5;
1170
+ const n = Number(args.flags.context) || 5;
1044
1171
  if (json)
1045
1172
  return printJson(await client.getSegmentThread(segId, n));
1046
1173
  await runHandler("get_thread", { segment_id: segId, n });
@@ -1049,13 +1176,13 @@ const COMMANDS = {
1049
1176
  analyze: {
1050
1177
  tool: "analyze_topic",
1051
1178
  description: "AI-powered topic analysis",
1052
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY,
1053
- { name: "workflow", alias: "w", type: "string", default: "simple_rag", desc: "Workflow: simple_rag | speaker_focus | entity_network | narrative_timeline | hierarchical_summary | discourse_summary" },
1179
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
1180
+ { name: "workflow", type: "string", default: "simple_rag", desc: "Workflow: simple_rag | speaker_focus | entity_network | narrative_timeline | hierarchical_summary | discourse_summary" },
1054
1181
  FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1055
1182
  run: async (args, json) => {
1056
1183
  const [query] = args.positionals;
1057
1184
  if (!query) {
1058
- print("Error: query is required.");
1185
+ printErr("Error: query is required.");
1059
1186
  process.exit(1);
1060
1187
  }
1061
1188
  const f = args.flags;
@@ -1069,6 +1196,7 @@ const COMMANDS = {
1069
1196
  country: f.country || undefined,
1070
1197
  days: Number(f.days) || undefined,
1071
1198
  skip_cache: f["skip-cache"],
1199
+ project: f.collection || undefined,
1072
1200
  };
1073
1201
  if (json)
1074
1202
  return printJson(await client.analyze(params));
@@ -1078,11 +1206,11 @@ const COMMANDS = {
1078
1206
  frames: {
1079
1207
  tool: "compare_frames",
1080
1208
  description: "Compare left vs right media framing on a topic",
1081
- specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1209
+ specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1082
1210
  run: async (args, json) => {
1083
1211
  const [topic] = args.positionals;
1084
1212
  if (!topic) {
1085
- print("Error: topic is required.");
1213
+ printErr("Error: topic is required.");
1086
1214
  process.exit(1);
1087
1215
  }
1088
1216
  const f = args.flags;
@@ -1094,7 +1222,7 @@ const COMMANDS = {
1094
1222
  skip_cache: f["skip-cache"],
1095
1223
  };
1096
1224
  if (json) {
1097
- const shared = { query: topic, workflow: "deep_analysis", days: params.days, language: params.language, country: params.country, skip_cache: params.skip_cache };
1225
+ const shared = { query: topic, workflow: "deep_analysis", days: params.days, language: params.language, country: params.country, skip_cache: params.skip_cache, project: f.collection || undefined };
1098
1226
  const [left, right] = await Promise.all([
1099
1227
  client.analyze({ ...shared, political_lean_min: 0.0, political_lean_max: 0.35 }),
1100
1228
  client.analyze({ ...shared, political_lean_min: 0.65, political_lean_max: 1.0 }),
@@ -1109,18 +1237,18 @@ const COMMANDS = {
1109
1237
  description: "Compare speakers on a topic",
1110
1238
  // FLAG_DAYS carries default:30, which silently overrode this command's
1111
1239
  // intended 90d window — spread it with the right default.
1112
- specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 }, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1240
+ specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 }, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1113
1241
  run: async (args, json) => {
1114
1242
  const [topic] = args.positionals;
1115
1243
  if (!topic) {
1116
- print("Error: topic is required.");
1244
+ printErr("Error: topic is required.");
1117
1245
  process.exit(1);
1118
1246
  }
1119
1247
  const f = args.flags;
1120
1248
  const speakersStr = f.speakers || f.speaker || "";
1121
1249
  const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
1122
1250
  if (speakers.length < 2) {
1123
- print("Error: --speakers requires 2-5 comma-separated names.");
1251
+ printErr("Error: --speakers requires 2-5 comma-separated names.");
1124
1252
  process.exit(1);
1125
1253
  }
1126
1254
  process.stderr.write("Comparing speakers... (may take 1-3 minutes)\n");
@@ -1134,7 +1262,7 @@ const COMMANDS = {
1134
1262
  return printJson(await client.analyze({
1135
1263
  query: topic, workflow: "speaker_compare", speaker_names: speakers,
1136
1264
  language: params.language, country: params.country,
1137
- days: params.days, skip_cache: params.skip_cache,
1265
+ days: params.days, skip_cache: params.skip_cache, project: f.collection || undefined,
1138
1266
  }));
1139
1267
  await runHandler("compare_speakers", params, f.out);
1140
1268
  },
@@ -1151,14 +1279,14 @@ const COMMANDS = {
1151
1279
  run: async (args, json) => {
1152
1280
  const [topic] = args.positionals;
1153
1281
  if (!topic) {
1154
- print("Error: topic is required.");
1282
+ printErr("Error: topic is required.");
1155
1283
  process.exit(1);
1156
1284
  }
1157
1285
  if (args.flags.speakers)
1158
1286
  return runDossierBatch(topic, args, json);
1159
1287
  const speaker = args.flags.speaker;
1160
1288
  if (!speaker) {
1161
- print("Error: --speaker is required (or --speakers <file> for batch mode).");
1289
+ printErr("Error: --speaker is required (or --speakers <file> for batch mode).");
1162
1290
  process.exit(1);
1163
1291
  }
1164
1292
  process.stderr.write("Building dossier... (may take 1-3 minutes)\n");
@@ -1179,18 +1307,18 @@ const COMMANDS = {
1179
1307
  run: async (args, json) => {
1180
1308
  const [topic] = args.positionals;
1181
1309
  if (!topic) {
1182
- print("Error: topic is required.");
1310
+ printErr("Error: topic is required.");
1183
1311
  process.exit(1);
1184
1312
  }
1185
1313
  const f = args.flags;
1186
1314
  const speaker = f.speaker;
1187
1315
  if (!speaker) {
1188
- print("Error: --speaker is required.");
1316
+ printErr("Error: --speaker is required.");
1189
1317
  process.exit(1);
1190
1318
  }
1191
1319
  const periods = (f.periods || "").split(",").map((s) => s.trim()).filter(Boolean);
1192
1320
  if (periods.length < 2) {
1193
- print("Error: --periods requires 2+ comma-separated periods (e.g. 2024,2026).");
1321
+ printErr("Error: --periods requires 2+ comma-separated periods (e.g. 2024,2026).");
1194
1322
  process.exit(1);
1195
1323
  }
1196
1324
  process.stderr.write("Comparing periods... (may take 1-3 minutes)\n");
@@ -1201,7 +1329,7 @@ const COMMANDS = {
1201
1329
  if (json)
1202
1330
  return printJson(data);
1203
1331
  if (data.error) {
1204
- print(`Error: ${data.error}`);
1332
+ printErr(`Error: ${data.error}`);
1205
1333
  process.exit(1);
1206
1334
  }
1207
1335
  const lines = [`**Speaker evolution: ${speaker} on ${topic} (${periods.join(" vs ")})**\n`];
@@ -1234,12 +1362,12 @@ const COMMANDS = {
1234
1362
  run: async (args, json) => {
1235
1363
  const [topic] = args.positionals;
1236
1364
  if (!topic) {
1237
- print("Error: topic is required.");
1365
+ printErr("Error: topic is required.");
1238
1366
  process.exit(1);
1239
1367
  }
1240
1368
  const speaker = args.flags.speaker;
1241
1369
  if (!speaker) {
1242
- print("Error: --speaker is required.");
1370
+ printErr("Error: --speaker is required.");
1243
1371
  process.exit(1);
1244
1372
  }
1245
1373
  process.stderr.write("Checking contradictions... (may take 1-3 minutes)\n");
@@ -1254,22 +1382,32 @@ const COMMANDS = {
1254
1382
  briefing: {
1255
1383
  tool: "get_briefing",
1256
1384
  description: "AI discourse briefing",
1257
- specs: [{ ...FLAG_DAYS, default: 3, desc: "days to cover (1-14)" }, { name: "max-topics", type: "number", default: 8, desc: "Max topics in the briefing" }, FLAG_JSON],
1385
+ specs: [{ ...FLAG_DAYS, default: 3, desc: "days to cover (1-14)" }, { name: "max-topics", type: "number", default: 8, desc: "Max topics in the briefing" }, FLAG_COLLECTION, FLAG_JSON],
1258
1386
  run: async (args, json) => {
1259
1387
  const f = args.flags;
1260
1388
  const params = { days: Number(f.days) || 3, max_topics: Number(f["max-topics"]) || 8 };
1261
1389
  if (json)
1262
- return printJson(await client.getDiscourseSummary(params));
1390
+ return printJson(await client.getDiscourseSummary({ ...params, project: f.collection || undefined }));
1263
1391
  await runHandler("get_briefing", params);
1264
1392
  },
1265
1393
  },
1394
+ collections: {
1395
+ tool: "list_collections",
1396
+ description: "List searchable collections (use with --collection)",
1397
+ specs: [FLAG_JSON],
1398
+ run: async (_args, json) => {
1399
+ if (json)
1400
+ return printJson(await client.getCollections());
1401
+ await runHandler("list_collections", {});
1402
+ },
1403
+ },
1266
1404
  stats: {
1267
1405
  tool: "get_platform_stats",
1268
1406
  description: "Platform statistics",
1269
- specs: [FLAG_DAYS, { name: "projects", type: "boolean", desc: "Per-project stats instead of platform totals" }, FLAG_JSON],
1407
+ specs: [FLAG_DAYS, { name: "collections", long: ["projects"], type: "boolean", desc: "Per-collection stats instead of platform totals" }, FLAG_JSON],
1270
1408
  run: async (args, json) => {
1271
1409
  const f = args.flags;
1272
- if (f.projects) {
1410
+ if (f.collections) {
1273
1411
  if (json)
1274
1412
  return printJson(await client.getProjectStats());
1275
1413
  await runHandler("get_project_stats", {});
@@ -1284,36 +1422,48 @@ const COMMANDS = {
1284
1422
  },
1285
1423
  "top-channels": {
1286
1424
  tool: "get_top_channels",
1287
- description: "Top channels by episode count",
1288
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_JSON],
1425
+ description: "Top channels by episode count; with --topic, by how much they say about it",
1426
+ specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_LIMIT, ...PAGING, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
1427
+ { name: "topic", type: "string", desc: "Rank by matching segments per channel, from the transcripts" },
1428
+ { name: "lean", type: "string", desc: "Channel lean: left | center-left | center | center-right | right | 0.0-0.5" },
1429
+ FLAG_JSON],
1289
1430
  run: async (args, json) => {
1290
1431
  const f = args.flags;
1291
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || undefined, country: f.country || undefined };
1292
- if (json)
1293
- return printJson(await client.getTopChannels(params));
1432
+ const topic = f.topic || args.positionals.join(" ") || undefined;
1433
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, offset: Number(f.offset) || undefined, language: f.language || undefined, country: f.country || undefined, topic, political_lean: f.lean || undefined, ...dateFlags(f) };
1434
+ if (json) {
1435
+ const lean = parsePoliticalLean(params.political_lean);
1436
+ return printJson(await client.getTopChannels({ ...params, political_lean_min: lean.min, political_lean_max: lean.max, project: f.collection || undefined }));
1437
+ }
1294
1438
  await runHandler("get_top_channels", params);
1295
1439
  },
1296
1440
  },
1297
1441
  "top-speakers": {
1298
1442
  tool: "get_top_speakers",
1299
- description: "Top speakers by appearances, talk time, or clips",
1300
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_PROJECT,
1301
- { name: "ranking", alias: "r", type: "string", default: "appearances", desc: "Rank by: appearances | talkative (talk time) | clipped" }, FLAG_JSON],
1443
+ description: "Top speakers by appearances, talk time or clips; with --topic, by how much they talk about it",
1444
+ specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
1445
+ { name: "ranking", type: "string", default: "appearances", desc: "Rank by: appearances | talkative (talk time) | clipped" },
1446
+ { name: "topic", type: "string", desc: "Rank by matching segments each speaker speaks in, from the transcripts (overrides --ranking)" },
1447
+ { name: "lean", type: "string", desc: "Channel lean: left | center-left | center | center-right | right | 0.0-0.5" },
1448
+ ...PAGING, FLAG_JSON],
1302
1449
  run: async (args, json) => {
1303
1450
  const f = args.flags;
1304
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, ranking: f.ranking, language: f.language || undefined, country: f.country || undefined, project: f.project || undefined };
1305
- if (json)
1306
- return printJson(await client.getTopSpeakers(params));
1451
+ const topic = f.topic || args.positionals.join(" ") || undefined;
1452
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, offset: Number(f.offset) || undefined, ranking: f.ranking, language: f.language || undefined, country: f.country || undefined, project: f.collection || undefined, topic, political_lean: f.lean || undefined, ...dateFlags(f) };
1453
+ if (json) {
1454
+ const lean = parsePoliticalLean(params.political_lean);
1455
+ return printJson(await client.getTopSpeakers({ ...params, political_lean_min: lean.min, political_lean_max: lean.max }));
1456
+ }
1307
1457
  await runHandler("get_top_speakers", params);
1308
1458
  },
1309
1459
  },
1310
1460
  network: {
1311
1461
  tool: "get_speaker_network",
1312
1462
  description: "Speaker co-appearance network",
1313
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_PROJECT, FLAG_JSON],
1463
+ specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_JSON],
1314
1464
  run: async (args, json) => {
1315
1465
  const f = args.flags;
1316
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || "en", project: f.project || undefined };
1466
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || "en", project: f.collection || undefined };
1317
1467
  if (json)
1318
1468
  return printJson(await client.getSpeakerNetwork(params));
1319
1469
  await runHandler("get_speaker_network", params);
@@ -1323,7 +1473,7 @@ const COMMANDS = {
1323
1473
  tool: "get_topics",
1324
1474
  description: "Trending topic clusters",
1325
1475
  specs: [FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_LIMIT, FLAG_DAYS,
1326
- { name: "query", alias: "q", type: "string", desc: "Semantic filter — only clusters relevant to this query" },
1476
+ { name: "query", type: "string", desc: "Semantic filter — only clusters relevant to this query" },
1327
1477
  { name: "min-segments", type: "number", default: 50, desc: "Minimum segments per cluster" },
1328
1478
  FLAG_START, FLAG_END, FLAG_JSON],
1329
1479
  run: async (args, json) => {
@@ -1344,7 +1494,7 @@ const COMMANDS = {
1344
1494
  ads: {
1345
1495
  tool: "get_ad_analytics",
1346
1496
  description: "Ad intelligence",
1347
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET,
1497
+ specs: [FLAG_DAYS, FLAG_LIMIT, ...PAGING,
1348
1498
  { name: "advertisers", type: "boolean", desc: "List advertisers instead of the overview" },
1349
1499
  { name: "trending", type: "boolean", desc: "Show growing/declining/new advertisers" },
1350
1500
  FLAG_CHANNEL,
@@ -1382,7 +1532,7 @@ const COMMANDS = {
1382
1532
  const profile = await client.getChannelByName(channelArg);
1383
1533
  const resolvedId = profile.channel_id ?? profile.id;
1384
1534
  if (!resolvedId) {
1385
- print(`Error: channel "${channelArg}" not found.`);
1535
+ printErr(`Error: channel "${channelArg}" not found.`);
1386
1536
  process.exit(1);
1387
1537
  }
1388
1538
  if (json)
@@ -1402,7 +1552,7 @@ const COMMANDS = {
1402
1552
  description: "Manage bookmarks",
1403
1553
  specs: [{ name: "type", type: "string", desc: "Filter by bookmark type: segment | episode | speaker | channel" },
1404
1554
  { name: "search", type: "string", desc: "Filter bookmarks by search text" },
1405
- FLAG_LIMIT, FLAG_OFFSET,
1555
+ FLAG_LIMIT, ...PAGING,
1406
1556
  { name: "note", type: "string", desc: "Note to attach when adding a bookmark" }, FLAG_JSON],
1407
1557
  run: async (args, json) => {
1408
1558
  const [sub, entityType, entityId] = args.positionals;
@@ -1452,7 +1602,7 @@ async function cmdSetup(force) {
1452
1602
  const key = await rl.question("API key: ");
1453
1603
  rl.close();
1454
1604
  if (!key.trim()) {
1455
- print("Error: no API key provided.");
1605
+ printErr("Error: no API key provided.");
1456
1606
  process.exit(1);
1457
1607
  }
1458
1608
  process.env.SIGNAL4_API_KEY = key.trim();
@@ -1461,7 +1611,7 @@ async function cmdSetup(force) {
1461
1611
  print(`Authenticated as ${who.user_email ?? who.key_prefix ?? "unknown"}`);
1462
1612
  }
1463
1613
  catch (err) {
1464
- print(`Error: invalid API key (${err instanceof Error ? err.message : String(err)})`);
1614
+ printErr(`Error: invalid API key (${err instanceof Error ? err.message : String(err)})`);
1465
1615
  process.exit(1);
1466
1616
  }
1467
1617
  if (!existsSync(configDir))
@@ -1553,7 +1703,7 @@ async function showHelp() {
1553
1703
  lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
1554
1704
  for (const c of cmds) {
1555
1705
  shown.add(c.name);
1556
- lines.push(` ${c.name.padEnd(19)}${c.desc}`);
1706
+ lines.push(` ${c.name.padEnd(19)}${c.desc}${speedWord(c.name)}`);
1557
1707
  }
1558
1708
  lines.push("");
1559
1709
  }
@@ -1564,7 +1714,7 @@ async function showHelp() {
1564
1714
  lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
1565
1715
  for (const c of cmds) {
1566
1716
  shown.add(c.name);
1567
- lines.push(` ${c.name.padEnd(19)}${c.desc}`);
1717
+ lines.push(` ${c.name.padEnd(19)}${c.desc}${speedWord(c.name)}`);
1568
1718
  }
1569
1719
  lines.push("");
1570
1720
  }
@@ -1577,7 +1727,7 @@ async function showHelp() {
1577
1727
  lines.push("\u2500\u2500 also available \u2500\u2500");
1578
1728
  for (const n of missing) {
1579
1729
  const pad = n.length >= 19 ? `${n} ` : n.padEnd(19);
1580
- lines.push(` ${pad}${COMMANDS[n].description}`);
1730
+ lines.push(` ${pad}${COMMANDS[n].description}${speedWord(n)}`);
1581
1731
  }
1582
1732
  lines.push("");
1583
1733
  }
@@ -1596,11 +1746,14 @@ async function showHelp() {
1596
1746
  lines.push(` ${data.available_commands.join(", ")}`);
1597
1747
  lines.push("");
1598
1748
  }
1749
+ lines.push("Speed: instant <0.5s · fast 0.5–2.5s · slow >2.5s (at default settings; longer windows take longer — see s4 <command> --help). Slow commands print an estimate to stderr first.");
1750
+ lines.push("");
1599
1751
  lines.push("Flags:");
1600
1752
  lines.push(" --json Raw JSON output");
1601
1753
  lines.push(" --help, -h Show this help");
1602
1754
  lines.push(" s4 <command> --help Flags for one command (flags vary by command)");
1603
- lines.push(" --days is capped at 365; use --start YYYY-MM-DD for older material");
1755
+ lines.push(" --collection NAME Scope to one collection (s4 collections); omit = whole corpus");
1756
+ lines.push(" -V, --version Version and build time");
1604
1757
  lines.push("");
1605
1758
  lines.push("MCP server:");
1606
1759
  lines.push(" s4 --mcp Start MCP server for Claude Desktop / Cursor");
@@ -1615,10 +1768,17 @@ async function showHelp() {
1615
1768
  ].join("\n"));
1616
1769
  }
1617
1770
  }
1771
+ /** " (fast)" after a command in the global help, from speed.ts; nothing for unknown commands. */
1772
+ function speedWord(name) {
1773
+ const cmd = COMMANDS[name];
1774
+ return cmd ? ` (${estimate(cmd.tool, {}).tier})` : "";
1775
+ }
1618
1776
  function showCommandHelp(name, cmd) {
1619
1777
  const lines = [
1620
1778
  `s4 ${name} — ${cmd.description}`,
1621
1779
  "",
1780
+ `Speed: ${descriptionTag(cmd.tool).slice(1, -1)} (instant <0.5s · fast 0.5–2.5s · slow >2.5s)`,
1781
+ "",
1622
1782
  `Usage: s4 ${name} [args] [flags]`,
1623
1783
  "",
1624
1784
  "Flags:",
@@ -1635,12 +1795,35 @@ function showCommandHelp(name, cmd) {
1635
1795
  // ---------------------------------------------------------------------------
1636
1796
  // Main
1637
1797
  // ---------------------------------------------------------------------------
1798
+ const COMMANDS_WITH_TOPIC = new Set([
1799
+ "search", "quotes", "claims", "analyze-claims", "trend-claims", "contradict-claims", "compare-claims",
1800
+ "channel-dossier", "compare-channels", "channel-contradictions", "trend", "timeline", "analyze",
1801
+ "frames", "compare", "dossier", "contradictions", "compare-periods", "entities",
1802
+ ]);
1803
+ /** Commands whose backend takes explicit dates, so --days has no ceiling. */
1804
+ const DATE_RANGE_COMMANDS = new Set(["search", "quotes", "claims", "timeline", "count"]);
1805
+ const BUILD_INFO = (() => {
1806
+ try {
1807
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1808
+ const built = statSync(new URL("./cli.js", import.meta.url)).mtime.toISOString().slice(0, 16).replace("T", " ");
1809
+ return `s4 ${pkg.version} (built ${built} UTC)`;
1810
+ }
1811
+ catch {
1812
+ return "s4 (version unknown)";
1813
+ }
1814
+ })();
1815
+ const BUILTIN_COMMANDS = ["setup", "whoami", "version", "mcp", "help"];
1638
1816
  export async function main() {
1639
1817
  applyConfig();
1818
+ setSurface("cli");
1640
1819
  const argv = process.argv.slice(2);
1820
+ if (argv[0] === "version" || argv[0] === "-V" || argv[0] === "--version") {
1821
+ print(BUILD_INFO);
1822
+ process.exit(0);
1823
+ }
1641
1824
  // Global help — but `s4 <command> --help` gets that command's flags instead.
1642
1825
  const wantsHelp = argv.includes("--help") || argv.includes("-h");
1643
- if (!argv.length || (wantsHelp && !COMMANDS[argv[0]])) {
1826
+ if (!argv.length || argv[0] === "help" || (wantsHelp && !COMMANDS[argv[0]])) {
1644
1827
  await showHelp();
1645
1828
  process.exit(0);
1646
1829
  }
@@ -1653,6 +1836,8 @@ export async function main() {
1653
1836
  await import("./index.js");
1654
1837
  return;
1655
1838
  }
1839
+ // CLI mode gets its own UA (MCP entry points keep the default).
1840
+ client.setUserAgent("Signal4-S4CLI/2.7");
1656
1841
  const command = argv[0];
1657
1842
  const rest = argv.slice(1);
1658
1843
  // Setup (no API key needed)
@@ -1673,12 +1858,38 @@ export async function main() {
1673
1858
  // Lookup command
1674
1859
  const cmd = COMMANDS[command];
1675
1860
  if (!cmd) {
1676
- process.stderr.write(`Unknown command: ${command}\n\nRun s4 --help for usage.\n`);
1861
+ const near = didYouMean(command, [...Object.keys(COMMANDS), ...BUILTIN_COMMANDS]);
1862
+ process.stderr.write(`Error: unknown command "${command}"${near ? ` — did you mean "${near}"?` : ""}\nRun s4 --help for usage.\n`);
1677
1863
  process.exit(1);
1678
1864
  }
1679
1865
  // Parse args
1680
1866
  const args = parseArgs(rest, cmd.specs);
1867
+ // Unquoted multi-word topics: `s4 search immigration policy`.
1868
+ if (COMMANDS_WITH_TOPIC.has(command) && args.positionals.length > 1) {
1869
+ args.positionals = [args.positionals.join(" ")];
1870
+ }
1681
1871
  const json = args.flags.json;
1872
+ activeCollection = args.flags.collection || undefined;
1873
+ // --page N is --offset (N-1)*limit; --all follows the next-page hint (signal4#329).
1874
+ const page = Number(args.flags.page);
1875
+ if (page) {
1876
+ if (page < 1 || !Number.isInteger(page)) {
1877
+ printErr("Error: --page is a whole number from 1.");
1878
+ process.exit(1);
1879
+ }
1880
+ args.flags.offset = (page - 1) * (Number(args.flags.limit) || 20);
1881
+ }
1882
+ followAllPages = !!args.flags.all;
1883
+ if (followAllPages && json) {
1884
+ printErr("Error: --all pages the text output. For JSON, page with --offset, or use paginate() from @signal4/s4/client.");
1885
+ process.exit(1);
1886
+ }
1887
+ // --days has no ceiling where the backend takes dates; report endpoints cap at 365.
1888
+ const days = args.flags.days;
1889
+ if (typeof days === "number" && days > 365 && !args.flags.start && !DATE_RANGE_COMMANDS.has(command)) {
1890
+ process.stderr.write(`Error: --days is capped at 365 for "${command}". For older material use s4 search / claims / timeline (any --days, or --since 2023).\n`);
1891
+ process.exit(1);
1892
+ }
1682
1893
  // Narrate pipeline steps on stderr while analytical commands stream.
1683
1894
  let progress = null;
1684
1895
  if (PROGRESS_COMMANDS.has(command)) {