@signal4/s4 2.6.0 → 2.7.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 (75) hide show
  1. package/README.md +33 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +317 -175
  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 +80 -3
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +128 -24
  12. package/dist/client.js.map +1 -1
  13. package/dist/format.d.ts +81 -2
  14. package/dist/format.d.ts.map +1 -1
  15. package/dist/format.js +209 -47
  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.js +2 -2
  29. package/dist/handlers/claim_detail.js.map +1 -1
  30. package/dist/handlers/claims.d.ts.map +1 -1
  31. package/dist/handlers/claims.js +81 -26
  32. package/dist/handlers/claims.js.map +1 -1
  33. package/dist/handlers/count.d.ts +7 -0
  34. package/dist/handlers/count.d.ts.map +1 -0
  35. package/dist/handlers/count.js +146 -0
  36. package/dist/handlers/count.js.map +1 -0
  37. package/dist/handlers/explore.d.ts.map +1 -1
  38. package/dist/handlers/explore.js +53 -14
  39. package/dist/handlers/explore.js.map +1 -1
  40. package/dist/handlers/registry.d.ts.map +1 -1
  41. package/dist/handlers/registry.js +5 -1
  42. package/dist/handlers/registry.js.map +1 -1
  43. package/dist/handlers/reports.d.ts +1 -0
  44. package/dist/handlers/reports.d.ts.map +1 -1
  45. package/dist/handlers/reports.js +47 -11
  46. package/dist/handlers/reports.js.map +1 -1
  47. package/dist/handlers/search.d.ts.map +1 -1
  48. package/dist/handlers/search.js +70 -22
  49. package/dist/handlers/search.js.map +1 -1
  50. package/dist/handlers/timeline.d.ts.map +1 -1
  51. package/dist/handlers/timeline.js +14 -8
  52. package/dist/handlers/timeline.js.map +1 -1
  53. package/dist/http.js +25 -3
  54. package/dist/http.js.map +1 -1
  55. package/dist/index.js +2 -2
  56. package/dist/index.js.map +1 -1
  57. package/dist/provenance.d.ts +29 -2
  58. package/dist/provenance.d.ts.map +1 -1
  59. package/dist/provenance.js +63 -4
  60. package/dist/provenance.js.map +1 -1
  61. package/dist/server_core.d.ts +8 -1
  62. package/dist/server_core.d.ts.map +1 -1
  63. package/dist/server_core.js +90 -8
  64. package/dist/server_core.js.map +1 -1
  65. package/dist/speed.d.ts +40 -0
  66. package/dist/speed.d.ts.map +1 -0
  67. package/dist/speed.js +196 -0
  68. package/dist/speed.js.map +1 -0
  69. package/dist/surface.d.ts +7 -0
  70. package/dist/surface.d.ts.map +1 -0
  71. package/dist/surface.js +18 -0
  72. package/dist/surface.js.map +1 -0
  73. package/dist/types.d.ts +155 -8
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -1,105 +1,57 @@
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
8
  import { formatDossier } 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)" };
92
32
  const FLAG_SPEAKER = { name: "speaker", alias: "s", type: "string", desc: "Filter by speaker name (full name)" };
93
33
  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, ...)" };
34
+ const FLAG_LANGUAGE = { name: "language", type: "string", desc: "Filter by language code (en, fr, ...)" };
95
35
  const FLAG_COUNTRY = { name: "country", type: "string", desc: "Filter by channel country code" };
36
+ const FLAG_COLLECTION = { name: "collection", long: ["project"], type: "string", desc: "Restrict to one collection (see: s4 collections). Default: whole corpus" };
96
37
  const FLAG_MODE = { name: "mode", alias: "m", type: "string", default: "semantic", desc: "Search mode: semantic | keyword | hybrid" };
97
- const FLAG_START = { name: "start", type: "string", desc: "Start date YYYY-MM-DD (no cap — overrides --days)" };
98
- const FLAG_END = { name: "end", type: "string", desc: "End date YYYY-MM-DD (default today)" };
99
- const FLAG_KEYWORD = { name: "keyword", alias: "k", type: "string", desc: "Exact keyword search (implies --mode keyword)" };
38
+ const FLAG_START = { name: "start", long: ["since"], type: "string", desc: "Start date: YYYY, YYYY-MM or YYYY-MM-DD (overrides --days)" };
39
+ const FLAG_END = { name: "end", long: ["until"], type: "string", desc: "End date: YYYY, YYYY-MM or YYYY-MM-DD (default today)" };
40
+ const FLAG_KEYWORD = { name: "keyword", type: "string", desc: "Exact phrase search (implies --mode keyword)" };
100
41
  const FLAG_SKIP_CACHE = { name: "skip-cache", type: "boolean", desc: "Force fresh analysis (bypass cache)" };
101
- const FLAG_OUT = { name: "out", type: "string", desc: "Write the rendered output to a file (.md, or .csv on search)" };
42
+ const FLAG_OUT = { name: "out", alias: "o", long: ["output"], type: "string", desc: "Write the rendered output to a file (.md, or .csv on search)" };
102
43
  const FLAG_JSON = { name: "json", type: "boolean", desc: "Raw JSON output" };
44
+ const GRANULARITIES = ["day", "week", "month", "quarter", "year"];
45
+ const COUNT_GROUPS = ["none", ...GRANULARITIES, "channel", "episode", "language", "country", "platform", "collection", "lean"];
46
+ /** --start/--end as tool params (already normalized to YYYY-MM-DD by parseArgs); absent when not given. */
47
+ function dateFlags(f) {
48
+ const out = {};
49
+ if (f.start)
50
+ out.start_date = f.start;
51
+ if (f.end)
52
+ out.end_date = f.end;
53
+ return out;
54
+ }
103
55
  // ---------------------------------------------------------------------------
104
56
  // Progress reporting for long-running analysis commands
105
57
  // ---------------------------------------------------------------------------
@@ -210,11 +162,15 @@ function parseSegmentId(raw) {
210
162
  // Accept all of: N, seg:N, seg_N, {seg_N}, {seg:N}.
211
163
  const id = Number(raw.replace(/^\{(.*)\}$/, "$1").replace(/^seg[:_]/, ""));
212
164
  if (!Number.isInteger(id)) {
213
- print(`Error: invalid segment ID "${raw}" (expected a number, seg:<number>, or seg_<number>).`);
165
+ printErr(`Error: invalid segment ID "${raw}" (expected a number, seg:<number>, or seg_<number>).`);
214
166
  process.exit(1);
215
167
  }
216
168
  return id;
217
169
  }
170
+ function printErr(text) {
171
+ activeProgress?.finish();
172
+ process.stderr.write(text + "\n");
173
+ }
218
174
  function print(text) {
219
175
  activeProgress?.finish();
220
176
  process.stdout.write(text + "\n");
@@ -238,13 +194,25 @@ function toCsv(rows, columns) {
238
194
  function printJson(data) {
239
195
  print(JSON.stringify(data, null, 2));
240
196
  }
197
+ /** The parsed --collection for the running command; injected into every handler call. */
198
+ let activeCollection;
241
199
  async function runHandler(tool, params, out) {
242
200
  const handler = HANDLERS[tool];
243
201
  if (!handler) {
244
- print(`Error: handler "${tool}" not found.`);
202
+ printErr(`Error: handler "${tool}" not found.`);
245
203
  process.exit(1);
246
204
  }
205
+ if (params.collection === undefined && activeCollection)
206
+ params = { ...params, collection: activeCollection };
207
+ // Slow calls say how long to expect before they start; stderr, so stdout stays the answer.
208
+ const expected = estimate(tool, params);
209
+ if (expected.tier === "slow")
210
+ process.stderr.write(`${startNotice(expected)}\n`);
211
+ const started = Date.now();
247
212
  const result = await handler(params);
213
+ const took = (Date.now() - started) / 1000;
214
+ if (took > REPORT_ELAPSED_OVER_S)
215
+ process.stderr.write(`Took ${took.toFixed(1)}s.\n`);
248
216
  if (result.isError) {
249
217
  process.stderr.write((result.content[0]?.text ?? "Unknown error") + "\n");
250
218
  process.exit(1);
@@ -255,6 +223,41 @@ async function runHandler(tool, params, out) {
255
223
  else
256
224
  print(text);
257
225
  }
226
+ // A `--channel` name that doesn't resolve to a real channel must fail loudly
227
+ // in keyword mode, not fall through as an unfiltered corpus-wide query
228
+ // (signal4#85). Keyword-mode search silently drops channel_names server-side
229
+ // (a server-side defect, out of scope here) so an unresolvable name there
230
+ // would otherwise read as a channel-scoped count that is actually
231
+ // corpus-wide. Semantic mode is NOT gated: the server already returns an
232
+ // empty result set for an unknown channel there, which is the correct UX —
233
+ // adding a second, client-side source of truth on that path risks disagreeing
234
+ // with the server and regressing it.
235
+ //
236
+ // Resolved against /entities/channels/by-name — the one client-reachable
237
+ // place this can be caught. Multiple names resolve concurrently. A non-404
238
+ // failure (network blip, 5xx, timeout) fails OPEN: warn on stderr and let the
239
+ // search proceed rather than blocking a query that might otherwise succeed.
240
+ async function assertChannelsExistForKeywordMode(channelArg, mode) {
241
+ if (!channelArg || mode !== "keyword")
242
+ return;
243
+ const names = channelArg.split(",").map((s) => s.trim()).filter(Boolean);
244
+ await Promise.all(names.map(async (name) => {
245
+ try {
246
+ await client.getChannelByName(name);
247
+ }
248
+ catch (err) {
249
+ const msg = err instanceof Error ? err.message : String(err);
250
+ if (/^HTTP 404\b/.test(msg)) {
251
+ process.stderr.write(`Error: channel "${name}" not found. This checks the channels registry ` +
252
+ `(display_name) only — a name that exists only as a content-level ` +
253
+ `channel_name variant may still be valid; verify against the exact ` +
254
+ `registry name (s4 channel "${name}" or s4 top-channels).\n`);
255
+ process.exit(1);
256
+ }
257
+ process.stderr.write(`Warning: could not verify channel "${name}" exists (${msg}) — proceeding without the check.\n`);
258
+ }
259
+ }));
260
+ }
258
261
  // ---------------------------------------------------------------------------
259
262
  // Batch dossiers (surrogate-roster sweeps): s4 dossier "<topic>" --speakers file.txt --out dir/
260
263
  // ---------------------------------------------------------------------------
@@ -408,21 +411,22 @@ const COMMANDS = {
408
411
  search: {
409
412
  tool: "search_quotes",
410
413
  description: "Search quotes by topic and/or speaker",
411
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, FLAG_LIMIT, FLAG_OFFSET, FLAG_LANGUAGE, FLAG_COUNTRY,
414
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, { ...FLAG_LIMIT, default: 10 }, FLAG_OFFSET, FLAG_LANGUAGE, FLAG_COUNTRY,
412
415
  { name: "sort", type: "string", default: "relevance", desc: "relevance | chronological" },
413
416
  { name: "count", type: "boolean", desc: "Return only the match count" },
414
417
  { name: "full-history", type: "boolean", desc: "Search the entire corpus (requires topic + speaker)" },
415
418
  { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0, default 0.40)" },
416
419
  FLAG_KEYWORD,
417
420
  { name: "speaker-id", type: "number", desc: "Speaker identity ID (from s4 speaker)" }, FLAG_OUT,
418
- FLAG_START, FLAG_END,
421
+ FLAG_START, FLAG_END, FLAG_COLLECTION,
419
422
  FLAG_JSON],
420
423
  run: async (args, json) => {
421
424
  const [positionalTopic] = args.positionals;
422
425
  const f = args.flags;
423
426
  const keyword = f.keyword || undefined;
424
- const topic = positionalTopic || keyword || undefined;
427
+ const topic = keyword || positionalTopic || undefined;
425
428
  const mode = keyword ? "keyword" : f.mode;
429
+ await assertChannelsExistForKeywordMode(f.channel || undefined, mode);
426
430
  const days = Number(f.days) || 30;
427
431
  const fullHistory = f["full-history"];
428
432
  // Full history means FULL history — only pass dates given explicitly,
@@ -441,6 +445,7 @@ const COMMANDS = {
441
445
  count_only: f.count, sort: f.sort,
442
446
  full_history: fullHistory,
443
447
  min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
448
+ project: f.collection || undefined,
444
449
  };
445
450
  const out = f.out;
446
451
  if (json || (out && out.endsWith(".csv"))) {
@@ -455,6 +460,7 @@ const COMMANDS = {
455
460
  full_history: fullHistory || undefined,
456
461
  count_only: params.count_only || undefined,
457
462
  min_similarity: params.min_similarity,
463
+ project: params.project,
458
464
  });
459
465
  if (out && out.endsWith(".csv")) {
460
466
  const rows = (data.quotes ?? []).map((q) => ({
@@ -484,7 +490,7 @@ const COMMANDS = {
484
490
  claims: {
485
491
  tool: "search_claims",
486
492
  description: "Search extracted claims",
487
- specs: [{ name: "entity", alias: "e", type: "string", desc: "Filter by entity name (person, org, place, subject)" }, FLAG_SPEAKER, FLAG_CHANNEL,
493
+ specs: [{ name: "entity", type: "string", desc: "Filter by entity name (person, org, place, subject)" }, FLAG_SPEAKER, FLAG_CHANNEL,
488
494
  { name: "category", type: "string", desc: "Topic category filter" },
489
495
  { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
490
496
  FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_MODE, FLAG_KEYWORD,
@@ -494,12 +500,12 @@ const COMMANDS = {
494
500
  { name: "political-lean-min", type: "number", desc: "Min channel political lean (0=left, 1=right)" },
495
501
  { name: "political-lean-max", type: "number", desc: "Max channel political lean (0=left, 1=right)" },
496
502
  { name: "source", type: "string", desc: "Claim source filter" },
497
- FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_START, FLAG_END, FLAG_OUT, FLAG_JSON],
503
+ FLAG_DAYS, { ...FLAG_LIMIT, default: 10 }, FLAG_OFFSET, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
498
504
  run: async (args, json) => {
499
505
  const [positionalTopic] = args.positionals;
500
506
  const f = args.flags;
501
507
  const keyword = f.keyword || undefined;
502
- const topic = positionalTopic || keyword || undefined;
508
+ const topic = keyword || positionalTopic || undefined;
503
509
  const mode = keyword ? "keyword" : f.mode;
504
510
  const days = Number(f.days) || 30;
505
511
  const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : undefined;
@@ -520,6 +526,7 @@ const COMMANDS = {
520
526
  days, mode,
521
527
  sort: f.sort, min_observations: Number(f["min-obs"]) || 1,
522
528
  limit: Number(f.limit), offset: Number(f.offset),
529
+ project: f.collection || undefined,
523
530
  ...dr,
524
531
  };
525
532
  const claimsDr = dr ?? daysRange(days);
@@ -535,6 +542,7 @@ const COMMANDS = {
535
542
  mode: params.mode, sort: params.sort,
536
543
  min_observations: params.min_observations > 1 ? params.min_observations : undefined,
537
544
  limit: params.limit, offset: params.offset,
545
+ project: params.project,
538
546
  }));
539
547
  await runHandler("search_claims", params, f.out);
540
548
  },
@@ -542,12 +550,12 @@ const COMMANDS = {
542
550
  "analyze-claims": {
543
551
  tool: "analyze_claims",
544
552
  description: "Analyze a topic over extracted claims — themes with observation stats (fast, no LLM)",
545
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_DAYS,
553
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_DAYS, FLAG_START, FLAG_END, FLAG_COLLECTION,
546
554
  { name: "min-obs", type: "number", default: 1, desc: "Minimum observation count per claim" }, FLAG_JSON],
547
555
  run: async (args, json) => {
548
556
  const [topic] = args.positionals;
549
557
  if (!topic) {
550
- print("Error: topic is required.");
558
+ printErr("Error: topic is required.");
551
559
  process.exit(1);
552
560
  }
553
561
  const f = args.flags;
@@ -559,11 +567,13 @@ const COMMANDS = {
559
567
  language: f.language || undefined,
560
568
  days: Number(f.days) || undefined,
561
569
  min_observations: Number(f["min-obs"]) || undefined,
570
+ ...dateFlags(f),
562
571
  };
563
572
  if (json)
564
573
  return printJson(await client.analyzeTopicClaims({
565
574
  topic, speaker_name: params.speaker, channel_names: params.channels,
566
575
  language: params.language, days: params.days, min_observations: params.min_observations,
576
+ project: f.collection || undefined, ...dateFlags(f),
567
577
  }));
568
578
  await runHandler("analyze_claims", params);
569
579
  },
@@ -573,11 +583,12 @@ const COMMANDS = {
573
583
  description: "Detect whether a topic is surging/fading by claim volume (fast)",
574
584
  specs: [{ name: "recent-days", type: "number", default: 7, desc: "Recent window in days" },
575
585
  { name: "baseline-days", type: "number", default: 30, desc: "Baseline window in days" },
576
- { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0)" }, FLAG_JSON],
586
+ { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0)" },
587
+ FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_JSON],
577
588
  run: async (args, json) => {
578
589
  const [topic] = args.positionals;
579
590
  if (!topic) {
580
- print("Error: topic is required.");
591
+ printErr("Error: topic is required.");
581
592
  process.exit(1);
582
593
  }
583
594
  const f = args.flags;
@@ -586,32 +597,34 @@ const COMMANDS = {
586
597
  recent_days: Number(f["recent-days"]) || undefined,
587
598
  baseline_days: Number(f["baseline-days"]) || undefined,
588
599
  min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
600
+ language: f.language || undefined,
589
601
  };
590
602
  if (json)
591
- return printJson(await client.detectTrendClaims(params));
603
+ return printJson(await client.detectTrendClaims({ ...params, project: f.collection || undefined }));
592
604
  await runHandler("trend_claims", params);
593
605
  },
594
606
  },
595
607
  "contradict-claims": {
596
608
  tool: "contradict_claims",
597
609
  description: "Find a speaker's contradictory claims on a topic via stance analysis (fast)",
598
- specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_OUT, FLAG_JSON],
610
+ specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
599
611
  run: async (args, json) => {
600
612
  const [topic] = args.positionals;
601
613
  if (!topic) {
602
- print("Error: topic is required.");
614
+ printErr("Error: topic is required.");
603
615
  process.exit(1);
604
616
  }
605
617
  const f = args.flags;
606
618
  const speaker = f.speaker || undefined;
607
619
  if (!speaker) {
608
- print("Error: --speaker is required.");
620
+ printErr("Error: --speaker is required.");
609
621
  process.exit(1);
610
622
  }
611
- const params = { topic, speaker, days: Number(f.days) || undefined };
623
+ const params = { topic, speaker, days: Number(f.days) || undefined, ...dateFlags(f) };
612
624
  if (json)
613
625
  return printJson(await client.checkContradictionsClaims({
614
626
  topic, speaker_name: speaker, days: params.days,
627
+ project: f.collection || undefined, ...dateFlags(f),
615
628
  }));
616
629
  await runHandler("contradict_claims", params, f.out);
617
630
  },
@@ -619,24 +632,26 @@ const COMMANDS = {
619
632
  "compare-claims": {
620
633
  tool: "compare_claims",
621
634
  description: "Compare 2-5 speakers' claims on a topic — side-by-side stance (fast)",
622
- specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 }, FLAG_OUT, FLAG_JSON],
635
+ specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 },
636
+ FLAG_START, FLAG_END, FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
623
637
  run: async (args, json) => {
624
638
  const [topic] = args.positionals;
625
639
  if (!topic) {
626
- print("Error: topic is required.");
640
+ printErr("Error: topic is required.");
627
641
  process.exit(1);
628
642
  }
629
643
  const f = args.flags;
630
644
  const speakersStr = f.speakers || f.speaker || "";
631
645
  const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
632
646
  if (speakers.length < 2) {
633
- print("Error: --speakers requires 2-5 comma-separated names.");
647
+ printErr("Error: --speakers requires 2-5 comma-separated names.");
634
648
  process.exit(1);
635
649
  }
636
- const params = { topic, speakers, days: Number(f.days) || undefined };
650
+ const params = { topic, speakers, days: Number(f.days) || undefined, language: f.language || undefined, ...dateFlags(f) };
637
651
  if (json)
638
652
  return printJson(await client.compareSpeakersClaims({
639
- topic, speaker_names: speakers, days: params.days,
653
+ topic, speaker_names: speakers, days: params.days, language: params.language,
654
+ project: f.collection || undefined, ...dateFlags(f),
640
655
  }));
641
656
  await runHandler("compare_claims", params, f.out);
642
657
  },
@@ -644,24 +659,24 @@ const COMMANDS = {
644
659
  "channel-dossier": {
645
660
  tool: "channel_dossier",
646
661
  description: "Claims dossier of what a channel (show) platformed on a topic (fast)",
647
- specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 180 }, FLAG_OUT, FLAG_JSON],
662
+ specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 180 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
648
663
  run: async (args, json) => {
649
664
  const [topic] = args.positionals;
650
665
  if (!topic) {
651
- print("Error: topic is required.");
666
+ printErr("Error: topic is required.");
652
667
  process.exit(1);
653
668
  }
654
669
  const f = args.flags;
655
670
  const channel = f.channel || undefined;
656
671
  if (!channel) {
657
- print("Error: --channel is required.");
672
+ printErr("Error: --channel is required.");
658
673
  process.exit(1);
659
674
  }
660
675
  const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
661
676
  const days = Number(f.days) || undefined;
662
677
  if (json)
663
678
  return printJson(await client.buildChannelDossier({
664
- topic, channel_names: channels, days,
679
+ topic, channel_names: channels, days, project: f.collection || undefined,
665
680
  }));
666
681
  await runHandler("channel_dossier", { topic, channels, days }, f.out);
667
682
  },
@@ -669,24 +684,24 @@ const COMMANDS = {
669
684
  "compare-channels": {
670
685
  tool: "compare_channels",
671
686
  description: "Compare 2-5 channels' claims on a topic — stance + speaker overlap (fast)",
672
- specs: [{ name: "channels", type: "string", desc: "2-5 comma-separated channel names" }, FLAG_CHANNEL, { ...FLAG_DAYS, default: 90 }, FLAG_OUT, FLAG_JSON],
687
+ specs: [{ name: "channels", type: "string", desc: "2-5 comma-separated channel names" }, FLAG_CHANNEL, { ...FLAG_DAYS, default: 90 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
673
688
  run: async (args, json) => {
674
689
  const [topic] = args.positionals;
675
690
  if (!topic) {
676
- print("Error: topic is required.");
691
+ printErr("Error: topic is required.");
677
692
  process.exit(1);
678
693
  }
679
694
  const f = args.flags;
680
695
  const channelsStr = f.channels || f.channel || "";
681
696
  const channels = channelsStr.split(",").map((s) => s.trim()).filter(Boolean);
682
697
  if (channels.length < 2) {
683
- print("Error: --channels requires 2-5 comma-separated names.");
698
+ printErr("Error: --channels requires 2-5 comma-separated names.");
684
699
  process.exit(1);
685
700
  }
686
701
  const days = Number(f.days) || undefined;
687
702
  if (json)
688
703
  return printJson(await client.compareChannelsClaims({
689
- topic, channel_names: channels, days,
704
+ topic, channel_names: channels, days, project: f.collection || undefined,
690
705
  }));
691
706
  await runHandler("compare_channels", { topic, channels, days }, f.out);
692
707
  },
@@ -694,24 +709,24 @@ const COMMANDS = {
694
709
  "channel-contradictions": {
695
710
  tool: "check_channel_contradictions",
696
711
  description: "Contradictory claims a channel platforms on a topic (fast)",
697
- specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 365 }, FLAG_OUT, FLAG_JSON],
712
+ specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 365 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
698
713
  run: async (args, json) => {
699
714
  const [topic] = args.positionals;
700
715
  if (!topic) {
701
- print("Error: topic is required.");
716
+ printErr("Error: topic is required.");
702
717
  process.exit(1);
703
718
  }
704
719
  const f = args.flags;
705
720
  const channel = f.channel || undefined;
706
721
  if (!channel) {
707
- print("Error: --channel is required.");
722
+ printErr("Error: --channel is required.");
708
723
  process.exit(1);
709
724
  }
710
725
  const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
711
726
  const days = Number(f.days) || undefined;
712
727
  if (json)
713
728
  return printJson(await client.checkChannelContradictionsClaims({
714
- topic, channel_names: channels, days,
729
+ topic, channel_names: channels, days, project: f.collection || undefined,
715
730
  }));
716
731
  await runHandler("check_channel_contradictions", { topic, channels, days }, f.out);
717
732
  },
@@ -723,7 +738,7 @@ const COMMANDS = {
723
738
  run: async (args, json) => {
724
739
  const [query] = args.positionals;
725
740
  if (!query) {
726
- print("Error: query is required.");
741
+ printErr("Error: query is required.");
727
742
  process.exit(1);
728
743
  }
729
744
  const f = args.flags;
@@ -739,11 +754,11 @@ const COMMANDS = {
739
754
  description: "Claims about a resolved canonical entity, ranked, with stance distribution",
740
755
  specs: [{ name: "sort", type: "string", default: "spread", desc: "Sort: spread | observations | recent" },
741
756
  { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
742
- FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
757
+ FLAG_LIMIT, FLAG_OFFSET, FLAG_COLLECTION, FLAG_JSON],
743
758
  run: async (args, json) => {
744
759
  const [name] = args.positionals;
745
760
  if (!name) {
746
- print("Error: entity name is required.");
761
+ printErr("Error: entity name is required.");
747
762
  process.exit(1);
748
763
  }
749
764
  const f = args.flags;
@@ -754,6 +769,7 @@ const COMMANDS = {
754
769
  if (json)
755
770
  return printJson(await client.getEntityClaims({
756
771
  name, sort: params.sort, stance: params.stance, limit: params.limit, offset: params.offset,
772
+ project: f.collection || undefined,
757
773
  }));
758
774
  await runHandler("entity_claims", params);
759
775
  },
@@ -761,16 +777,16 @@ const COMMANDS = {
761
777
  "claim-graph": {
762
778
  tool: "claim_graph",
763
779
  description: "A claim's parent, children, and sibling (variant) claims",
764
- specs: [FLAG_JSON],
780
+ specs: [FLAG_COLLECTION, FLAG_JSON],
765
781
  run: async (args, json) => {
766
782
  const [id] = args.positionals;
767
783
  if (!id || !/^\d+$/.test(id)) {
768
- print("Error: claim_id (numeric) required.");
784
+ printErr("Error: claim_id (numeric) required.");
769
785
  process.exit(1);
770
786
  }
771
787
  const claimId = Number(id);
772
788
  if (json)
773
- return printJson(await client.getClaimGraph(claimId));
789
+ return printJson(await client.getClaimGraph(claimId, args.flags.collection || undefined));
774
790
  await runHandler("claim_graph", { claim_id: claimId });
775
791
  },
776
792
  },
@@ -779,16 +795,18 @@ const COMMANDS = {
779
795
  description: "Leaderboard of the most widely-spread or most-contested claims",
780
796
  specs: [{ name: "metric", type: "string", default: "spread", desc: "Ranking: spread (reach) | contested (assert vs deny balance)" },
781
797
  { name: "category", type: "string", desc: "Optional topic-category filter" },
782
- { name: "min-obs", type: "number", default: 2, desc: "Minimum observation count per claim" }, FLAG_DAYS, FLAG_LIMIT, FLAG_JSON],
798
+ { name: "min-obs", type: "number", default: 2, desc: "Minimum observation count per claim" }, FLAG_DAYS, FLAG_START, FLAG_END,
799
+ FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_LIMIT, FLAG_JSON],
783
800
  run: async (args, json) => {
784
801
  const f = args.flags;
785
802
  const params = {
786
803
  metric: f.metric || undefined, category: f.category || undefined,
787
804
  days: Number(f.days) || undefined, min_observations: Number(f["min-obs"]) || undefined,
788
- limit: Number(f.limit) || undefined,
805
+ limit: Number(f.limit) || undefined, language: f.language || undefined,
806
+ ...dateFlags(f),
789
807
  };
790
808
  if (json)
791
- return printJson(await client.getTopClaims(params));
809
+ return printJson(await client.getTopClaims({ ...params, project: f.collection || undefined }));
792
810
  await runHandler("top_claims", params);
793
811
  },
794
812
  },
@@ -800,7 +818,7 @@ const COMMANDS = {
800
818
  run: async (args, json) => {
801
819
  const [id] = args.positionals;
802
820
  if (!id || !/^\d+$/.test(id)) {
803
- print("Error: claim_id (numeric) required.");
821
+ printErr("Error: claim_id (numeric) required.");
804
822
  process.exit(1);
805
823
  }
806
824
  const claimId = Number(id);
@@ -822,15 +840,17 @@ const COMMANDS = {
822
840
  trend: {
823
841
  tool: "detect_trend",
824
842
  description: "Detect topic volume trends",
825
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY,
843
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
826
844
  { name: "recent", type: "number", default: 7, desc: "Recent window in days" },
827
845
  { name: "baseline", type: "number", default: 30, desc: "Baseline window in days" },
828
846
  { name: "days", alias: "d", type: "number", desc: "Alias for --baseline" },
847
+ { ...FLAG_START, desc: "Baseline start: YYYY, YYYY-MM or YYYY-MM-DD (pins the baseline window)" },
848
+ { ...FLAG_END, desc: "Baseline end, inclusive (default today); recent = its last --recent days" },
829
849
  { name: "min-similarity", type: "number", desc: "Minimum similarity threshold" }, FLAG_JSON],
830
850
  run: async (args, json) => {
831
851
  const [topic] = args.positionals;
832
852
  if (!topic) {
833
- print("Error: topic is required.");
853
+ printErr("Error: topic is required.");
834
854
  process.exit(1);
835
855
  }
836
856
  const f = args.flags;
@@ -843,6 +863,8 @@ const COMMANDS = {
843
863
  recent_days: Number(f.recent) || 7,
844
864
  baseline_days: Number(f.days) || Number(f.baseline) || 30,
845
865
  min_similarity: Number(f["min-similarity"]) || undefined,
866
+ project: f.collection || undefined,
867
+ ...dateFlags(f),
846
868
  };
847
869
  if (json)
848
870
  return printJson(await client.detectTrend({
@@ -856,18 +878,28 @@ const COMMANDS = {
856
878
  tool: "get_timeline",
857
879
  description: "Topic mention volume over time",
858
880
  specs: [FLAG_DAYS, FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE,
859
- { name: "granularity", alias: "g", type: "string", default: "day", desc: "Bucket size: day | week" },
881
+ { name: "granularity", alias: "g", type: "string", default: "day", desc: "Bucket size: day | week | month | quarter | year (calendar buckets)" },
860
882
  { name: "min-similarity", type: "string", desc: "Relevance threshold 0-1 (default 0.30, same as quote search)" },
861
- FLAG_START, FLAG_END, FLAG_JSON],
883
+ FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_JSON],
862
884
  run: async (args, json) => {
863
885
  const [topic] = args.positionals;
864
886
  if (!topic) {
865
- print("Error: topic is required.");
887
+ printErr("Error: topic is required.");
866
888
  process.exit(1);
867
889
  }
868
890
  const f = args.flags;
891
+ if (!GRANULARITIES.includes(f.granularity)) {
892
+ printErr(`Error: --granularity must be one of ${GRANULARITIES.join(", ")} (got "${f.granularity}").`);
893
+ process.exit(1);
894
+ }
869
895
  const days = Number(f.days) || 30;
870
896
  const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : daysRange(days);
897
+ if (!f.start && f.end) {
898
+ // --end alone: the --days days ending there, not ending today.
899
+ const end = Date.parse(f.end);
900
+ dr.start_date = new Date(end - days * 86400000).toISOString().slice(0, 10);
901
+ dr.end_date = f.end;
902
+ }
871
903
  const tlChannel = f.channel || undefined;
872
904
  const tlParams = {
873
905
  topic,
@@ -878,10 +910,62 @@ const COMMANDS = {
878
910
  language: f.language || undefined,
879
911
  granularity: f.granularity || "day",
880
912
  min_similarity: f["min-similarity"] != null ? Number(f["min-similarity"]) : undefined,
913
+ project: f.collection || undefined,
881
914
  };
882
915
  if (json)
883
916
  return printJson(await client.getTimeline(tlParams));
884
- await runHandler("get_timeline", { ...tlParams, days, channel: tlChannel });
917
+ await runHandler("get_timeline", {
918
+ ...tlParams, days, channel: tlChannel,
919
+ // Explicit dates only, so the footer states them instead of "last N days".
920
+ start_date: f.start || f.end ? dr.start_date : undefined,
921
+ end_date: f.start || f.end ? dr.end_date : undefined,
922
+ });
923
+ },
924
+ },
925
+ count: {
926
+ tool: "count_mentions",
927
+ description: "Count segments, episodes and channels for a topic — by month, channel, episode, ...",
928
+ specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_SPEAKER, FLAG_CHANNEL,
929
+ { ...FLAG_LANGUAGE, desc: "Language code(s), comma-separated (default: every language)" },
930
+ FLAG_COUNTRY,
931
+ { name: "mode", alias: "m", type: "string", default: "semantic", desc: "semantic (estimate) | keyword (exact phrase, exact count)" },
932
+ { name: "by", long: ["group-by"], alias: "g", type: "string", default: "none", desc: `Break down by: ${COUNT_GROUPS.join(" | ")}` },
933
+ { name: "vs", type: "string", desc: "More topics to count side by side, comma-separated (up to 5 in all)" },
934
+ { name: "order", type: "string", default: "segments", desc: "Episode breakdown order: segments | date" },
935
+ { name: "min-similarity", type: "number", desc: "Semantic threshold (default 0.35)" },
936
+ { ...FLAG_LIMIT, desc: "Rows for channel/episode/... breakdowns (-n, -k; max 1000)" }, FLAG_OFFSET, FLAG_JSON],
937
+ run: async (args, json) => {
938
+ const f = args.flags;
939
+ const topic = args.positionals.join(" ").trim() || undefined;
940
+ const vs = f.vs?.split(",").map((s) => s.trim()).filter(Boolean);
941
+ if (!COUNT_GROUPS.includes(f.by)) {
942
+ printErr(`Error: --by must be one of ${COUNT_GROUPS.join(", ")} (got "${f.by}").`);
943
+ process.exit(1);
944
+ }
945
+ if (f.mode !== "semantic" && f.mode !== "keyword") {
946
+ printErr(`Error: --mode must be semantic or keyword for count (got "${f.mode}").`);
947
+ process.exit(1);
948
+ }
949
+ const params = {
950
+ topic,
951
+ topics: vs?.length ? [...(topic ? [topic] : []), ...vs] : undefined,
952
+ mode: f.mode,
953
+ group_by: f.by,
954
+ days: f.start || f.end ? undefined : Number(f.days) || 30,
955
+ ...dateFlags(f),
956
+ collection: f.collection || undefined,
957
+ speaker: f.speaker || undefined,
958
+ channel: f.channel || undefined,
959
+ language: f.language || undefined,
960
+ country: f.country || undefined,
961
+ min_similarity: f["min-similarity"] != null ? Number(f["min-similarity"]) : undefined,
962
+ limit: Number(f.limit) || 20,
963
+ offset: Number(f.offset) || 0,
964
+ order: f.order,
965
+ };
966
+ if (json)
967
+ params.response_format = "json";
968
+ await runHandler("count_mentions", params);
885
969
  },
886
970
  },
887
971
  speaker: {
@@ -892,7 +976,7 @@ const COMMANDS = {
892
976
  run: async (args, json) => {
893
977
  const [identity, topic] = args.positionals;
894
978
  if (!identity) {
895
- print("Error: speaker name or ID required.");
979
+ printErr("Error: speaker name or ID required.");
896
980
  process.exit(1);
897
981
  }
898
982
  const f = args.flags;
@@ -965,7 +1049,7 @@ const COMMANDS = {
965
1049
  run: async (args, json) => {
966
1050
  const [id] = args.positionals;
967
1051
  if (!id) {
968
- print("Error: channel ID or name required.");
1052
+ printErr("Error: channel ID or name required.");
969
1053
  process.exit(1);
970
1054
  }
971
1055
  const isId = /^\d+$/.test(id);
@@ -1000,7 +1084,7 @@ const COMMANDS = {
1000
1084
  run: async (args, json) => {
1001
1085
  const [id] = args.positionals;
1002
1086
  if (!id) {
1003
- print("Error: content ID required.");
1087
+ printErr("Error: content ID required.");
1004
1088
  process.exit(1);
1005
1089
  }
1006
1090
  if (json) {
@@ -1019,7 +1103,7 @@ const COMMANDS = {
1019
1103
  run: async (args, json) => {
1020
1104
  const [id] = args.positionals;
1021
1105
  if (!id) {
1022
- print("Error: segment ID required.");
1106
+ printErr("Error: segment ID required.");
1023
1107
  process.exit(1);
1024
1108
  }
1025
1109
  const segId = parseSegmentId(id);
@@ -1031,15 +1115,15 @@ const COMMANDS = {
1031
1115
  thread: {
1032
1116
  tool: "get_thread",
1033
1117
  description: "Conversation context around a quote",
1034
- specs: [{ name: "n", type: "number", default: 5, desc: "Segments of context before and after the quote" }, FLAG_JSON],
1118
+ specs: [{ name: "context", alias: "C", long: ["n"], type: "number", default: 5, desc: "Segments of context before and after the quote" }, FLAG_JSON],
1035
1119
  run: async (args, json) => {
1036
1120
  const [id] = args.positionals;
1037
1121
  if (!id) {
1038
- print("Error: segment ID required.");
1122
+ printErr("Error: segment ID required.");
1039
1123
  process.exit(1);
1040
1124
  }
1041
1125
  const segId = parseSegmentId(id);
1042
- const n = Number(args.flags.n) || 5;
1126
+ const n = Number(args.flags.context) || 5;
1043
1127
  if (json)
1044
1128
  return printJson(await client.getSegmentThread(segId, n));
1045
1129
  await runHandler("get_thread", { segment_id: segId, n });
@@ -1048,13 +1132,13 @@ const COMMANDS = {
1048
1132
  analyze: {
1049
1133
  tool: "analyze_topic",
1050
1134
  description: "AI-powered topic analysis",
1051
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY,
1052
- { name: "workflow", alias: "w", type: "string", default: "simple_rag", desc: "Workflow: simple_rag | speaker_focus | entity_network | narrative_timeline | hierarchical_summary | discourse_summary" },
1135
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
1136
+ { name: "workflow", type: "string", default: "simple_rag", desc: "Workflow: simple_rag | speaker_focus | entity_network | narrative_timeline | hierarchical_summary | discourse_summary" },
1053
1137
  FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1054
1138
  run: async (args, json) => {
1055
1139
  const [query] = args.positionals;
1056
1140
  if (!query) {
1057
- print("Error: query is required.");
1141
+ printErr("Error: query is required.");
1058
1142
  process.exit(1);
1059
1143
  }
1060
1144
  const f = args.flags;
@@ -1068,6 +1152,7 @@ const COMMANDS = {
1068
1152
  country: f.country || undefined,
1069
1153
  days: Number(f.days) || undefined,
1070
1154
  skip_cache: f["skip-cache"],
1155
+ project: f.collection || undefined,
1071
1156
  };
1072
1157
  if (json)
1073
1158
  return printJson(await client.analyze(params));
@@ -1077,11 +1162,11 @@ const COMMANDS = {
1077
1162
  frames: {
1078
1163
  tool: "compare_frames",
1079
1164
  description: "Compare left vs right media framing on a topic",
1080
- specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1165
+ specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1081
1166
  run: async (args, json) => {
1082
1167
  const [topic] = args.positionals;
1083
1168
  if (!topic) {
1084
- print("Error: topic is required.");
1169
+ printErr("Error: topic is required.");
1085
1170
  process.exit(1);
1086
1171
  }
1087
1172
  const f = args.flags;
@@ -1093,7 +1178,7 @@ const COMMANDS = {
1093
1178
  skip_cache: f["skip-cache"],
1094
1179
  };
1095
1180
  if (json) {
1096
- const shared = { query: topic, workflow: "deep_analysis", days: params.days, language: params.language, country: params.country, skip_cache: params.skip_cache };
1181
+ 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 };
1097
1182
  const [left, right] = await Promise.all([
1098
1183
  client.analyze({ ...shared, political_lean_min: 0.0, political_lean_max: 0.35 }),
1099
1184
  client.analyze({ ...shared, political_lean_min: 0.65, political_lean_max: 1.0 }),
@@ -1108,18 +1193,18 @@ const COMMANDS = {
1108
1193
  description: "Compare speakers on a topic",
1109
1194
  // FLAG_DAYS carries default:30, which silently overrode this command's
1110
1195
  // intended 90d window — spread it with the right default.
1111
- 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],
1196
+ 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],
1112
1197
  run: async (args, json) => {
1113
1198
  const [topic] = args.positionals;
1114
1199
  if (!topic) {
1115
- print("Error: topic is required.");
1200
+ printErr("Error: topic is required.");
1116
1201
  process.exit(1);
1117
1202
  }
1118
1203
  const f = args.flags;
1119
1204
  const speakersStr = f.speakers || f.speaker || "";
1120
1205
  const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
1121
1206
  if (speakers.length < 2) {
1122
- print("Error: --speakers requires 2-5 comma-separated names.");
1207
+ printErr("Error: --speakers requires 2-5 comma-separated names.");
1123
1208
  process.exit(1);
1124
1209
  }
1125
1210
  process.stderr.write("Comparing speakers... (may take 1-3 minutes)\n");
@@ -1133,7 +1218,7 @@ const COMMANDS = {
1133
1218
  return printJson(await client.analyze({
1134
1219
  query: topic, workflow: "speaker_compare", speaker_names: speakers,
1135
1220
  language: params.language, country: params.country,
1136
- days: params.days, skip_cache: params.skip_cache,
1221
+ days: params.days, skip_cache: params.skip_cache, project: f.collection || undefined,
1137
1222
  }));
1138
1223
  await runHandler("compare_speakers", params, f.out);
1139
1224
  },
@@ -1150,14 +1235,14 @@ const COMMANDS = {
1150
1235
  run: async (args, json) => {
1151
1236
  const [topic] = args.positionals;
1152
1237
  if (!topic) {
1153
- print("Error: topic is required.");
1238
+ printErr("Error: topic is required.");
1154
1239
  process.exit(1);
1155
1240
  }
1156
1241
  if (args.flags.speakers)
1157
1242
  return runDossierBatch(topic, args, json);
1158
1243
  const speaker = args.flags.speaker;
1159
1244
  if (!speaker) {
1160
- print("Error: --speaker is required (or --speakers <file> for batch mode).");
1245
+ printErr("Error: --speaker is required (or --speakers <file> for batch mode).");
1161
1246
  process.exit(1);
1162
1247
  }
1163
1248
  process.stderr.write("Building dossier... (may take 1-3 minutes)\n");
@@ -1178,18 +1263,18 @@ const COMMANDS = {
1178
1263
  run: async (args, json) => {
1179
1264
  const [topic] = args.positionals;
1180
1265
  if (!topic) {
1181
- print("Error: topic is required.");
1266
+ printErr("Error: topic is required.");
1182
1267
  process.exit(1);
1183
1268
  }
1184
1269
  const f = args.flags;
1185
1270
  const speaker = f.speaker;
1186
1271
  if (!speaker) {
1187
- print("Error: --speaker is required.");
1272
+ printErr("Error: --speaker is required.");
1188
1273
  process.exit(1);
1189
1274
  }
1190
1275
  const periods = (f.periods || "").split(",").map((s) => s.trim()).filter(Boolean);
1191
1276
  if (periods.length < 2) {
1192
- print("Error: --periods requires 2+ comma-separated periods (e.g. 2024,2026).");
1277
+ printErr("Error: --periods requires 2+ comma-separated periods (e.g. 2024,2026).");
1193
1278
  process.exit(1);
1194
1279
  }
1195
1280
  process.stderr.write("Comparing periods... (may take 1-3 minutes)\n");
@@ -1200,7 +1285,7 @@ const COMMANDS = {
1200
1285
  if (json)
1201
1286
  return printJson(data);
1202
1287
  if (data.error) {
1203
- print(`Error: ${data.error}`);
1288
+ printErr(`Error: ${data.error}`);
1204
1289
  process.exit(1);
1205
1290
  }
1206
1291
  const lines = [`**Speaker evolution: ${speaker} on ${topic} (${periods.join(" vs ")})**\n`];
@@ -1233,12 +1318,12 @@ const COMMANDS = {
1233
1318
  run: async (args, json) => {
1234
1319
  const [topic] = args.positionals;
1235
1320
  if (!topic) {
1236
- print("Error: topic is required.");
1321
+ printErr("Error: topic is required.");
1237
1322
  process.exit(1);
1238
1323
  }
1239
1324
  const speaker = args.flags.speaker;
1240
1325
  if (!speaker) {
1241
- print("Error: --speaker is required.");
1326
+ printErr("Error: --speaker is required.");
1242
1327
  process.exit(1);
1243
1328
  }
1244
1329
  process.stderr.write("Checking contradictions... (may take 1-3 minutes)\n");
@@ -1253,22 +1338,32 @@ const COMMANDS = {
1253
1338
  briefing: {
1254
1339
  tool: "get_briefing",
1255
1340
  description: "AI discourse briefing",
1256
- 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],
1341
+ 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],
1257
1342
  run: async (args, json) => {
1258
1343
  const f = args.flags;
1259
1344
  const params = { days: Number(f.days) || 3, max_topics: Number(f["max-topics"]) || 8 };
1260
1345
  if (json)
1261
- return printJson(await client.getDiscourseSummary(params));
1346
+ return printJson(await client.getDiscourseSummary({ ...params, project: f.collection || undefined }));
1262
1347
  await runHandler("get_briefing", params);
1263
1348
  },
1264
1349
  },
1350
+ collections: {
1351
+ tool: "list_collections",
1352
+ description: "List searchable collections (use with --collection)",
1353
+ specs: [FLAG_JSON],
1354
+ run: async (_args, json) => {
1355
+ if (json)
1356
+ return printJson(await client.getCollections());
1357
+ await runHandler("list_collections", {});
1358
+ },
1359
+ },
1265
1360
  stats: {
1266
1361
  tool: "get_platform_stats",
1267
1362
  description: "Platform statistics",
1268
- specs: [FLAG_DAYS, { name: "projects", type: "boolean", desc: "Per-project stats instead of platform totals" }, FLAG_JSON],
1363
+ specs: [FLAG_DAYS, { name: "collections", long: ["projects"], type: "boolean", desc: "Per-collection stats instead of platform totals" }, FLAG_JSON],
1269
1364
  run: async (args, json) => {
1270
1365
  const f = args.flags;
1271
- if (f.projects) {
1366
+ if (f.collections) {
1272
1367
  if (json)
1273
1368
  return printJson(await client.getProjectStats());
1274
1369
  await runHandler("get_project_stats", {});
@@ -1284,23 +1379,23 @@ const COMMANDS = {
1284
1379
  "top-channels": {
1285
1380
  tool: "get_top_channels",
1286
1381
  description: "Top channels by episode count",
1287
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_JSON],
1382
+ specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION, FLAG_JSON],
1288
1383
  run: async (args, json) => {
1289
1384
  const f = args.flags;
1290
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || undefined, country: f.country || undefined };
1385
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || undefined, country: f.country || undefined, ...dateFlags(f) };
1291
1386
  if (json)
1292
- return printJson(await client.getTopChannels(params));
1387
+ return printJson(await client.getTopChannels({ ...params, project: f.collection || undefined }));
1293
1388
  await runHandler("get_top_channels", params);
1294
1389
  },
1295
1390
  },
1296
1391
  "top-speakers": {
1297
1392
  tool: "get_top_speakers",
1298
1393
  description: "Top speakers by appearances, talk time, or clips",
1299
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY,
1300
- { name: "ranking", alias: "r", type: "string", default: "appearances", desc: "Rank by: appearances | talkative (talk time) | clipped" }, FLAG_JSON],
1394
+ specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
1395
+ { name: "ranking", type: "string", default: "appearances", desc: "Rank by: appearances | talkative (talk time) | clipped" }, FLAG_JSON],
1301
1396
  run: async (args, json) => {
1302
1397
  const f = args.flags;
1303
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, ranking: f.ranking, language: f.language || undefined, country: f.country || undefined };
1398
+ 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.collection || undefined, ...dateFlags(f) };
1304
1399
  if (json)
1305
1400
  return printJson(await client.getTopSpeakers(params));
1306
1401
  await runHandler("get_top_speakers", params);
@@ -1309,10 +1404,10 @@ const COMMANDS = {
1309
1404
  network: {
1310
1405
  tool: "get_speaker_network",
1311
1406
  description: "Speaker co-appearance network",
1312
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_JSON],
1407
+ specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_JSON],
1313
1408
  run: async (args, json) => {
1314
1409
  const f = args.flags;
1315
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || "en" };
1410
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || "en", project: f.collection || undefined };
1316
1411
  if (json)
1317
1412
  return printJson(await client.getSpeakerNetwork(params));
1318
1413
  await runHandler("get_speaker_network", params);
@@ -1322,7 +1417,7 @@ const COMMANDS = {
1322
1417
  tool: "get_topics",
1323
1418
  description: "Trending topic clusters",
1324
1419
  specs: [FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_LIMIT, FLAG_DAYS,
1325
- { name: "query", alias: "q", type: "string", desc: "Semantic filter — only clusters relevant to this query" },
1420
+ { name: "query", type: "string", desc: "Semantic filter — only clusters relevant to this query" },
1326
1421
  { name: "min-segments", type: "number", default: 50, desc: "Minimum segments per cluster" },
1327
1422
  FLAG_START, FLAG_END, FLAG_JSON],
1328
1423
  run: async (args, json) => {
@@ -1381,7 +1476,7 @@ const COMMANDS = {
1381
1476
  const profile = await client.getChannelByName(channelArg);
1382
1477
  const resolvedId = profile.channel_id ?? profile.id;
1383
1478
  if (!resolvedId) {
1384
- print(`Error: channel "${channelArg}" not found.`);
1479
+ printErr(`Error: channel "${channelArg}" not found.`);
1385
1480
  process.exit(1);
1386
1481
  }
1387
1482
  if (json)
@@ -1451,7 +1546,7 @@ async function cmdSetup(force) {
1451
1546
  const key = await rl.question("API key: ");
1452
1547
  rl.close();
1453
1548
  if (!key.trim()) {
1454
- print("Error: no API key provided.");
1549
+ printErr("Error: no API key provided.");
1455
1550
  process.exit(1);
1456
1551
  }
1457
1552
  process.env.SIGNAL4_API_KEY = key.trim();
@@ -1460,7 +1555,7 @@ async function cmdSetup(force) {
1460
1555
  print(`Authenticated as ${who.user_email ?? who.key_prefix ?? "unknown"}`);
1461
1556
  }
1462
1557
  catch (err) {
1463
- print(`Error: invalid API key (${err instanceof Error ? err.message : String(err)})`);
1558
+ printErr(`Error: invalid API key (${err instanceof Error ? err.message : String(err)})`);
1464
1559
  process.exit(1);
1465
1560
  }
1466
1561
  if (!existsSync(configDir))
@@ -1552,7 +1647,7 @@ async function showHelp() {
1552
1647
  lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
1553
1648
  for (const c of cmds) {
1554
1649
  shown.add(c.name);
1555
- lines.push(` ${c.name.padEnd(19)}${c.desc}`);
1650
+ lines.push(` ${c.name.padEnd(19)}${c.desc}${speedWord(c.name)}`);
1556
1651
  }
1557
1652
  lines.push("");
1558
1653
  }
@@ -1563,7 +1658,7 @@ async function showHelp() {
1563
1658
  lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
1564
1659
  for (const c of cmds) {
1565
1660
  shown.add(c.name);
1566
- lines.push(` ${c.name.padEnd(19)}${c.desc}`);
1661
+ lines.push(` ${c.name.padEnd(19)}${c.desc}${speedWord(c.name)}`);
1567
1662
  }
1568
1663
  lines.push("");
1569
1664
  }
@@ -1576,7 +1671,7 @@ async function showHelp() {
1576
1671
  lines.push("\u2500\u2500 also available \u2500\u2500");
1577
1672
  for (const n of missing) {
1578
1673
  const pad = n.length >= 19 ? `${n} ` : n.padEnd(19);
1579
- lines.push(` ${pad}${COMMANDS[n].description}`);
1674
+ lines.push(` ${pad}${COMMANDS[n].description}${speedWord(n)}`);
1580
1675
  }
1581
1676
  lines.push("");
1582
1677
  }
@@ -1595,11 +1690,14 @@ async function showHelp() {
1595
1690
  lines.push(` ${data.available_commands.join(", ")}`);
1596
1691
  lines.push("");
1597
1692
  }
1693
+ 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.");
1694
+ lines.push("");
1598
1695
  lines.push("Flags:");
1599
1696
  lines.push(" --json Raw JSON output");
1600
1697
  lines.push(" --help, -h Show this help");
1601
1698
  lines.push(" s4 <command> --help Flags for one command (flags vary by command)");
1602
- lines.push(" --days is capped at 365; use --start YYYY-MM-DD for older material");
1699
+ lines.push(" --collection NAME Scope to one collection (s4 collections); omit = whole corpus");
1700
+ lines.push(" -V, --version Version and build time");
1603
1701
  lines.push("");
1604
1702
  lines.push("MCP server:");
1605
1703
  lines.push(" s4 --mcp Start MCP server for Claude Desktop / Cursor");
@@ -1614,10 +1712,17 @@ async function showHelp() {
1614
1712
  ].join("\n"));
1615
1713
  }
1616
1714
  }
1715
+ /** " (fast)" after a command in the global help, from speed.ts; nothing for unknown commands. */
1716
+ function speedWord(name) {
1717
+ const cmd = COMMANDS[name];
1718
+ return cmd ? ` (${estimate(cmd.tool, {}).tier})` : "";
1719
+ }
1617
1720
  function showCommandHelp(name, cmd) {
1618
1721
  const lines = [
1619
1722
  `s4 ${name} — ${cmd.description}`,
1620
1723
  "",
1724
+ `Speed: ${descriptionTag(cmd.tool).slice(1, -1)} (instant <0.5s · fast 0.5–2.5s · slow >2.5s)`,
1725
+ "",
1621
1726
  `Usage: s4 ${name} [args] [flags]`,
1622
1727
  "",
1623
1728
  "Flags:",
@@ -1634,12 +1739,35 @@ function showCommandHelp(name, cmd) {
1634
1739
  // ---------------------------------------------------------------------------
1635
1740
  // Main
1636
1741
  // ---------------------------------------------------------------------------
1742
+ const COMMANDS_WITH_TOPIC = new Set([
1743
+ "search", "quotes", "claims", "analyze-claims", "trend-claims", "contradict-claims", "compare-claims",
1744
+ "channel-dossier", "compare-channels", "channel-contradictions", "trend", "timeline", "analyze",
1745
+ "frames", "compare", "dossier", "contradictions", "compare-periods", "entities",
1746
+ ]);
1747
+ /** Commands whose backend takes explicit dates, so --days has no ceiling. */
1748
+ const DATE_RANGE_COMMANDS = new Set(["search", "quotes", "claims", "timeline", "count"]);
1749
+ const BUILD_INFO = (() => {
1750
+ try {
1751
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
1752
+ const built = statSync(new URL("./cli.js", import.meta.url)).mtime.toISOString().slice(0, 16).replace("T", " ");
1753
+ return `s4 ${pkg.version} (built ${built} UTC)`;
1754
+ }
1755
+ catch {
1756
+ return "s4 (version unknown)";
1757
+ }
1758
+ })();
1759
+ const BUILTIN_COMMANDS = ["setup", "whoami", "version", "mcp", "help"];
1637
1760
  export async function main() {
1638
1761
  applyConfig();
1762
+ setSurface("cli");
1639
1763
  const argv = process.argv.slice(2);
1764
+ if (argv[0] === "version" || argv[0] === "-V" || argv[0] === "--version") {
1765
+ print(BUILD_INFO);
1766
+ process.exit(0);
1767
+ }
1640
1768
  // Global help — but `s4 <command> --help` gets that command's flags instead.
1641
1769
  const wantsHelp = argv.includes("--help") || argv.includes("-h");
1642
- if (!argv.length || (wantsHelp && !COMMANDS[argv[0]])) {
1770
+ if (!argv.length || argv[0] === "help" || (wantsHelp && !COMMANDS[argv[0]])) {
1643
1771
  await showHelp();
1644
1772
  process.exit(0);
1645
1773
  }
@@ -1652,6 +1780,8 @@ export async function main() {
1652
1780
  await import("./index.js");
1653
1781
  return;
1654
1782
  }
1783
+ // CLI mode gets its own UA (MCP entry points keep the default).
1784
+ client.setUserAgent("Signal4-S4CLI/2.7");
1655
1785
  const command = argv[0];
1656
1786
  const rest = argv.slice(1);
1657
1787
  // Setup (no API key needed)
@@ -1672,12 +1802,24 @@ export async function main() {
1672
1802
  // Lookup command
1673
1803
  const cmd = COMMANDS[command];
1674
1804
  if (!cmd) {
1675
- process.stderr.write(`Unknown command: ${command}\n\nRun s4 --help for usage.\n`);
1805
+ const near = didYouMean(command, [...Object.keys(COMMANDS), ...BUILTIN_COMMANDS]);
1806
+ process.stderr.write(`Error: unknown command "${command}"${near ? ` — did you mean "${near}"?` : ""}\nRun s4 --help for usage.\n`);
1676
1807
  process.exit(1);
1677
1808
  }
1678
1809
  // Parse args
1679
1810
  const args = parseArgs(rest, cmd.specs);
1811
+ // Unquoted multi-word topics: `s4 search immigration policy`.
1812
+ if (COMMANDS_WITH_TOPIC.has(command) && args.positionals.length > 1) {
1813
+ args.positionals = [args.positionals.join(" ")];
1814
+ }
1680
1815
  const json = args.flags.json;
1816
+ activeCollection = args.flags.collection || undefined;
1817
+ // --days has no ceiling where the backend takes dates; report endpoints cap at 365.
1818
+ const days = args.flags.days;
1819
+ if (typeof days === "number" && days > 365 && !args.flags.start && !DATE_RANGE_COMMANDS.has(command)) {
1820
+ 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`);
1821
+ process.exit(1);
1822
+ }
1681
1823
  // Narrate pipeline steps on stderr while analytical commands stream.
1682
1824
  let progress = null;
1683
1825
  if (PROGRESS_COMMANDS.has(command)) {