@signal4/s4 2.7.0 → 2.8.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 (41) hide show
  1. package/README.md +28 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +102 -32
  4. package/dist/cli.js.map +1 -1
  5. package/dist/client.d.ts +26 -0
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +72 -1
  8. package/dist/client.js.map +1 -1
  9. package/dist/format.d.ts +28 -3
  10. package/dist/format.d.ts.map +1 -1
  11. package/dist/format.js +103 -21
  12. package/dist/format.js.map +1 -1
  13. package/dist/handlers/claim_detail.d.ts.map +1 -1
  14. package/dist/handlers/claim_detail.js +16 -8
  15. package/dist/handlers/claim_detail.js.map +1 -1
  16. package/dist/handlers/claims.d.ts.map +1 -1
  17. package/dist/handlers/claims.js +15 -6
  18. package/dist/handlers/claims.js.map +1 -1
  19. package/dist/handlers/count.d.ts +7 -0
  20. package/dist/handlers/count.d.ts.map +1 -1
  21. package/dist/handlers/count.js +13 -1
  22. package/dist/handlers/count.js.map +1 -1
  23. package/dist/handlers/explore.d.ts.map +1 -1
  24. package/dist/handlers/explore.js +3 -0
  25. package/dist/handlers/explore.js.map +1 -1
  26. package/dist/handlers/reports.d.ts +5 -0
  27. package/dist/handlers/reports.d.ts.map +1 -1
  28. package/dist/handlers/reports.js +101 -16
  29. package/dist/handlers/reports.js.map +1 -1
  30. package/dist/handlers/search.d.ts.map +1 -1
  31. package/dist/handlers/search.js +13 -2
  32. package/dist/handlers/search.js.map +1 -1
  33. package/dist/speed.d.ts.map +1 -1
  34. package/dist/speed.js +22 -4
  35. package/dist/speed.js.map +1 -1
  36. package/dist/surface.d.ts.map +1 -1
  37. package/dist/surface.js +1 -0
  38. package/dist/surface.js.map +1 -1
  39. package/dist/types.d.ts +98 -7
  40. package/dist/types.d.ts.map +1 -1
  41. package/package.json +1 -1
package/README.md CHANGED
@@ -38,6 +38,11 @@ s4 count tariffs --since 2025 --by month # segments · episodes · channels
38
38
  s4 count "carbon tax" -m keyword --collection Canadian --by channel # exact; rank shows by what they said
39
39
  s4 count "carbon tax" --vs "carbon pricing" --since 2024 --by quarter # several topics side by side
40
40
  s4 count "Strong Borders Act" -m keyword --by episode --order date -n 100 # every matching episode, newest first
41
+ s4 count "carbon tax" -m keyword --since 2025 --by speaker # who speaks in the matching segments, names corroborated per episode
42
+ s4 top-speakers --topic tariffs -d 90 # same ranking, from the transcripts (not episode titles)
43
+ s4 entity-claims "Donald Trump" # every entity row of that name + merges; says what it matched
44
+ s4 search tariffs --keyword --since 2025 --page 3 # pages of -n; --all follows every page
45
+ s4 top-channels --topic Medicaid --country us -d 365 # channels ranked by what they said
41
46
  s4 segment 42314442 # full text of a quote (seg:ID from search)
42
47
  s4 --version
43
48
  ```
@@ -45,9 +50,31 @@ s4 --version
45
50
  Flags: `-n/-k/--limit` results · `--offset` skip · `-s` speaker · `-c` channel · `-d/--days` · `-m` mode ·
46
51
  `-o/--out` file · `--since/--until` (or `--start/--end`; `2023`, `2023-06`, `2023-06-15`; on search, claims, timeline, trend, the claims-analysis commands, top-claims, top-channels, top-speakers) · `--collection` (every corpus command) · `--json`.
47
52
  `--days` has no ceiling on search, claims, timeline and count; report commands cap at 365.
48
- `count` groups with `--by` (day · week · month · quarter · year · channel · episode · language · country · platform · collection · lean);
53
+ `count` groups with `--by` (day · week · month · quarter · year · channel · episode · speaker · language · country · platform · collection · lean);
49
54
  keyword counts are exact, semantic counts are estimates at similarity ≥0.35, and every language is counted unless `--language` names some. Errors go to stderr with exit 1.
50
55
 
56
+ ## Paging
57
+
58
+ Every list command and tool pages the same way: `limit` + `offset` (CLI `-n` and `--offset`,
59
+ or `--page N`), and every answer ends with the next call (`next: offset:40`, CLI `--offset 40`).
60
+ `s4 <command> --all` follows the pages for you (up to 50). How deep each list goes:
61
+
62
+ | List | Depth |
63
+ |---|---|
64
+ | keyword search, `count` breakdowns, entity claims, claim observations | every match |
65
+ | semantic / hybrid quote search | rank 2,000 (`depth_reached` says when you hit it) |
66
+ | semantic / hybrid claims search | rank 1,000 |
67
+ | top speakers / channels / talk time | 200 (topic rankings: every match) |
68
+ | top claims | 100 |
69
+
70
+ Past a semantic depth, `--keyword` search or `count ... --by episode` list every match.
71
+ From TypeScript, `paginate()` walks the pages of any list call:
72
+
73
+ ```typescript
74
+ import { paginate, searchQuotes } from '@signal4/s4/client';
75
+ for await (const quote of paginate(searchQuotes, { topic: 'tariffs', mode: 'keyword', days: 365 })) { /* ... */ }
76
+ ```
77
+
51
78
  ## Speed
52
79
 
53
80
  Every MCP tool description and every `s4 <command> --help` starts with how long the call takes at
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAgnD7E,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAmG1C"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAAA,6EAA6E;AA0qD7E,wBAAsB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CA+G1C"}
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { descriptionTag, estimate, startNotice, REPORT_ELAPSED_OVER_S } from "./
5
5
  import * as client from "./client.js";
6
6
  import { writeFileSync, mkdirSync, existsSync, chmodSync, readFileSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
- import { formatDossier } from "./format.js";
8
+ import { formatDossier, parsePoliticalLean } from "./format.js";
9
9
  import { homedir } from "node:os";
10
10
  import { createInterface } from "node:readline/promises";
11
11
  import { parseArgs as parseArgsRaw, UsageError, didYouMean } from "./cli_args.js";
@@ -29,6 +29,12 @@ function parseArgs(argv, specs) {
29
29
  const FLAG_DAYS = { name: "days", alias: "d", type: "number", default: 30, desc: "Time window in days (report commands cap at 365; search/claims/timeline do not)" };
30
30
  const FLAG_LIMIT = { name: "limit", alias: ["n", "k"], type: "number", default: 20, desc: "Number of results (-n, -k)" };
31
31
  const FLAG_OFFSET = { name: "offset", type: "number", default: 0, desc: "Skip this many results (pagination)" };
32
+ const FLAG_PAGE = { name: "page", type: "number", desc: "Page number, 1-based, in pages of --limit (sets --offset)" };
33
+ const FLAG_ALL = { name: "all", type: "boolean", desc: `Follow every page (up to ${50} pages)` };
34
+ /** Paging flags for every list command (signal4#329). */
35
+ const PAGING = [FLAG_OFFSET, FLAG_PAGE, FLAG_ALL];
36
+ /** --all stops after this many pages; the last page then says how to continue. */
37
+ const MAX_ALL_PAGES = 50;
32
38
  const FLAG_SPEAKER = { name: "speaker", alias: "s", type: "string", desc: "Filter by speaker name (full name)" };
33
39
  const FLAG_CHANNEL = { name: "channel", alias: "c", type: "string", desc: "Filter by channel name (comma-separated for multiple)" };
34
40
  const FLAG_LANGUAGE = { name: "language", type: "string", desc: "Filter by language code (en, fr, ...)" };
@@ -42,7 +48,7 @@ const FLAG_SKIP_CACHE = { name: "skip-cache", type: "boolean", desc: "Force fres
42
48
  const FLAG_OUT = { name: "out", alias: "o", long: ["output"], type: "string", desc: "Write the rendered output to a file (.md, or .csv on search)" };
43
49
  const FLAG_JSON = { name: "json", type: "boolean", desc: "Raw JSON output" };
44
50
  const GRANULARITIES = ["day", "week", "month", "quarter", "year"];
45
- const COUNT_GROUPS = ["none", ...GRANULARITIES, "channel", "episode", "language", "country", "platform", "collection", "lean"];
51
+ const COUNT_GROUPS = ["none", ...GRANULARITIES, "channel", "episode", "speaker", "language", "country", "platform", "collection", "lean"];
46
52
  /** --start/--end as tool params (already normalized to YYYY-MM-DD by parseArgs); absent when not given. */
47
53
  function dateFlags(f) {
48
54
  const out = {};
@@ -217,12 +223,40 @@ async function runHandler(tool, params, out) {
217
223
  process.stderr.write((result.content[0]?.text ?? "Unknown error") + "\n");
218
224
  process.exit(1);
219
225
  }
220
- const text = result.content[0]?.text ?? "";
226
+ let text = result.content[0]?.text ?? "";
227
+ // --all: follow the next-page hint every list answer ends with, until it stops.
228
+ let pages = 1;
229
+ let next = nextPage(text);
230
+ while (followAllPages && next && pages < MAX_ALL_PAGES) {
231
+ const current = Number(params[next.param] ?? 0);
232
+ if (next.offset <= current)
233
+ break;
234
+ params = { ...params, [next.param]: next.offset };
235
+ const more = await handler(params);
236
+ if (more.isError)
237
+ break;
238
+ const pageText = more.content[0]?.text ?? "";
239
+ text += `\n\n--- page ${++pages} (${next.param} ${next.offset}) ---\n` + pageText;
240
+ next = nextPage(pageText);
241
+ }
242
+ if (followAllPages && next && pages >= MAX_ALL_PAGES) {
243
+ text += `\n\n(--all stopped after ${MAX_ALL_PAGES} pages; continue with --offset ${next.offset})`;
244
+ }
221
245
  if (out)
222
246
  writeOut(out, text);
223
247
  else
224
248
  print(text);
225
249
  }
250
+ /** Set by main from --all. */
251
+ let followAllPages = false;
252
+ /** The next page a list answer offers ("--offset 40", "--obs-offset 40"), or null. */
253
+ function nextPage(text) {
254
+ const obs = [...text.matchAll(/--obs-offset (\d+)/g)].pop();
255
+ if (obs)
256
+ return { param: "observations_offset", offset: Number(obs[1]) };
257
+ const m = [...text.matchAll(/(?<![-\w])--offset (\d+)/g)].pop();
258
+ return m ? { param: "offset", offset: Number(m[1]) } : null;
259
+ }
226
260
  // A `--channel` name that doesn't resolve to a real channel must fail loudly
227
261
  // in keyword mode, not fall through as an unfiltered corpus-wide query
228
262
  // (signal4#85). Keyword-mode search silently drops channel_names server-side
@@ -411,7 +445,7 @@ const COMMANDS = {
411
445
  search: {
412
446
  tool: "search_quotes",
413
447
  description: "Search quotes by topic and/or speaker",
414
- specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, { ...FLAG_LIMIT, default: 10 }, FLAG_OFFSET, FLAG_LANGUAGE, FLAG_COUNTRY,
448
+ specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, { ...FLAG_LIMIT, default: 10 }, ...PAGING, FLAG_LANGUAGE, FLAG_COUNTRY,
415
449
  { name: "sort", type: "string", default: "relevance", desc: "relevance | chronological" },
416
450
  { name: "count", type: "boolean", desc: "Return only the match count" },
417
451
  { name: "full-history", type: "boolean", desc: "Search the entire corpus (requires topic + speaker)" },
@@ -500,7 +534,7 @@ const COMMANDS = {
500
534
  { name: "political-lean-min", type: "number", desc: "Min channel political lean (0=left, 1=right)" },
501
535
  { name: "political-lean-max", type: "number", desc: "Max channel political lean (0=left, 1=right)" },
502
536
  { name: "source", type: "string", desc: "Claim source filter" },
503
- FLAG_DAYS, { ...FLAG_LIMIT, default: 10 }, FLAG_OFFSET, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
537
+ FLAG_DAYS, { ...FLAG_LIMIT, default: 10 }, ...PAGING, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
504
538
  run: async (args, json) => {
505
539
  const [positionalTopic] = args.positionals;
506
540
  const f = args.flags;
@@ -733,8 +767,8 @@ const COMMANDS = {
733
767
  },
734
768
  "entities": {
735
769
  tool: "find_entities",
736
- description: "Fuzzy search canonical entities by name/alias (people, orgs, places, subjects)",
737
- specs: [{ name: "type", type: "string", desc: "Entity type filter: person | organization | place | subject" }, FLAG_LIMIT, FLAG_JSON],
770
+ description: "Find entities by name (people, orgs, places, subjects); the first row is what entity-claims uses",
771
+ specs: [{ name: "type", type: "string", desc: "Entity type filter: person | organization | place | subject" }, FLAG_LIMIT, ...PAGING, FLAG_JSON],
738
772
  run: async (args, json) => {
739
773
  const [query] = args.positionals;
740
774
  if (!query) {
@@ -744,17 +778,18 @@ const COMMANDS = {
744
778
  const f = args.flags;
745
779
  const entityType = f.type || undefined;
746
780
  const limit = Number(f.limit) || undefined;
781
+ const offset = Number(f.offset) || undefined;
747
782
  if (json)
748
- return printJson(await client.searchEntities({ query, entity_type: entityType, limit }));
749
- await runHandler("find_entities", { query, entity_type: entityType, limit });
783
+ return printJson(await client.searchEntities({ query, entity_type: entityType, limit, offset }));
784
+ await runHandler("find_entities", { query, entity_type: entityType, limit, offset });
750
785
  },
751
786
  },
752
787
  "entity-claims": {
753
788
  tool: "entity_claims",
754
- description: "Claims about a resolved canonical entity, ranked, with stance distribution",
789
+ description: "Most-spread claims about an entity (every row of that name or alias, merges included)",
755
790
  specs: [{ name: "sort", type: "string", default: "spread", desc: "Sort: spread | observations | recent" },
756
791
  { name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
757
- FLAG_LIMIT, FLAG_OFFSET, FLAG_COLLECTION, FLAG_JSON],
792
+ FLAG_LIMIT, ...PAGING, FLAG_COLLECTION, FLAG_JSON],
758
793
  run: async (args, json) => {
759
794
  const [name] = args.positionals;
760
795
  if (!name) {
@@ -796,13 +831,14 @@ const COMMANDS = {
796
831
  specs: [{ name: "metric", type: "string", default: "spread", desc: "Ranking: spread (reach) | contested (assert vs deny balance)" },
797
832
  { name: "category", type: "string", desc: "Optional topic-category filter" },
798
833
  { name: "min-obs", type: "number", default: 2, desc: "Minimum observation count per claim" }, FLAG_DAYS, FLAG_START, FLAG_END,
799
- FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_LIMIT, FLAG_JSON],
834
+ FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_LIMIT, ...PAGING, FLAG_JSON],
800
835
  run: async (args, json) => {
801
836
  const f = args.flags;
802
837
  const params = {
803
838
  metric: f.metric || undefined, category: f.category || undefined,
804
839
  days: Number(f.days) || undefined, min_observations: Number(f["min-obs"]) || undefined,
805
- limit: Number(f.limit) || undefined, language: f.language || undefined,
840
+ limit: Number(f.limit) || undefined, offset: Number(f.offset) || undefined,
841
+ language: f.language || undefined,
806
842
  ...dateFlags(f),
807
843
  };
808
844
  if (json)
@@ -813,7 +849,10 @@ const COMMANDS = {
813
849
  claim: {
814
850
  tool: "get_claim",
815
851
  description: "Get full claim detail with observations and related claims",
816
- specs: [{ name: "observations", type: "boolean", desc: "Include the full observation list" },
852
+ specs: [{ name: "observations", type: "boolean", desc: "Include the observation list (20 a page)" },
853
+ { name: "obs-offset", type: "number", desc: "Observations: skip this many (implies --observations)" },
854
+ { name: "obs-limit", type: "number", desc: "Observations per page (max 100)" },
855
+ FLAG_ALL,
817
856
  { name: "related", type: "boolean", desc: "Include semantically related claims" }, FLAG_JSON],
818
857
  run: async (args, json) => {
819
858
  const [id] = args.positionals;
@@ -822,17 +861,22 @@ const COMMANDS = {
822
861
  process.exit(1);
823
862
  }
824
863
  const claimId = Number(id);
864
+ const obsOffset = args.flags["obs-offset"] != null ? Number(args.flags["obs-offset"]) : undefined;
865
+ const obsLimit = args.flags["obs-limit"] != null ? Number(args.flags["obs-limit"]) : undefined;
825
866
  if (json) {
826
867
  const detail = await client.getClaim(claimId);
827
- if (args.flags.observations)
828
- detail.observations = await client.getClaimObservations({ claim_id: claimId });
868
+ if (args.flags.observations || obsOffset != null || obsLimit != null) {
869
+ detail.observations = await client.getClaimObservations({ claim_id: claimId, limit: obsLimit, offset: obsOffset });
870
+ }
829
871
  if (args.flags.related)
830
872
  detail.related = await client.getRelatedClaims(claimId);
831
873
  return printJson(detail);
832
874
  }
833
875
  await runHandler("get_claim", {
834
876
  claim_id: claimId,
835
- include_observations: args.flags.observations,
877
+ include_observations: args.flags.observations || followAllPages,
878
+ observations_offset: obsOffset,
879
+ observations_limit: obsLimit,
836
880
  include_related: args.flags.related,
837
881
  });
838
882
  },
@@ -933,7 +977,7 @@ const COMMANDS = {
933
977
  { name: "vs", type: "string", desc: "More topics to count side by side, comma-separated (up to 5 in all)" },
934
978
  { name: "order", type: "string", default: "segments", desc: "Episode breakdown order: segments | date" },
935
979
  { name: "min-similarity", type: "number", desc: "Semantic threshold (default 0.35)" },
936
- { ...FLAG_LIMIT, desc: "Rows for channel/episode/... breakdowns (-n, -k; max 1000)" }, FLAG_OFFSET, FLAG_JSON],
980
+ { ...FLAG_LIMIT, desc: "Rows for channel/episode/... breakdowns (-n, -k; max 1000)" }, ...PAGING, FLAG_JSON],
937
981
  run: async (args, json) => {
938
982
  const f = args.flags;
939
983
  const topic = args.positionals.join(" ").trim() || undefined;
@@ -971,7 +1015,7 @@ const COMMANDS = {
971
1015
  speaker: {
972
1016
  tool: "find_speakers",
973
1017
  description: "Speaker search, profile, or discourse",
974
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_CHANNEL,
1018
+ specs: [FLAG_DAYS, FLAG_LIMIT, ...PAGING, FLAG_CHANNEL,
975
1019
  { name: "segments", type: "boolean", desc: "List the speaker's recent segments (chronological)" }, FLAG_JSON],
976
1020
  run: async (args, json) => {
977
1021
  const [identity, topic] = args.positionals;
@@ -1045,7 +1089,7 @@ const COMMANDS = {
1045
1089
  channel: {
1046
1090
  tool: "get_channel",
1047
1091
  description: "Channel profile or episode listing",
1048
- specs: [{ name: "episodes", type: "boolean", desc: "List the channel's recent episodes instead of the profile" }, FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET, FLAG_JSON],
1092
+ specs: [{ name: "episodes", type: "boolean", desc: "List the channel's recent episodes instead of the profile" }, FLAG_DAYS, FLAG_LIMIT, ...PAGING, FLAG_JSON],
1049
1093
  run: async (args, json) => {
1050
1094
  const [id] = args.positionals;
1051
1095
  if (!id) {
@@ -1378,26 +1422,38 @@ const COMMANDS = {
1378
1422
  },
1379
1423
  "top-channels": {
1380
1424
  tool: "get_top_channels",
1381
- description: "Top channels by episode count",
1382
- specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION, FLAG_JSON],
1425
+ description: "Top channels by episode count; with --topic, by how much they say about it",
1426
+ specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_LIMIT, ...PAGING, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
1427
+ { name: "topic", type: "string", desc: "Rank by matching segments per channel, from the transcripts" },
1428
+ { name: "lean", type: "string", desc: "Channel lean: left | center-left | center | center-right | right | 0.0-0.5" },
1429
+ FLAG_JSON],
1383
1430
  run: async (args, json) => {
1384
1431
  const f = args.flags;
1385
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || undefined, country: f.country || undefined, ...dateFlags(f) };
1386
- if (json)
1387
- return printJson(await client.getTopChannels({ ...params, project: f.collection || undefined }));
1432
+ const topic = f.topic || args.positionals.join(" ") || undefined;
1433
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, offset: Number(f.offset) || undefined, language: f.language || undefined, country: f.country || undefined, topic, political_lean: f.lean || undefined, ...dateFlags(f) };
1434
+ if (json) {
1435
+ const lean = parsePoliticalLean(params.political_lean);
1436
+ return printJson(await client.getTopChannels({ ...params, political_lean_min: lean.min, political_lean_max: lean.max, project: f.collection || undefined }));
1437
+ }
1388
1438
  await runHandler("get_top_channels", params);
1389
1439
  },
1390
1440
  },
1391
1441
  "top-speakers": {
1392
1442
  tool: "get_top_speakers",
1393
- description: "Top speakers by appearances, talk time, or clips",
1443
+ description: "Top speakers by appearances, talk time or clips; with --topic, by how much they talk about it",
1394
1444
  specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
1395
- { name: "ranking", type: "string", default: "appearances", desc: "Rank by: appearances | talkative (talk time) | clipped" }, FLAG_JSON],
1445
+ { name: "ranking", type: "string", default: "appearances", desc: "Rank by: appearances | talkative (talk time) | clipped" },
1446
+ { name: "topic", type: "string", desc: "Rank by matching segments each speaker speaks in, from the transcripts (overrides --ranking)" },
1447
+ { name: "lean", type: "string", desc: "Channel lean: left | center-left | center | center-right | right | 0.0-0.5" },
1448
+ ...PAGING, FLAG_JSON],
1396
1449
  run: async (args, json) => {
1397
1450
  const f = args.flags;
1398
- const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, ranking: f.ranking, language: f.language || undefined, country: f.country || undefined, project: f.collection || undefined, ...dateFlags(f) };
1399
- if (json)
1400
- return printJson(await client.getTopSpeakers(params));
1451
+ const topic = f.topic || args.positionals.join(" ") || undefined;
1452
+ const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, offset: Number(f.offset) || undefined, ranking: f.ranking, language: f.language || undefined, country: f.country || undefined, project: f.collection || undefined, topic, political_lean: f.lean || undefined, ...dateFlags(f) };
1453
+ if (json) {
1454
+ const lean = parsePoliticalLean(params.political_lean);
1455
+ return printJson(await client.getTopSpeakers({ ...params, political_lean_min: lean.min, political_lean_max: lean.max }));
1456
+ }
1401
1457
  await runHandler("get_top_speakers", params);
1402
1458
  },
1403
1459
  },
@@ -1438,7 +1494,7 @@ const COMMANDS = {
1438
1494
  ads: {
1439
1495
  tool: "get_ad_analytics",
1440
1496
  description: "Ad intelligence",
1441
- specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_OFFSET,
1497
+ specs: [FLAG_DAYS, FLAG_LIMIT, ...PAGING,
1442
1498
  { name: "advertisers", type: "boolean", desc: "List advertisers instead of the overview" },
1443
1499
  { name: "trending", type: "boolean", desc: "Show growing/declining/new advertisers" },
1444
1500
  FLAG_CHANNEL,
@@ -1496,7 +1552,7 @@ const COMMANDS = {
1496
1552
  description: "Manage bookmarks",
1497
1553
  specs: [{ name: "type", type: "string", desc: "Filter by bookmark type: segment | episode | speaker | channel" },
1498
1554
  { name: "search", type: "string", desc: "Filter bookmarks by search text" },
1499
- FLAG_LIMIT, FLAG_OFFSET,
1555
+ FLAG_LIMIT, ...PAGING,
1500
1556
  { name: "note", type: "string", desc: "Note to attach when adding a bookmark" }, FLAG_JSON],
1501
1557
  run: async (args, json) => {
1502
1558
  const [sub, entityType, entityId] = args.positionals;
@@ -1814,6 +1870,20 @@ export async function main() {
1814
1870
  }
1815
1871
  const json = args.flags.json;
1816
1872
  activeCollection = args.flags.collection || undefined;
1873
+ // --page N is --offset (N-1)*limit; --all follows the next-page hint (signal4#329).
1874
+ const page = Number(args.flags.page);
1875
+ if (page) {
1876
+ if (page < 1 || !Number.isInteger(page)) {
1877
+ printErr("Error: --page is a whole number from 1.");
1878
+ process.exit(1);
1879
+ }
1880
+ args.flags.offset = (page - 1) * (Number(args.flags.limit) || 20);
1881
+ }
1882
+ followAllPages = !!args.flags.all;
1883
+ if (followAllPages && json) {
1884
+ printErr("Error: --all pages the text output. For JSON, page with --offset, or use paginate() from @signal4/s4/client.");
1885
+ process.exit(1);
1886
+ }
1817
1887
  // --days has no ceiling where the backend takes dates; report endpoints cap at 365.
1818
1888
  const days = args.flags.days;
1819
1889
  if (typeof days === "number" && days > 365 && !args.flags.start && !DATE_RANGE_COMMANDS.has(command)) {