@signal4/s4 2.5.0 → 2.6.1

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