@signal4/s4 2.6.1 → 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 -176
  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 +77 -3
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +123 -22
  12. package/dist/client.js.map +1 -1
  13. package/dist/format.d.ts +76 -2
  14. package/dist/format.d.ts.map +1 -1
  15. package/dist/format.js +197 -44
  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 +60 -24
  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 +41 -12
  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 +67 -21
  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 +151 -8
  74. package/dist/types.d.ts.map +1 -1
  75. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -1,106 +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" };
96
- const FLAG_PROJECT = { name: "project", type: "string", desc: "Restrict to a project (e.g. Big_Channels)" };
36
+ const FLAG_COLLECTION = { name: "collection", long: ["project"], type: "string", desc: "Restrict to one collection (see: s4 collections). Default: whole corpus" };
97
37
  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)" };
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)" };
101
41
  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)" };
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)" };
103
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
+ }
104
55
  // ---------------------------------------------------------------------------
105
56
  // Progress reporting for long-running analysis commands
106
57
  // ---------------------------------------------------------------------------
@@ -211,11 +162,15 @@ function parseSegmentId(raw) {
211
162
  // Accept all of: N, seg:N, seg_N, {seg_N}, {seg:N}.
212
163
  const id = Number(raw.replace(/^\{(.*)\}$/, "$1").replace(/^seg[:_]/, ""));
213
164
  if (!Number.isInteger(id)) {
214
- 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>).`);
215
166
  process.exit(1);
216
167
  }
217
168
  return id;
218
169
  }
170
+ function printErr(text) {
171
+ activeProgress?.finish();
172
+ process.stderr.write(text + "\n");
173
+ }
219
174
  function print(text) {
220
175
  activeProgress?.finish();
221
176
  process.stdout.write(text + "\n");
@@ -239,13 +194,25 @@ function toCsv(rows, columns) {
239
194
  function printJson(data) {
240
195
  print(JSON.stringify(data, null, 2));
241
196
  }
197
+ /** The parsed --collection for the running command; injected into every handler call. */
198
+ let activeCollection;
242
199
  async function runHandler(tool, params, out) {
243
200
  const handler = HANDLERS[tool];
244
201
  if (!handler) {
245
- print(`Error: handler "${tool}" not found.`);
202
+ printErr(`Error: handler "${tool}" not found.`);
246
203
  process.exit(1);
247
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();
248
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`);
249
216
  if (result.isError) {
250
217
  process.stderr.write((result.content[0]?.text ?? "Unknown error") + "\n");
251
218
  process.exit(1);
@@ -256,6 +223,41 @@ async function runHandler(tool, params, out) {
256
223
  else
257
224
  print(text);
258
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
+ }
259
261
  // ---------------------------------------------------------------------------
260
262
  // Batch dossiers (surrogate-roster sweeps): s4 dossier "<topic>" --speakers file.txt --out dir/
261
263
  // ---------------------------------------------------------------------------
@@ -409,21 +411,22 @@ const COMMANDS = {
409
411
  search: {
410
412
  tool: "search_quotes",
411
413
  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,
414
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, { ...FLAG_LIMIT, default: 10 }, FLAG_OFFSET, FLAG_LANGUAGE, FLAG_COUNTRY,
413
415
  { name: "sort", type: "string", default: "relevance", desc: "relevance | chronological" },
414
416
  { name: "count", type: "boolean", desc: "Return only the match count" },
415
417
  { name: "full-history", type: "boolean", desc: "Search the entire corpus (requires topic + speaker)" },
416
418
  { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0, default 0.40)" },
417
419
  FLAG_KEYWORD,
418
420
  { name: "speaker-id", type: "number", desc: "Speaker identity ID (from s4 speaker)" }, FLAG_OUT,
419
- FLAG_START, FLAG_END,
421
+ FLAG_START, FLAG_END, FLAG_COLLECTION,
420
422
  FLAG_JSON],
421
423
  run: async (args, json) => {
422
424
  const [positionalTopic] = args.positionals;
423
425
  const f = args.flags;
424
426
  const keyword = f.keyword || undefined;
425
- const topic = positionalTopic || keyword || undefined;
427
+ const topic = keyword || positionalTopic || undefined;
426
428
  const mode = keyword ? "keyword" : f.mode;
429
+ await assertChannelsExistForKeywordMode(f.channel || undefined, mode);
427
430
  const days = Number(f.days) || 30;
428
431
  const fullHistory = f["full-history"];
429
432
  // Full history means FULL history — only pass dates given explicitly,
@@ -442,6 +445,7 @@ const COMMANDS = {
442
445
  count_only: f.count, sort: f.sort,
443
446
  full_history: fullHistory,
444
447
  min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
448
+ project: f.collection || undefined,
445
449
  };
446
450
  const out = f.out;
447
451
  if (json || (out && out.endsWith(".csv"))) {
@@ -456,6 +460,7 @@ const COMMANDS = {
456
460
  full_history: fullHistory || undefined,
457
461
  count_only: params.count_only || undefined,
458
462
  min_similarity: params.min_similarity,
463
+ project: params.project,
459
464
  });
460
465
  if (out && out.endsWith(".csv")) {
461
466
  const rows = (data.quotes ?? []).map((q) => ({
@@ -485,7 +490,7 @@ const COMMANDS = {
485
490
  claims: {
486
491
  tool: "search_claims",
487
492
  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,
493
+ specs: [{ name: "entity", type: "string", desc: "Filter by entity name (person, org, place, subject)" }, FLAG_SPEAKER, FLAG_CHANNEL,
489
494
  { name: "category", type: "string", desc: "Topic category filter" },
490
495
  { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
491
496
  FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_MODE, FLAG_KEYWORD,
@@ -495,12 +500,12 @@ const COMMANDS = {
495
500
  { name: "political-lean-min", type: "number", desc: "Min channel political lean (0=left, 1=right)" },
496
501
  { name: "political-lean-max", type: "number", desc: "Max channel political lean (0=left, 1=right)" },
497
502
  { name: "source", type: "string", desc: "Claim source filter" },
498
- 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],
499
504
  run: async (args, json) => {
500
505
  const [positionalTopic] = args.positionals;
501
506
  const f = args.flags;
502
507
  const keyword = f.keyword || undefined;
503
- const topic = positionalTopic || keyword || undefined;
508
+ const topic = keyword || positionalTopic || undefined;
504
509
  const mode = keyword ? "keyword" : f.mode;
505
510
  const days = Number(f.days) || 30;
506
511
  const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : undefined;
@@ -521,6 +526,7 @@ const COMMANDS = {
521
526
  days, mode,
522
527
  sort: f.sort, min_observations: Number(f["min-obs"]) || 1,
523
528
  limit: Number(f.limit), offset: Number(f.offset),
529
+ project: f.collection || undefined,
524
530
  ...dr,
525
531
  };
526
532
  const claimsDr = dr ?? daysRange(days);
@@ -536,6 +542,7 @@ const COMMANDS = {
536
542
  mode: params.mode, sort: params.sort,
537
543
  min_observations: params.min_observations > 1 ? params.min_observations : undefined,
538
544
  limit: params.limit, offset: params.offset,
545
+ project: params.project,
539
546
  }));
540
547
  await runHandler("search_claims", params, f.out);
541
548
  },
@@ -543,12 +550,12 @@ const COMMANDS = {
543
550
  "analyze-claims": {
544
551
  tool: "analyze_claims",
545
552
  description: "Analyze a topic over extracted claims — themes with observation stats (fast, no LLM)",
546
- 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,
547
554
  { name: "min-obs", type: "number", default: 1, desc: "Minimum observation count per claim" }, FLAG_JSON],
548
555
  run: async (args, json) => {
549
556
  const [topic] = args.positionals;
550
557
  if (!topic) {
551
- print("Error: topic is required.");
558
+ printErr("Error: topic is required.");
552
559
  process.exit(1);
553
560
  }
554
561
  const f = args.flags;
@@ -560,11 +567,13 @@ const COMMANDS = {
560
567
  language: f.language || undefined,
561
568
  days: Number(f.days) || undefined,
562
569
  min_observations: Number(f["min-obs"]) || undefined,
570
+ ...dateFlags(f),
563
571
  };
564
572
  if (json)
565
573
  return printJson(await client.analyzeTopicClaims({
566
574
  topic, speaker_name: params.speaker, channel_names: params.channels,
567
575
  language: params.language, days: params.days, min_observations: params.min_observations,
576
+ project: f.collection || undefined, ...dateFlags(f),
568
577
  }));
569
578
  await runHandler("analyze_claims", params);
570
579
  },
@@ -574,11 +583,12 @@ const COMMANDS = {
574
583
  description: "Detect whether a topic is surging/fading by claim volume (fast)",
575
584
  specs: [{ name: "recent-days", type: "number", default: 7, desc: "Recent window in days" },
576
585
  { 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],
586
+ { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0)" },
587
+ FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_JSON],
578
588
  run: async (args, json) => {
579
589
  const [topic] = args.positionals;
580
590
  if (!topic) {
581
- print("Error: topic is required.");
591
+ printErr("Error: topic is required.");
582
592
  process.exit(1);
583
593
  }
584
594
  const f = args.flags;
@@ -587,32 +597,34 @@ const COMMANDS = {
587
597
  recent_days: Number(f["recent-days"]) || undefined,
588
598
  baseline_days: Number(f["baseline-days"]) || undefined,
589
599
  min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
600
+ language: f.language || undefined,
590
601
  };
591
602
  if (json)
592
- return printJson(await client.detectTrendClaims(params));
603
+ return printJson(await client.detectTrendClaims({ ...params, project: f.collection || undefined }));
593
604
  await runHandler("trend_claims", params);
594
605
  },
595
606
  },
596
607
  "contradict-claims": {
597
608
  tool: "contradict_claims",
598
609
  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],
610
+ specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
600
611
  run: async (args, json) => {
601
612
  const [topic] = args.positionals;
602
613
  if (!topic) {
603
- print("Error: topic is required.");
614
+ printErr("Error: topic is required.");
604
615
  process.exit(1);
605
616
  }
606
617
  const f = args.flags;
607
618
  const speaker = f.speaker || undefined;
608
619
  if (!speaker) {
609
- print("Error: --speaker is required.");
620
+ printErr("Error: --speaker is required.");
610
621
  process.exit(1);
611
622
  }
612
- const params = { topic, speaker, days: Number(f.days) || undefined };
623
+ const params = { topic, speaker, days: Number(f.days) || undefined, ...dateFlags(f) };
613
624
  if (json)
614
625
  return printJson(await client.checkContradictionsClaims({
615
626
  topic, speaker_name: speaker, days: params.days,
627
+ project: f.collection || undefined, ...dateFlags(f),
616
628
  }));
617
629
  await runHandler("contradict_claims", params, f.out);
618
630
  },
@@ -620,24 +632,26 @@ const COMMANDS = {
620
632
  "compare-claims": {
621
633
  tool: "compare_claims",
622
634
  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],
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],
624
637
  run: async (args, json) => {
625
638
  const [topic] = args.positionals;
626
639
  if (!topic) {
627
- print("Error: topic is required.");
640
+ printErr("Error: topic is required.");
628
641
  process.exit(1);
629
642
  }
630
643
  const f = args.flags;
631
644
  const speakersStr = f.speakers || f.speaker || "";
632
645
  const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
633
646
  if (speakers.length < 2) {
634
- print("Error: --speakers requires 2-5 comma-separated names.");
647
+ printErr("Error: --speakers requires 2-5 comma-separated names.");
635
648
  process.exit(1);
636
649
  }
637
- 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) };
638
651
  if (json)
639
652
  return printJson(await client.compareSpeakersClaims({
640
- 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),
641
655
  }));
642
656
  await runHandler("compare_claims", params, f.out);
643
657
  },
@@ -645,24 +659,24 @@ const COMMANDS = {
645
659
  "channel-dossier": {
646
660
  tool: "channel_dossier",
647
661
  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],
662
+ specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 180 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
649
663
  run: async (args, json) => {
650
664
  const [topic] = args.positionals;
651
665
  if (!topic) {
652
- print("Error: topic is required.");
666
+ printErr("Error: topic is required.");
653
667
  process.exit(1);
654
668
  }
655
669
  const f = args.flags;
656
670
  const channel = f.channel || undefined;
657
671
  if (!channel) {
658
- print("Error: --channel is required.");
672
+ printErr("Error: --channel is required.");
659
673
  process.exit(1);
660
674
  }
661
675
  const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
662
676
  const days = Number(f.days) || undefined;
663
677
  if (json)
664
678
  return printJson(await client.buildChannelDossier({
665
- topic, channel_names: channels, days,
679
+ topic, channel_names: channels, days, project: f.collection || undefined,
666
680
  }));
667
681
  await runHandler("channel_dossier", { topic, channels, days }, f.out);
668
682
  },
@@ -670,24 +684,24 @@ const COMMANDS = {
670
684
  "compare-channels": {
671
685
  tool: "compare_channels",
672
686
  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],
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],
674
688
  run: async (args, json) => {
675
689
  const [topic] = args.positionals;
676
690
  if (!topic) {
677
- print("Error: topic is required.");
691
+ printErr("Error: topic is required.");
678
692
  process.exit(1);
679
693
  }
680
694
  const f = args.flags;
681
695
  const channelsStr = f.channels || f.channel || "";
682
696
  const channels = channelsStr.split(",").map((s) => s.trim()).filter(Boolean);
683
697
  if (channels.length < 2) {
684
- print("Error: --channels requires 2-5 comma-separated names.");
698
+ printErr("Error: --channels requires 2-5 comma-separated names.");
685
699
  process.exit(1);
686
700
  }
687
701
  const days = Number(f.days) || undefined;
688
702
  if (json)
689
703
  return printJson(await client.compareChannelsClaims({
690
- topic, channel_names: channels, days,
704
+ topic, channel_names: channels, days, project: f.collection || undefined,
691
705
  }));
692
706
  await runHandler("compare_channels", { topic, channels, days }, f.out);
693
707
  },
@@ -695,24 +709,24 @@ const COMMANDS = {
695
709
  "channel-contradictions": {
696
710
  tool: "check_channel_contradictions",
697
711
  description: "Contradictory claims a channel platforms on a topic (fast)",
698
- 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],
699
713
  run: async (args, json) => {
700
714
  const [topic] = args.positionals;
701
715
  if (!topic) {
702
- print("Error: topic is required.");
716
+ printErr("Error: topic is required.");
703
717
  process.exit(1);
704
718
  }
705
719
  const f = args.flags;
706
720
  const channel = f.channel || undefined;
707
721
  if (!channel) {
708
- print("Error: --channel is required.");
722
+ printErr("Error: --channel is required.");
709
723
  process.exit(1);
710
724
  }
711
725
  const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
712
726
  const days = Number(f.days) || undefined;
713
727
  if (json)
714
728
  return printJson(await client.checkChannelContradictionsClaims({
715
- topic, channel_names: channels, days,
729
+ topic, channel_names: channels, days, project: f.collection || undefined,
716
730
  }));
717
731
  await runHandler("check_channel_contradictions", { topic, channels, days }, f.out);
718
732
  },
@@ -724,7 +738,7 @@ const COMMANDS = {
724
738
  run: async (args, json) => {
725
739
  const [query] = args.positionals;
726
740
  if (!query) {
727
- print("Error: query is required.");
741
+ printErr("Error: query is required.");
728
742
  process.exit(1);
729
743
  }
730
744
  const f = args.flags;
@@ -740,11 +754,11 @@ const COMMANDS = {
740
754
  description: "Claims about a resolved canonical entity, ranked, with stance distribution",
741
755
  specs: [{ name: "sort", type: "string", default: "spread", desc: "Sort: spread | observations | recent" },
742
756
  { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
743
- FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
757
+ FLAG_LIMIT, FLAG_OFFSET, FLAG_COLLECTION, FLAG_JSON],
744
758
  run: async (args, json) => {
745
759
  const [name] = args.positionals;
746
760
  if (!name) {
747
- print("Error: entity name is required.");
761
+ printErr("Error: entity name is required.");
748
762
  process.exit(1);
749
763
  }
750
764
  const f = args.flags;
@@ -755,6 +769,7 @@ const COMMANDS = {
755
769
  if (json)
756
770
  return printJson(await client.getEntityClaims({
757
771
  name, sort: params.sort, stance: params.stance, limit: params.limit, offset: params.offset,
772
+ project: f.collection || undefined,
758
773
  }));
759
774
  await runHandler("entity_claims", params);
760
775
  },
@@ -762,16 +777,16 @@ const COMMANDS = {
762
777
  "claim-graph": {
763
778
  tool: "claim_graph",
764
779
  description: "A claim's parent, children, and sibling (variant) claims",
765
- specs: [FLAG_JSON],
780
+ specs: [FLAG_COLLECTION, FLAG_JSON],
766
781
  run: async (args, json) => {
767
782
  const [id] = args.positionals;
768
783
  if (!id || !/^\d+$/.test(id)) {
769
- print("Error: claim_id (numeric) required.");
784
+ printErr("Error: claim_id (numeric) required.");
770
785
  process.exit(1);
771
786
  }
772
787
  const claimId = Number(id);
773
788
  if (json)
774
- return printJson(await client.getClaimGraph(claimId));
789
+ return printJson(await client.getClaimGraph(claimId, args.flags.collection || undefined));
775
790
  await runHandler("claim_graph", { claim_id: claimId });
776
791
  },
777
792
  },
@@ -780,16 +795,18 @@ const COMMANDS = {
780
795
  description: "Leaderboard of the most widely-spread or most-contested claims",
781
796
  specs: [{ name: "metric", type: "string", default: "spread", desc: "Ranking: spread (reach) | contested (assert vs deny balance)" },
782
797
  { 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],
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],
784
800
  run: async (args, json) => {
785
801
  const f = args.flags;
786
802
  const params = {
787
803
  metric: f.metric || undefined, category: f.category || undefined,
788
804
  days: Number(f.days) || undefined, min_observations: Number(f["min-obs"]) || undefined,
789
- limit: Number(f.limit) || undefined,
805
+ limit: Number(f.limit) || undefined, language: f.language || undefined,
806
+ ...dateFlags(f),
790
807
  };
791
808
  if (json)
792
- return printJson(await client.getTopClaims(params));
809
+ return printJson(await client.getTopClaims({ ...params, project: f.collection || undefined }));
793
810
  await runHandler("top_claims", params);
794
811
  },
795
812
  },
@@ -801,7 +818,7 @@ const COMMANDS = {
801
818
  run: async (args, json) => {
802
819
  const [id] = args.positionals;
803
820
  if (!id || !/^\d+$/.test(id)) {
804
- print("Error: claim_id (numeric) required.");
821
+ printErr("Error: claim_id (numeric) required.");
805
822
  process.exit(1);
806
823
  }
807
824
  const claimId = Number(id);
@@ -823,15 +840,17 @@ const COMMANDS = {
823
840
  trend: {
824
841
  tool: "detect_trend",
825
842
  description: "Detect topic volume trends",
826
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY,
843
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
827
844
  { name: "recent", type: "number", default: 7, desc: "Recent window in days" },
828
845
  { name: "baseline", type: "number", default: 30, desc: "Baseline window in days" },
829
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" },
830
849
  { name: "min-similarity", type: "number", desc: "Minimum similarity threshold" }, FLAG_JSON],
831
850
  run: async (args, json) => {
832
851
  const [topic] = args.positionals;
833
852
  if (!topic) {
834
- print("Error: topic is required.");
853
+ printErr("Error: topic is required.");
835
854
  process.exit(1);
836
855
  }
837
856
  const f = args.flags;
@@ -844,6 +863,8 @@ const COMMANDS = {
844
863
  recent_days: Number(f.recent) || 7,
845
864
  baseline_days: Number(f.days) || Number(f.baseline) || 30,
846
865
  min_similarity: Number(f["min-similarity"]) || undefined,
866
+ project: f.collection || undefined,
867
+ ...dateFlags(f),
847
868
  };
848
869
  if (json)
849
870
  return printJson(await client.detectTrend({
@@ -857,18 +878,28 @@ const COMMANDS = {
857
878
  tool: "get_timeline",
858
879
  description: "Topic mention volume over time",
859
880
  specs: [FLAG_DAYS, FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE,
860
- { 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)" },
861
882
  { name: "min-similarity", type: "string", desc: "Relevance threshold 0-1 (default 0.30, same as quote search)" },
862
- FLAG_START, FLAG_END, FLAG_JSON],
883
+ FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_JSON],
863
884
  run: async (args, json) => {
864
885
  const [topic] = args.positionals;
865
886
  if (!topic) {
866
- print("Error: topic is required.");
887
+ printErr("Error: topic is required.");
867
888
  process.exit(1);
868
889
  }
869
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
+ }
870
895
  const days = Number(f.days) || 30;
871
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
+ }
872
903
  const tlChannel = f.channel || undefined;
873
904
  const tlParams = {
874
905
  topic,
@@ -879,10 +910,62 @@ const COMMANDS = {
879
910
  language: f.language || undefined,
880
911
  granularity: f.granularity || "day",
881
912
  min_similarity: f["min-similarity"] != null ? Number(f["min-similarity"]) : undefined,
913
+ project: f.collection || undefined,
882
914
  };
883
915
  if (json)
884
916
  return printJson(await client.getTimeline(tlParams));
885
- 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);
886
969
  },
887
970
  },
888
971
  speaker: {
@@ -893,7 +976,7 @@ const COMMANDS = {
893
976
  run: async (args, json) => {
894
977
  const [identity, topic] = args.positionals;
895
978
  if (!identity) {
896
- print("Error: speaker name or ID required.");
979
+ printErr("Error: speaker name or ID required.");
897
980
  process.exit(1);
898
981
  }
899
982
  const f = args.flags;
@@ -966,7 +1049,7 @@ const COMMANDS = {
966
1049
  run: async (args, json) => {
967
1050
  const [id] = args.positionals;
968
1051
  if (!id) {
969
- print("Error: channel ID or name required.");
1052
+ printErr("Error: channel ID or name required.");
970
1053
  process.exit(1);
971
1054
  }
972
1055
  const isId = /^\d+$/.test(id);
@@ -1001,7 +1084,7 @@ const COMMANDS = {
1001
1084
  run: async (args, json) => {
1002
1085
  const [id] = args.positionals;
1003
1086
  if (!id) {
1004
- print("Error: content ID required.");
1087
+ printErr("Error: content ID required.");
1005
1088
  process.exit(1);
1006
1089
  }
1007
1090
  if (json) {
@@ -1020,7 +1103,7 @@ const COMMANDS = {
1020
1103
  run: async (args, json) => {
1021
1104
  const [id] = args.positionals;
1022
1105
  if (!id) {
1023
- print("Error: segment ID required.");
1106
+ printErr("Error: segment ID required.");
1024
1107
  process.exit(1);
1025
1108
  }
1026
1109
  const segId = parseSegmentId(id);
@@ -1032,15 +1115,15 @@ const COMMANDS = {
1032
1115
  thread: {
1033
1116
  tool: "get_thread",
1034
1117
  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],
1118
+ specs: [{ name: "context", alias: "C", long: ["n"], type: "number", default: 5, desc: "Segments of context before and after the quote" }, FLAG_JSON],
1036
1119
  run: async (args, json) => {
1037
1120
  const [id] = args.positionals;
1038
1121
  if (!id) {
1039
- print("Error: segment ID required.");
1122
+ printErr("Error: segment ID required.");
1040
1123
  process.exit(1);
1041
1124
  }
1042
1125
  const segId = parseSegmentId(id);
1043
- const n = Number(args.flags.n) || 5;
1126
+ const n = Number(args.flags.context) || 5;
1044
1127
  if (json)
1045
1128
  return printJson(await client.getSegmentThread(segId, n));
1046
1129
  await runHandler("get_thread", { segment_id: segId, n });
@@ -1049,13 +1132,13 @@ const COMMANDS = {
1049
1132
  analyze: {
1050
1133
  tool: "analyze_topic",
1051
1134
  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" },
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" },
1054
1137
  FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1055
1138
  run: async (args, json) => {
1056
1139
  const [query] = args.positionals;
1057
1140
  if (!query) {
1058
- print("Error: query is required.");
1141
+ printErr("Error: query is required.");
1059
1142
  process.exit(1);
1060
1143
  }
1061
1144
  const f = args.flags;
@@ -1069,6 +1152,7 @@ const COMMANDS = {
1069
1152
  country: f.country || undefined,
1070
1153
  days: Number(f.days) || undefined,
1071
1154
  skip_cache: f["skip-cache"],
1155
+ project: f.collection || undefined,
1072
1156
  };
1073
1157
  if (json)
1074
1158
  return printJson(await client.analyze(params));
@@ -1078,11 +1162,11 @@ const COMMANDS = {
1078
1162
  frames: {
1079
1163
  tool: "compare_frames",
1080
1164
  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],
1165
+ specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1082
1166
  run: async (args, json) => {
1083
1167
  const [topic] = args.positionals;
1084
1168
  if (!topic) {
1085
- print("Error: topic is required.");
1169
+ printErr("Error: topic is required.");
1086
1170
  process.exit(1);
1087
1171
  }
1088
1172
  const f = args.flags;
@@ -1094,7 +1178,7 @@ const COMMANDS = {
1094
1178
  skip_cache: f["skip-cache"],
1095
1179
  };
1096
1180
  if (json) {
1097
- 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 };
1098
1182
  const [left, right] = await Promise.all([
1099
1183
  client.analyze({ ...shared, political_lean_min: 0.0, political_lean_max: 0.35 }),
1100
1184
  client.analyze({ ...shared, political_lean_min: 0.65, political_lean_max: 1.0 }),
@@ -1109,18 +1193,18 @@ const COMMANDS = {
1109
1193
  description: "Compare speakers on a topic",
1110
1194
  // FLAG_DAYS carries default:30, which silently overrode this command's
1111
1195
  // 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],
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],
1113
1197
  run: async (args, json) => {
1114
1198
  const [topic] = args.positionals;
1115
1199
  if (!topic) {
1116
- print("Error: topic is required.");
1200
+ printErr("Error: topic is required.");
1117
1201
  process.exit(1);
1118
1202
  }
1119
1203
  const f = args.flags;
1120
1204
  const speakersStr = f.speakers || f.speaker || "";
1121
1205
  const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
1122
1206
  if (speakers.length < 2) {
1123
- print("Error: --speakers requires 2-5 comma-separated names.");
1207
+ printErr("Error: --speakers requires 2-5 comma-separated names.");
1124
1208
  process.exit(1);
1125
1209
  }
1126
1210
  process.stderr.write("Comparing speakers... (may take 1-3 minutes)\n");
@@ -1134,7 +1218,7 @@ const COMMANDS = {
1134
1218
  return printJson(await client.analyze({
1135
1219
  query: topic, workflow: "speaker_compare", speaker_names: speakers,
1136
1220
  language: params.language, country: params.country,
1137
- days: params.days, skip_cache: params.skip_cache,
1221
+ days: params.days, skip_cache: params.skip_cache, project: f.collection || undefined,
1138
1222
  }));
1139
1223
  await runHandler("compare_speakers", params, f.out);
1140
1224
  },
@@ -1151,14 +1235,14 @@ const COMMANDS = {
1151
1235
  run: async (args, json) => {
1152
1236
  const [topic] = args.positionals;
1153
1237
  if (!topic) {
1154
- print("Error: topic is required.");
1238
+ printErr("Error: topic is required.");
1155
1239
  process.exit(1);
1156
1240
  }
1157
1241
  if (args.flags.speakers)
1158
1242
  return runDossierBatch(topic, args, json);
1159
1243
  const speaker = args.flags.speaker;
1160
1244
  if (!speaker) {
1161
- print("Error: --speaker is required (or --speakers <file> for batch mode).");
1245
+ printErr("Error: --speaker is required (or --speakers <file> for batch mode).");
1162
1246
  process.exit(1);
1163
1247
  }
1164
1248
  process.stderr.write("Building dossier... (may take 1-3 minutes)\n");
@@ -1179,18 +1263,18 @@ const COMMANDS = {
1179
1263
  run: async (args, json) => {
1180
1264
  const [topic] = args.positionals;
1181
1265
  if (!topic) {
1182
- print("Error: topic is required.");
1266
+ printErr("Error: topic is required.");
1183
1267
  process.exit(1);
1184
1268
  }
1185
1269
  const f = args.flags;
1186
1270
  const speaker = f.speaker;
1187
1271
  if (!speaker) {
1188
- print("Error: --speaker is required.");
1272
+ printErr("Error: --speaker is required.");
1189
1273
  process.exit(1);
1190
1274
  }
1191
1275
  const periods = (f.periods || "").split(",").map((s) => s.trim()).filter(Boolean);
1192
1276
  if (periods.length < 2) {
1193
- 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).");
1194
1278
  process.exit(1);
1195
1279
  }
1196
1280
  process.stderr.write("Comparing periods... (may take 1-3 minutes)\n");
@@ -1201,7 +1285,7 @@ const COMMANDS = {
1201
1285
  if (json)
1202
1286
  return printJson(data);
1203
1287
  if (data.error) {
1204
- print(`Error: ${data.error}`);
1288
+ printErr(`Error: ${data.error}`);
1205
1289
  process.exit(1);
1206
1290
  }
1207
1291
  const lines = [`**Speaker evolution: ${speaker} on ${topic} (${periods.join(" vs ")})**\n`];
@@ -1234,12 +1318,12 @@ const COMMANDS = {
1234
1318
  run: async (args, json) => {
1235
1319
  const [topic] = args.positionals;
1236
1320
  if (!topic) {
1237
- print("Error: topic is required.");
1321
+ printErr("Error: topic is required.");
1238
1322
  process.exit(1);
1239
1323
  }
1240
1324
  const speaker = args.flags.speaker;
1241
1325
  if (!speaker) {
1242
- print("Error: --speaker is required.");
1326
+ printErr("Error: --speaker is required.");
1243
1327
  process.exit(1);
1244
1328
  }
1245
1329
  process.stderr.write("Checking contradictions... (may take 1-3 minutes)\n");
@@ -1254,22 +1338,32 @@ const COMMANDS = {
1254
1338
  briefing: {
1255
1339
  tool: "get_briefing",
1256
1340
  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],
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],
1258
1342
  run: async (args, json) => {
1259
1343
  const f = args.flags;
1260
1344
  const params = { days: Number(f.days) || 3, max_topics: Number(f["max-topics"]) || 8 };
1261
1345
  if (json)
1262
- return printJson(await client.getDiscourseSummary(params));
1346
+ return printJson(await client.getDiscourseSummary({ ...params, project: f.collection || undefined }));
1263
1347
  await runHandler("get_briefing", params);
1264
1348
  },
1265
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
+ },
1266
1360
  stats: {
1267
1361
  tool: "get_platform_stats",
1268
1362
  description: "Platform statistics",
1269
- 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],
1270
1364
  run: async (args, json) => {
1271
1365
  const f = args.flags;
1272
- if (f.projects) {
1366
+ if (f.collections) {
1273
1367
  if (json)
1274
1368
  return printJson(await client.getProjectStats());
1275
1369
  await runHandler("get_project_stats", {});
@@ -1285,23 +1379,23 @@ const COMMANDS = {
1285
1379
  "top-channels": {
1286
1380
  tool: "get_top_channels",
1287
1381
  description: "Top channels by episode count",
1288
- 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],
1289
1383
  run: async (args, json) => {
1290
1384
  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 };
1385
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || undefined, country: f.country || undefined, ...dateFlags(f) };
1292
1386
  if (json)
1293
- return printJson(await client.getTopChannels(params));
1387
+ return printJson(await client.getTopChannels({ ...params, project: f.collection || undefined }));
1294
1388
  await runHandler("get_top_channels", params);
1295
1389
  },
1296
1390
  },
1297
1391
  "top-speakers": {
1298
1392
  tool: "get_top_speakers",
1299
1393
  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],
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],
1302
1396
  run: async (args, json) => {
1303
1397
  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 };
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) };
1305
1399
  if (json)
1306
1400
  return printJson(await client.getTopSpeakers(params));
1307
1401
  await runHandler("get_top_speakers", params);
@@ -1310,10 +1404,10 @@ const COMMANDS = {
1310
1404
  network: {
1311
1405
  tool: "get_speaker_network",
1312
1406
  description: "Speaker co-appearance network",
1313
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_PROJECT, FLAG_JSON],
1407
+ specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_JSON],
1314
1408
  run: async (args, json) => {
1315
1409
  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 };
1410
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || "en", project: f.collection || undefined };
1317
1411
  if (json)
1318
1412
  return printJson(await client.getSpeakerNetwork(params));
1319
1413
  await runHandler("get_speaker_network", params);
@@ -1323,7 +1417,7 @@ const COMMANDS = {
1323
1417
  tool: "get_topics",
1324
1418
  description: "Trending topic clusters",
1325
1419
  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" },
1420
+ { name: "query", type: "string", desc: "Semantic filter — only clusters relevant to this query" },
1327
1421
  { name: "min-segments", type: "number", default: 50, desc: "Minimum segments per cluster" },
1328
1422
  FLAG_START, FLAG_END, FLAG_JSON],
1329
1423
  run: async (args, json) => {
@@ -1382,7 +1476,7 @@ const COMMANDS = {
1382
1476
  const profile = await client.getChannelByName(channelArg);
1383
1477
  const resolvedId = profile.channel_id ?? profile.id;
1384
1478
  if (!resolvedId) {
1385
- print(`Error: channel "${channelArg}" not found.`);
1479
+ printErr(`Error: channel "${channelArg}" not found.`);
1386
1480
  process.exit(1);
1387
1481
  }
1388
1482
  if (json)
@@ -1452,7 +1546,7 @@ async function cmdSetup(force) {
1452
1546
  const key = await rl.question("API key: ");
1453
1547
  rl.close();
1454
1548
  if (!key.trim()) {
1455
- print("Error: no API key provided.");
1549
+ printErr("Error: no API key provided.");
1456
1550
  process.exit(1);
1457
1551
  }
1458
1552
  process.env.SIGNAL4_API_KEY = key.trim();
@@ -1461,7 +1555,7 @@ async function cmdSetup(force) {
1461
1555
  print(`Authenticated as ${who.user_email ?? who.key_prefix ?? "unknown"}`);
1462
1556
  }
1463
1557
  catch (err) {
1464
- 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)})`);
1465
1559
  process.exit(1);
1466
1560
  }
1467
1561
  if (!existsSync(configDir))
@@ -1553,7 +1647,7 @@ async function showHelp() {
1553
1647
  lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
1554
1648
  for (const c of cmds) {
1555
1649
  shown.add(c.name);
1556
- lines.push(` ${c.name.padEnd(19)}${c.desc}`);
1650
+ lines.push(` ${c.name.padEnd(19)}${c.desc}${speedWord(c.name)}`);
1557
1651
  }
1558
1652
  lines.push("");
1559
1653
  }
@@ -1564,7 +1658,7 @@ async function showHelp() {
1564
1658
  lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
1565
1659
  for (const c of cmds) {
1566
1660
  shown.add(c.name);
1567
- lines.push(` ${c.name.padEnd(19)}${c.desc}`);
1661
+ lines.push(` ${c.name.padEnd(19)}${c.desc}${speedWord(c.name)}`);
1568
1662
  }
1569
1663
  lines.push("");
1570
1664
  }
@@ -1577,7 +1671,7 @@ async function showHelp() {
1577
1671
  lines.push("\u2500\u2500 also available \u2500\u2500");
1578
1672
  for (const n of missing) {
1579
1673
  const pad = n.length >= 19 ? `${n} ` : n.padEnd(19);
1580
- lines.push(` ${pad}${COMMANDS[n].description}`);
1674
+ lines.push(` ${pad}${COMMANDS[n].description}${speedWord(n)}`);
1581
1675
  }
1582
1676
  lines.push("");
1583
1677
  }
@@ -1596,11 +1690,14 @@ async function showHelp() {
1596
1690
  lines.push(` ${data.available_commands.join(", ")}`);
1597
1691
  lines.push("");
1598
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("");
1599
1695
  lines.push("Flags:");
1600
1696
  lines.push(" --json Raw JSON output");
1601
1697
  lines.push(" --help, -h Show this help");
1602
1698
  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");
1699
+ lines.push(" --collection NAME Scope to one collection (s4 collections); omit = whole corpus");
1700
+ lines.push(" -V, --version Version and build time");
1604
1701
  lines.push("");
1605
1702
  lines.push("MCP server:");
1606
1703
  lines.push(" s4 --mcp Start MCP server for Claude Desktop / Cursor");
@@ -1615,10 +1712,17 @@ async function showHelp() {
1615
1712
  ].join("\n"));
1616
1713
  }
1617
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
+ }
1618
1720
  function showCommandHelp(name, cmd) {
1619
1721
  const lines = [
1620
1722
  `s4 ${name} — ${cmd.description}`,
1621
1723
  "",
1724
+ `Speed: ${descriptionTag(cmd.tool).slice(1, -1)} (instant <0.5s · fast 0.5–2.5s · slow >2.5s)`,
1725
+ "",
1622
1726
  `Usage: s4 ${name} [args] [flags]`,
1623
1727
  "",
1624
1728
  "Flags:",
@@ -1635,12 +1739,35 @@ function showCommandHelp(name, cmd) {
1635
1739
  // ---------------------------------------------------------------------------
1636
1740
  // Main
1637
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"];
1638
1760
  export async function main() {
1639
1761
  applyConfig();
1762
+ setSurface("cli");
1640
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
+ }
1641
1768
  // Global help — but `s4 <command> --help` gets that command's flags instead.
1642
1769
  const wantsHelp = argv.includes("--help") || argv.includes("-h");
1643
- if (!argv.length || (wantsHelp && !COMMANDS[argv[0]])) {
1770
+ if (!argv.length || argv[0] === "help" || (wantsHelp && !COMMANDS[argv[0]])) {
1644
1771
  await showHelp();
1645
1772
  process.exit(0);
1646
1773
  }
@@ -1653,6 +1780,8 @@ export async function main() {
1653
1780
  await import("./index.js");
1654
1781
  return;
1655
1782
  }
1783
+ // CLI mode gets its own UA (MCP entry points keep the default).
1784
+ client.setUserAgent("Signal4-S4CLI/2.7");
1656
1785
  const command = argv[0];
1657
1786
  const rest = argv.slice(1);
1658
1787
  // Setup (no API key needed)
@@ -1673,12 +1802,24 @@ export async function main() {
1673
1802
  // Lookup command
1674
1803
  const cmd = COMMANDS[command];
1675
1804
  if (!cmd) {
1676
- 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`);
1677
1807
  process.exit(1);
1678
1808
  }
1679
1809
  // Parse args
1680
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
+ }
1681
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
+ }
1682
1823
  // Narrate pipeline steps on stderr while analytical commands stream.
1683
1824
  let progress = null;
1684
1825
  if (PROGRESS_COMMANDS.has(command)) {