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