@signal4/s4 2.6.1 → 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.
- package/README.md +60 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +407 -196
- package/dist/cli.js.map +1 -1
- package/dist/cli_args.d.ts +24 -0
- package/dist/cli_args.d.ts.map +1 -0
- package/dist/cli_args.js +130 -0
- package/dist/cli_args.js.map +1 -0
- package/dist/client.d.ts +103 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +195 -23
- package/dist/client.js.map +1 -1
- package/dist/format.d.ts +103 -4
- package/dist/format.d.ts.map +1 -1
- package/dist/format.js +299 -64
- package/dist/format.js.map +1 -1
- package/dist/handler_types.d.ts +10 -0
- package/dist/handler_types.d.ts.map +1 -1
- package/dist/handler_types.js +25 -0
- package/dist/handler_types.js.map +1 -1
- package/dist/handlers/ads.js +4 -4
- package/dist/handlers/ads.js.map +1 -1
- package/dist/handlers/analyze.d.ts.map +1 -1
- package/dist/handlers/analyze.js +22 -6
- package/dist/handlers/analyze.js.map +1 -1
- package/dist/handlers/bookmarks.js +2 -2
- package/dist/handlers/bookmarks.js.map +1 -1
- package/dist/handlers/claim_detail.d.ts.map +1 -1
- package/dist/handlers/claim_detail.js +18 -10
- package/dist/handlers/claim_detail.js.map +1 -1
- package/dist/handlers/claims.d.ts.map +1 -1
- package/dist/handlers/claims.js +72 -27
- package/dist/handlers/claims.js.map +1 -1
- package/dist/handlers/count.d.ts +14 -0
- package/dist/handlers/count.d.ts.map +1 -0
- package/dist/handlers/count.js +158 -0
- package/dist/handlers/count.js.map +1 -0
- package/dist/handlers/explore.d.ts.map +1 -1
- package/dist/handlers/explore.js +56 -14
- package/dist/handlers/explore.js.map +1 -1
- package/dist/handlers/registry.d.ts.map +1 -1
- package/dist/handlers/registry.js +5 -1
- package/dist/handlers/registry.js.map +1 -1
- package/dist/handlers/reports.d.ts +6 -0
- package/dist/handlers/reports.d.ts.map +1 -1
- package/dist/handlers/reports.js +140 -26
- package/dist/handlers/reports.js.map +1 -1
- package/dist/handlers/search.d.ts.map +1 -1
- package/dist/handlers/search.js +80 -23
- package/dist/handlers/search.js.map +1 -1
- package/dist/handlers/timeline.d.ts.map +1 -1
- package/dist/handlers/timeline.js +14 -8
- package/dist/handlers/timeline.js.map +1 -1
- package/dist/http.js +25 -3
- package/dist/http.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/provenance.d.ts +29 -2
- package/dist/provenance.d.ts.map +1 -1
- package/dist/provenance.js +63 -4
- package/dist/provenance.js.map +1 -1
- package/dist/server_core.d.ts +8 -1
- package/dist/server_core.d.ts.map +1 -1
- package/dist/server_core.js +90 -8
- package/dist/server_core.js.map +1 -1
- package/dist/speed.d.ts +40 -0
- package/dist/speed.d.ts.map +1 -0
- package/dist/speed.js +214 -0
- package/dist/speed.js.map +1 -0
- package/dist/surface.d.ts +7 -0
- package/dist/surface.d.ts.map +1 -0
- package/dist/surface.js +19 -0
- package/dist/surface.js.map +1 -0
- package/dist/types.d.ts +249 -15
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -2
package/dist/cli.js
CHANGED
|
@@ -1,106 +1,63 @@
|
|
|
1
1
|
/** Signal4 CLI — terminal interface sharing handlers with the MCP server. */
|
|
2
2
|
import { applyConfig } from "./config.js";
|
|
3
3
|
import { HANDLERS } from "./handlers/registry.js";
|
|
4
|
+
import { descriptionTag, estimate, startNotice, REPORT_ELAPSED_OVER_S } from "./speed.js";
|
|
4
5
|
import * as client from "./client.js";
|
|
5
6
|
import { writeFileSync, mkdirSync, existsSync, chmodSync, readFileSync, statSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
7
|
-
import { formatDossier } from "./format.js";
|
|
8
|
+
import { formatDossier, parsePoliticalLean } from "./format.js";
|
|
8
9
|
import { homedir } from "node:os";
|
|
9
10
|
import { createInterface } from "node:readline/promises";
|
|
11
|
+
import { parseArgs as parseArgsRaw, UsageError, didYouMean } from "./cli_args.js";
|
|
12
|
+
import { setSurface } from "./surface.js";
|
|
13
|
+
/** Parse or exit 1 with the usage error on stderr. */
|
|
10
14
|
function parseArgs(argv, specs) {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
// Apply defaults. Flags without a declared default stay undefined —
|
|
14
|
-
// pre-filling numbers with 0 turns optional filters into active ones
|
|
15
|
-
// (political-lean-min=0 + political-lean-max=0 matched zero claims).
|
|
16
|
-
for (const s of specs) {
|
|
17
|
-
if (s.default !== undefined)
|
|
18
|
-
flags[s.name] = s.default;
|
|
19
|
-
else if (s.type === "boolean")
|
|
20
|
-
flags[s.name] = false;
|
|
21
|
-
}
|
|
22
|
-
// Build lookup: --name and -alias -> spec
|
|
23
|
-
const lookup = new Map();
|
|
24
|
-
for (const s of specs) {
|
|
25
|
-
lookup.set(`--${s.name}`, s);
|
|
26
|
-
if (s.alias)
|
|
27
|
-
lookup.set(`-${s.alias}`, s);
|
|
28
|
-
}
|
|
29
|
-
let i = 0;
|
|
30
|
-
while (i < argv.length) {
|
|
31
|
-
const arg = argv[i];
|
|
32
|
-
const spec = lookup.get(arg);
|
|
33
|
-
if (spec) {
|
|
34
|
-
if (spec.type === "boolean") {
|
|
35
|
-
flags[spec.name] = true;
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
const val = argv[++i];
|
|
39
|
-
if (spec.type === "number") {
|
|
40
|
-
const num = Number(val);
|
|
41
|
-
if (val === undefined || val === "" || Number.isNaN(num)) {
|
|
42
|
-
process.stderr.write(`Error: --${spec.name} expects a number, got "${val ?? ""}".\n`);
|
|
43
|
-
process.exit(1);
|
|
44
|
-
}
|
|
45
|
-
flags[spec.name] = num;
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
flags[spec.name] = val ?? "";
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
else if (arg.startsWith("-") && arg !== "-") {
|
|
53
|
-
// Hard error, not a warning: a silently-dropped flag falls through to
|
|
54
|
-
// default behavior, and the caller reads the wrong answer as the right one.
|
|
55
|
-
const valid = specs.map((s) => `--${s.name}${s.alias ? ` (-${s.alias})` : ""}`).join(", ");
|
|
56
|
-
process.stderr.write(`Error: unknown flag "${arg}" for this command.\nValid flags: ${valid}\nRun with --help for details.\n`);
|
|
57
|
-
process.exit(1);
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
positionals.push(arg);
|
|
61
|
-
}
|
|
62
|
-
i++;
|
|
63
|
-
}
|
|
64
|
-
// Post-parse validation for shared flags — a silently NaN-coerced --days or
|
|
65
|
-
// a raw backend error for a malformed date both read as broken tooling.
|
|
66
|
-
const days = flags.days;
|
|
67
|
-
if (typeof days === "number" && specs.some((s) => s.name === "days")) {
|
|
68
|
-
if (!Number.isInteger(days) || days < 1) {
|
|
69
|
-
process.stderr.write(`Error: --days must be a positive integer (got "${days}").\n`);
|
|
70
|
-
process.exit(1);
|
|
71
|
-
}
|
|
72
|
-
if (days > 365) {
|
|
73
|
-
process.stderr.write(`Error: --days is capped at 365. Use --start YYYY-MM-DD for older material (s4 search also supports --full-history).\n`);
|
|
74
|
-
process.exit(1);
|
|
75
|
-
}
|
|
15
|
+
try {
|
|
16
|
+
return parseArgsRaw(argv, specs);
|
|
76
17
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
process.stderr.write(`Error: --${name} must be YYYY-MM-DD (e.g. 2026-07-13), got "${v}".\n`);
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err instanceof UsageError) {
|
|
20
|
+
process.stderr.write(`Error: ${err.message}\n`);
|
|
81
21
|
process.exit(1);
|
|
82
22
|
}
|
|
23
|
+
throw err;
|
|
83
24
|
}
|
|
84
|
-
return { positionals, flags };
|
|
85
25
|
}
|
|
86
26
|
// ---------------------------------------------------------------------------
|
|
87
27
|
// Shared flag specs
|
|
88
28
|
// ---------------------------------------------------------------------------
|
|
89
|
-
const FLAG_DAYS = { name: "days", alias: "d", type: "number", default: 30, desc: "Time window in days (
|
|
90
|
-
const FLAG_LIMIT = { name: "limit", alias: "n", type: "number", default: 20, desc: "
|
|
91
|
-
const FLAG_OFFSET = { name: "offset",
|
|
29
|
+
const FLAG_DAYS = { name: "days", alias: "d", type: "number", default: 30, desc: "Time window in days (report commands cap at 365; search/claims/timeline do not)" };
|
|
30
|
+
const FLAG_LIMIT = { name: "limit", alias: ["n", "k"], type: "number", default: 20, desc: "Number of results (-n, -k)" };
|
|
31
|
+
const FLAG_OFFSET = { name: "offset", type: "number", default: 0, desc: "Skip this many results (pagination)" };
|
|
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;
|
|
92
38
|
const FLAG_SPEAKER = { name: "speaker", alias: "s", type: "string", desc: "Filter by speaker name (full name)" };
|
|
93
39
|
const FLAG_CHANNEL = { name: "channel", alias: "c", type: "string", desc: "Filter by channel name (comma-separated for multiple)" };
|
|
94
|
-
const FLAG_LANGUAGE = { name: "language",
|
|
40
|
+
const FLAG_LANGUAGE = { name: "language", type: "string", desc: "Filter by language code (en, fr, ...)" };
|
|
95
41
|
const FLAG_COUNTRY = { name: "country", type: "string", desc: "Filter by channel country code" };
|
|
96
|
-
const
|
|
42
|
+
const FLAG_COLLECTION = { name: "collection", long: ["project"], type: "string", desc: "Restrict to one collection (see: s4 collections). Default: whole corpus" };
|
|
97
43
|
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 (
|
|
99
|
-
const FLAG_END = { name: "end", type: "string", desc: "End date YYYY-MM-DD (default today)" };
|
|
100
|
-
const FLAG_KEYWORD = { name: "keyword",
|
|
44
|
+
const FLAG_START = { name: "start", long: ["since"], type: "string", desc: "Start date: YYYY, YYYY-MM or YYYY-MM-DD (overrides --days)" };
|
|
45
|
+
const FLAG_END = { name: "end", long: ["until"], type: "string", desc: "End date: YYYY, YYYY-MM or YYYY-MM-DD (default today)" };
|
|
46
|
+
const FLAG_KEYWORD = { name: "keyword", type: "string", desc: "Exact phrase search (implies --mode keyword)" };
|
|
101
47
|
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)" };
|
|
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)" };
|
|
103
49
|
const FLAG_JSON = { name: "json", type: "boolean", desc: "Raw JSON output" };
|
|
50
|
+
const GRANULARITIES = ["day", "week", "month", "quarter", "year"];
|
|
51
|
+
const COUNT_GROUPS = ["none", ...GRANULARITIES, "channel", "episode", "speaker", "language", "country", "platform", "collection", "lean"];
|
|
52
|
+
/** --start/--end as tool params (already normalized to YYYY-MM-DD by parseArgs); absent when not given. */
|
|
53
|
+
function dateFlags(f) {
|
|
54
|
+
const out = {};
|
|
55
|
+
if (f.start)
|
|
56
|
+
out.start_date = f.start;
|
|
57
|
+
if (f.end)
|
|
58
|
+
out.end_date = f.end;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
104
61
|
// ---------------------------------------------------------------------------
|
|
105
62
|
// Progress reporting for long-running analysis commands
|
|
106
63
|
// ---------------------------------------------------------------------------
|
|
@@ -211,11 +168,15 @@ function parseSegmentId(raw) {
|
|
|
211
168
|
// Accept all of: N, seg:N, seg_N, {seg_N}, {seg:N}.
|
|
212
169
|
const id = Number(raw.replace(/^\{(.*)\}$/, "$1").replace(/^seg[:_]/, ""));
|
|
213
170
|
if (!Number.isInteger(id)) {
|
|
214
|
-
|
|
171
|
+
printErr(`Error: invalid segment ID "${raw}" (expected a number, seg:<number>, or seg_<number>).`);
|
|
215
172
|
process.exit(1);
|
|
216
173
|
}
|
|
217
174
|
return id;
|
|
218
175
|
}
|
|
176
|
+
function printErr(text) {
|
|
177
|
+
activeProgress?.finish();
|
|
178
|
+
process.stderr.write(text + "\n");
|
|
179
|
+
}
|
|
219
180
|
function print(text) {
|
|
220
181
|
activeProgress?.finish();
|
|
221
182
|
process.stdout.write(text + "\n");
|
|
@@ -239,23 +200,98 @@ function toCsv(rows, columns) {
|
|
|
239
200
|
function printJson(data) {
|
|
240
201
|
print(JSON.stringify(data, null, 2));
|
|
241
202
|
}
|
|
203
|
+
/** The parsed --collection for the running command; injected into every handler call. */
|
|
204
|
+
let activeCollection;
|
|
242
205
|
async function runHandler(tool, params, out) {
|
|
243
206
|
const handler = HANDLERS[tool];
|
|
244
207
|
if (!handler) {
|
|
245
|
-
|
|
208
|
+
printErr(`Error: handler "${tool}" not found.`);
|
|
246
209
|
process.exit(1);
|
|
247
210
|
}
|
|
211
|
+
if (params.collection === undefined && activeCollection)
|
|
212
|
+
params = { ...params, collection: activeCollection };
|
|
213
|
+
// Slow calls say how long to expect before they start; stderr, so stdout stays the answer.
|
|
214
|
+
const expected = estimate(tool, params);
|
|
215
|
+
if (expected.tier === "slow")
|
|
216
|
+
process.stderr.write(`${startNotice(expected)}\n`);
|
|
217
|
+
const started = Date.now();
|
|
248
218
|
const result = await handler(params);
|
|
219
|
+
const took = (Date.now() - started) / 1000;
|
|
220
|
+
if (took > REPORT_ELAPSED_OVER_S)
|
|
221
|
+
process.stderr.write(`Took ${took.toFixed(1)}s.\n`);
|
|
249
222
|
if (result.isError) {
|
|
250
223
|
process.stderr.write((result.content[0]?.text ?? "Unknown error") + "\n");
|
|
251
224
|
process.exit(1);
|
|
252
225
|
}
|
|
253
|
-
|
|
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
|
+
}
|
|
254
245
|
if (out)
|
|
255
246
|
writeOut(out, text);
|
|
256
247
|
else
|
|
257
248
|
print(text);
|
|
258
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
|
+
}
|
|
260
|
+
// A `--channel` name that doesn't resolve to a real channel must fail loudly
|
|
261
|
+
// in keyword mode, not fall through as an unfiltered corpus-wide query
|
|
262
|
+
// (signal4#85). Keyword-mode search silently drops channel_names server-side
|
|
263
|
+
// (a server-side defect, out of scope here) so an unresolvable name there
|
|
264
|
+
// would otherwise read as a channel-scoped count that is actually
|
|
265
|
+
// corpus-wide. Semantic mode is NOT gated: the server already returns an
|
|
266
|
+
// empty result set for an unknown channel there, which is the correct UX —
|
|
267
|
+
// adding a second, client-side source of truth on that path risks disagreeing
|
|
268
|
+
// with the server and regressing it.
|
|
269
|
+
//
|
|
270
|
+
// Resolved against /entities/channels/by-name — the one client-reachable
|
|
271
|
+
// place this can be caught. Multiple names resolve concurrently. A non-404
|
|
272
|
+
// failure (network blip, 5xx, timeout) fails OPEN: warn on stderr and let the
|
|
273
|
+
// search proceed rather than blocking a query that might otherwise succeed.
|
|
274
|
+
async function assertChannelsExistForKeywordMode(channelArg, mode) {
|
|
275
|
+
if (!channelArg || mode !== "keyword")
|
|
276
|
+
return;
|
|
277
|
+
const names = channelArg.split(",").map((s) => s.trim()).filter(Boolean);
|
|
278
|
+
await Promise.all(names.map(async (name) => {
|
|
279
|
+
try {
|
|
280
|
+
await client.getChannelByName(name);
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
284
|
+
if (/^HTTP 404\b/.test(msg)) {
|
|
285
|
+
process.stderr.write(`Error: channel "${name}" not found. This checks the channels registry ` +
|
|
286
|
+
`(display_name) only — a name that exists only as a content-level ` +
|
|
287
|
+
`channel_name variant may still be valid; verify against the exact ` +
|
|
288
|
+
`registry name (s4 channel "${name}" or s4 top-channels).\n`);
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
process.stderr.write(`Warning: could not verify channel "${name}" exists (${msg}) — proceeding without the check.\n`);
|
|
292
|
+
}
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
259
295
|
// ---------------------------------------------------------------------------
|
|
260
296
|
// Batch dossiers (surrogate-roster sweeps): s4 dossier "<topic>" --speakers file.txt --out dir/
|
|
261
297
|
// ---------------------------------------------------------------------------
|
|
@@ -409,21 +445,22 @@ const COMMANDS = {
|
|
|
409
445
|
search: {
|
|
410
446
|
tool: "search_quotes",
|
|
411
447
|
description: "Search quotes by topic and/or speaker",
|
|
412
|
-
specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, FLAG_LIMIT,
|
|
448
|
+
specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_MODE, { ...FLAG_LIMIT, default: 10 }, ...PAGING, FLAG_LANGUAGE, FLAG_COUNTRY,
|
|
413
449
|
{ name: "sort", type: "string", default: "relevance", desc: "relevance | chronological" },
|
|
414
450
|
{ name: "count", type: "boolean", desc: "Return only the match count" },
|
|
415
451
|
{ name: "full-history", type: "boolean", desc: "Search the entire corpus (requires topic + speaker)" },
|
|
416
452
|
{ name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0, default 0.40)" },
|
|
417
453
|
FLAG_KEYWORD,
|
|
418
454
|
{ name: "speaker-id", type: "number", desc: "Speaker identity ID (from s4 speaker)" }, FLAG_OUT,
|
|
419
|
-
FLAG_START, FLAG_END,
|
|
455
|
+
FLAG_START, FLAG_END, FLAG_COLLECTION,
|
|
420
456
|
FLAG_JSON],
|
|
421
457
|
run: async (args, json) => {
|
|
422
458
|
const [positionalTopic] = args.positionals;
|
|
423
459
|
const f = args.flags;
|
|
424
460
|
const keyword = f.keyword || undefined;
|
|
425
|
-
const topic =
|
|
461
|
+
const topic = keyword || positionalTopic || undefined;
|
|
426
462
|
const mode = keyword ? "keyword" : f.mode;
|
|
463
|
+
await assertChannelsExistForKeywordMode(f.channel || undefined, mode);
|
|
427
464
|
const days = Number(f.days) || 30;
|
|
428
465
|
const fullHistory = f["full-history"];
|
|
429
466
|
// Full history means FULL history — only pass dates given explicitly,
|
|
@@ -442,6 +479,7 @@ const COMMANDS = {
|
|
|
442
479
|
count_only: f.count, sort: f.sort,
|
|
443
480
|
full_history: fullHistory,
|
|
444
481
|
min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
|
|
482
|
+
project: f.collection || undefined,
|
|
445
483
|
};
|
|
446
484
|
const out = f.out;
|
|
447
485
|
if (json || (out && out.endsWith(".csv"))) {
|
|
@@ -456,6 +494,7 @@ const COMMANDS = {
|
|
|
456
494
|
full_history: fullHistory || undefined,
|
|
457
495
|
count_only: params.count_only || undefined,
|
|
458
496
|
min_similarity: params.min_similarity,
|
|
497
|
+
project: params.project,
|
|
459
498
|
});
|
|
460
499
|
if (out && out.endsWith(".csv")) {
|
|
461
500
|
const rows = (data.quotes ?? []).map((q) => ({
|
|
@@ -485,7 +524,7 @@ const COMMANDS = {
|
|
|
485
524
|
claims: {
|
|
486
525
|
tool: "search_claims",
|
|
487
526
|
description: "Search extracted claims",
|
|
488
|
-
specs: [{ name: "entity",
|
|
527
|
+
specs: [{ name: "entity", type: "string", desc: "Filter by entity name (person, org, place, subject)" }, FLAG_SPEAKER, FLAG_CHANNEL,
|
|
489
528
|
{ name: "category", type: "string", desc: "Topic category filter" },
|
|
490
529
|
{ name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
|
|
491
530
|
FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_MODE, FLAG_KEYWORD,
|
|
@@ -495,12 +534,12 @@ const COMMANDS = {
|
|
|
495
534
|
{ name: "political-lean-min", type: "number", desc: "Min channel political lean (0=left, 1=right)" },
|
|
496
535
|
{ name: "political-lean-max", type: "number", desc: "Max channel political lean (0=left, 1=right)" },
|
|
497
536
|
{ name: "source", type: "string", desc: "Claim source filter" },
|
|
498
|
-
FLAG_DAYS, FLAG_LIMIT,
|
|
537
|
+
FLAG_DAYS, { ...FLAG_LIMIT, default: 10 }, ...PAGING, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
|
|
499
538
|
run: async (args, json) => {
|
|
500
539
|
const [positionalTopic] = args.positionals;
|
|
501
540
|
const f = args.flags;
|
|
502
541
|
const keyword = f.keyword || undefined;
|
|
503
|
-
const topic =
|
|
542
|
+
const topic = keyword || positionalTopic || undefined;
|
|
504
543
|
const mode = keyword ? "keyword" : f.mode;
|
|
505
544
|
const days = Number(f.days) || 30;
|
|
506
545
|
const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : undefined;
|
|
@@ -521,6 +560,7 @@ const COMMANDS = {
|
|
|
521
560
|
days, mode,
|
|
522
561
|
sort: f.sort, min_observations: Number(f["min-obs"]) || 1,
|
|
523
562
|
limit: Number(f.limit), offset: Number(f.offset),
|
|
563
|
+
project: f.collection || undefined,
|
|
524
564
|
...dr,
|
|
525
565
|
};
|
|
526
566
|
const claimsDr = dr ?? daysRange(days);
|
|
@@ -536,6 +576,7 @@ const COMMANDS = {
|
|
|
536
576
|
mode: params.mode, sort: params.sort,
|
|
537
577
|
min_observations: params.min_observations > 1 ? params.min_observations : undefined,
|
|
538
578
|
limit: params.limit, offset: params.offset,
|
|
579
|
+
project: params.project,
|
|
539
580
|
}));
|
|
540
581
|
await runHandler("search_claims", params, f.out);
|
|
541
582
|
},
|
|
@@ -543,12 +584,12 @@ const COMMANDS = {
|
|
|
543
584
|
"analyze-claims": {
|
|
544
585
|
tool: "analyze_claims",
|
|
545
586
|
description: "Analyze a topic over extracted claims — themes with observation stats (fast, no LLM)",
|
|
546
|
-
specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_DAYS,
|
|
587
|
+
specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_DAYS, FLAG_START, FLAG_END, FLAG_COLLECTION,
|
|
547
588
|
{ name: "min-obs", type: "number", default: 1, desc: "Minimum observation count per claim" }, FLAG_JSON],
|
|
548
589
|
run: async (args, json) => {
|
|
549
590
|
const [topic] = args.positionals;
|
|
550
591
|
if (!topic) {
|
|
551
|
-
|
|
592
|
+
printErr("Error: topic is required.");
|
|
552
593
|
process.exit(1);
|
|
553
594
|
}
|
|
554
595
|
const f = args.flags;
|
|
@@ -560,11 +601,13 @@ const COMMANDS = {
|
|
|
560
601
|
language: f.language || undefined,
|
|
561
602
|
days: Number(f.days) || undefined,
|
|
562
603
|
min_observations: Number(f["min-obs"]) || undefined,
|
|
604
|
+
...dateFlags(f),
|
|
563
605
|
};
|
|
564
606
|
if (json)
|
|
565
607
|
return printJson(await client.analyzeTopicClaims({
|
|
566
608
|
topic, speaker_name: params.speaker, channel_names: params.channels,
|
|
567
609
|
language: params.language, days: params.days, min_observations: params.min_observations,
|
|
610
|
+
project: f.collection || undefined, ...dateFlags(f),
|
|
568
611
|
}));
|
|
569
612
|
await runHandler("analyze_claims", params);
|
|
570
613
|
},
|
|
@@ -574,11 +617,12 @@ const COMMANDS = {
|
|
|
574
617
|
description: "Detect whether a topic is surging/fading by claim volume (fast)",
|
|
575
618
|
specs: [{ name: "recent-days", type: "number", default: 7, desc: "Recent window in days" },
|
|
576
619
|
{ 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)" },
|
|
620
|
+
{ name: "min-similarity", type: "number", desc: "Minimum similarity threshold (0.0-1.0)" },
|
|
621
|
+
FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_JSON],
|
|
578
622
|
run: async (args, json) => {
|
|
579
623
|
const [topic] = args.positionals;
|
|
580
624
|
if (!topic) {
|
|
581
|
-
|
|
625
|
+
printErr("Error: topic is required.");
|
|
582
626
|
process.exit(1);
|
|
583
627
|
}
|
|
584
628
|
const f = args.flags;
|
|
@@ -587,32 +631,34 @@ const COMMANDS = {
|
|
|
587
631
|
recent_days: Number(f["recent-days"]) || undefined,
|
|
588
632
|
baseline_days: Number(f["baseline-days"]) || undefined,
|
|
589
633
|
min_similarity: f["min-similarity"] !== undefined ? Number(f["min-similarity"]) : undefined,
|
|
634
|
+
language: f.language || undefined,
|
|
590
635
|
};
|
|
591
636
|
if (json)
|
|
592
|
-
return printJson(await client.detectTrendClaims(params));
|
|
637
|
+
return printJson(await client.detectTrendClaims({ ...params, project: f.collection || undefined }));
|
|
593
638
|
await runHandler("trend_claims", params);
|
|
594
639
|
},
|
|
595
640
|
},
|
|
596
641
|
"contradict-claims": {
|
|
597
642
|
tool: "contradict_claims",
|
|
598
643
|
description: "Find a speaker's contradictory claims on a topic via stance analysis (fast)",
|
|
599
|
-
specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_OUT, FLAG_JSON],
|
|
644
|
+
specs: [FLAG_SPEAKER, { ...FLAG_DAYS, default: 365 }, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
|
|
600
645
|
run: async (args, json) => {
|
|
601
646
|
const [topic] = args.positionals;
|
|
602
647
|
if (!topic) {
|
|
603
|
-
|
|
648
|
+
printErr("Error: topic is required.");
|
|
604
649
|
process.exit(1);
|
|
605
650
|
}
|
|
606
651
|
const f = args.flags;
|
|
607
652
|
const speaker = f.speaker || undefined;
|
|
608
653
|
if (!speaker) {
|
|
609
|
-
|
|
654
|
+
printErr("Error: --speaker is required.");
|
|
610
655
|
process.exit(1);
|
|
611
656
|
}
|
|
612
|
-
const params = { topic, speaker, days: Number(f.days) || undefined };
|
|
657
|
+
const params = { topic, speaker, days: Number(f.days) || undefined, ...dateFlags(f) };
|
|
613
658
|
if (json)
|
|
614
659
|
return printJson(await client.checkContradictionsClaims({
|
|
615
660
|
topic, speaker_name: speaker, days: params.days,
|
|
661
|
+
project: f.collection || undefined, ...dateFlags(f),
|
|
616
662
|
}));
|
|
617
663
|
await runHandler("contradict_claims", params, f.out);
|
|
618
664
|
},
|
|
@@ -620,24 +666,26 @@ const COMMANDS = {
|
|
|
620
666
|
"compare-claims": {
|
|
621
667
|
tool: "compare_claims",
|
|
622
668
|
description: "Compare 2-5 speakers' claims on a topic — side-by-side stance (fast)",
|
|
623
|
-
specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 },
|
|
669
|
+
specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 },
|
|
670
|
+
FLAG_START, FLAG_END, FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
|
|
624
671
|
run: async (args, json) => {
|
|
625
672
|
const [topic] = args.positionals;
|
|
626
673
|
if (!topic) {
|
|
627
|
-
|
|
674
|
+
printErr("Error: topic is required.");
|
|
628
675
|
process.exit(1);
|
|
629
676
|
}
|
|
630
677
|
const f = args.flags;
|
|
631
678
|
const speakersStr = f.speakers || f.speaker || "";
|
|
632
679
|
const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
633
680
|
if (speakers.length < 2) {
|
|
634
|
-
|
|
681
|
+
printErr("Error: --speakers requires 2-5 comma-separated names.");
|
|
635
682
|
process.exit(1);
|
|
636
683
|
}
|
|
637
|
-
const params = { topic, speakers, days: Number(f.days) || undefined };
|
|
684
|
+
const params = { topic, speakers, days: Number(f.days) || undefined, language: f.language || undefined, ...dateFlags(f) };
|
|
638
685
|
if (json)
|
|
639
686
|
return printJson(await client.compareSpeakersClaims({
|
|
640
|
-
topic, speaker_names: speakers, days: params.days,
|
|
687
|
+
topic, speaker_names: speakers, days: params.days, language: params.language,
|
|
688
|
+
project: f.collection || undefined, ...dateFlags(f),
|
|
641
689
|
}));
|
|
642
690
|
await runHandler("compare_claims", params, f.out);
|
|
643
691
|
},
|
|
@@ -645,24 +693,24 @@ const COMMANDS = {
|
|
|
645
693
|
"channel-dossier": {
|
|
646
694
|
tool: "channel_dossier",
|
|
647
695
|
description: "Claims dossier of what a channel (show) platformed on a topic (fast)",
|
|
648
|
-
specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 180 }, FLAG_OUT, FLAG_JSON],
|
|
696
|
+
specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 180 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
|
|
649
697
|
run: async (args, json) => {
|
|
650
698
|
const [topic] = args.positionals;
|
|
651
699
|
if (!topic) {
|
|
652
|
-
|
|
700
|
+
printErr("Error: topic is required.");
|
|
653
701
|
process.exit(1);
|
|
654
702
|
}
|
|
655
703
|
const f = args.flags;
|
|
656
704
|
const channel = f.channel || undefined;
|
|
657
705
|
if (!channel) {
|
|
658
|
-
|
|
706
|
+
printErr("Error: --channel is required.");
|
|
659
707
|
process.exit(1);
|
|
660
708
|
}
|
|
661
709
|
const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
|
|
662
710
|
const days = Number(f.days) || undefined;
|
|
663
711
|
if (json)
|
|
664
712
|
return printJson(await client.buildChannelDossier({
|
|
665
|
-
topic, channel_names: channels, days,
|
|
713
|
+
topic, channel_names: channels, days, project: f.collection || undefined,
|
|
666
714
|
}));
|
|
667
715
|
await runHandler("channel_dossier", { topic, channels, days }, f.out);
|
|
668
716
|
},
|
|
@@ -670,24 +718,24 @@ const COMMANDS = {
|
|
|
670
718
|
"compare-channels": {
|
|
671
719
|
tool: "compare_channels",
|
|
672
720
|
description: "Compare 2-5 channels' claims on a topic — stance + speaker overlap (fast)",
|
|
673
|
-
specs: [{ name: "channels", type: "string", desc: "2-5 comma-separated channel names" }, FLAG_CHANNEL, { ...FLAG_DAYS, default: 90 }, FLAG_OUT, FLAG_JSON],
|
|
721
|
+
specs: [{ name: "channels", type: "string", desc: "2-5 comma-separated channel names" }, FLAG_CHANNEL, { ...FLAG_DAYS, default: 90 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
|
|
674
722
|
run: async (args, json) => {
|
|
675
723
|
const [topic] = args.positionals;
|
|
676
724
|
if (!topic) {
|
|
677
|
-
|
|
725
|
+
printErr("Error: topic is required.");
|
|
678
726
|
process.exit(1);
|
|
679
727
|
}
|
|
680
728
|
const f = args.flags;
|
|
681
729
|
const channelsStr = f.channels || f.channel || "";
|
|
682
730
|
const channels = channelsStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
683
731
|
if (channels.length < 2) {
|
|
684
|
-
|
|
732
|
+
printErr("Error: --channels requires 2-5 comma-separated names.");
|
|
685
733
|
process.exit(1);
|
|
686
734
|
}
|
|
687
735
|
const days = Number(f.days) || undefined;
|
|
688
736
|
if (json)
|
|
689
737
|
return printJson(await client.compareChannelsClaims({
|
|
690
|
-
topic, channel_names: channels, days,
|
|
738
|
+
topic, channel_names: channels, days, project: f.collection || undefined,
|
|
691
739
|
}));
|
|
692
740
|
await runHandler("compare_channels", { topic, channels, days }, f.out);
|
|
693
741
|
},
|
|
@@ -695,56 +743,57 @@ const COMMANDS = {
|
|
|
695
743
|
"channel-contradictions": {
|
|
696
744
|
tool: "check_channel_contradictions",
|
|
697
745
|
description: "Contradictory claims a channel platforms on a topic (fast)",
|
|
698
|
-
specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 365 }, FLAG_OUT, FLAG_JSON],
|
|
746
|
+
specs: [FLAG_CHANNEL, { ...FLAG_DAYS, default: 365 }, FLAG_COLLECTION, FLAG_OUT, FLAG_JSON],
|
|
699
747
|
run: async (args, json) => {
|
|
700
748
|
const [topic] = args.positionals;
|
|
701
749
|
if (!topic) {
|
|
702
|
-
|
|
750
|
+
printErr("Error: topic is required.");
|
|
703
751
|
process.exit(1);
|
|
704
752
|
}
|
|
705
753
|
const f = args.flags;
|
|
706
754
|
const channel = f.channel || undefined;
|
|
707
755
|
if (!channel) {
|
|
708
|
-
|
|
756
|
+
printErr("Error: --channel is required.");
|
|
709
757
|
process.exit(1);
|
|
710
758
|
}
|
|
711
759
|
const channels = channel.split(",").map((s) => s.trim()).filter(Boolean);
|
|
712
760
|
const days = Number(f.days) || undefined;
|
|
713
761
|
if (json)
|
|
714
762
|
return printJson(await client.checkChannelContradictionsClaims({
|
|
715
|
-
topic, channel_names: channels, days,
|
|
763
|
+
topic, channel_names: channels, days, project: f.collection || undefined,
|
|
716
764
|
}));
|
|
717
765
|
await runHandler("check_channel_contradictions", { topic, channels, days }, f.out);
|
|
718
766
|
},
|
|
719
767
|
},
|
|
720
768
|
"entities": {
|
|
721
769
|
tool: "find_entities",
|
|
722
|
-
description: "
|
|
723
|
-
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],
|
|
724
772
|
run: async (args, json) => {
|
|
725
773
|
const [query] = args.positionals;
|
|
726
774
|
if (!query) {
|
|
727
|
-
|
|
775
|
+
printErr("Error: query is required.");
|
|
728
776
|
process.exit(1);
|
|
729
777
|
}
|
|
730
778
|
const f = args.flags;
|
|
731
779
|
const entityType = f.type || undefined;
|
|
732
780
|
const limit = Number(f.limit) || undefined;
|
|
781
|
+
const offset = Number(f.offset) || undefined;
|
|
733
782
|
if (json)
|
|
734
|
-
return printJson(await client.searchEntities({ query, entity_type: entityType, limit }));
|
|
735
|
-
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 });
|
|
736
785
|
},
|
|
737
786
|
},
|
|
738
787
|
"entity-claims": {
|
|
739
788
|
tool: "entity_claims",
|
|
740
|
-
description: "
|
|
789
|
+
description: "Most-spread claims about an entity (every row of that name or alias, merges included)",
|
|
741
790
|
specs: [{ name: "sort", type: "string", default: "spread", desc: "Sort: spread | observations | recent" },
|
|
742
791
|
{ name: "stance", type: "string", desc: "Filter by stance: assert | deny | speculate | attribute" },
|
|
743
|
-
FLAG_LIMIT,
|
|
792
|
+
FLAG_LIMIT, ...PAGING, FLAG_COLLECTION, FLAG_JSON],
|
|
744
793
|
run: async (args, json) => {
|
|
745
794
|
const [name] = args.positionals;
|
|
746
795
|
if (!name) {
|
|
747
|
-
|
|
796
|
+
printErr("Error: entity name is required.");
|
|
748
797
|
process.exit(1);
|
|
749
798
|
}
|
|
750
799
|
const f = args.flags;
|
|
@@ -755,6 +804,7 @@ const COMMANDS = {
|
|
|
755
804
|
if (json)
|
|
756
805
|
return printJson(await client.getEntityClaims({
|
|
757
806
|
name, sort: params.sort, stance: params.stance, limit: params.limit, offset: params.offset,
|
|
807
|
+
project: f.collection || undefined,
|
|
758
808
|
}));
|
|
759
809
|
await runHandler("entity_claims", params);
|
|
760
810
|
},
|
|
@@ -762,16 +812,16 @@ const COMMANDS = {
|
|
|
762
812
|
"claim-graph": {
|
|
763
813
|
tool: "claim_graph",
|
|
764
814
|
description: "A claim's parent, children, and sibling (variant) claims",
|
|
765
|
-
specs: [FLAG_JSON],
|
|
815
|
+
specs: [FLAG_COLLECTION, FLAG_JSON],
|
|
766
816
|
run: async (args, json) => {
|
|
767
817
|
const [id] = args.positionals;
|
|
768
818
|
if (!id || !/^\d+$/.test(id)) {
|
|
769
|
-
|
|
819
|
+
printErr("Error: claim_id (numeric) required.");
|
|
770
820
|
process.exit(1);
|
|
771
821
|
}
|
|
772
822
|
const claimId = Number(id);
|
|
773
823
|
if (json)
|
|
774
|
-
return printJson(await client.getClaimGraph(claimId));
|
|
824
|
+
return printJson(await client.getClaimGraph(claimId, args.flags.collection || undefined));
|
|
775
825
|
await runHandler("claim_graph", { claim_id: claimId });
|
|
776
826
|
},
|
|
777
827
|
},
|
|
@@ -780,42 +830,53 @@ const COMMANDS = {
|
|
|
780
830
|
description: "Leaderboard of the most widely-spread or most-contested claims",
|
|
781
831
|
specs: [{ name: "metric", type: "string", default: "spread", desc: "Ranking: spread (reach) | contested (assert vs deny balance)" },
|
|
782
832
|
{ 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,
|
|
833
|
+
{ name: "min-obs", type: "number", default: 2, desc: "Minimum observation count per claim" }, FLAG_DAYS, FLAG_START, FLAG_END,
|
|
834
|
+
FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_LIMIT, ...PAGING, FLAG_JSON],
|
|
784
835
|
run: async (args, json) => {
|
|
785
836
|
const f = args.flags;
|
|
786
837
|
const params = {
|
|
787
838
|
metric: f.metric || undefined, category: f.category || undefined,
|
|
788
839
|
days: Number(f.days) || undefined, min_observations: Number(f["min-obs"]) || undefined,
|
|
789
|
-
limit: Number(f.limit) || undefined,
|
|
840
|
+
limit: Number(f.limit) || undefined, offset: Number(f.offset) || undefined,
|
|
841
|
+
language: f.language || undefined,
|
|
842
|
+
...dateFlags(f),
|
|
790
843
|
};
|
|
791
844
|
if (json)
|
|
792
|
-
return printJson(await client.getTopClaims(params));
|
|
845
|
+
return printJson(await client.getTopClaims({ ...params, project: f.collection || undefined }));
|
|
793
846
|
await runHandler("top_claims", params);
|
|
794
847
|
},
|
|
795
848
|
},
|
|
796
849
|
claim: {
|
|
797
850
|
tool: "get_claim",
|
|
798
851
|
description: "Get full claim detail with observations and related claims",
|
|
799
|
-
specs: [{ name: "observations", type: "boolean", desc: "Include the
|
|
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,
|
|
800
856
|
{ name: "related", type: "boolean", desc: "Include semantically related claims" }, FLAG_JSON],
|
|
801
857
|
run: async (args, json) => {
|
|
802
858
|
const [id] = args.positionals;
|
|
803
859
|
if (!id || !/^\d+$/.test(id)) {
|
|
804
|
-
|
|
860
|
+
printErr("Error: claim_id (numeric) required.");
|
|
805
861
|
process.exit(1);
|
|
806
862
|
}
|
|
807
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;
|
|
808
866
|
if (json) {
|
|
809
867
|
const detail = await client.getClaim(claimId);
|
|
810
|
-
if (args.flags.observations)
|
|
811
|
-
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
|
+
}
|
|
812
871
|
if (args.flags.related)
|
|
813
872
|
detail.related = await client.getRelatedClaims(claimId);
|
|
814
873
|
return printJson(detail);
|
|
815
874
|
}
|
|
816
875
|
await runHandler("get_claim", {
|
|
817
876
|
claim_id: claimId,
|
|
818
|
-
include_observations: args.flags.observations,
|
|
877
|
+
include_observations: args.flags.observations || followAllPages,
|
|
878
|
+
observations_offset: obsOffset,
|
|
879
|
+
observations_limit: obsLimit,
|
|
819
880
|
include_related: args.flags.related,
|
|
820
881
|
});
|
|
821
882
|
},
|
|
@@ -823,15 +884,17 @@ const COMMANDS = {
|
|
|
823
884
|
trend: {
|
|
824
885
|
tool: "detect_trend",
|
|
825
886
|
description: "Detect topic volume trends",
|
|
826
|
-
specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY,
|
|
887
|
+
specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
|
|
827
888
|
{ name: "recent", type: "number", default: 7, desc: "Recent window in days" },
|
|
828
889
|
{ name: "baseline", type: "number", default: 30, desc: "Baseline window in days" },
|
|
829
890
|
{ name: "days", alias: "d", type: "number", desc: "Alias for --baseline" },
|
|
891
|
+
{ ...FLAG_START, desc: "Baseline start: YYYY, YYYY-MM or YYYY-MM-DD (pins the baseline window)" },
|
|
892
|
+
{ ...FLAG_END, desc: "Baseline end, inclusive (default today); recent = its last --recent days" },
|
|
830
893
|
{ name: "min-similarity", type: "number", desc: "Minimum similarity threshold" }, FLAG_JSON],
|
|
831
894
|
run: async (args, json) => {
|
|
832
895
|
const [topic] = args.positionals;
|
|
833
896
|
if (!topic) {
|
|
834
|
-
|
|
897
|
+
printErr("Error: topic is required.");
|
|
835
898
|
process.exit(1);
|
|
836
899
|
}
|
|
837
900
|
const f = args.flags;
|
|
@@ -844,6 +907,8 @@ const COMMANDS = {
|
|
|
844
907
|
recent_days: Number(f.recent) || 7,
|
|
845
908
|
baseline_days: Number(f.days) || Number(f.baseline) || 30,
|
|
846
909
|
min_similarity: Number(f["min-similarity"]) || undefined,
|
|
910
|
+
project: f.collection || undefined,
|
|
911
|
+
...dateFlags(f),
|
|
847
912
|
};
|
|
848
913
|
if (json)
|
|
849
914
|
return printJson(await client.detectTrend({
|
|
@@ -857,18 +922,28 @@ const COMMANDS = {
|
|
|
857
922
|
tool: "get_timeline",
|
|
858
923
|
description: "Topic mention volume over time",
|
|
859
924
|
specs: [FLAG_DAYS, FLAG_SPEAKER, FLAG_CHANNEL, FLAG_LANGUAGE,
|
|
860
|
-
{ name: "granularity", alias: "g", type: "string", default: "day", desc: "Bucket size: day | week" },
|
|
925
|
+
{ name: "granularity", alias: "g", type: "string", default: "day", desc: "Bucket size: day | week | month | quarter | year (calendar buckets)" },
|
|
861
926
|
{ name: "min-similarity", type: "string", desc: "Relevance threshold 0-1 (default 0.30, same as quote search)" },
|
|
862
|
-
FLAG_START, FLAG_END, FLAG_JSON],
|
|
927
|
+
FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_JSON],
|
|
863
928
|
run: async (args, json) => {
|
|
864
929
|
const [topic] = args.positionals;
|
|
865
930
|
if (!topic) {
|
|
866
|
-
|
|
931
|
+
printErr("Error: topic is required.");
|
|
867
932
|
process.exit(1);
|
|
868
933
|
}
|
|
869
934
|
const f = args.flags;
|
|
935
|
+
if (!GRANULARITIES.includes(f.granularity)) {
|
|
936
|
+
printErr(`Error: --granularity must be one of ${GRANULARITIES.join(", ")} (got "${f.granularity}").`);
|
|
937
|
+
process.exit(1);
|
|
938
|
+
}
|
|
870
939
|
const days = Number(f.days) || 30;
|
|
871
940
|
const dr = f.start ? { start_date: f.start, end_date: f.end || new Date().toISOString().slice(0, 10) } : daysRange(days);
|
|
941
|
+
if (!f.start && f.end) {
|
|
942
|
+
// --end alone: the --days days ending there, not ending today.
|
|
943
|
+
const end = Date.parse(f.end);
|
|
944
|
+
dr.start_date = new Date(end - days * 86400000).toISOString().slice(0, 10);
|
|
945
|
+
dr.end_date = f.end;
|
|
946
|
+
}
|
|
872
947
|
const tlChannel = f.channel || undefined;
|
|
873
948
|
const tlParams = {
|
|
874
949
|
topic,
|
|
@@ -879,21 +954,73 @@ const COMMANDS = {
|
|
|
879
954
|
language: f.language || undefined,
|
|
880
955
|
granularity: f.granularity || "day",
|
|
881
956
|
min_similarity: f["min-similarity"] != null ? Number(f["min-similarity"]) : undefined,
|
|
957
|
+
project: f.collection || undefined,
|
|
882
958
|
};
|
|
883
959
|
if (json)
|
|
884
960
|
return printJson(await client.getTimeline(tlParams));
|
|
885
|
-
await runHandler("get_timeline", {
|
|
961
|
+
await runHandler("get_timeline", {
|
|
962
|
+
...tlParams, days, channel: tlChannel,
|
|
963
|
+
// Explicit dates only, so the footer states them instead of "last N days".
|
|
964
|
+
start_date: f.start || f.end ? dr.start_date : undefined,
|
|
965
|
+
end_date: f.start || f.end ? dr.end_date : undefined,
|
|
966
|
+
});
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
count: {
|
|
970
|
+
tool: "count_mentions",
|
|
971
|
+
description: "Count segments, episodes and channels for a topic — by month, channel, episode, ...",
|
|
972
|
+
specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_COLLECTION, FLAG_SPEAKER, FLAG_CHANNEL,
|
|
973
|
+
{ ...FLAG_LANGUAGE, desc: "Language code(s), comma-separated (default: every language)" },
|
|
974
|
+
FLAG_COUNTRY,
|
|
975
|
+
{ name: "mode", alias: "m", type: "string", default: "semantic", desc: "semantic (estimate) | keyword (exact phrase, exact count)" },
|
|
976
|
+
{ name: "by", long: ["group-by"], alias: "g", type: "string", default: "none", desc: `Break down by: ${COUNT_GROUPS.join(" | ")}` },
|
|
977
|
+
{ name: "vs", type: "string", desc: "More topics to count side by side, comma-separated (up to 5 in all)" },
|
|
978
|
+
{ name: "order", type: "string", default: "segments", desc: "Episode breakdown order: segments | date" },
|
|
979
|
+
{ name: "min-similarity", type: "number", desc: "Semantic threshold (default 0.35)" },
|
|
980
|
+
{ ...FLAG_LIMIT, desc: "Rows for channel/episode/... breakdowns (-n, -k; max 1000)" }, ...PAGING, FLAG_JSON],
|
|
981
|
+
run: async (args, json) => {
|
|
982
|
+
const f = args.flags;
|
|
983
|
+
const topic = args.positionals.join(" ").trim() || undefined;
|
|
984
|
+
const vs = f.vs?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
985
|
+
if (!COUNT_GROUPS.includes(f.by)) {
|
|
986
|
+
printErr(`Error: --by must be one of ${COUNT_GROUPS.join(", ")} (got "${f.by}").`);
|
|
987
|
+
process.exit(1);
|
|
988
|
+
}
|
|
989
|
+
if (f.mode !== "semantic" && f.mode !== "keyword") {
|
|
990
|
+
printErr(`Error: --mode must be semantic or keyword for count (got "${f.mode}").`);
|
|
991
|
+
process.exit(1);
|
|
992
|
+
}
|
|
993
|
+
const params = {
|
|
994
|
+
topic,
|
|
995
|
+
topics: vs?.length ? [...(topic ? [topic] : []), ...vs] : undefined,
|
|
996
|
+
mode: f.mode,
|
|
997
|
+
group_by: f.by,
|
|
998
|
+
days: f.start || f.end ? undefined : Number(f.days) || 30,
|
|
999
|
+
...dateFlags(f),
|
|
1000
|
+
collection: f.collection || undefined,
|
|
1001
|
+
speaker: f.speaker || undefined,
|
|
1002
|
+
channel: f.channel || undefined,
|
|
1003
|
+
language: f.language || undefined,
|
|
1004
|
+
country: f.country || undefined,
|
|
1005
|
+
min_similarity: f["min-similarity"] != null ? Number(f["min-similarity"]) : undefined,
|
|
1006
|
+
limit: Number(f.limit) || 20,
|
|
1007
|
+
offset: Number(f.offset) || 0,
|
|
1008
|
+
order: f.order,
|
|
1009
|
+
};
|
|
1010
|
+
if (json)
|
|
1011
|
+
params.response_format = "json";
|
|
1012
|
+
await runHandler("count_mentions", params);
|
|
886
1013
|
},
|
|
887
1014
|
},
|
|
888
1015
|
speaker: {
|
|
889
1016
|
tool: "find_speakers",
|
|
890
1017
|
description: "Speaker search, profile, or discourse",
|
|
891
|
-
specs: [FLAG_DAYS, FLAG_LIMIT,
|
|
1018
|
+
specs: [FLAG_DAYS, FLAG_LIMIT, ...PAGING, FLAG_CHANNEL,
|
|
892
1019
|
{ name: "segments", type: "boolean", desc: "List the speaker's recent segments (chronological)" }, FLAG_JSON],
|
|
893
1020
|
run: async (args, json) => {
|
|
894
1021
|
const [identity, topic] = args.positionals;
|
|
895
1022
|
if (!identity) {
|
|
896
|
-
|
|
1023
|
+
printErr("Error: speaker name or ID required.");
|
|
897
1024
|
process.exit(1);
|
|
898
1025
|
}
|
|
899
1026
|
const f = args.flags;
|
|
@@ -962,11 +1089,11 @@ const COMMANDS = {
|
|
|
962
1089
|
channel: {
|
|
963
1090
|
tool: "get_channel",
|
|
964
1091
|
description: "Channel profile or episode listing",
|
|
965
|
-
specs: [{ name: "episodes", type: "boolean", desc: "List the channel's recent episodes instead of the profile" }, FLAG_DAYS, FLAG_LIMIT,
|
|
1092
|
+
specs: [{ name: "episodes", type: "boolean", desc: "List the channel's recent episodes instead of the profile" }, FLAG_DAYS, FLAG_LIMIT, ...PAGING, FLAG_JSON],
|
|
966
1093
|
run: async (args, json) => {
|
|
967
1094
|
const [id] = args.positionals;
|
|
968
1095
|
if (!id) {
|
|
969
|
-
|
|
1096
|
+
printErr("Error: channel ID or name required.");
|
|
970
1097
|
process.exit(1);
|
|
971
1098
|
}
|
|
972
1099
|
const isId = /^\d+$/.test(id);
|
|
@@ -1001,7 +1128,7 @@ const COMMANDS = {
|
|
|
1001
1128
|
run: async (args, json) => {
|
|
1002
1129
|
const [id] = args.positionals;
|
|
1003
1130
|
if (!id) {
|
|
1004
|
-
|
|
1131
|
+
printErr("Error: content ID required.");
|
|
1005
1132
|
process.exit(1);
|
|
1006
1133
|
}
|
|
1007
1134
|
if (json) {
|
|
@@ -1020,7 +1147,7 @@ const COMMANDS = {
|
|
|
1020
1147
|
run: async (args, json) => {
|
|
1021
1148
|
const [id] = args.positionals;
|
|
1022
1149
|
if (!id) {
|
|
1023
|
-
|
|
1150
|
+
printErr("Error: segment ID required.");
|
|
1024
1151
|
process.exit(1);
|
|
1025
1152
|
}
|
|
1026
1153
|
const segId = parseSegmentId(id);
|
|
@@ -1032,15 +1159,15 @@ const COMMANDS = {
|
|
|
1032
1159
|
thread: {
|
|
1033
1160
|
tool: "get_thread",
|
|
1034
1161
|
description: "Conversation context around a quote",
|
|
1035
|
-
specs: [{ name: "n", type: "number", default: 5, desc: "Segments of context before and after the quote" }, FLAG_JSON],
|
|
1162
|
+
specs: [{ name: "context", alias: "C", long: ["n"], type: "number", default: 5, desc: "Segments of context before and after the quote" }, FLAG_JSON],
|
|
1036
1163
|
run: async (args, json) => {
|
|
1037
1164
|
const [id] = args.positionals;
|
|
1038
1165
|
if (!id) {
|
|
1039
|
-
|
|
1166
|
+
printErr("Error: segment ID required.");
|
|
1040
1167
|
process.exit(1);
|
|
1041
1168
|
}
|
|
1042
1169
|
const segId = parseSegmentId(id);
|
|
1043
|
-
const n = Number(args.flags.
|
|
1170
|
+
const n = Number(args.flags.context) || 5;
|
|
1044
1171
|
if (json)
|
|
1045
1172
|
return printJson(await client.getSegmentThread(segId, n));
|
|
1046
1173
|
await runHandler("get_thread", { segment_id: segId, n });
|
|
@@ -1049,13 +1176,13 @@ const COMMANDS = {
|
|
|
1049
1176
|
analyze: {
|
|
1050
1177
|
tool: "analyze_topic",
|
|
1051
1178
|
description: "AI-powered topic analysis",
|
|
1052
|
-
specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY,
|
|
1053
|
-
{ name: "workflow",
|
|
1179
|
+
specs: [FLAG_SPEAKER, FLAG_CHANNEL, FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
|
|
1180
|
+
{ name: "workflow", type: "string", default: "simple_rag", desc: "Workflow: simple_rag | speaker_focus | entity_network | narrative_timeline | hierarchical_summary | discourse_summary" },
|
|
1054
1181
|
FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
|
|
1055
1182
|
run: async (args, json) => {
|
|
1056
1183
|
const [query] = args.positionals;
|
|
1057
1184
|
if (!query) {
|
|
1058
|
-
|
|
1185
|
+
printErr("Error: query is required.");
|
|
1059
1186
|
process.exit(1);
|
|
1060
1187
|
}
|
|
1061
1188
|
const f = args.flags;
|
|
@@ -1069,6 +1196,7 @@ const COMMANDS = {
|
|
|
1069
1196
|
country: f.country || undefined,
|
|
1070
1197
|
days: Number(f.days) || undefined,
|
|
1071
1198
|
skip_cache: f["skip-cache"],
|
|
1199
|
+
project: f.collection || undefined,
|
|
1072
1200
|
};
|
|
1073
1201
|
if (json)
|
|
1074
1202
|
return printJson(await client.analyze(params));
|
|
@@ -1078,11 +1206,11 @@ const COMMANDS = {
|
|
|
1078
1206
|
frames: {
|
|
1079
1207
|
tool: "compare_frames",
|
|
1080
1208
|
description: "Compare left vs right media framing on a topic",
|
|
1081
|
-
specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
|
|
1209
|
+
specs: [FLAG_DAYS, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
|
|
1082
1210
|
run: async (args, json) => {
|
|
1083
1211
|
const [topic] = args.positionals;
|
|
1084
1212
|
if (!topic) {
|
|
1085
|
-
|
|
1213
|
+
printErr("Error: topic is required.");
|
|
1086
1214
|
process.exit(1);
|
|
1087
1215
|
}
|
|
1088
1216
|
const f = args.flags;
|
|
@@ -1094,7 +1222,7 @@ const COMMANDS = {
|
|
|
1094
1222
|
skip_cache: f["skip-cache"],
|
|
1095
1223
|
};
|
|
1096
1224
|
if (json) {
|
|
1097
|
-
const shared = { query: topic, workflow: "deep_analysis", days: params.days, language: params.language, country: params.country, skip_cache: params.skip_cache };
|
|
1225
|
+
const shared = { query: topic, workflow: "deep_analysis", days: params.days, language: params.language, country: params.country, skip_cache: params.skip_cache, project: f.collection || undefined };
|
|
1098
1226
|
const [left, right] = await Promise.all([
|
|
1099
1227
|
client.analyze({ ...shared, political_lean_min: 0.0, political_lean_max: 0.35 }),
|
|
1100
1228
|
client.analyze({ ...shared, political_lean_min: 0.65, political_lean_max: 1.0 }),
|
|
@@ -1109,18 +1237,18 @@ const COMMANDS = {
|
|
|
1109
1237
|
description: "Compare speakers on a topic",
|
|
1110
1238
|
// FLAG_DAYS carries default:30, which silently overrode this command's
|
|
1111
1239
|
// 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],
|
|
1240
|
+
specs: [{ name: "speakers", type: "string", desc: "2-5 comma-separated speaker names" }, FLAG_SPEAKER, { ...FLAG_DAYS, default: 90 }, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION, FLAG_SKIP_CACHE, FLAG_OUT, FLAG_JSON],
|
|
1113
1241
|
run: async (args, json) => {
|
|
1114
1242
|
const [topic] = args.positionals;
|
|
1115
1243
|
if (!topic) {
|
|
1116
|
-
|
|
1244
|
+
printErr("Error: topic is required.");
|
|
1117
1245
|
process.exit(1);
|
|
1118
1246
|
}
|
|
1119
1247
|
const f = args.flags;
|
|
1120
1248
|
const speakersStr = f.speakers || f.speaker || "";
|
|
1121
1249
|
const speakers = speakersStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1122
1250
|
if (speakers.length < 2) {
|
|
1123
|
-
|
|
1251
|
+
printErr("Error: --speakers requires 2-5 comma-separated names.");
|
|
1124
1252
|
process.exit(1);
|
|
1125
1253
|
}
|
|
1126
1254
|
process.stderr.write("Comparing speakers... (may take 1-3 minutes)\n");
|
|
@@ -1134,7 +1262,7 @@ const COMMANDS = {
|
|
|
1134
1262
|
return printJson(await client.analyze({
|
|
1135
1263
|
query: topic, workflow: "speaker_compare", speaker_names: speakers,
|
|
1136
1264
|
language: params.language, country: params.country,
|
|
1137
|
-
days: params.days, skip_cache: params.skip_cache,
|
|
1265
|
+
days: params.days, skip_cache: params.skip_cache, project: f.collection || undefined,
|
|
1138
1266
|
}));
|
|
1139
1267
|
await runHandler("compare_speakers", params, f.out);
|
|
1140
1268
|
},
|
|
@@ -1151,14 +1279,14 @@ const COMMANDS = {
|
|
|
1151
1279
|
run: async (args, json) => {
|
|
1152
1280
|
const [topic] = args.positionals;
|
|
1153
1281
|
if (!topic) {
|
|
1154
|
-
|
|
1282
|
+
printErr("Error: topic is required.");
|
|
1155
1283
|
process.exit(1);
|
|
1156
1284
|
}
|
|
1157
1285
|
if (args.flags.speakers)
|
|
1158
1286
|
return runDossierBatch(topic, args, json);
|
|
1159
1287
|
const speaker = args.flags.speaker;
|
|
1160
1288
|
if (!speaker) {
|
|
1161
|
-
|
|
1289
|
+
printErr("Error: --speaker is required (or --speakers <file> for batch mode).");
|
|
1162
1290
|
process.exit(1);
|
|
1163
1291
|
}
|
|
1164
1292
|
process.stderr.write("Building dossier... (may take 1-3 minutes)\n");
|
|
@@ -1179,18 +1307,18 @@ const COMMANDS = {
|
|
|
1179
1307
|
run: async (args, json) => {
|
|
1180
1308
|
const [topic] = args.positionals;
|
|
1181
1309
|
if (!topic) {
|
|
1182
|
-
|
|
1310
|
+
printErr("Error: topic is required.");
|
|
1183
1311
|
process.exit(1);
|
|
1184
1312
|
}
|
|
1185
1313
|
const f = args.flags;
|
|
1186
1314
|
const speaker = f.speaker;
|
|
1187
1315
|
if (!speaker) {
|
|
1188
|
-
|
|
1316
|
+
printErr("Error: --speaker is required.");
|
|
1189
1317
|
process.exit(1);
|
|
1190
1318
|
}
|
|
1191
1319
|
const periods = (f.periods || "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
1192
1320
|
if (periods.length < 2) {
|
|
1193
|
-
|
|
1321
|
+
printErr("Error: --periods requires 2+ comma-separated periods (e.g. 2024,2026).");
|
|
1194
1322
|
process.exit(1);
|
|
1195
1323
|
}
|
|
1196
1324
|
process.stderr.write("Comparing periods... (may take 1-3 minutes)\n");
|
|
@@ -1201,7 +1329,7 @@ const COMMANDS = {
|
|
|
1201
1329
|
if (json)
|
|
1202
1330
|
return printJson(data);
|
|
1203
1331
|
if (data.error) {
|
|
1204
|
-
|
|
1332
|
+
printErr(`Error: ${data.error}`);
|
|
1205
1333
|
process.exit(1);
|
|
1206
1334
|
}
|
|
1207
1335
|
const lines = [`**Speaker evolution: ${speaker} on ${topic} (${periods.join(" vs ")})**\n`];
|
|
@@ -1234,12 +1362,12 @@ const COMMANDS = {
|
|
|
1234
1362
|
run: async (args, json) => {
|
|
1235
1363
|
const [topic] = args.positionals;
|
|
1236
1364
|
if (!topic) {
|
|
1237
|
-
|
|
1365
|
+
printErr("Error: topic is required.");
|
|
1238
1366
|
process.exit(1);
|
|
1239
1367
|
}
|
|
1240
1368
|
const speaker = args.flags.speaker;
|
|
1241
1369
|
if (!speaker) {
|
|
1242
|
-
|
|
1370
|
+
printErr("Error: --speaker is required.");
|
|
1243
1371
|
process.exit(1);
|
|
1244
1372
|
}
|
|
1245
1373
|
process.stderr.write("Checking contradictions... (may take 1-3 minutes)\n");
|
|
@@ -1254,22 +1382,32 @@ const COMMANDS = {
|
|
|
1254
1382
|
briefing: {
|
|
1255
1383
|
tool: "get_briefing",
|
|
1256
1384
|
description: "AI discourse briefing",
|
|
1257
|
-
specs: [{ ...FLAG_DAYS, default: 3, desc: "days to cover (1-14)" }, { name: "max-topics", type: "number", default: 8, desc: "Max topics in the briefing" }, FLAG_JSON],
|
|
1385
|
+
specs: [{ ...FLAG_DAYS, default: 3, desc: "days to cover (1-14)" }, { name: "max-topics", type: "number", default: 8, desc: "Max topics in the briefing" }, FLAG_COLLECTION, FLAG_JSON],
|
|
1258
1386
|
run: async (args, json) => {
|
|
1259
1387
|
const f = args.flags;
|
|
1260
1388
|
const params = { days: Number(f.days) || 3, max_topics: Number(f["max-topics"]) || 8 };
|
|
1261
1389
|
if (json)
|
|
1262
|
-
return printJson(await client.getDiscourseSummary(params));
|
|
1390
|
+
return printJson(await client.getDiscourseSummary({ ...params, project: f.collection || undefined }));
|
|
1263
1391
|
await runHandler("get_briefing", params);
|
|
1264
1392
|
},
|
|
1265
1393
|
},
|
|
1394
|
+
collections: {
|
|
1395
|
+
tool: "list_collections",
|
|
1396
|
+
description: "List searchable collections (use with --collection)",
|
|
1397
|
+
specs: [FLAG_JSON],
|
|
1398
|
+
run: async (_args, json) => {
|
|
1399
|
+
if (json)
|
|
1400
|
+
return printJson(await client.getCollections());
|
|
1401
|
+
await runHandler("list_collections", {});
|
|
1402
|
+
},
|
|
1403
|
+
},
|
|
1266
1404
|
stats: {
|
|
1267
1405
|
tool: "get_platform_stats",
|
|
1268
1406
|
description: "Platform statistics",
|
|
1269
|
-
specs: [FLAG_DAYS, { name: "projects", type: "boolean", desc: "Per-
|
|
1407
|
+
specs: [FLAG_DAYS, { name: "collections", long: ["projects"], type: "boolean", desc: "Per-collection stats instead of platform totals" }, FLAG_JSON],
|
|
1270
1408
|
run: async (args, json) => {
|
|
1271
1409
|
const f = args.flags;
|
|
1272
|
-
if (f.
|
|
1410
|
+
if (f.collections) {
|
|
1273
1411
|
if (json)
|
|
1274
1412
|
return printJson(await client.getProjectStats());
|
|
1275
1413
|
await runHandler("get_project_stats", {});
|
|
@@ -1284,36 +1422,48 @@ const COMMANDS = {
|
|
|
1284
1422
|
},
|
|
1285
1423
|
"top-channels": {
|
|
1286
1424
|
tool: "get_top_channels",
|
|
1287
|
-
description: "Top channels by episode count",
|
|
1288
|
-
specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY,
|
|
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],
|
|
1289
1430
|
run: async (args, json) => {
|
|
1290
1431
|
const f = args.flags;
|
|
1291
|
-
const
|
|
1292
|
-
|
|
1293
|
-
|
|
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
|
+
}
|
|
1294
1438
|
await runHandler("get_top_channels", params);
|
|
1295
1439
|
},
|
|
1296
1440
|
},
|
|
1297
1441
|
"top-speakers": {
|
|
1298
1442
|
tool: "get_top_speakers",
|
|
1299
|
-
description: "Top speakers by appearances, talk time
|
|
1300
|
-
specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY,
|
|
1301
|
-
{ name: "ranking",
|
|
1443
|
+
description: "Top speakers by appearances, talk time or clips; with --topic, by how much they talk about it",
|
|
1444
|
+
specs: [FLAG_DAYS, FLAG_START, FLAG_END, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_COLLECTION,
|
|
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],
|
|
1302
1449
|
run: async (args, json) => {
|
|
1303
1450
|
const f = args.flags;
|
|
1304
|
-
const
|
|
1305
|
-
|
|
1306
|
-
|
|
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
|
+
}
|
|
1307
1457
|
await runHandler("get_top_speakers", params);
|
|
1308
1458
|
},
|
|
1309
1459
|
},
|
|
1310
1460
|
network: {
|
|
1311
1461
|
tool: "get_speaker_network",
|
|
1312
1462
|
description: "Speaker co-appearance network",
|
|
1313
|
-
specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE,
|
|
1463
|
+
specs: [FLAG_DAYS, FLAG_LIMIT, FLAG_LANGUAGE, FLAG_COLLECTION, FLAG_JSON],
|
|
1314
1464
|
run: async (args, json) => {
|
|
1315
1465
|
const f = args.flags;
|
|
1316
|
-
const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || "en", project: f.
|
|
1466
|
+
const params = { days: Number(f.days) || 30, limit: Number(f.limit) || 20, language: f.language || "en", project: f.collection || undefined };
|
|
1317
1467
|
if (json)
|
|
1318
1468
|
return printJson(await client.getSpeakerNetwork(params));
|
|
1319
1469
|
await runHandler("get_speaker_network", params);
|
|
@@ -1323,7 +1473,7 @@ const COMMANDS = {
|
|
|
1323
1473
|
tool: "get_topics",
|
|
1324
1474
|
description: "Trending topic clusters",
|
|
1325
1475
|
specs: [FLAG_LANGUAGE, FLAG_COUNTRY, FLAG_LIMIT, FLAG_DAYS,
|
|
1326
|
-
{ name: "query",
|
|
1476
|
+
{ name: "query", type: "string", desc: "Semantic filter — only clusters relevant to this query" },
|
|
1327
1477
|
{ name: "min-segments", type: "number", default: 50, desc: "Minimum segments per cluster" },
|
|
1328
1478
|
FLAG_START, FLAG_END, FLAG_JSON],
|
|
1329
1479
|
run: async (args, json) => {
|
|
@@ -1344,7 +1494,7 @@ const COMMANDS = {
|
|
|
1344
1494
|
ads: {
|
|
1345
1495
|
tool: "get_ad_analytics",
|
|
1346
1496
|
description: "Ad intelligence",
|
|
1347
|
-
specs: [FLAG_DAYS, FLAG_LIMIT,
|
|
1497
|
+
specs: [FLAG_DAYS, FLAG_LIMIT, ...PAGING,
|
|
1348
1498
|
{ name: "advertisers", type: "boolean", desc: "List advertisers instead of the overview" },
|
|
1349
1499
|
{ name: "trending", type: "boolean", desc: "Show growing/declining/new advertisers" },
|
|
1350
1500
|
FLAG_CHANNEL,
|
|
@@ -1382,7 +1532,7 @@ const COMMANDS = {
|
|
|
1382
1532
|
const profile = await client.getChannelByName(channelArg);
|
|
1383
1533
|
const resolvedId = profile.channel_id ?? profile.id;
|
|
1384
1534
|
if (!resolvedId) {
|
|
1385
|
-
|
|
1535
|
+
printErr(`Error: channel "${channelArg}" not found.`);
|
|
1386
1536
|
process.exit(1);
|
|
1387
1537
|
}
|
|
1388
1538
|
if (json)
|
|
@@ -1402,7 +1552,7 @@ const COMMANDS = {
|
|
|
1402
1552
|
description: "Manage bookmarks",
|
|
1403
1553
|
specs: [{ name: "type", type: "string", desc: "Filter by bookmark type: segment | episode | speaker | channel" },
|
|
1404
1554
|
{ name: "search", type: "string", desc: "Filter bookmarks by search text" },
|
|
1405
|
-
FLAG_LIMIT,
|
|
1555
|
+
FLAG_LIMIT, ...PAGING,
|
|
1406
1556
|
{ name: "note", type: "string", desc: "Note to attach when adding a bookmark" }, FLAG_JSON],
|
|
1407
1557
|
run: async (args, json) => {
|
|
1408
1558
|
const [sub, entityType, entityId] = args.positionals;
|
|
@@ -1452,7 +1602,7 @@ async function cmdSetup(force) {
|
|
|
1452
1602
|
const key = await rl.question("API key: ");
|
|
1453
1603
|
rl.close();
|
|
1454
1604
|
if (!key.trim()) {
|
|
1455
|
-
|
|
1605
|
+
printErr("Error: no API key provided.");
|
|
1456
1606
|
process.exit(1);
|
|
1457
1607
|
}
|
|
1458
1608
|
process.env.SIGNAL4_API_KEY = key.trim();
|
|
@@ -1461,7 +1611,7 @@ async function cmdSetup(force) {
|
|
|
1461
1611
|
print(`Authenticated as ${who.user_email ?? who.key_prefix ?? "unknown"}`);
|
|
1462
1612
|
}
|
|
1463
1613
|
catch (err) {
|
|
1464
|
-
|
|
1614
|
+
printErr(`Error: invalid API key (${err instanceof Error ? err.message : String(err)})`);
|
|
1465
1615
|
process.exit(1);
|
|
1466
1616
|
}
|
|
1467
1617
|
if (!existsSync(configDir))
|
|
@@ -1553,7 +1703,7 @@ async function showHelp() {
|
|
|
1553
1703
|
lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
|
|
1554
1704
|
for (const c of cmds) {
|
|
1555
1705
|
shown.add(c.name);
|
|
1556
|
-
lines.push(` ${c.name.padEnd(19)}${c.desc}`);
|
|
1706
|
+
lines.push(` ${c.name.padEnd(19)}${c.desc}${speedWord(c.name)}`);
|
|
1557
1707
|
}
|
|
1558
1708
|
lines.push("");
|
|
1559
1709
|
}
|
|
@@ -1564,7 +1714,7 @@ async function showHelp() {
|
|
|
1564
1714
|
lines.push(`\u2500\u2500 ${tier} \u2500\u2500`);
|
|
1565
1715
|
for (const c of cmds) {
|
|
1566
1716
|
shown.add(c.name);
|
|
1567
|
-
lines.push(` ${c.name.padEnd(19)}${c.desc}`);
|
|
1717
|
+
lines.push(` ${c.name.padEnd(19)}${c.desc}${speedWord(c.name)}`);
|
|
1568
1718
|
}
|
|
1569
1719
|
lines.push("");
|
|
1570
1720
|
}
|
|
@@ -1577,7 +1727,7 @@ async function showHelp() {
|
|
|
1577
1727
|
lines.push("\u2500\u2500 also available \u2500\u2500");
|
|
1578
1728
|
for (const n of missing) {
|
|
1579
1729
|
const pad = n.length >= 19 ? `${n} ` : n.padEnd(19);
|
|
1580
|
-
lines.push(` ${pad}${COMMANDS[n].description}`);
|
|
1730
|
+
lines.push(` ${pad}${COMMANDS[n].description}${speedWord(n)}`);
|
|
1581
1731
|
}
|
|
1582
1732
|
lines.push("");
|
|
1583
1733
|
}
|
|
@@ -1596,11 +1746,14 @@ async function showHelp() {
|
|
|
1596
1746
|
lines.push(` ${data.available_commands.join(", ")}`);
|
|
1597
1747
|
lines.push("");
|
|
1598
1748
|
}
|
|
1749
|
+
lines.push("Speed: instant <0.5s · fast 0.5–2.5s · slow >2.5s (at default settings; longer windows take longer — see s4 <command> --help). Slow commands print an estimate to stderr first.");
|
|
1750
|
+
lines.push("");
|
|
1599
1751
|
lines.push("Flags:");
|
|
1600
1752
|
lines.push(" --json Raw JSON output");
|
|
1601
1753
|
lines.push(" --help, -h Show this help");
|
|
1602
1754
|
lines.push(" s4 <command> --help Flags for one command (flags vary by command)");
|
|
1603
|
-
lines.push(" --
|
|
1755
|
+
lines.push(" --collection NAME Scope to one collection (s4 collections); omit = whole corpus");
|
|
1756
|
+
lines.push(" -V, --version Version and build time");
|
|
1604
1757
|
lines.push("");
|
|
1605
1758
|
lines.push("MCP server:");
|
|
1606
1759
|
lines.push(" s4 --mcp Start MCP server for Claude Desktop / Cursor");
|
|
@@ -1615,10 +1768,17 @@ async function showHelp() {
|
|
|
1615
1768
|
].join("\n"));
|
|
1616
1769
|
}
|
|
1617
1770
|
}
|
|
1771
|
+
/** " (fast)" after a command in the global help, from speed.ts; nothing for unknown commands. */
|
|
1772
|
+
function speedWord(name) {
|
|
1773
|
+
const cmd = COMMANDS[name];
|
|
1774
|
+
return cmd ? ` (${estimate(cmd.tool, {}).tier})` : "";
|
|
1775
|
+
}
|
|
1618
1776
|
function showCommandHelp(name, cmd) {
|
|
1619
1777
|
const lines = [
|
|
1620
1778
|
`s4 ${name} — ${cmd.description}`,
|
|
1621
1779
|
"",
|
|
1780
|
+
`Speed: ${descriptionTag(cmd.tool).slice(1, -1)} (instant <0.5s · fast 0.5–2.5s · slow >2.5s)`,
|
|
1781
|
+
"",
|
|
1622
1782
|
`Usage: s4 ${name} [args] [flags]`,
|
|
1623
1783
|
"",
|
|
1624
1784
|
"Flags:",
|
|
@@ -1635,12 +1795,35 @@ function showCommandHelp(name, cmd) {
|
|
|
1635
1795
|
// ---------------------------------------------------------------------------
|
|
1636
1796
|
// Main
|
|
1637
1797
|
// ---------------------------------------------------------------------------
|
|
1798
|
+
const COMMANDS_WITH_TOPIC = new Set([
|
|
1799
|
+
"search", "quotes", "claims", "analyze-claims", "trend-claims", "contradict-claims", "compare-claims",
|
|
1800
|
+
"channel-dossier", "compare-channels", "channel-contradictions", "trend", "timeline", "analyze",
|
|
1801
|
+
"frames", "compare", "dossier", "contradictions", "compare-periods", "entities",
|
|
1802
|
+
]);
|
|
1803
|
+
/** Commands whose backend takes explicit dates, so --days has no ceiling. */
|
|
1804
|
+
const DATE_RANGE_COMMANDS = new Set(["search", "quotes", "claims", "timeline", "count"]);
|
|
1805
|
+
const BUILD_INFO = (() => {
|
|
1806
|
+
try {
|
|
1807
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
1808
|
+
const built = statSync(new URL("./cli.js", import.meta.url)).mtime.toISOString().slice(0, 16).replace("T", " ");
|
|
1809
|
+
return `s4 ${pkg.version} (built ${built} UTC)`;
|
|
1810
|
+
}
|
|
1811
|
+
catch {
|
|
1812
|
+
return "s4 (version unknown)";
|
|
1813
|
+
}
|
|
1814
|
+
})();
|
|
1815
|
+
const BUILTIN_COMMANDS = ["setup", "whoami", "version", "mcp", "help"];
|
|
1638
1816
|
export async function main() {
|
|
1639
1817
|
applyConfig();
|
|
1818
|
+
setSurface("cli");
|
|
1640
1819
|
const argv = process.argv.slice(2);
|
|
1820
|
+
if (argv[0] === "version" || argv[0] === "-V" || argv[0] === "--version") {
|
|
1821
|
+
print(BUILD_INFO);
|
|
1822
|
+
process.exit(0);
|
|
1823
|
+
}
|
|
1641
1824
|
// Global help — but `s4 <command> --help` gets that command's flags instead.
|
|
1642
1825
|
const wantsHelp = argv.includes("--help") || argv.includes("-h");
|
|
1643
|
-
if (!argv.length || (wantsHelp && !COMMANDS[argv[0]])) {
|
|
1826
|
+
if (!argv.length || argv[0] === "help" || (wantsHelp && !COMMANDS[argv[0]])) {
|
|
1644
1827
|
await showHelp();
|
|
1645
1828
|
process.exit(0);
|
|
1646
1829
|
}
|
|
@@ -1653,6 +1836,8 @@ export async function main() {
|
|
|
1653
1836
|
await import("./index.js");
|
|
1654
1837
|
return;
|
|
1655
1838
|
}
|
|
1839
|
+
// CLI mode gets its own UA (MCP entry points keep the default).
|
|
1840
|
+
client.setUserAgent("Signal4-S4CLI/2.7");
|
|
1656
1841
|
const command = argv[0];
|
|
1657
1842
|
const rest = argv.slice(1);
|
|
1658
1843
|
// Setup (no API key needed)
|
|
@@ -1673,12 +1858,38 @@ export async function main() {
|
|
|
1673
1858
|
// Lookup command
|
|
1674
1859
|
const cmd = COMMANDS[command];
|
|
1675
1860
|
if (!cmd) {
|
|
1676
|
-
|
|
1861
|
+
const near = didYouMean(command, [...Object.keys(COMMANDS), ...BUILTIN_COMMANDS]);
|
|
1862
|
+
process.stderr.write(`Error: unknown command "${command}"${near ? ` — did you mean "${near}"?` : ""}\nRun s4 --help for usage.\n`);
|
|
1677
1863
|
process.exit(1);
|
|
1678
1864
|
}
|
|
1679
1865
|
// Parse args
|
|
1680
1866
|
const args = parseArgs(rest, cmd.specs);
|
|
1867
|
+
// Unquoted multi-word topics: `s4 search immigration policy`.
|
|
1868
|
+
if (COMMANDS_WITH_TOPIC.has(command) && args.positionals.length > 1) {
|
|
1869
|
+
args.positionals = [args.positionals.join(" ")];
|
|
1870
|
+
}
|
|
1681
1871
|
const json = args.flags.json;
|
|
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
|
+
}
|
|
1887
|
+
// --days has no ceiling where the backend takes dates; report endpoints cap at 365.
|
|
1888
|
+
const days = args.flags.days;
|
|
1889
|
+
if (typeof days === "number" && days > 365 && !args.flags.start && !DATE_RANGE_COMMANDS.has(command)) {
|
|
1890
|
+
process.stderr.write(`Error: --days is capped at 365 for "${command}". For older material use s4 search / claims / timeline (any --days, or --since 2023).\n`);
|
|
1891
|
+
process.exit(1);
|
|
1892
|
+
}
|
|
1682
1893
|
// Narrate pipeline steps on stderr while analytical commands stream.
|
|
1683
1894
|
let progress = null;
|
|
1684
1895
|
if (PROGRESS_COMMANDS.has(command)) {
|