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