@signal4/s4 2.4.0 → 2.6.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 (60) hide show
  1. package/README.md +15 -0
  2. package/bin/signal4-mcp-http.js +2 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +584 -99
  5. package/dist/cli.js.map +1 -1
  6. package/dist/client.d.ts +56 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +108 -12
  9. package/dist/client.js.map +1 -1
  10. package/dist/format.d.ts +67 -18
  11. package/dist/format.d.ts.map +1 -1
  12. package/dist/format.js +637 -358
  13. package/dist/format.js.map +1 -1
  14. package/dist/handler_types.d.ts +4 -0
  15. package/dist/handler_types.d.ts.map +1 -1
  16. package/dist/handler_types.js +8 -1
  17. package/dist/handler_types.js.map +1 -1
  18. package/dist/handlers/ads.d.ts.map +1 -1
  19. package/dist/handlers/ads.js +11 -0
  20. package/dist/handlers/ads.js.map +1 -1
  21. package/dist/handlers/bookmarks.js +4 -4
  22. package/dist/handlers/bookmarks.js.map +1 -1
  23. package/dist/handlers/claims.d.ts.map +1 -1
  24. package/dist/handlers/claims.js +57 -17
  25. package/dist/handlers/claims.js.map +1 -1
  26. package/dist/handlers/explore.d.ts +2 -1
  27. package/dist/handlers/explore.d.ts.map +1 -1
  28. package/dist/handlers/explore.js +64 -10
  29. package/dist/handlers/explore.js.map +1 -1
  30. package/dist/handlers/registry.d.ts.map +1 -1
  31. package/dist/handlers/registry.js +2 -1
  32. package/dist/handlers/registry.js.map +1 -1
  33. package/dist/handlers/reports.d.ts.map +1 -1
  34. package/dist/handlers/reports.js +53 -31
  35. package/dist/handlers/reports.js.map +1 -1
  36. package/dist/handlers/search.d.ts.map +1 -1
  37. package/dist/handlers/search.js +63 -37
  38. package/dist/handlers/search.js.map +1 -1
  39. package/dist/handlers/timeline.d.ts.map +1 -1
  40. package/dist/handlers/timeline.js +12 -1
  41. package/dist/handlers/timeline.js.map +1 -1
  42. package/dist/http.d.ts +16 -0
  43. package/dist/http.d.ts.map +1 -0
  44. package/dist/http.js +132 -0
  45. package/dist/http.js.map +1 -0
  46. package/dist/index.d.ts +3 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +12 -149
  49. package/dist/index.js.map +1 -1
  50. package/dist/provenance.d.ts +41 -0
  51. package/dist/provenance.d.ts.map +1 -0
  52. package/dist/provenance.js +65 -0
  53. package/dist/provenance.js.map +1 -0
  54. package/dist/server_core.d.ts +40 -0
  55. package/dist/server_core.d.ts.map +1 -0
  56. package/dist/server_core.js +174 -0
  57. package/dist/server_core.js.map +1 -0
  58. package/dist/types.d.ts +140 -0
  59. package/dist/types.d.ts.map +1 -1
  60. package/package.json +6 -2
package/dist/cli.js CHANGED
@@ -2,8 +2,9 @@
2
2
  import { applyConfig } from "./config.js";
3
3
  import { HANDLERS } from "./handlers/registry.js";
4
4
  import * as client from "./client.js";
5
- import { writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
5
+ import { writeFileSync, mkdirSync, existsSync, chmodSync, readFileSync, statSync } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { formatDossier } from "./format.js";
7
8
  import { homedir } from "node:os";
8
9
  import { createInterface } from "node:readline/promises";
9
10
  function parseArgs(argv, specs) {
@@ -35,56 +36,209 @@ function parseArgs(argv, specs) {
35
36
  }
36
37
  else {
37
38
  const val = argv[++i];
38
- flags[spec.name] = spec.type === "number" ? Number(val) : (val ?? "");
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
+ }
39
50
  }
40
51
  }
41
52
  else if (arg.startsWith("-") && arg !== "-") {
42
- process.stderr.write(`Warning: unknown flag "${arg}"\n`);
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);
43
58
  }
44
59
  else {
45
60
  positionals.push(arg);
46
61
  }
47
62
  i++;
48
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
+ }
76
+ }
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`);
81
+ process.exit(1);
82
+ }
83
+ }
49
84
  return { positionals, flags };
50
85
  }
51
86
  // ---------------------------------------------------------------------------
52
87
  // Shared flag specs
53
88
  // ---------------------------------------------------------------------------
54
- const FLAG_DAYS = { name: "days", alias: "d", type: "number", default: 30 };
55
- const FLAG_LIMIT = { name: "limit", alias: "n", type: "number", default: 20 };
56
- const FLAG_OFFSET = { name: "offset", alias: "o", type: "number", default: 0 };
57
- const FLAG_SPEAKER = { name: "speaker", alias: "s", type: "string" };
58
- const FLAG_CHANNEL = { name: "channel", alias: "c", type: "string" };
59
- const FLAG_LANGUAGE = { name: "language", alias: "l", type: "string" };
60
- const FLAG_COUNTRY = { name: "country", type: "string" };
61
- const FLAG_MODE = { name: "mode", alias: "m", type: "string", default: "semantic" };
62
- const FLAG_START = { name: "start", type: "string" };
63
- const FLAG_END = { name: "end", type: "string" };
64
- const FLAG_KEYWORD = { name: "keyword", alias: "k", type: "string" };
65
- const FLAG_SKIP_CACHE = { name: "skip-cache", type: "boolean" };
66
- const FLAG_JSON = { name: "json", type: "boolean" };
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" };
92
+ const FLAG_SPEAKER = { name: "speaker", alias: "s", type: "string", desc: "Filter by speaker name (full name)" };
93
+ 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, ...)" };
95
+ const FLAG_COUNTRY = { name: "country", type: "string", desc: "Filter by channel country code" };
96
+ 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)" };
100
+ 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)" };
102
+ const FLAG_JSON = { name: "json", type: "boolean", desc: "Raw JSON output" };
103
+ // ---------------------------------------------------------------------------
104
+ // Progress reporting for long-running analysis commands
105
+ // ---------------------------------------------------------------------------
106
+ /** Human phrases for backend pipeline step names (unknown steps print as-is). */
107
+ const STEP_LABELS = {
108
+ expand_query: "expanding query",
109
+ retrieve_segments: "retrieving evidence",
110
+ retrieve_segments_by_search: "retrieving evidence",
111
+ generate_summaries: "writing summaries",
112
+ retrieve_all_segments: "retrieving evidence",
113
+ retrieve_clustered_segments: "retrieving evidence",
114
+ retrieve_per_speaker: "retrieving evidence per speaker",
115
+ retrieve_per_period: "retrieving evidence per period",
116
+ resolve_speakers: "resolving speakers",
117
+ cluster_segments: "clustering segments",
118
+ quick_cluster_check: "checking clusters",
119
+ filter_clusters: "filtering clusters",
120
+ rank_and_select: "ranking evidence",
121
+ select_segments: "selecting segments",
122
+ select_segments_per_bucket: "selecting segments",
123
+ name_themes: "naming themes",
124
+ analyze_themes_with_subthemes: "analyzing themes",
125
+ extract_subthemes: "extracting subthemes",
126
+ extract_stances: "extracting stances",
127
+ extract_co_mentions: "extracting co-mentions",
128
+ detect_contradictions: "detecting contradictions",
129
+ detect_stance_shifts: "detecting stance shifts",
130
+ detect_trajectory: "detecting trajectory",
131
+ detect_velocity: "measuring velocity",
132
+ quantitative_analysis: "computing metrics",
133
+ corpus_analysis: "analyzing corpus",
134
+ group_by_time: "grouping by time",
135
+ entity_timeline: "building timeline",
136
+ build_relationship_graph: "building relationship graph",
137
+ pack_channel_segments: "packing channel evidence",
138
+ generate_summary: "writing summary",
139
+ generate_dossier: "writing dossier",
140
+ generate_comparison: "writing comparison",
141
+ generate_evolution: "writing evolution analysis",
142
+ generate_temporal_evolution: "writing temporal evolution",
143
+ };
144
+ function stepLabel(step) {
145
+ return STEP_LABELS[step] ?? step.replace(/_/g, " ");
146
+ }
147
+ /**
148
+ * Stderr progress line for analytical commands. TTY: a single line rewritten
149
+ * in place with elapsed seconds. Non-TTY: one plain line per step change,
150
+ * plus a heartbeat every 15s so piped/captured stderr shows liveness.
151
+ * Everything goes to stderr — stdout (--json, --out, pipes) stays clean.
152
+ */
153
+ function createProgress() {
154
+ const t0 = Date.now();
155
+ const tty = process.stderr.isTTY === true;
156
+ let current = "working";
157
+ let finished = false;
158
+ let lastWrite = Date.now();
159
+ const secs = () => Math.round((Date.now() - t0) / 1000);
160
+ const write = (line) => {
161
+ if (tty)
162
+ process.stderr.write(`\r\x1b[2K${line}`);
163
+ else
164
+ process.stderr.write(line + "\n");
165
+ lastWrite = Date.now();
166
+ };
167
+ const timer = setInterval(() => {
168
+ if (finished)
169
+ return;
170
+ if (tty)
171
+ write(`⏳ ${current}… [${secs()}s]`);
172
+ else if (Date.now() - lastWrite >= 15000)
173
+ write(`still working (${current})… [${secs()}s]`);
174
+ }, 1000);
175
+ timer.unref?.();
176
+ return {
177
+ update(step) {
178
+ if (finished)
179
+ return;
180
+ const label = stepLabel(step);
181
+ if (label === current)
182
+ return;
183
+ current = label;
184
+ write(`⏳ ${label}… [${secs()}s]`);
185
+ },
186
+ finish() {
187
+ if (finished)
188
+ return;
189
+ finished = true;
190
+ clearInterval(timer);
191
+ if (tty)
192
+ process.stderr.write("\r\x1b[2K");
193
+ },
194
+ };
195
+ }
196
+ /** Commands that stream the analysis pipeline and deserve step narration. */
197
+ const PROGRESS_COMMANDS = new Set([
198
+ "analyze", "frames", "compare", "dossier", "compare-periods", "contradictions",
199
+ ]);
200
+ /** Active reporter — print()/writeOut() finish it before emitting output so a
201
+ * TTY progress line (no trailing newline) never collides with results. */
202
+ let activeProgress = null;
67
203
  function daysRange(days) {
68
204
  const end = new Date().toISOString().slice(0, 10);
69
205
  const start = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
70
206
  return { start_date: start, end_date: end };
71
207
  }
72
208
  function parseSegmentId(raw) {
73
- // Quote listings display IDs as "seg:12345" — accept that form verbatim.
74
- const id = Number(raw.replace(/^seg:/, ""));
209
+ // Quote listings display IDs as "seg:12345"; dossier citations as "{seg_12345}".
210
+ // Accept all of: N, seg:N, seg_N, {seg_N}, {seg:N}.
211
+ const id = Number(raw.replace(/^\{(.*)\}$/, "$1").replace(/^seg[:_]/, ""));
75
212
  if (!Number.isInteger(id)) {
76
- print(`Error: invalid segment ID "${raw}" (expected a number or seg:<number>).`);
213
+ print(`Error: invalid segment ID "${raw}" (expected a number, seg:<number>, or seg_<number>).`);
77
214
  process.exit(1);
78
215
  }
79
216
  return id;
80
217
  }
81
218
  function print(text) {
219
+ activeProgress?.finish();
82
220
  process.stdout.write(text + "\n");
83
221
  }
222
+ /** Write a deliverable to disk (markdown as rendered; CSV via toCsv). */
223
+ function writeOut(path, text) {
224
+ activeProgress?.finish();
225
+ writeFileSync(path, text.endsWith("\n") ? text : text + "\n");
226
+ process.stderr.write(`Wrote ${path}\n`);
227
+ }
228
+ function csvEscape(v) {
229
+ const s = v == null ? "" : String(v);
230
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
231
+ }
232
+ function toCsv(rows, columns) {
233
+ const lines = [columns.join(",")];
234
+ for (const r of rows)
235
+ lines.push(columns.map((c) => csvEscape(r[c])).join(","));
236
+ return lines.join("\n");
237
+ }
84
238
  function printJson(data) {
85
239
  print(JSON.stringify(data, null, 2));
86
240
  }
87
- async function runHandler(tool, params) {
241
+ async function runHandler(tool, params, out) {
88
242
  const handler = HANDLERS[tool];
89
243
  if (!handler) {
90
244
  print(`Error: handler "${tool}" not found.`);
@@ -95,18 +249,172 @@ async function runHandler(tool, params) {
95
249
  process.stderr.write((result.content[0]?.text ?? "Unknown error") + "\n");
96
250
  process.exit(1);
97
251
  }
98
- print(result.content[0]?.text ?? "");
252
+ const text = result.content[0]?.text ?? "";
253
+ if (out)
254
+ writeOut(out, text);
255
+ else
256
+ print(text);
257
+ }
258
+ // ---------------------------------------------------------------------------
259
+ // Batch dossiers (surrogate-roster sweeps): s4 dossier "<topic>" --speakers file.txt --out dir/
260
+ // ---------------------------------------------------------------------------
261
+ function slugify(name) {
262
+ const slug = name.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
263
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
264
+ return slug || "speaker";
265
+ }
266
+ function readRoster(path) {
267
+ let raw;
268
+ try {
269
+ raw = readFileSync(path, "utf8");
270
+ }
271
+ catch (err) {
272
+ process.stderr.write(`Error: cannot read roster file "${path}": ${err instanceof Error ? err.message : String(err)}\n`);
273
+ process.exit(1);
274
+ }
275
+ const seen = new Set();
276
+ const names = [];
277
+ for (const line of raw.split("\n")) {
278
+ const name = line.trim();
279
+ if (!name || name.startsWith("#"))
280
+ continue;
281
+ if (seen.has(name.toLowerCase()))
282
+ continue;
283
+ seen.add(name.toLowerCase());
284
+ names.push(name);
285
+ }
286
+ return names;
287
+ }
288
+ function writeBatchIndex(dir, topic, days, rows) {
289
+ const lines = [
290
+ `# Dossier batch: ${topic}`,
291
+ "",
292
+ `Generated ${new Date().toISOString().slice(0, 16).replace("T", " ")} UTC · window: last ${days} days · ${rows.length} speakers`,
293
+ "",
294
+ "| Speaker | On-topic segments | Gated out | Thin evidence | Contradictions | Dossier |",
295
+ "|---|---|---|---|---|---|",
296
+ ];
297
+ const cell = (v) => (v === undefined || v === null ? "—" : String(v));
298
+ for (const r of rows) {
299
+ const file = r.file ? `[${r.file}](./${r.file})` : "—";
300
+ if (r.status === "error") {
301
+ lines.push(`| ${r.speaker} | — | — | — | — | ERROR: ${(r.error ?? "unknown").replace(/\|/g, "/").replace(/\n/g, " ")} |`);
302
+ }
303
+ else if (r.status === "skipped") {
304
+ lines.push(`| ${r.speaker} | — | — | — | — | ${file} (existing, not re-analyzed) |`);
305
+ }
306
+ else {
307
+ lines.push(`| ${r.speaker} | ${cell(r.segments)} | ${cell(r.gatedOut)} | ${r.thin ? "⚠ yes" : "no"} | ${cell(r.contradictions)} | ${file} |`);
308
+ }
309
+ }
310
+ lines.push("");
311
+ writeFileSync(join(dir, "INDEX.md"), lines.join("\n"));
312
+ }
313
+ async function runDossierBatch(topic, args, json) {
314
+ const f = args.flags;
315
+ if (json) {
316
+ process.stderr.write("Error: --json is not supported with --speakers (batch mode writes markdown files).\n");
317
+ process.exit(1);
318
+ }
319
+ const outDir = f.out;
320
+ if (!outDir) {
321
+ process.stderr.write("Error: batch mode (--speakers) requires --out <directory> — one <speaker>.md per speaker plus INDEX.md are written there.\n");
322
+ process.exit(1);
323
+ }
324
+ if (outDir.endsWith(".md")) {
325
+ process.stderr.write(`Error: with --speakers, --out must be a directory, not a .md file (got "${outDir}").\n`);
326
+ process.exit(1);
327
+ }
328
+ if (existsSync(outDir) && !statSync(outDir).isDirectory()) {
329
+ process.stderr.write(`Error: --out "${outDir}" exists and is not a directory.\n`);
330
+ process.exit(1);
331
+ }
332
+ mkdirSync(outDir, { recursive: true });
333
+ const names = readRoster(f.speakers);
334
+ if (!names.length) {
335
+ process.stderr.write(`Error: roster file "${f.speakers}" contains no speaker names.\n`);
336
+ process.exit(1);
337
+ }
338
+ const days = Number(f.days) || 180;
339
+ const skipCache = f["skip-cache"];
340
+ const force = f.force;
341
+ // The outer progress reporter from main() would smear step updates across
342
+ // speakers — retire it and run a fresh reporter per dossier.
343
+ activeProgress?.finish();
344
+ activeProgress = null;
345
+ const rows = [];
346
+ let failures = 0;
347
+ for (let i = 0; i < names.length; i++) {
348
+ const speaker = names[i];
349
+ const file = `${slugify(speaker)}.md`;
350
+ const path = join(outDir, file);
351
+ const tag = `[${i + 1}/${names.length}]`;
352
+ if (!force && existsSync(path)) {
353
+ process.stderr.write(`${tag} skipping ${speaker} (exists)\n`);
354
+ rows.push({ speaker, file, status: "skipped" });
355
+ writeBatchIndex(outDir, topic, days, rows);
356
+ continue;
357
+ }
358
+ process.stderr.write(`${tag} Building dossier: ${speaker}…\n`);
359
+ const progress = createProgress();
360
+ activeProgress = progress;
361
+ client.setAnalysisProgressListener((e) => {
362
+ if (e.step && (e.type === "progress" || e.type === "result"))
363
+ progress.update(e.step);
364
+ });
365
+ try {
366
+ const data = await client.analyze({
367
+ query: topic, workflow: "speaker_dossier", speaker_name: speaker,
368
+ days, skip_cache: skipCache || undefined,
369
+ });
370
+ progress.finish();
371
+ if (data.error)
372
+ throw new Error(data.error);
373
+ const text = formatDossier(data, speaker, topic);
374
+ writeFileSync(path, text.endsWith("\n") ? text : text + "\n");
375
+ process.stderr.write(`${tag} Wrote ${path}\n`);
376
+ const sp = (data.speaker ?? {});
377
+ const ret = (sp.retrieval ?? {});
378
+ rows.push({
379
+ speaker, file, status: "ok",
380
+ segments: sp.segment_count ?? 0,
381
+ gatedOut: ret.gated_out,
382
+ thin: Boolean(ret.thin_evidence),
383
+ contradictions: data.contradictions?.contradictions?.length,
384
+ });
385
+ }
386
+ catch (err) {
387
+ progress.finish();
388
+ const msg = err instanceof Error ? err.message : String(err);
389
+ process.stderr.write(`${tag} ERROR for ${speaker}: ${msg}\n`);
390
+ rows.push({ speaker, status: "error", error: msg });
391
+ failures++;
392
+ }
393
+ finally {
394
+ client.setAnalysisProgressListener(null);
395
+ activeProgress = null;
396
+ }
397
+ // Rewrite the index after every speaker so a killed batch leaves a usable summary.
398
+ writeBatchIndex(outDir, topic, days, rows);
399
+ }
400
+ writeBatchIndex(outDir, topic, days, rows);
401
+ const ok = rows.filter((r) => r.status === "ok").length;
402
+ const skipped = rows.filter((r) => r.status === "skipped").length;
403
+ process.stderr.write(`Done: ${ok} built, ${skipped} skipped, ${failures} failed → ${join(outDir, "INDEX.md")}\n`);
404
+ if (failures)
405
+ process.exitCode = 1;
99
406
  }
100
407
  const COMMANDS = {
101
408
  search: {
102
409
  tool: "search_quotes",
103
410
  description: "Search quotes by topic and/or speaker",
104
411
  specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, FLAG_LIMIT, FLAG_OFFSET, FLAG_LANGUAGE, FLAG_COUNTRY,
105
- { name: "sort", type: "string", default: "relevance" },
106
- { name: "count", type: "boolean" },
107
- { name: "full-history", type: "boolean" },
412
+ { name: "sort", type: "string", default: "relevance", desc: "relevance | chronological" },
413
+ { name: "count", type: "boolean", desc: "Return only the match count" },
414
+ { name: "full-history", type: "boolean", desc: "Search the entire corpus (requires topic + speaker)" },
415
+ { name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0, default 0.40)" },
108
416
  FLAG_KEYWORD,
109
- { name: "speaker-id", type: "number" },
417
+ { name: "speaker-id", type: "number", desc: "Speaker identity ID (from s4 speaker)" }, FLAG_OUT,
110
418
  FLAG_START, FLAG_END,
111
419
  FLAG_JSON],
112
420
  run: async (args, json) => {
@@ -116,8 +424,12 @@ const COMMANDS = {
116
424
  const topic = positionalTopic || keyword || undefined;
117
425
  const mode = keyword ? "keyword" : f.mode;
118
426
  const days = Number(f.days) || 30;
119
- const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : daysRange(days);
120
427
  const fullHistory = f["full-history"];
428
+ // Full history means FULL history — only pass dates given explicitly,
429
+ // or the default range silently re-windows the search to --days.
430
+ const dr = f.start
431
+ ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) }
432
+ : (fullHistory ? {} : daysRange(days));
121
433
  const params = {
122
434
  topic, speaker: f.speaker || undefined,
123
435
  speaker_id: Number(f["speaker-id"]) || undefined,
@@ -128,33 +440,61 @@ const COMMANDS = {
128
440
  mode, limit: Number(f.limit), offset: Number(f.offset),
129
441
  count_only: f.count, sort: f.sort,
130
442
  full_history: fullHistory,
443
+ min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
131
444
  };
132
- if (json)
133
- return printJson(await client.searchQuotes({
445
+ const out = f.out;
446
+ if (json || (out && out.endsWith(".csv"))) {
447
+ const data = await client.searchQuotes({
134
448
  topic: params.topic, speaker_name: params.speaker,
135
449
  speaker_identity_id: params.speaker_id,
136
450
  channel_names: params.channel ? params.channel.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
137
451
  start_date: params.start_date, end_date: params.end_date,
138
452
  language: params.language, country: params.country,
139
453
  mode: params.mode !== "semantic" ? params.mode : undefined,
140
- limit: params.count_only ? 1 : params.limit, offset: params.offset,
454
+ limit: params.limit, offset: params.offset,
141
455
  full_history: fullHistory || undefined,
142
- }));
143
- await runHandler("search_quotes", params);
456
+ count_only: params.count_only || undefined,
457
+ min_similarity: params.min_similarity,
458
+ });
459
+ if (out && out.endsWith(".csv")) {
460
+ const rows = (data.quotes ?? []).map((q) => ({
461
+ segment_id: q.segment_id,
462
+ date: (q.publish_date ?? q.episode_date ?? "").slice(0, 10),
463
+ speaker: q.speaker?.name,
464
+ speaker_confidence: q.speaker?.confidence,
465
+ appearance_type: q.speaker?.appearance_type,
466
+ channel: q.channel_name,
467
+ episode: q.episode_title,
468
+ similarity: q.similarity,
469
+ start_time: q.start_time ?? q.timestamp_start,
470
+ end_time: q.end_time ?? q.timestamp_end,
471
+ text: q.text,
472
+ audio_url: q.clip_url,
473
+ }));
474
+ return writeOut(out, toCsv(rows, [
475
+ "segment_id", "date", "speaker", "speaker_confidence", "appearance_type",
476
+ "channel", "episode", "similarity", "start_time", "end_time", "text", "audio_url",
477
+ ]));
478
+ }
479
+ return printJson(data);
480
+ }
481
+ await runHandler("search_quotes", params, out);
144
482
  },
145
483
  },
146
484
  claims: {
147
485
  tool: "search_claims",
148
486
  description: "Search extracted claims",
149
- specs: [{ name: "entity", alias: "e", type: "string" }, FLAG_SPEAKER, FLAG_CHANNEL,
150
- { name: "category", type: "string" }, { name: "stance", type: "string" },
487
+ specs: [{ name: "entity", alias: "e", type: "string", desc: "Filter by entity name (person, org, place, subject)" }, FLAG_SPEAKER, FLAG_CHANNEL,
488
+ { name: "category", type: "string", desc: "Topic category filter" },
489
+ { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
151
490
  FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_MODE, FLAG_KEYWORD,
152
- { name: "sort", type: "string", default: "relevance" },
153
- { name: "min-obs", type: "number", default: 1 },
154
- { name: "min-similarity", type: "number" },
155
- { name: "political-lean-min", type: "number" }, { name: "political-lean-max", type: "number" },
156
- { name: "source", type: "string" },
157
- FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_START, FLAG_END, FLAG_JSON],
491
+ { name: "sort", type: "string", default: "relevance", desc: "Sort: relevance | observations | recent | spread" },
492
+ { name: "min-obs", type: "number", default: 1, desc: "Minimum observation count per claim" },
493
+ { name: "min-similarity", type: "number", desc: "Min semantic similarity (0.0-1.0, default 0.4) — raise to tighten matching" },
494
+ { name: "political-lean-min", type: "number", desc: "Min channel political lean (0=left, 1=right)" },
495
+ { name: "political-lean-max", type: "number", desc: "Max channel political lean (0=left, 1=right)" },
496
+ { name: "source", type: "string", desc: "Claim source filter" },
497
+ FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_START, FLAG_END, FLAG_OUT, FLAG_JSON],
158
498
  run: async (args, json) => {
159
499
  const [positionalTopic] = args.positionals;
160
500
  const f = args.flags;
@@ -196,14 +536,14 @@ const COMMANDS = {
196
536
  min_observations: params.min_observations > 1 ? params.min_observations : undefined,
197
537
  limit: params.limit, offset: params.offset,
198
538
  }));
199
- await runHandler("search_claims", params);
539
+ await runHandler("search_claims", params, f.out);
200
540
  },
201
541
  },
202
542
  "analyze-claims": {
203
543
  tool: "analyze_claims",
204
544
  description: "Analyze a topic over extracted claims — themes with observation stats (fast, no LLM)",
205
545
  specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_DAYS,
206
- { name: "min-obs", type: "number", default: 1 }, FLAG_JSON],
546
+ { name: "min-obs", type: "number", default: 1, desc: "Minimum observation count per claim" }, FLAG_JSON],
207
547
  run: async (args, json) => {
208
548
  const [topic] = args.positionals;
209
549
  if (!topic) {
@@ -231,9 +571,9 @@ const COMMANDS = {
231
571
  "trend-claims": {
232
572
  tool: "trend_claims",
233
573
  description: "Detect whether a topic is surging/fading by claim volume (fast)",
234
- specs: [{ name: "recent-days", type: "number", default: 7 },
235
- { name: "baseline-days", type: "number", default: 30 },
236
- { name: "min-similarity", type: "number" }, FLAG_JSON],
574
+ specs: [{ name: "recent-days", type: "number", default: 7, desc: "Recent window in days" },
575
+ { 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],
237
577
  run: async (args, json) => {
238
578
  const [topic] = args.positionals;
239
579
  if (!topic) {
@@ -255,7 +595,7 @@ const COMMANDS = {
255
595
  "contradict-claims": {
256
596
  tool: "contradict_claims",
257
597
  description: "Find a speaker's contradictory claims on a topic via stance analysis (fast)",
258
- specs: [FLAG_SPEAKER, FLAG_DAYS, FLAG_JSON],
598
+ specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_OUT, FLAG_JSON],
259
599
  run: async (args, json) => {
260
600
  const [topic] = args.positionals;
261
601
  if (!topic) {
@@ -273,13 +613,13 @@ const COMMANDS = {
273
613
  return printJson(await client.checkContradictionsClaims({
274
614
  topic, speaker_name: speaker, days: params.days,
275
615
  }));
276
- await runHandler("contradict_claims", params);
616
+ await runHandler("contradict_claims", params, f.out);
277
617
  },
278
618
  },
279
619
  "compare-claims": {
280
620
  tool: "compare_claims",
281
621
  description: "Compare 2-5 speakers' claims on a topic — side-by-side stance (fast)",
282
- specs: [{ name: "speakers", type: "string" }, FLAG_SPEAKER, FLAG_DAYS, FLAG_JSON],
622
+ specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 }, FLAG_OUT, FLAG_JSON],
283
623
  run: async (args, json) => {
284
624
  const [topic] = args.positionals;
285
625
  if (!topic) {
@@ -298,13 +638,13 @@ const COMMANDS = {
298
638
  return printJson(await client.compareSpeakersClaims({
299
639
  topic, speaker_names: speakers, days: params.days,
300
640
  }));
301
- await runHandler("compare_claims", params);
641
+ await runHandler("compare_claims", params, f.out);
302
642
  },
303
643
  },
304
644
  "channel-dossier": {
305
645
  tool: "channel_dossier",
306
646
  description: "Claims dossier of what a channel (show) platformed on a topic (fast)",
307
- specs: [FLAG_CHANNEL, FLAG_DAYS, FLAG_JSON],
647
+ specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 180 }, FLAG_OUT, FLAG_JSON],
308
648
  run: async (args, json) => {
309
649
  const [topic] = args.positionals;
310
650
  if (!topic) {
@@ -323,13 +663,13 @@ const COMMANDS = {
323
663
  return printJson(await client.buildChannelDossier({
324
664
  topic, channel_names: channels, days,
325
665
  }));
326
- await runHandler("channel_dossier", { topic, channels, days });
666
+ await runHandler("channel_dossier", { topic, channels, days }, f.out);
327
667
  },
328
668
  },
329
669
  "compare-channels": {
330
670
  tool: "compare_channels",
331
671
  description: "Compare 2-5 channels' claims on a topic — stance + speaker overlap (fast)",
332
- specs: [{ name: "channels", type: "string" }, FLAG_CHANNEL, FLAG_DAYS, FLAG_JSON],
672
+ specs: [{ name: "channels", type: "string", desc: "2-5 comma-separated channel names" }, FLAG_CHANNEL, { ...FLAG_DAYS, default: 90 }, FLAG_OUT, FLAG_JSON],
333
673
  run: async (args, json) => {
334
674
  const [topic] = args.positionals;
335
675
  if (!topic) {
@@ -348,13 +688,13 @@ const COMMANDS = {
348
688
  return printJson(await client.compareChannelsClaims({
349
689
  topic, channel_names: channels, days,
350
690
  }));
351
- await runHandler("compare_channels", { topic, channels, days });
691
+ await runHandler("compare_channels", { topic, channels, days }, f.out);
352
692
  },
353
693
  },
354
694
  "channel-contradictions": {
355
695
  tool: "check_channel_contradictions",
356
696
  description: "Contradictory claims a channel platforms on a topic (fast)",
357
- specs: [FLAG_CHANNEL, FLAG_DAYS, FLAG_JSON],
697
+ specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 365 }, FLAG_OUT, FLAG_JSON],
358
698
  run: async (args, json) => {
359
699
  const [topic] = args.positionals;
360
700
  if (!topic) {
@@ -373,13 +713,13 @@ const COMMANDS = {
373
713
  return printJson(await client.checkChannelContradictionsClaims({
374
714
  topic, channel_names: channels, days,
375
715
  }));
376
- await runHandler("check_channel_contradictions", { topic, channels, days });
716
+ await runHandler("check_channel_contradictions", { topic, channels, days }, f.out);
377
717
  },
378
718
  },
379
719
  "entities": {
380
720
  tool: "find_entities",
381
721
  description: "Fuzzy search canonical entities by name/alias (people, orgs, places, subjects)",
382
- specs: [{ name: "type", type: "string" }, FLAG_LIMIT, FLAG_JSON],
722
+ specs: [{ name: "type", type: "string", desc: "Entity type filter: person | organization | place | subject" }, FLAG_LIMIT, FLAG_JSON],
383
723
  run: async (args, json) => {
384
724
  const [query] = args.positionals;
385
725
  if (!query) {
@@ -397,7 +737,8 @@ const COMMANDS = {
397
737
  "entity-claims": {
398
738
  tool: "entity_claims",
399
739
  description: "Claims about a resolved canonical entity, ranked, with stance distribution",
400
- specs: [{ name: "sort", type: "string", default: "spread" }, { name: "stance", type: "string" },
740
+ specs: [{ name: "sort", type: "string", default: "spread", desc: "Sort: spread | observations | recent" },
741
+ { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
401
742
  FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
402
743
  run: async (args, json) => {
403
744
  const [name] = args.positionals;
@@ -436,8 +777,9 @@ const COMMANDS = {
436
777
  "top-claims": {
437
778
  tool: "top_claims",
438
779
  description: "Leaderboard of the most widely-spread or most-contested claims",
439
- specs: [{ name: "metric", type: "string", default: "spread" }, { name: "category", type: "string" },
440
- { name: "min-obs", type: "number", default: 2 }, FLAG_DAYS, FLAG_LIMIT, FLAG_JSON],
780
+ specs: [{ name: "metric", type: "string", default: "spread", desc: "Ranking: spread (reach) | contested (assert vs deny balance)" },
781
+ { 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],
441
783
  run: async (args, json) => {
442
784
  const f = args.flags;
443
785
  const params = {
@@ -453,7 +795,8 @@ const COMMANDS = {
453
795
  claim: {
454
796
  tool: "get_claim",
455
797
  description: "Get full claim detail with observations and related claims",
456
- specs: [{ name: "observations", type: "boolean" }, { name: "related", type: "boolean" }, FLAG_JSON],
798
+ specs: [{ name: "observations", type: "boolean", desc: "Include the full observation list" },
799
+ { name: "related", type: "boolean", desc: "Include semantically related claims" }, FLAG_JSON],
457
800
  run: async (args, json) => {
458
801
  const [id] = args.positionals;
459
802
  if (!id || !/^\d+$/.test(id)) {
@@ -480,9 +823,10 @@ const COMMANDS = {
480
823
  tool: "detect_trend",
481
824
  description: "Detect topic volume trends",
482
825
  specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY,
483
- { name: "recent", type: "number", default: 7 },
484
- { name: "baseline", type: "number", default: 30 },
485
- { name: "min-similarity", type: "number" }, FLAG_JSON],
826
+ { name: "recent", type: "number", default: 7, desc: "Recent window in days" },
827
+ { name: "baseline", type: "number", default: 30, desc: "Baseline window in days" },
828
+ { name: "days", alias: "d", type: "number", desc: "Alias for --baseline" },
829
+ { name: "min-similarity", type: "number", desc: "Minimum similarity threshold" }, FLAG_JSON],
486
830
  run: async (args, json) => {
487
831
  const [topic] = args.positionals;
488
832
  if (!topic) {
@@ -496,7 +840,8 @@ const COMMANDS = {
496
840
  channel: trendChannel,
497
841
  language: f.language || undefined,
498
842
  country: f.country || undefined,
499
- recent_days: Number(f.recent) || 7, baseline_days: Number(f.baseline) || 30,
843
+ recent_days: Number(f.recent) || 7,
844
+ baseline_days: Number(f.days) || Number(f.baseline) || 30,
500
845
  min_similarity: Number(f["min-similarity"]) || undefined,
501
846
  };
502
847
  if (json)
@@ -511,7 +856,8 @@ const COMMANDS = {
511
856
  tool: "get_timeline",
512
857
  description: "Topic mention volume over time",
513
858
  specs: [FLAG_DAYS, FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE,
514
- { name: "granularity", alias: "g", type: "string", default: "day" },
859
+ { name: "granularity", alias: "g", type: "string", default: "day", desc: "Bucket size: day | week" },
860
+ { name: "min-similarity", type: "string", desc: "Relevance threshold 0-1 (default 0.30, same as quote search)" },
515
861
  FLAG_START, FLAG_END, FLAG_JSON],
516
862
  run: async (args, json) => {
517
863
  const [topic] = args.positionals;
@@ -531,6 +877,7 @@ const COMMANDS = {
531
877
  end_date: dr.end_date,
532
878
  language: f.language || undefined,
533
879
  granularity: f.granularity || "day",
880
+ min_similarity: f["min-similarity"] != null ? Number(f["min-similarity"]) : undefined,
534
881
  };
535
882
  if (json)
536
883
  return printJson(await client.getTimeline(tlParams));
@@ -541,7 +888,7 @@ const COMMANDS = {
541
888
  tool: "find_speakers",
542
889
  description: "Speaker search, profile, or discourse",
543
890
  specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_CHANNEL,
544
- { name: "segments", type: "boolean" }, FLAG_JSON],
891
+ { name: "segments", type: "boolean", desc: "List the speaker's recent segments (chronological)" }, FLAG_JSON],
545
892
  run: async (args, json) => {
546
893
  const [identity, topic] = args.positionals;
547
894
  if (!identity) {
@@ -614,7 +961,7 @@ const COMMANDS = {
614
961
  channel: {
615
962
  tool: "get_channel",
616
963
  description: "Channel profile or episode listing",
617
- specs: [{ name: "episodes", type: "boolean" }, FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
964
+ specs: [{ name: "episodes", type: "boolean", desc: "List the channel's recent episodes instead of the profile" }, FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
618
965
  run: async (args, json) => {
619
966
  const [id] = args.positionals;
620
967
  if (!id) {
@@ -649,7 +996,7 @@ const COMMANDS = {
649
996
  episode: {
650
997
  tool: "get_episode",
651
998
  description: "Episode details or transcript",
652
- specs: [{ name: "transcript", type: "boolean" }, FLAG_JSON],
999
+ specs: [{ name: "transcript", type: "boolean", desc: "Include the full episode transcript" }, FLAG_JSON],
653
1000
  run: async (args, json) => {
654
1001
  const [id] = args.positionals;
655
1002
  if (!id) {
@@ -684,7 +1031,7 @@ const COMMANDS = {
684
1031
  thread: {
685
1032
  tool: "get_thread",
686
1033
  description: "Conversation context around a quote",
687
- specs: [{ name: "n", type: "number", default: 5 }, FLAG_JSON],
1034
+ specs: [{ name: "n", type: "number", default: 5, desc: "Segments of context before and after the quote" }, FLAG_JSON],
688
1035
  run: async (args, json) => {
689
1036
  const [id] = args.positionals;
690
1037
  if (!id) {
@@ -702,8 +1049,8 @@ const COMMANDS = {
702
1049
  tool: "analyze_topic",
703
1050
  description: "AI-powered topic analysis",
704
1051
  specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY,
705
- { name: "workflow", alias: "w", type: "string", default: "simple_rag" },
706
- FLAG_SKIP_CACHE, FLAG_JSON],
1052
+ { name: "workflow", alias: "w", type: "string", default: "simple_rag", desc: "Workflow: simple_rag | speaker_focus | entity_network | narrative_timeline | hierarchical_summary | discourse_summary" },
1053
+ FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
707
1054
  run: async (args, json) => {
708
1055
  const [query] = args.positionals;
709
1056
  if (!query) {
@@ -711,7 +1058,7 @@ const COMMANDS = {
711
1058
  process.exit(1);
712
1059
  }
713
1060
  const f = args.flags;
714
- process.stderr.write("Analyzing... (this may take up to 60 seconds)\n");
1061
+ process.stderr.write("Analyzing... (may take 1-3 minutes)\n");
715
1062
  const channelArg = f.channel || undefined;
716
1063
  const params = {
717
1064
  query, workflow: f.workflow,
@@ -724,13 +1071,13 @@ const COMMANDS = {
724
1071
  };
725
1072
  if (json)
726
1073
  return printJson(await client.analyze(params));
727
- await runHandler("analyze_topic", params);
1074
+ await runHandler("analyze_topic", params, f.out);
728
1075
  },
729
1076
  },
730
1077
  frames: {
731
1078
  tool: "compare_frames",
732
1079
  description: "Compare left vs right media framing on a topic",
733
- specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_JSON],
1080
+ specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
734
1081
  run: async (args, json) => {
735
1082
  const [topic] = args.positionals;
736
1083
  if (!topic) {
@@ -738,7 +1085,7 @@ const COMMANDS = {
738
1085
  process.exit(1);
739
1086
  }
740
1087
  const f = args.flags;
741
- process.stderr.write("Comparing left vs right framing... (this may take up to 60 seconds)\n");
1088
+ process.stderr.write("Comparing left vs right framing... (may take 1-3 minutes)\n");
742
1089
  const params = {
743
1090
  topic, days: Number(f.days) || 30,
744
1091
  language: f.language || undefined,
@@ -753,13 +1100,15 @@ const COMMANDS = {
753
1100
  ]);
754
1101
  return printJson({ left, right });
755
1102
  }
756
- await runHandler("compare_frames", params);
1103
+ await runHandler("compare_frames", params, f.out);
757
1104
  },
758
1105
  },
759
1106
  compare: {
760
1107
  tool: "compare_speakers",
761
1108
  description: "Compare speakers on a topic",
762
- specs: [{ name: "speakers", type: "string" }, FLAG_SPEAKER, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_JSON],
1109
+ // FLAG_DAYS carries default:30, which silently overrode this command's
1110
+ // 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],
763
1112
  run: async (args, json) => {
764
1113
  const [topic] = args.positionals;
765
1114
  if (!topic) {
@@ -773,7 +1122,7 @@ const COMMANDS = {
773
1122
  print("Error: --speakers requires 2-5 comma-separated names.");
774
1123
  process.exit(1);
775
1124
  }
776
- process.stderr.write("Comparing speakers... (this may take up to 60 seconds)\n");
1125
+ process.stderr.write("Comparing speakers... (may take 1-3 minutes)\n");
777
1126
  const params = {
778
1127
  topic, speakers, days: Number(f.days) || 90,
779
1128
  language: f.language || undefined,
@@ -786,37 +1135,101 @@ const COMMANDS = {
786
1135
  language: params.language, country: params.country,
787
1136
  days: params.days, skip_cache: params.skip_cache,
788
1137
  }));
789
- await runHandler("compare_speakers", params);
1138
+ await runHandler("compare_speakers", params, f.out);
790
1139
  },
791
1140
  },
792
1141
  dossier: {
793
1142
  tool: "build_dossier",
794
- description: "Comprehensive speaker briefing on a topic",
795
- specs: [FLAG_SPEAKER, FLAG_DAYS, FLAG_SKIP_CACHE, FLAG_JSON],
1143
+ description: "Comprehensive speaker briefing on a topic (single speaker, or batch via --speakers roster file)",
1144
+ specs: [FLAG_SPEAKER,
1145
+ { name: "speakers", type: "string", desc: "Roster file: one speaker name per line (# comments, blank lines ignored) — batch mode, requires --out <dir>" },
1146
+ { name: "force", type: "boolean", desc: "Batch mode: rebuild dossiers whose output file already exists" },
1147
+ { ...FLAG_DAYS, default: 180 }, FLAG_SKIP_CACHE,
1148
+ { ...FLAG_OUT, desc: "Write output to a .md file; with --speakers: output directory (one .md per speaker + INDEX.md)" },
1149
+ FLAG_JSON],
796
1150
  run: async (args, json) => {
797
1151
  const [topic] = args.positionals;
798
1152
  if (!topic) {
799
1153
  print("Error: topic is required.");
800
1154
  process.exit(1);
801
1155
  }
1156
+ if (args.flags.speakers)
1157
+ return runDossierBatch(topic, args, json);
802
1158
  const speaker = args.flags.speaker;
803
1159
  if (!speaker) {
804
- print("Error: --speaker is required.");
1160
+ print("Error: --speaker is required (or --speakers <file> for batch mode).");
805
1161
  process.exit(1);
806
1162
  }
807
- process.stderr.write("Building dossier... (this may take up to 60 seconds)\n");
1163
+ process.stderr.write("Building dossier... (may take 1-3 minutes)\n");
808
1164
  const params = {
809
1165
  topic, speaker: speaker, days: Number(args.flags.days) || 180, skip_cache: args.flags["skip-cache"],
810
1166
  };
811
1167
  if (json)
812
1168
  return printJson(await client.analyze({ query: topic, workflow: "speaker_dossier", speaker_name: speaker, days: params.days, skip_cache: params.skip_cache }));
813
- await runHandler("build_dossier", params);
1169
+ await runHandler("build_dossier", params, args.flags.out);
1170
+ },
1171
+ },
1172
+ "compare-periods": {
1173
+ tool: "compare_periods",
1174
+ description: "How a speaker's positions on a topic changed between time periods",
1175
+ specs: [FLAG_SPEAKER,
1176
+ { name: "periods", type: "string", desc: "Comma-separated periods: 2024,2026 or 2024-01:2024-06,2026-01:2026-06" },
1177
+ FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
1178
+ run: async (args, json) => {
1179
+ const [topic] = args.positionals;
1180
+ if (!topic) {
1181
+ print("Error: topic is required.");
1182
+ process.exit(1);
1183
+ }
1184
+ const f = args.flags;
1185
+ const speaker = f.speaker;
1186
+ if (!speaker) {
1187
+ print("Error: --speaker is required.");
1188
+ process.exit(1);
1189
+ }
1190
+ const periods = (f.periods || "").split(",").map((s) => s.trim()).filter(Boolean);
1191
+ if (periods.length < 2) {
1192
+ print("Error: --periods requires 2+ comma-separated periods (e.g. 2024,2026).");
1193
+ process.exit(1);
1194
+ }
1195
+ process.stderr.write("Comparing periods... (may take 1-3 minutes)\n");
1196
+ const data = await client.analyze({
1197
+ query: topic, workflow: "speaker_evolution", speaker_name: speaker,
1198
+ periods, skip_cache: f["skip-cache"],
1199
+ });
1200
+ if (json)
1201
+ return printJson(data);
1202
+ if (data.error) {
1203
+ print(`Error: ${data.error}`);
1204
+ process.exit(1);
1205
+ }
1206
+ const lines = [`**Speaker evolution: ${speaker} on ${topic} (${periods.join(" vs ")})**\n`];
1207
+ const sp = data.speaker;
1208
+ for (const [label, p] of Object.entries(sp?.periods ?? {})) {
1209
+ const parts = [`${p.segment_count ?? 0} on-topic segments`];
1210
+ if (p.gated_out)
1211
+ parts.push(`${p.gated_out} weak matches excluded`);
1212
+ if (p.date_range?.length === 2)
1213
+ parts.push(`evidence ${p.date_range[0]} → ${p.date_range[1]}`);
1214
+ if (p.thin_evidence)
1215
+ parts.push("⚠ thin evidence");
1216
+ lines.push(`- **${label}**: ${parts.join(" · ")}`);
1217
+ }
1218
+ lines.push("");
1219
+ if (data.summary)
1220
+ lines.push(data.summary);
1221
+ const text = lines.join("\n");
1222
+ const out = f.out;
1223
+ if (out)
1224
+ writeOut(out, text);
1225
+ else
1226
+ print(text);
814
1227
  },
815
1228
  },
816
1229
  contradictions: {
817
1230
  tool: "check_contradictions",
818
1231
  description: "Find contradictory statements from a speaker",
819
- specs: [FLAG_SPEAKER, FLAG_DAYS, FLAG_SKIP_CACHE, FLAG_JSON],
1232
+ specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
820
1233
  run: async (args, json) => {
821
1234
  const [topic] = args.positionals;
822
1235
  if (!topic) {
@@ -828,19 +1241,19 @@ const COMMANDS = {
828
1241
  print("Error: --speaker is required.");
829
1242
  process.exit(1);
830
1243
  }
831
- process.stderr.write("Checking contradictions...\n");
1244
+ process.stderr.write("Checking contradictions... (may take 1-3 minutes)\n");
832
1245
  const params = {
833
1246
  topic, speaker: speaker, days: Number(args.flags.days) || 365, skip_cache: args.flags["skip-cache"],
834
1247
  };
835
1248
  if (json)
836
1249
  return printJson(await client.analyze({ query: topic, workflow: "contradiction_check", speaker_name: speaker, days: params.days, skip_cache: params.skip_cache }));
837
- await runHandler("check_contradictions", params);
1250
+ await runHandler("check_contradictions", params, args.flags.out);
838
1251
  },
839
1252
  },
840
1253
  briefing: {
841
1254
  tool: "get_briefing",
842
1255
  description: "AI discourse briefing",
843
- specs: [FLAG_DAYS, { name: "max-topics", type: "number", default: 8 }, FLAG_JSON],
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],
844
1257
  run: async (args, json) => {
845
1258
  const f = args.flags;
846
1259
  const params = { days: Number(f.days) || 3, max_topics: Number(f["max-topics"]) || 8 };
@@ -852,7 +1265,7 @@ const COMMANDS = {
852
1265
  stats: {
853
1266
  tool: "get_platform_stats",
854
1267
  description: "Platform statistics",
855
- specs: [FLAG_DAYS, { name: "projects", type: "boolean" }, FLAG_JSON],
1268
+ specs: [FLAG_DAYS, { name: "projects", type: "boolean", desc: "Per-project stats instead of platform totals" }, FLAG_JSON],
856
1269
  run: async (args, json) => {
857
1270
  const f = args.flags;
858
1271
  if (f.projects) {
@@ -884,7 +1297,7 @@ const COMMANDS = {
884
1297
  tool: "get_top_speakers",
885
1298
  description: "Top speakers by appearances, talk time, or clips",
886
1299
  specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY,
887
- { name: "ranking", alias: "r", type: "string", default: "appearances" }, FLAG_JSON],
1300
+ { name: "ranking", alias: "r", type: "string", default: "appearances", desc: "Rank by: appearances | talkative (talk time) | clipped" }, FLAG_JSON],
888
1301
  run: async (args, json) => {
889
1302
  const f = args.flags;
890
1303
  const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, ranking: f.ranking, language: f.language || undefined, country: f.country || undefined };
@@ -908,9 +1321,10 @@ const COMMANDS = {
908
1321
  topics: {
909
1322
  tool: "get_topics",
910
1323
  description: "Trending topic clusters",
911
- specs: [FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_LIMIT, FLAG_DAYS, { name: "query", alias: "q", type: "string" },
912
- { name: "min-segments", type: "number", default: 50 },
913
- { name: "start", type: "string" }, { name: "end", type: "string" }, FLAG_JSON],
1324
+ 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" },
1326
+ { name: "min-segments", type: "number", default: 50, desc: "Minimum segments per cluster" },
1327
+ FLAG_START, FLAG_END, FLAG_JSON],
914
1328
  run: async (args, json) => {
915
1329
  const f = args.flags;
916
1330
  const dr = f.days ? daysRange(Number(f.days)) : undefined;
@@ -930,9 +1344,11 @@ const COMMANDS = {
930
1344
  tool: "get_ad_analytics",
931
1345
  description: "Ad intelligence",
932
1346
  specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET,
933
- { name: "advertisers", type: "boolean" }, { name: "trending", type: "boolean" },
1347
+ { name: "advertisers", type: "boolean", desc: "List advertisers instead of the overview" },
1348
+ { name: "trending", type: "boolean", desc: "Show growing/declining/new advertisers" },
934
1349
  FLAG_CHANNEL,
935
- { name: "category", type: "string" }, { name: "sort", type: "string", default: "episodes" },
1350
+ { name: "category", type: "string", desc: "Advertiser category filter (e.g. Financial, Tech/VPN)" },
1351
+ { name: "sort", type: "string", default: "episodes", desc: "Advertiser sort order" },
936
1352
  FLAG_JSON],
937
1353
  run: async (args, json) => {
938
1354
  const [adId] = args.positionals;
@@ -983,8 +1399,10 @@ const COMMANDS = {
983
1399
  bookmarks: {
984
1400
  tool: "manage_bookmarks",
985
1401
  description: "Manage bookmarks",
986
- specs: [{ name: "type", type: "string" }, { name: "search", type: "string" },
987
- FLAG_LIMIT, FLAG_OFFSET, { name: "note", type: "string" }, FLAG_JSON],
1402
+ specs: [{ name: "type", type: "string", desc: "Filter by bookmark type: segment | episode | speaker | channel" },
1403
+ { name: "search", type: "string", desc: "Filter bookmarks by search text" },
1404
+ FLAG_LIMIT, FLAG_OFFSET,
1405
+ { name: "note", type: "string", desc: "Note to attach when adding a bookmark" }, FLAG_JSON],
988
1406
  run: async (args, json) => {
989
1407
  const [sub, entityType, entityId] = args.positionals;
990
1408
  const f = args.flags;
@@ -1106,7 +1524,7 @@ async function showHelp() {
1106
1524
  "",
1107
1525
  ];
1108
1526
  // Key info
1109
- lines.push(` Key: ${data.key_prefix ?? "?"}...`);
1527
+ lines.push(` Key: ${data.key_prefix ?? "n/a"}...`);
1110
1528
  if (data.user_email)
1111
1529
  lines.push(` Email: ${data.user_email}`);
1112
1530
  lines.push(` Access: ${data.full_access ? "full" : "scoped"}`);
@@ -1126,16 +1544,42 @@ async function showHelp() {
1126
1544
  tiers[tier].push({ name, desc: info.description, example: info.example });
1127
1545
  }
1128
1546
  const tierOrder = ["search", "explore", "analyze", "overview", "more"];
1547
+ const shown = new Set();
1129
1548
  for (const tier of tierOrder) {
1130
1549
  const cmds = tiers[tier];
1131
1550
  if (!cmds?.length)
1132
1551
  continue;
1133
1552
  lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
1134
1553
  for (const c of cmds) {
1554
+ shown.add(c.name);
1135
1555
  lines.push(` ${c.name.padEnd(19)}${c.desc}`);
1136
1556
  }
1137
1557
  lines.push("");
1138
1558
  }
1559
+ // Any tier the backend guide doesn't know about
1560
+ for (const [tier, cmds] of Object.entries(tiers)) {
1561
+ if (tierOrder.includes(tier))
1562
+ continue;
1563
+ lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
1564
+ for (const c of cmds) {
1565
+ shown.add(c.name);
1566
+ lines.push(` ${c.name.padEnd(19)}${c.desc}`);
1567
+ }
1568
+ lines.push("");
1569
+ }
1570
+ // Registered CLI commands the backend guide omits (e.g. timeline,
1571
+ // top-claims) \u2014 a command absent from --help may as well not exist.
1572
+ const missing = Object.keys(COMMANDS)
1573
+ .filter((n) => n !== "quotes" && !shown.has(n))
1574
+ .sort();
1575
+ if (missing.length) {
1576
+ lines.push("\u2500\u2500 also available \u2500\u2500");
1577
+ for (const n of missing) {
1578
+ const pad = n.length >= 19 ? `${n} ` : n.padEnd(19);
1579
+ lines.push(` ${pad}${COMMANDS[n].description}`);
1580
+ }
1581
+ lines.push("");
1582
+ }
1139
1583
  // Search tips
1140
1584
  const tips = data.guide.search_tips;
1141
1585
  if (tips?.length) {
@@ -1154,6 +1598,8 @@ async function showHelp() {
1154
1598
  lines.push("Flags:");
1155
1599
  lines.push(" --json Raw JSON output");
1156
1600
  lines.push(" --help, -h Show this help");
1601
+ 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");
1157
1603
  lines.push("");
1158
1604
  lines.push("MCP server:");
1159
1605
  lines.push(" s4 --mcp Start MCP server for Claude Desktop / Cursor");
@@ -1168,17 +1614,39 @@ async function showHelp() {
1168
1614
  ].join("\n"));
1169
1615
  }
1170
1616
  }
1617
+ function showCommandHelp(name, cmd) {
1618
+ const lines = [
1619
+ `s4 ${name} — ${cmd.description}`,
1620
+ "",
1621
+ `Usage: s4 ${name} [args] [flags]`,
1622
+ "",
1623
+ "Flags:",
1624
+ ];
1625
+ for (const s of cmd.specs) {
1626
+ const flag = `--${s.name}${s.alias ? `, -${s.alias}` : ""}`;
1627
+ const def = s.default !== undefined ? ` (default: ${s.default})` : "";
1628
+ lines.push(` ${flag.padEnd(24)}${s.desc ?? ""}${def}`);
1629
+ }
1630
+ lines.push("");
1631
+ lines.push("Run s4 --help for the full command list.");
1632
+ print(lines.join("\n"));
1633
+ }
1171
1634
  // ---------------------------------------------------------------------------
1172
1635
  // Main
1173
1636
  // ---------------------------------------------------------------------------
1174
1637
  export async function main() {
1175
1638
  applyConfig();
1176
1639
  const argv = process.argv.slice(2);
1177
- // Help
1178
- if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
1640
+ // Global help — but `s4 <command> --help` gets that command's flags instead.
1641
+ const wantsHelp = argv.includes("--help") || argv.includes("-h");
1642
+ if (!argv.length || (wantsHelp && !COMMANDS[argv[0]])) {
1179
1643
  await showHelp();
1180
1644
  process.exit(0);
1181
1645
  }
1646
+ if (wantsHelp) {
1647
+ showCommandHelp(argv[0], COMMANDS[argv[0]]);
1648
+ process.exit(0);
1649
+ }
1182
1650
  // MCP mode
1183
1651
  if (argv[0] === "mcp" || argv.includes("--mcp")) {
1184
1652
  await import("./index.js");
@@ -1210,14 +1678,31 @@ export async function main() {
1210
1678
  // Parse args
1211
1679
  const args = parseArgs(rest, cmd.specs);
1212
1680
  const json = args.flags.json;
1681
+ // Narrate pipeline steps on stderr while analytical commands stream.
1682
+ let progress = null;
1683
+ if (PROGRESS_COMMANDS.has(command)) {
1684
+ progress = createProgress();
1685
+ activeProgress = progress;
1686
+ client.setAnalysisProgressListener((e) => {
1687
+ // "progress" fires at step start (verbose mode); "result" at step end —
1688
+ // either way the named step is the latest thing the pipeline touched.
1689
+ if (e.step && (e.type === "progress" || e.type === "result"))
1690
+ progress.update(e.step);
1691
+ });
1692
+ }
1213
1693
  try {
1214
1694
  await cmd.run(args, json);
1215
1695
  }
1216
1696
  catch (err) {
1697
+ progress?.finish();
1217
1698
  const msg = err instanceof Error ? err.message : String(err);
1218
1699
  process.stderr.write(`Error: ${msg}\n`);
1219
1700
  process.exit(1);
1220
1701
  }
1702
+ finally {
1703
+ progress?.finish();
1704
+ client.setAnalysisProgressListener(null);
1705
+ }
1221
1706
  }
1222
1707
  main().catch((err) => {
1223
1708
  process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);