@signal4/s4 2.1.2 → 2.4.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 (50) hide show
  1. package/README.md +145 -17
  2. package/bin/s4.js +0 -0
  3. package/bin/signal4-mcp.js +0 -0
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +510 -63
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +124 -4
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +172 -5
  10. package/dist/client.js.map +1 -1
  11. package/dist/format.d.ts +23 -1
  12. package/dist/format.d.ts.map +1 -1
  13. package/dist/format.js +455 -9
  14. package/dist/format.js.map +1 -1
  15. package/dist/handlers/ads.d.ts.map +1 -1
  16. package/dist/handlers/ads.js +20 -2
  17. package/dist/handlers/ads.js.map +1 -1
  18. package/dist/handlers/analyze.d.ts +1 -0
  19. package/dist/handlers/analyze.d.ts.map +1 -1
  20. package/dist/handlers/analyze.js +44 -6
  21. package/dist/handlers/analyze.js.map +1 -1
  22. package/dist/handlers/claim_detail.d.ts +4 -0
  23. package/dist/handlers/claim_detail.d.ts.map +1 -0
  24. package/dist/handlers/claim_detail.js +29 -0
  25. package/dist/handlers/claim_detail.js.map +1 -0
  26. package/dist/handlers/claims.d.ts +12 -1
  27. package/dist/handlers/claims.d.ts.map +1 -1
  28. package/dist/handlers/claims.js +190 -13
  29. package/dist/handlers/claims.js.map +1 -1
  30. package/dist/handlers/explore.d.ts.map +1 -1
  31. package/dist/handlers/explore.js +103 -20
  32. package/dist/handlers/explore.js.map +1 -1
  33. package/dist/handlers/registry.d.ts.map +1 -1
  34. package/dist/handlers/registry.js +18 -2
  35. package/dist/handlers/registry.js.map +1 -1
  36. package/dist/handlers/reports.d.ts.map +1 -1
  37. package/dist/handlers/reports.js +69 -10
  38. package/dist/handlers/reports.js.map +1 -1
  39. package/dist/handlers/search.d.ts.map +1 -1
  40. package/dist/handlers/search.js +39 -10
  41. package/dist/handlers/search.js.map +1 -1
  42. package/dist/handlers/timeline.d.ts +4 -0
  43. package/dist/handlers/timeline.d.ts.map +1 -0
  44. package/dist/handlers/timeline.js +28 -0
  45. package/dist/handlers/timeline.js.map +1 -0
  46. package/dist/index.js +9 -1
  47. package/dist/index.js.map +1 -1
  48. package/dist/types.d.ts +251 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/package.json +13 -1
package/dist/cli.js CHANGED
@@ -9,9 +9,15 @@ import { createInterface } from "node:readline/promises";
9
9
  function parseArgs(argv, specs) {
10
10
  const flags = {};
11
11
  const positionals = [];
12
- // Apply defaults
13
- for (const s of specs)
14
- flags[s.name] = s.default ?? (s.type === "boolean" ? false : s.type === "number" ? 0 : "");
12
+ // Apply defaults. Flags without a declared default stay undefined —
13
+ // pre-filling numbers with 0 turns optional filters into active ones
14
+ // (political-lean-min=0 + political-lean-max=0 matched zero claims).
15
+ for (const s of specs) {
16
+ if (s.default !== undefined)
17
+ flags[s.name] = s.default;
18
+ else if (s.type === "boolean")
19
+ flags[s.name] = false;
20
+ }
15
21
  // Build lookup: --name and -alias -> spec
16
22
  const lookup = new Map();
17
23
  for (const s of specs) {
@@ -32,8 +38,8 @@ function parseArgs(argv, specs) {
32
38
  flags[spec.name] = spec.type === "number" ? Number(val) : (val ?? "");
33
39
  }
34
40
  }
35
- else if (arg.startsWith("-")) {
36
- // Unknown flag — skip
41
+ else if (arg.startsWith("-") && arg !== "-") {
42
+ process.stderr.write(`Warning: unknown flag "${arg}"\n`);
37
43
  }
38
44
  else {
39
45
  positionals.push(arg);
@@ -53,6 +59,9 @@ const FLAG_CHANNEL = { name: "channel", alias: "c", type: "string" };
53
59
  const FLAG_LANGUAGE = { name: "language", alias: "l", type: "string" };
54
60
  const FLAG_COUNTRY = { name: "country", type: "string" };
55
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" };
56
65
  const FLAG_SKIP_CACHE = { name: "skip-cache", type: "boolean" };
57
66
  const FLAG_JSON = { name: "json", type: "boolean" };
58
67
  function daysRange(days) {
@@ -60,6 +69,15 @@ function daysRange(days) {
60
69
  const start = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10);
61
70
  return { start_date: start, end_date: end };
62
71
  }
72
+ function parseSegmentId(raw) {
73
+ // Quote listings display IDs as "seg:12345" — accept that form verbatim.
74
+ const id = Number(raw.replace(/^seg:/, ""));
75
+ if (!Number.isInteger(id)) {
76
+ print(`Error: invalid segment ID "${raw}" (expected a number or seg:<number>).`);
77
+ process.exit(1);
78
+ }
79
+ return id;
80
+ }
63
81
  function print(text) {
64
82
  process.stdout.write(text + "\n");
65
83
  }
@@ -86,32 +104,41 @@ const COMMANDS = {
86
104
  specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, FLAG_LIMIT, FLAG_OFFSET, FLAG_LANGUAGE, FLAG_COUNTRY,
87
105
  { name: "sort", type: "string", default: "relevance" },
88
106
  { name: "count", type: "boolean" },
107
+ { name: "full-history", type: "boolean" },
108
+ FLAG_KEYWORD,
89
109
  { name: "speaker-id", type: "number" },
90
- { name: "start", type: "string" }, { name: "end", type: "string" },
110
+ FLAG_START, FLAG_END,
91
111
  FLAG_JSON],
92
112
  run: async (args, json) => {
93
- const [topic] = args.positionals;
113
+ const [positionalTopic] = args.positionals;
94
114
  const f = args.flags;
115
+ const keyword = f.keyword || undefined;
116
+ const topic = positionalTopic || keyword || undefined;
117
+ const mode = keyword ? "keyword" : f.mode;
95
118
  const days = Number(f.days) || 30;
96
119
  const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : daysRange(days);
120
+ const fullHistory = f["full-history"];
97
121
  const params = {
98
- topic, speaker_name: f.speaker || undefined,
122
+ topic, speaker: f.speaker || undefined,
99
123
  speaker_id: Number(f["speaker-id"]) || undefined,
100
- channel_name: f.channel || undefined,
124
+ channel: f.channel || undefined,
101
125
  language: f.language || undefined,
102
126
  country: f.country || undefined,
103
127
  days, ...dr,
104
- mode: f.mode, limit: Number(f.limit), offset: Number(f.offset),
128
+ mode, limit: Number(f.limit), offset: Number(f.offset),
105
129
  count_only: f.count, sort: f.sort,
130
+ full_history: fullHistory,
106
131
  };
107
132
  if (json)
108
133
  return printJson(await client.searchQuotes({
109
- topic: params.topic, speaker_name: params.speaker_name,
110
- speaker_identity_id: params.speaker_id, channel_name: params.channel_name,
134
+ topic: params.topic, speaker_name: params.speaker,
135
+ speaker_identity_id: params.speaker_id,
136
+ channel_names: params.channel ? params.channel.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
111
137
  start_date: params.start_date, end_date: params.end_date,
112
138
  language: params.language, country: params.country,
113
139
  mode: params.mode !== "semantic" ? params.mode : undefined,
114
- limit: params.count_only ? 1 : Math.min(params.limit, 50), offset: params.offset,
140
+ limit: params.count_only ? 1 : params.limit, offset: params.offset,
141
+ full_history: fullHistory || undefined,
115
142
  }));
116
143
  await runHandler("search_quotes", params);
117
144
  },
@@ -121,41 +148,339 @@ const COMMANDS = {
121
148
  description: "Search extracted claims",
122
149
  specs: [{ name: "entity", alias: "e", type: "string" }, FLAG_SPEAKER, FLAG_CHANNEL,
123
150
  { name: "category", type: "string" }, { name: "stance", type: "string" },
124
- FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_MODE,
151
+ FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_MODE, FLAG_KEYWORD,
125
152
  { name: "sort", type: "string", default: "relevance" },
126
153
  { name: "min-obs", type: "number", default: 1 },
127
- FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
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],
128
158
  run: async (args, json) => {
129
- const [topic] = args.positionals;
159
+ const [positionalTopic] = args.positionals;
130
160
  const f = args.flags;
161
+ const keyword = f.keyword || undefined;
162
+ const topic = positionalTopic || keyword || undefined;
163
+ const mode = keyword ? "keyword" : f.mode;
131
164
  const days = Number(f.days) || 30;
165
+ const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : undefined;
166
+ const leanMin = f["political-lean-min"] !== undefined ? Number(f["political-lean-min"]) : undefined;
167
+ const leanMax = f["political-lean-max"] !== undefined ? Number(f["political-lean-max"]) : undefined;
168
+ const minSim = f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined;
132
169
  const params = {
133
170
  topic, entity: f.entity || undefined,
134
- speaker_name: f.speaker || undefined,
135
- channel_name: f.channel || undefined,
171
+ speaker: f.speaker || undefined,
172
+ channel: f.channel || undefined,
136
173
  category: f.category || undefined,
137
174
  stance: f.stance || undefined,
138
175
  language: f.language || undefined,
139
- days, mode: f.mode,
176
+ country: f.country || undefined,
177
+ political_lean_min: leanMin, political_lean_max: leanMax,
178
+ min_similarity: minSim,
179
+ source: f.source || undefined,
180
+ days, mode,
140
181
  sort: f.sort, min_observations: Number(f["min-obs"]) || 1,
141
182
  limit: Number(f.limit), offset: Number(f.offset),
183
+ ...dr,
142
184
  };
185
+ const claimsDr = dr ?? daysRange(days);
143
186
  if (json)
144
187
  return printJson(await client.searchClaims({
145
- topic: params.topic, entity: params.entity, speaker_name: params.speaker_name,
146
- channel_names: params.channel_name ? [params.channel_name] : undefined,
147
- primary_topic: params.category, stance: params.stance, language: params.language,
148
- days: params.days, mode: params.mode, sort: params.sort,
188
+ topic: params.topic, entity: params.entity, speaker_name: params.speaker,
189
+ channel_names: params.channel ? params.channel.split(",").map((s) => s.trim()).filter(Boolean) : undefined,
190
+ category: params.category, stance: params.stance, language: params.language,
191
+ country: params.country,
192
+ political_lean_min: leanMin, political_lean_max: leanMax,
193
+ min_similarity: minSim,
194
+ start_date: claimsDr.start_date, end_date: claimsDr.end_date,
195
+ mode: params.mode, sort: params.sort,
149
196
  min_observations: params.min_observations > 1 ? params.min_observations : undefined,
150
- limit: Math.min(params.limit, 50), offset: params.offset,
197
+ limit: params.limit, offset: params.offset,
151
198
  }));
152
199
  await runHandler("search_claims", params);
153
200
  },
154
201
  },
202
+ "analyze-claims": {
203
+ tool: "analyze_claims",
204
+ description: "Analyze a topic over extracted claims — themes with observation stats (fast, no LLM)",
205
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_DAYS,
206
+ { name: "min-obs", type: "number", default: 1 }, FLAG_JSON],
207
+ run: async (args, json) => {
208
+ const [topic] = args.positionals;
209
+ if (!topic) {
210
+ print("Error: topic is required.");
211
+ process.exit(1);
212
+ }
213
+ const f = args.flags;
214
+ const channelArg = f.channel || undefined;
215
+ const params = {
216
+ topic,
217
+ speaker: f.speaker || undefined,
218
+ channels: channelArg?.split(",").map((s) => s.trim()).filter(Boolean),
219
+ language: f.language || undefined,
220
+ days: Number(f.days) || undefined,
221
+ min_observations: Number(f["min-obs"]) || undefined,
222
+ };
223
+ if (json)
224
+ return printJson(await client.analyzeTopicClaims({
225
+ topic, speaker_name: params.speaker, channel_names: params.channels,
226
+ language: params.language, days: params.days, min_observations: params.min_observations,
227
+ }));
228
+ await runHandler("analyze_claims", params);
229
+ },
230
+ },
231
+ "trend-claims": {
232
+ tool: "trend_claims",
233
+ 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],
237
+ run: async (args, json) => {
238
+ const [topic] = args.positionals;
239
+ if (!topic) {
240
+ print("Error: topic is required.");
241
+ process.exit(1);
242
+ }
243
+ const f = args.flags;
244
+ const params = {
245
+ topic,
246
+ recent_days: Number(f["recent-days"]) || undefined,
247
+ baseline_days: Number(f["baseline-days"]) || undefined,
248
+ min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
249
+ };
250
+ if (json)
251
+ return printJson(await client.detectTrendClaims(params));
252
+ await runHandler("trend_claims", params);
253
+ },
254
+ },
255
+ "contradict-claims": {
256
+ tool: "contradict_claims",
257
+ description: "Find a speaker's contradictory claims on a topic via stance analysis (fast)",
258
+ specs: [FLAG_SPEAKER, FLAG_DAYS, FLAG_JSON],
259
+ run: async (args, json) => {
260
+ const [topic] = args.positionals;
261
+ if (!topic) {
262
+ print("Error: topic is required.");
263
+ process.exit(1);
264
+ }
265
+ const f = args.flags;
266
+ const speaker = f.speaker || undefined;
267
+ if (!speaker) {
268
+ print("Error: --speaker is required.");
269
+ process.exit(1);
270
+ }
271
+ const params = { topic, speaker, days: Number(f.days) || undefined };
272
+ if (json)
273
+ return printJson(await client.checkContradictionsClaims({
274
+ topic, speaker_name: speaker, days: params.days,
275
+ }));
276
+ await runHandler("contradict_claims", params);
277
+ },
278
+ },
279
+ "compare-claims": {
280
+ tool: "compare_claims",
281
+ 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],
283
+ run: async (args, json) => {
284
+ const [topic] = args.positionals;
285
+ if (!topic) {
286
+ print("Error: topic is required.");
287
+ process.exit(1);
288
+ }
289
+ const f = args.flags;
290
+ const speakersStr = f.speakers || f.speaker || "";
291
+ const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
292
+ if (speakers.length < 2) {
293
+ print("Error: --speakers requires 2-5 comma-separated names.");
294
+ process.exit(1);
295
+ }
296
+ const params = { topic, speakers, days: Number(f.days) || undefined };
297
+ if (json)
298
+ return printJson(await client.compareSpeakersClaims({
299
+ topic, speaker_names: speakers, days: params.days,
300
+ }));
301
+ await runHandler("compare_claims", params);
302
+ },
303
+ },
304
+ "channel-dossier": {
305
+ tool: "channel_dossier",
306
+ description: "Claims dossier of what a channel (show) platformed on a topic (fast)",
307
+ specs: [FLAG_CHANNEL, FLAG_DAYS, FLAG_JSON],
308
+ run: async (args, json) => {
309
+ const [topic] = args.positionals;
310
+ if (!topic) {
311
+ print("Error: topic is required.");
312
+ process.exit(1);
313
+ }
314
+ const f = args.flags;
315
+ const channel = f.channel || undefined;
316
+ if (!channel) {
317
+ print("Error: --channel is required.");
318
+ process.exit(1);
319
+ }
320
+ const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
321
+ const days = Number(f.days) || undefined;
322
+ if (json)
323
+ return printJson(await client.buildChannelDossier({
324
+ topic, channel_names: channels, days,
325
+ }));
326
+ await runHandler("channel_dossier", { topic, channels, days });
327
+ },
328
+ },
329
+ "compare-channels": {
330
+ tool: "compare_channels",
331
+ 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],
333
+ run: async (args, json) => {
334
+ const [topic] = args.positionals;
335
+ if (!topic) {
336
+ print("Error: topic is required.");
337
+ process.exit(1);
338
+ }
339
+ const f = args.flags;
340
+ const channelsStr = f.channels || f.channel || "";
341
+ const channels = channelsStr.split(",").map((s) => s.trim()).filter(Boolean);
342
+ if (channels.length < 2) {
343
+ print("Error: --channels requires 2-5 comma-separated names.");
344
+ process.exit(1);
345
+ }
346
+ const days = Number(f.days) || undefined;
347
+ if (json)
348
+ return printJson(await client.compareChannelsClaims({
349
+ topic, channel_names: channels, days,
350
+ }));
351
+ await runHandler("compare_channels", { topic, channels, days });
352
+ },
353
+ },
354
+ "channel-contradictions": {
355
+ tool: "check_channel_contradictions",
356
+ description: "Contradictory claims a channel platforms on a topic (fast)",
357
+ specs: [FLAG_CHANNEL, FLAG_DAYS, FLAG_JSON],
358
+ run: async (args, json) => {
359
+ const [topic] = args.positionals;
360
+ if (!topic) {
361
+ print("Error: topic is required.");
362
+ process.exit(1);
363
+ }
364
+ const f = args.flags;
365
+ const channel = f.channel || undefined;
366
+ if (!channel) {
367
+ print("Error: --channel is required.");
368
+ process.exit(1);
369
+ }
370
+ const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
371
+ const days = Number(f.days) || undefined;
372
+ if (json)
373
+ return printJson(await client.checkChannelContradictionsClaims({
374
+ topic, channel_names: channels, days,
375
+ }));
376
+ await runHandler("check_channel_contradictions", { topic, channels, days });
377
+ },
378
+ },
379
+ "entities": {
380
+ tool: "find_entities",
381
+ description: "Fuzzy search canonical entities by name/alias (people, orgs, places, subjects)",
382
+ specs: [{ name: "type", type: "string" }, FLAG_LIMIT, FLAG_JSON],
383
+ run: async (args, json) => {
384
+ const [query] = args.positionals;
385
+ if (!query) {
386
+ print("Error: query is required.");
387
+ process.exit(1);
388
+ }
389
+ const f = args.flags;
390
+ const entityType = f.type || undefined;
391
+ const limit = Number(f.limit) || undefined;
392
+ if (json)
393
+ return printJson(await client.searchEntities({ query, entity_type: entityType, limit }));
394
+ await runHandler("find_entities", { query, entity_type: entityType, limit });
395
+ },
396
+ },
397
+ "entity-claims": {
398
+ tool: "entity_claims",
399
+ description: "Claims about a resolved canonical entity, ranked, with stance distribution",
400
+ specs: [{ name: "sort", type: "string", default: "spread" }, { name: "stance", type: "string" },
401
+ FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
402
+ run: async (args, json) => {
403
+ const [name] = args.positionals;
404
+ if (!name) {
405
+ print("Error: entity name is required.");
406
+ process.exit(1);
407
+ }
408
+ const f = args.flags;
409
+ const params = {
410
+ entity: name, sort: f.sort || undefined, stance: f.stance || undefined,
411
+ limit: Number(f.limit) || undefined, offset: Number(f.offset) || undefined,
412
+ };
413
+ if (json)
414
+ return printJson(await client.getEntityClaims({
415
+ name, sort: params.sort, stance: params.stance, limit: params.limit, offset: params.offset,
416
+ }));
417
+ await runHandler("entity_claims", params);
418
+ },
419
+ },
420
+ "claim-graph": {
421
+ tool: "claim_graph",
422
+ description: "A claim's parent, children, and sibling (variant) claims",
423
+ specs: [FLAG_JSON],
424
+ run: async (args, json) => {
425
+ const [id] = args.positionals;
426
+ if (!id || !/^\d+$/.test(id)) {
427
+ print("Error: claim_id (numeric) required.");
428
+ process.exit(1);
429
+ }
430
+ const claimId = Number(id);
431
+ if (json)
432
+ return printJson(await client.getClaimGraph(claimId));
433
+ await runHandler("claim_graph", { claim_id: claimId });
434
+ },
435
+ },
436
+ "top-claims": {
437
+ tool: "top_claims",
438
+ 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],
441
+ run: async (args, json) => {
442
+ const f = args.flags;
443
+ const params = {
444
+ metric: f.metric || undefined, category: f.category || undefined,
445
+ days: Number(f.days) || undefined, min_observations: Number(f["min-obs"]) || undefined,
446
+ limit: Number(f.limit) || undefined,
447
+ };
448
+ if (json)
449
+ return printJson(await client.getTopClaims(params));
450
+ await runHandler("top_claims", params);
451
+ },
452
+ },
453
+ claim: {
454
+ tool: "get_claim",
455
+ description: "Get full claim detail with observations and related claims",
456
+ specs: [{ name: "observations", type: "boolean" }, { name: "related", type: "boolean" }, FLAG_JSON],
457
+ run: async (args, json) => {
458
+ const [id] = args.positionals;
459
+ if (!id || !/^\d+$/.test(id)) {
460
+ print("Error: claim_id (numeric) required.");
461
+ process.exit(1);
462
+ }
463
+ const claimId = Number(id);
464
+ if (json) {
465
+ const detail = await client.getClaim(claimId);
466
+ if (args.flags.observations)
467
+ detail.observations = await client.getClaimObservations({ claim_id: claimId });
468
+ if (args.flags.related)
469
+ detail.related = await client.getRelatedClaims(claimId);
470
+ return printJson(detail);
471
+ }
472
+ await runHandler("get_claim", {
473
+ claim_id: claimId,
474
+ include_observations: args.flags.observations,
475
+ include_related: args.flags.related,
476
+ });
477
+ },
478
+ },
155
479
  trend: {
156
480
  tool: "detect_trend",
157
481
  description: "Detect topic volume trends",
158
- specs: [{ name: "recent", type: "number", default: 7 },
482
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY,
483
+ { name: "recent", type: "number", default: 7 },
159
484
  { name: "baseline", type: "number", default: 30 },
160
485
  { name: "min-similarity", type: "number" }, FLAG_JSON],
161
486
  run: async (args, json) => {
@@ -165,19 +490,57 @@ const COMMANDS = {
165
490
  process.exit(1);
166
491
  }
167
492
  const f = args.flags;
493
+ const trendChannel = f.channel || undefined;
168
494
  const params = {
169
- topic, recent_days: Number(f.recent) || 7, baseline_days: Number(f.baseline) || 30,
495
+ topic, speaker: f.speaker || undefined,
496
+ channel: trendChannel,
497
+ language: f.language || undefined,
498
+ country: f.country || undefined,
499
+ recent_days: Number(f.recent) || 7, baseline_days: Number(f.baseline) || 30,
170
500
  min_similarity: Number(f["min-similarity"]) || undefined,
171
501
  };
172
502
  if (json)
173
- return printJson(await client.detectTrend(params));
503
+ return printJson(await client.detectTrend({
504
+ ...params,
505
+ channel_names: trendChannel?.split(",").map((s) => s.trim()).filter(Boolean),
506
+ }));
174
507
  await runHandler("detect_trend", params);
175
508
  },
176
509
  },
510
+ timeline: {
511
+ tool: "get_timeline",
512
+ description: "Topic mention volume over time",
513
+ specs: [FLAG_DAYS, FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE,
514
+ { name: "granularity", alias: "g", type: "string", default: "day" },
515
+ FLAG_START, FLAG_END, FLAG_JSON],
516
+ run: async (args, json) => {
517
+ const [topic] = args.positionals;
518
+ if (!topic) {
519
+ print("Error: topic is required.");
520
+ process.exit(1);
521
+ }
522
+ const f = args.flags;
523
+ const days = Number(f.days) || 30;
524
+ const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : daysRange(days);
525
+ const tlChannel = f.channel || undefined;
526
+ const tlParams = {
527
+ topic,
528
+ speaker: f.speaker || undefined,
529
+ channel_names: tlChannel?.split(",").map((s) => s.trim()).filter(Boolean),
530
+ start_date: dr.start_date,
531
+ end_date: dr.end_date,
532
+ language: f.language || undefined,
533
+ granularity: f.granularity || "day",
534
+ };
535
+ if (json)
536
+ return printJson(await client.getTimeline(tlParams));
537
+ await runHandler("get_timeline", { ...tlParams, days, channel: tlChannel });
538
+ },
539
+ },
177
540
  speaker: {
178
541
  tool: "find_speakers",
179
542
  description: "Speaker search, profile, or discourse",
180
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET,
543
+ specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_CHANNEL,
181
544
  { name: "segments", type: "boolean" }, FLAG_JSON],
182
545
  run: async (args, json) => {
183
546
  const [identity, topic] = args.positionals;
@@ -189,19 +552,21 @@ const COMMANDS = {
189
552
  const isId = /^\d+$/.test(identity);
190
553
  const days = Number(f.days) || 30;
191
554
  const limit = Number(f.limit) || 20;
555
+ const offset = Number(f.offset) || 0;
556
+ const channelName = f.channel || undefined;
192
557
  // Profile by ID
193
558
  if (isId) {
194
559
  const sid = Number(identity);
195
560
  if (f.segments) {
196
- const params = { speaker_id: sid, sort: "chronological", days, limit, offset: Number(f.offset) };
561
+ const params = { speaker_id: sid, sort: "chronological", days, limit, offset, channel: channelName };
197
562
  if (json)
198
- return printJson(await client.getSpeakerSegments({ identity_id: sid, days, limit, offset: Number(f.offset) }));
563
+ return printJson(await client.getSpeakerSegments({ identity_id: sid, days, limit, offset }));
199
564
  await runHandler("search_quotes", params);
200
565
  }
201
566
  else if (topic) {
202
- const params = { speaker_id: sid, topic, days, limit };
567
+ const params = { speaker_id: sid, topic, days, limit, offset, channel: channelName };
203
568
  if (json)
204
- return printJson(await client.searchSpeakerSegments({ identity_id: sid, query: topic, days, limit }));
569
+ return printJson(await client.searchSpeakerSegments({ identity_id: sid, query: topic, days, limit, offset }));
205
570
  await runHandler("search_quotes", params);
206
571
  }
207
572
  else {
@@ -213,12 +578,12 @@ const COMMANDS = {
213
578
  }
214
579
  // Segments by name
215
580
  if (f.segments) {
216
- const params = { speaker_name: identity, sort: "chronological", days, limit, offset: Number(f.offset) };
581
+ const params = { speaker: identity, sort: "chronological", days, limit, offset, channel: channelName };
217
582
  if (json) {
218
583
  const byName = await client.getSpeakerByName(identity);
219
584
  const sid = byName.speaker_id ?? byName.id;
220
585
  if (sid)
221
- return printJson(await client.getSpeakerSegments({ identity_id: sid, days, limit, offset: Number(f.offset) }));
586
+ return printJson(await client.getSpeakerSegments({ identity_id: sid, days, limit, offset }));
222
587
  return printJson({ error: `Speaker "${identity}" not found` });
223
588
  }
224
589
  await runHandler("search_quotes", params);
@@ -226,9 +591,9 @@ const COMMANDS = {
226
591
  }
227
592
  // Discourse search (name + topic)
228
593
  if (topic) {
229
- const params = { speaker_name: identity, topic, days, limit };
594
+ const params = { speaker: identity, topic, days, limit, offset, channel: channelName };
230
595
  if (json)
231
- return printJson(await client.searchQuotes({ topic, speaker_name: identity, limit }));
596
+ return printJson(await client.searchQuotes({ topic, speaker_name: identity, channel_names: channelName ? [channelName] : undefined, limit, offset }));
232
597
  await runHandler("search_quotes", params);
233
598
  return;
234
599
  }
@@ -248,8 +613,8 @@ const COMMANDS = {
248
613
  },
249
614
  channel: {
250
615
  tool: "get_channel",
251
- description: "Channel profile",
252
- specs: [FLAG_JSON],
616
+ description: "Channel profile or episode listing",
617
+ specs: [{ name: "episodes", type: "boolean" }, FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
253
618
  run: async (args, json) => {
254
619
  const [id] = args.positionals;
255
620
  if (!id) {
@@ -257,9 +622,28 @@ const COMMANDS = {
257
622
  process.exit(1);
258
623
  }
259
624
  const isId = /^\d+$/.test(id);
625
+ const f = args.flags;
626
+ if (f.episodes) {
627
+ const channelName = isId ? undefined : id;
628
+ const days = Number(f.days) || 30;
629
+ const limit = Number(f.limit) || 20;
630
+ const offset = Number(f.offset) || 0;
631
+ const dr = daysRange(days);
632
+ const params = {
633
+ channel: channelName, topic: undefined, sort: "chronological",
634
+ days, ...dr, limit, offset,
635
+ };
636
+ if (json)
637
+ return printJson(await client.searchQuotes({
638
+ channel_names: channelName ? [channelName] : undefined, start_date: dr.start_date, end_date: dr.end_date,
639
+ limit, offset,
640
+ }));
641
+ await runHandler("search_quotes", params);
642
+ return;
643
+ }
260
644
  if (json)
261
645
  return printJson(isId ? await client.getChannel(Number(id)) : await client.getChannelByName(id));
262
- await runHandler("get_channel", isId ? { channel_id: Number(id) } : { channel_name: id });
646
+ await runHandler("get_channel", isId ? { channel_id: Number(id) } : { channel: id });
263
647
  },
264
648
  },
265
649
  episode: {
@@ -291,9 +675,10 @@ const COMMANDS = {
291
675
  print("Error: segment ID required.");
292
676
  process.exit(1);
293
677
  }
678
+ const segId = parseSegmentId(id);
294
679
  if (json)
295
- return printJson(await client.getSegment(Number(id)));
296
- await runHandler("get_segment", { segment_id: Number(id) });
680
+ return printJson(await client.getSegment(segId));
681
+ await runHandler("get_segment", { segment_id: segId });
297
682
  },
298
683
  },
299
684
  thread: {
@@ -306,16 +691,17 @@ const COMMANDS = {
306
691
  print("Error: segment ID required.");
307
692
  process.exit(1);
308
693
  }
694
+ const segId = parseSegmentId(id);
309
695
  const n = Number(args.flags.n) || 5;
310
696
  if (json)
311
- return printJson(await client.getSegmentThread(Number(id), n));
312
- await runHandler("get_thread", { segment_id: Number(id), n });
697
+ return printJson(await client.getSegmentThread(segId, n));
698
+ await runHandler("get_thread", { segment_id: segId, n });
313
699
  },
314
700
  },
315
701
  analyze: {
316
702
  tool: "analyze_topic",
317
703
  description: "AI-powered topic analysis",
318
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS,
704
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY,
319
705
  { name: "workflow", alias: "w", type: "string", default: "simple_rag" },
320
706
  FLAG_SKIP_CACHE, FLAG_JSON],
321
707
  run: async (args, json) => {
@@ -326,10 +712,13 @@ const COMMANDS = {
326
712
  }
327
713
  const f = args.flags;
328
714
  process.stderr.write("Analyzing... (this may take up to 60 seconds)\n");
715
+ const channelArg = f.channel || undefined;
329
716
  const params = {
330
717
  query, workflow: f.workflow,
331
- speaker_name: f.speaker || undefined,
332
- channel: f.channel || undefined,
718
+ speaker: f.speaker || undefined,
719
+ channels: channelArg?.split(",").map((s) => s.trim()).filter(Boolean),
720
+ language: f.language || undefined,
721
+ country: f.country || undefined,
333
722
  days: Number(f.days) || undefined,
334
723
  skip_cache: f["skip-cache"],
335
724
  };
@@ -338,10 +727,39 @@ const COMMANDS = {
338
727
  await runHandler("analyze_topic", params);
339
728
  },
340
729
  },
730
+ frames: {
731
+ tool: "compare_frames",
732
+ description: "Compare left vs right media framing on a topic",
733
+ specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_JSON],
734
+ run: async (args, json) => {
735
+ const [topic] = args.positionals;
736
+ if (!topic) {
737
+ print("Error: topic is required.");
738
+ process.exit(1);
739
+ }
740
+ const f = args.flags;
741
+ process.stderr.write("Comparing left vs right framing... (this may take up to 60 seconds)\n");
742
+ const params = {
743
+ topic, days: Number(f.days) || 30,
744
+ language: f.language || undefined,
745
+ country: f.country || undefined,
746
+ skip_cache: f["skip-cache"],
747
+ };
748
+ if (json) {
749
+ const shared = { query: topic, workflow: "deep_analysis", days: params.days, language: params.language, country: params.country, skip_cache: params.skip_cache };
750
+ const [left, right] = await Promise.all([
751
+ client.analyze({ ...shared, political_lean_min: 0.0, political_lean_max: 0.35 }),
752
+ client.analyze({ ...shared, political_lean_min: 0.65, political_lean_max: 1.0 }),
753
+ ]);
754
+ return printJson({ left, right });
755
+ }
756
+ await runHandler("compare_frames", params);
757
+ },
758
+ },
341
759
  compare: {
342
760
  tool: "compare_speakers",
343
761
  description: "Compare speakers on a topic",
344
- specs: [{ name: "speakers", type: "string" }, FLAG_DAYS, FLAG_SKIP_CACHE, FLAG_JSON],
762
+ specs: [{ name: "speakers", type: "string" }, FLAG_SPEAKER, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_JSON],
345
763
  run: async (args, json) => {
346
764
  const [topic] = args.positionals;
347
765
  if (!topic) {
@@ -349,17 +767,25 @@ const COMMANDS = {
349
767
  process.exit(1);
350
768
  }
351
769
  const f = args.flags;
352
- const speakers = (f.speakers || "").split(",").map((s) => s.trim()).filter(Boolean);
770
+ const speakersStr = f.speakers || f.speaker || "";
771
+ const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
353
772
  if (speakers.length < 2) {
354
773
  print("Error: --speakers requires 2-5 comma-separated names.");
355
774
  process.exit(1);
356
775
  }
357
776
  process.stderr.write("Comparing speakers... (this may take up to 60 seconds)\n");
358
777
  const params = {
359
- topic, speakers, days: Number(f.days) || 90, skip_cache: f["skip-cache"],
778
+ topic, speakers, days: Number(f.days) || 90,
779
+ language: f.language || undefined,
780
+ country: f.country || undefined,
781
+ skip_cache: f["skip-cache"],
360
782
  };
361
783
  if (json)
362
- return printJson(await client.analyze({ query: topic, workflow: "speaker_compare", speaker_names: speakers, days: params.days, skip_cache: params.skip_cache }));
784
+ return printJson(await client.analyze({
785
+ query: topic, workflow: "speaker_compare", speaker_names: speakers,
786
+ language: params.language, country: params.country,
787
+ days: params.days, skip_cache: params.skip_cache,
788
+ }));
363
789
  await runHandler("compare_speakers", params);
364
790
  },
365
791
  },
@@ -380,7 +806,7 @@ const COMMANDS = {
380
806
  }
381
807
  process.stderr.write("Building dossier... (this may take up to 60 seconds)\n");
382
808
  const params = {
383
- topic, speaker_name: speaker, days: Number(args.flags.days) || 180, skip_cache: args.flags["skip-cache"],
809
+ topic, speaker: speaker, days: Number(args.flags.days) || 180, skip_cache: args.flags["skip-cache"],
384
810
  };
385
811
  if (json)
386
812
  return printJson(await client.analyze({ query: topic, workflow: "speaker_dossier", speaker_name: speaker, days: params.days, skip_cache: params.skip_cache }));
@@ -404,7 +830,7 @@ const COMMANDS = {
404
830
  }
405
831
  process.stderr.write("Checking contradictions...\n");
406
832
  const params = {
407
- topic, speaker_name: speaker, days: Number(args.flags.days) || 365, skip_cache: args.flags["skip-cache"],
833
+ topic, speaker: speaker, days: Number(args.flags.days) || 365, skip_cache: args.flags["skip-cache"],
408
834
  };
409
835
  if (json)
410
836
  return printJson(await client.analyze({ query: topic, workflow: "contradiction_check", speaker_name: speaker, days: params.days, skip_cache: params.skip_cache }));
@@ -448,7 +874,7 @@ const COMMANDS = {
448
874
  specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_JSON],
449
875
  run: async (args, json) => {
450
876
  const f = args.flags;
451
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 15, language: f.language || undefined, country: f.country || undefined };
877
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || undefined, country: f.country || undefined };
452
878
  if (json)
453
879
  return printJson(await client.getTopChannels(params));
454
880
  await runHandler("get_top_channels", params);
@@ -457,10 +883,11 @@ const COMMANDS = {
457
883
  "top-speakers": {
458
884
  tool: "get_top_speakers",
459
885
  description: "Top speakers by appearances, talk time, or clips",
460
- specs: [FLAG_DAYS, FLAG_LIMIT, { name: "ranking", alias: "r", type: "string", default: "appearances" }, FLAG_JSON],
886
+ specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY,
887
+ { name: "ranking", alias: "r", type: "string", default: "appearances" }, FLAG_JSON],
461
888
  run: async (args, json) => {
462
889
  const f = args.flags;
463
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 15, ranking: f.ranking };
890
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, ranking: f.ranking, language: f.language || undefined, country: f.country || undefined };
464
891
  if (json)
465
892
  return printJson(await client.getTopSpeakers(params));
466
893
  await runHandler("get_top_speakers", params);
@@ -481,15 +908,18 @@ const COMMANDS = {
481
908
  topics: {
482
909
  tool: "get_topics",
483
910
  description: "Trending topic clusters",
484
- specs: [FLAG_LANGUAGE, FLAG_LIMIT, { name: "query", alias: "q", type: "string" },
911
+ specs: [FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_LIMIT, FLAG_DAYS, { name: "query", alias: "q", type: "string" },
485
912
  { name: "min-segments", type: "number", default: 50 },
486
913
  { name: "start", type: "string" }, { name: "end", type: "string" }, FLAG_JSON],
487
914
  run: async (args, json) => {
488
915
  const f = args.flags;
916
+ const dr = f.days ? daysRange(Number(f.days)) : undefined;
489
917
  const params = {
490
918
  language: f.language || "en", limit: Number(f.limit) || 20,
491
- query: f.query || undefined, min_segments: Number(f["min-segments"]) || 50,
492
- start_date: f.start || undefined, end_date: f.end || undefined,
919
+ query: f.query || undefined, country: f.country || undefined,
920
+ min_segments: Number(f["min-segments"]) || 50,
921
+ start_date: f.start || dr?.start_date || undefined,
922
+ end_date: f.end || dr?.end_date || undefined,
493
923
  };
494
924
  if (json)
495
925
  return printJson(await client.getTopics(params));
@@ -501,13 +931,14 @@ const COMMANDS = {
501
931
  description: "Ad intelligence",
502
932
  specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET,
503
933
  { name: "advertisers", type: "boolean" }, { name: "trending", type: "boolean" },
504
- { name: "channel", alias: "c", type: "number" },
934
+ FLAG_CHANNEL,
505
935
  { name: "category", type: "string" }, { name: "sort", type: "string", default: "episodes" },
506
936
  FLAG_JSON],
507
937
  run: async (args, json) => {
508
938
  const [adId] = args.positionals;
509
939
  const f = args.flags;
510
940
  const days = Number(f.days) || 30;
941
+ const channelArg = f.channel || undefined;
511
942
  if (adId && /^\d+$/.test(adId)) {
512
943
  if (json)
513
944
  return printJson(await client.getAdvertiser(Number(adId)));
@@ -523,10 +954,24 @@ const COMMANDS = {
523
954
  return printJson(await client.getAdTrends(days));
524
955
  await runHandler("get_ad_analytics", { view: "trending", days });
525
956
  }
526
- else if (Number(f.channel)) {
527
- if (json)
528
- return printJson(await client.getAdsByChannel(Number(f.channel), days));
529
- await runHandler("get_ad_analytics", { view: "channel", channel_id: Number(f.channel), days });
957
+ else if (channelArg) {
958
+ const channelId = /^\d+$/.test(channelArg) ? Number(channelArg) : undefined;
959
+ if (channelId) {
960
+ if (json)
961
+ return printJson(await client.getAdsByChannel(channelId, days));
962
+ await runHandler("get_ad_analytics", { view: "channel", channel_id: channelId, days });
963
+ }
964
+ else {
965
+ const profile = await client.getChannelByName(channelArg);
966
+ const resolvedId = profile.channel_id ?? profile.id;
967
+ if (!resolvedId) {
968
+ print(`Error: channel "${channelArg}" not found.`);
969
+ process.exit(1);
970
+ }
971
+ if (json)
972
+ return printJson(await client.getAdsByChannel(resolvedId, days));
973
+ await runHandler("get_ad_analytics", { view: "channel", channel_id: resolvedId, days });
974
+ }
530
975
  }
531
976
  else {
532
977
  if (json)
@@ -572,6 +1017,8 @@ const COMMANDS = {
572
1017
  },
573
1018
  },
574
1019
  };
1020
+ // The backend's whoami guide advertises "quotes" — keep it working as an alias.
1021
+ COMMANDS.quotes = COMMANDS.search;
575
1022
  // ---------------------------------------------------------------------------
576
1023
  // Setup command
577
1024
  // ---------------------------------------------------------------------------