@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.
Files changed (60) hide show
  1. package/README.md +15 -0
  2. package/bin/signal4-mcp-http.js +2 -0
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +587 -102
  5. package/dist/cli.js.map +1 -1
  6. package/dist/client.d.ts +21 -0
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +58 -9
  9. package/dist/client.js.map +1 -1
  10. package/dist/format.d.ts +72 -18
  11. package/dist/format.d.ts.map +1 -1
  12. package/dist/format.js +646 -358
  13. package/dist/format.js.map +1 -1
  14. package/dist/handler_types.d.ts +4 -0
  15. package/dist/handler_types.d.ts.map +1 -1
  16. package/dist/handler_types.js +8 -1
  17. package/dist/handler_types.js.map +1 -1
  18. package/dist/handlers/ads.d.ts.map +1 -1
  19. package/dist/handlers/ads.js +11 -0
  20. package/dist/handlers/ads.js.map +1 -1
  21. package/dist/handlers/bookmarks.js +4 -4
  22. package/dist/handlers/bookmarks.js.map +1 -1
  23. package/dist/handlers/claims.d.ts.map +1 -1
  24. package/dist/handlers/claims.js +78 -19
  25. package/dist/handlers/claims.js.map +1 -1
  26. package/dist/handlers/explore.d.ts +2 -1
  27. package/dist/handlers/explore.d.ts.map +1 -1
  28. package/dist/handlers/explore.js +64 -10
  29. package/dist/handlers/explore.js.map +1 -1
  30. package/dist/handlers/registry.d.ts.map +1 -1
  31. package/dist/handlers/registry.js +2 -1
  32. package/dist/handlers/registry.js.map +1 -1
  33. package/dist/handlers/reports.d.ts.map +1 -1
  34. package/dist/handlers/reports.js +62 -33
  35. package/dist/handlers/reports.js.map +1 -1
  36. package/dist/handlers/search.d.ts.map +1 -1
  37. package/dist/handlers/search.js +64 -39
  38. package/dist/handlers/search.js.map +1 -1
  39. package/dist/handlers/timeline.d.ts.map +1 -1
  40. package/dist/handlers/timeline.js +12 -1
  41. package/dist/handlers/timeline.js.map +1 -1
  42. package/dist/http.d.ts +16 -0
  43. package/dist/http.d.ts.map +1 -0
  44. package/dist/http.js +132 -0
  45. package/dist/http.js.map +1 -0
  46. package/dist/index.d.ts +3 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +12 -149
  49. package/dist/index.js.map +1 -1
  50. package/dist/provenance.d.ts +41 -0
  51. package/dist/provenance.d.ts.map +1 -0
  52. package/dist/provenance.js +65 -0
  53. package/dist/provenance.js.map +1 -0
  54. package/dist/server_core.d.ts +40 -0
  55. package/dist/server_core.d.ts.map +1 -0
  56. package/dist/server_core.js +174 -0
  57. package/dist/server_core.js.map +1 -0
  58. package/dist/types.d.ts +59 -0
  59. package/dist/types.d.ts.map +1 -1
  60. package/package.json +6 -2
package/dist/format.js CHANGED
@@ -1,13 +1,21 @@
1
- /** Shared formatting utilities — markdown output for MCP tools. */
1
+ /**
2
+ * Shared formatting utilities — markdown output for MCP tools.
3
+ *
4
+ * Implements RESPONSE_SPEC.md (v1). This file is the reference renderer;
5
+ * the golden fixtures in test/drift/ are the byte-authoritative contract for
6
+ * any port. Canonical tokens, truncation markers, and footers all live
7
+ * here — do not invent per-tool vocabulary in handlers.
8
+ */
2
9
  const PUBLIC_URL = "https://api.signal4.ca";
3
10
  // ---------------------------------------------------------------------------
4
- // Political lean parsing
11
+ // Political lean — single canonical bucket table (spec §5), shared with the
12
+ // query presets so a `lean:` label always round-trips into the same filter.
5
13
  // ---------------------------------------------------------------------------
6
14
  const LEAN_PRESETS = {
7
15
  left: [0.0, 0.3],
8
- "center-left": [0.2, 0.4],
9
- center: [0.35, 0.65],
10
- "center-right": [0.6, 0.8],
16
+ "center-left": [0.3, 0.42],
17
+ center: [0.42, 0.58],
18
+ "center-right": [0.58, 0.7],
11
19
  right: [0.7, 1.0],
12
20
  };
13
21
  export function parsePoliticalLean(lean) {
@@ -26,6 +34,21 @@ export function parsePoliticalLean(lean) {
26
34
  }
27
35
  return {};
28
36
  }
37
+ export function leanLabel(lean) {
38
+ if (lean < 0.3)
39
+ return "left";
40
+ if (lean < 0.42)
41
+ return "center-left";
42
+ if (lean <= 0.58)
43
+ return "center";
44
+ if (lean <= 0.7)
45
+ return "center-right";
46
+ return "right";
47
+ }
48
+ /** `lean:0.72 (right)` — numeric always present (spec §3). */
49
+ export function leanToken(lean) {
50
+ return `lean:${lean.toFixed(2)} (${leanLabel(lean)})`;
51
+ }
29
52
  // ---------------------------------------------------------------------------
30
53
  // Primitives
31
54
  // ---------------------------------------------------------------------------
@@ -40,17 +63,24 @@ export function fmtTime(seconds) {
40
63
  return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
41
64
  return `${m}:${String(sec).padStart(2, "0")}`;
42
65
  }
66
+ /** `t:512.4-598.2s (8:32-9:58)` — raw seconds, humanized in parens (spec §3). */
67
+ export function fmtTimeRange(start, end) {
68
+ if (start == null)
69
+ return "t:n/a";
70
+ const rawEnd = end ?? start;
71
+ return `t:${start.toFixed(1)}-${rawEnd.toFixed(1)}s (${fmtTime(start)}-${fmtTime(rawEnd)})`;
72
+ }
43
73
  export function fmtHours(seconds) {
44
74
  if (!seconds)
45
- return "?";
75
+ return "n/a";
46
76
  const h = seconds / 3600;
47
77
  if (h >= 1)
48
- return `${h.toLocaleString("en", { maximumFractionDigits: 1 })}h`;
78
+ return `${h.toFixed(1)}h`;
49
79
  return fmtTime(seconds);
50
80
  }
51
81
  export function fmtDuration(seconds) {
52
82
  if (!seconds)
53
- return "?";
83
+ return "n/a";
54
84
  const m = Math.floor(seconds / 60);
55
85
  const s = Math.floor(seconds % 60);
56
86
  if (m >= 60) {
@@ -60,6 +90,32 @@ export function fmtDuration(seconds) {
60
90
  }
61
91
  return `${m}m${String(s).padStart(2, "0")}s`;
62
92
  }
93
+ export function fmtDate(iso) {
94
+ return iso ? iso.slice(0, 10) : "n/a";
95
+ }
96
+ /** `conf:87%` or `conf:unverified` — null is NEVER 0% (spec §3/§9). */
97
+ export function confToken(confidence) {
98
+ if (confidence == null)
99
+ return "conf:unverified";
100
+ return `conf:${Math.round(confidence * 100)}%`;
101
+ }
102
+ /** `trend:surging (+340%)` / `trend:new` / `trend:steady (n/a)` (spec §5). */
103
+ export function trendToken(direction, changePct) {
104
+ if (direction === "new")
105
+ return "trend:new";
106
+ const pct = changePct != null ? `${changePct >= 0 ? "+" : ""}${changePct.toFixed(0)}%` : "n/a";
107
+ return `trend:${direction} (${pct})`;
108
+ }
109
+ /** `lang:es` when non-English; empty string for en/unknown. */
110
+ export function langToken(language) {
111
+ if (!language || language === "en")
112
+ return "";
113
+ return `lang:${language}`;
114
+ }
115
+ export const NON_ENGLISH_NOTE = "note: non-English claim extraction is beta (elevated error rate)";
116
+ /** Plain word-boundary truncation with no marker — ONLY for synthesized text
117
+ * where a retrieval pointer exists nearby. Verbatim text must use
118
+ * truncateWithPointer instead. */
63
119
  export function truncate(text, maxlen = 200) {
64
120
  if (!text)
65
121
  return "";
@@ -70,20 +126,75 @@ export function truncate(text, maxlen = 200) {
70
126
  const last = cut.lastIndexOf(" ");
71
127
  return (last > maxlen * 0.5 ? cut.slice(0, last) : cut) + "...";
72
128
  }
73
- export function fmtDate(iso) {
74
- return iso ? iso.slice(0, 10) : "?";
129
+ /** Default budget for verbatim quote text (spec §4). */
130
+ export const QUOTE_BUDGET = 600;
131
+ /**
132
+ * Truncation with mandatory marker + retrieval pointer (spec §4):
133
+ * `…[truncated — full text: get_segment seg:42314442]`.
134
+ * When no retrieval pointer exists, the text is NOT cut — nothing is
135
+ * silently unrecoverable.
136
+ */
137
+ export function truncateWithPointer(text, pointer, maxlen = QUOTE_BUDGET) {
138
+ if (!text)
139
+ return "";
140
+ const clean = text.replace(/\n/g, " ").trim();
141
+ if (clean.length <= maxlen || !pointer)
142
+ return clean;
143
+ const cut = clean.slice(0, maxlen);
144
+ const last = cut.lastIndexOf(" ");
145
+ const body = last > maxlen * 0.5 ? cut.slice(0, last) : cut;
146
+ return `${body}…[truncated — full text: ${pointer}]`;
147
+ }
148
+ /** Rewrite LLM `{seg_N}` / `{{seg_N}}` citations to the spec token `seg:N`. */
149
+ export function rewriteSegCitations(text) {
150
+ if (!text)
151
+ return text;
152
+ return text.replace(/\{\{?seg[_:](\d+)\}?\}/g, "seg:$1");
75
153
  }
76
154
  // ---------------------------------------------------------------------------
77
- // Quote dedup
155
+ // Footers (spec §7): provenance footer → paging note → next-call hint
78
156
  // ---------------------------------------------------------------------------
157
+ /** `showing 20 of 249 — offset:20 for more` (spec §7.2). */
158
+ export function pagingNote(opts) {
159
+ if (opts.countCapped) {
160
+ return `\nshowing ${opts.shown} of ${opts.total ?? "n/a"} (count_capped — true total unknown) — offset:${(opts.offset ?? 0) + opts.shown} for more`;
161
+ }
162
+ if (opts.total != null && opts.total > opts.shown + (opts.offset ?? 0)) {
163
+ return `\nshowing ${opts.shown} of ${opts.total} — offset:${(opts.offset ?? 0) + opts.shown} for more`;
164
+ }
165
+ return "";
166
+ }
167
+ /** `→ get_claim claim:1499738 include_observations:true · get_segment seg:42314442` */
168
+ export function nextCallHint(...calls) {
169
+ const real = calls.filter((c) => !!c);
170
+ if (!real.length)
171
+ return "";
172
+ return `\n→ ${real.join(" · ")}`;
173
+ }
174
+ /** Assemble body + footers in spec order. */
175
+ function withFooters(body, footer, paging, hint) {
176
+ return body + footer + paging + hint;
177
+ }
178
+ // ---------------------------------------------------------------------------
179
+ // Quote dedup — episode + timestamp + full-text hash (spec / audit M8)
180
+ // ---------------------------------------------------------------------------
181
+ function textHash(text) {
182
+ // djb2 — stable, dependency-free, over the FULL text so distinct quotes
183
+ // sharing an 80-char prefix never collapse.
184
+ let h = 5381;
185
+ for (let i = 0; i < text.length; i++) {
186
+ h = ((h << 5) + h + text.charCodeAt(i)) >>> 0;
187
+ }
188
+ return h.toString(16);
189
+ }
79
190
  export function dedupQuotes(quotes) {
80
191
  const seen = new Set();
81
192
  const out = [];
82
193
  for (const q of quotes) {
83
194
  const key = [
84
- q.episode_title ?? q.content_id ?? "",
195
+ q.content_id ?? q.episode_title ?? "",
85
196
  q.timestamp_start ?? q.start_time ?? 0,
86
- (q.text ?? "").slice(0, 80),
197
+ textHash(q.text ?? ""),
87
198
  ].join("|");
88
199
  if (!seen.has(key)) {
89
200
  seen.add(key);
@@ -93,38 +204,6 @@ export function dedupQuotes(quotes) {
93
204
  return out;
94
205
  }
95
206
  // ---------------------------------------------------------------------------
96
- // Similarity tier
97
- // ---------------------------------------------------------------------------
98
- function simTier(sim) {
99
- if (sim >= 0.60)
100
- return "strong";
101
- if (sim >= 0.45)
102
- return "relevant";
103
- if (sim >= 0.40)
104
- return "borderline";
105
- if (sim > 0)
106
- return "weak";
107
- return "";
108
- }
109
- function claimSimTier(sim) {
110
- if (sim >= 0.45)
111
- return "strong";
112
- if (sim >= 0.35)
113
- return "good";
114
- if (sim >= 0.25)
115
- return "relevant";
116
- if (sim > 0)
117
- return "weak";
118
- return "";
119
- }
120
- function topicRelevanceTier(relevance) {
121
- if (relevance >= 0.35)
122
- return "strong";
123
- if (relevance >= 0.28)
124
- return "good";
125
- return "weak";
126
- }
127
- // ---------------------------------------------------------------------------
128
207
  // Clip URL
129
208
  // ---------------------------------------------------------------------------
130
209
  function clipUrl(contentId, start, end, existing) {
@@ -135,28 +214,45 @@ function clipUrl(contentId, start, end, existing) {
135
214
  }
136
215
  return "";
137
216
  }
217
+ /** Missing clips are disclosed, never silently omitted (spec §9). */
218
+ function clipLine(contentType, url) {
219
+ if (url)
220
+ return `clip:${url}`;
221
+ if (contentType === "text")
222
+ return "clip:n/a (text source)";
223
+ return "clip:n/a (unresolved segment)";
224
+ }
138
225
  // ---------------------------------------------------------------------------
139
226
  // Empty state
140
227
  // ---------------------------------------------------------------------------
141
228
  export function emptyStateSuggestions(params) {
142
229
  const suggestions = [];
143
- if (params.days && params.days < 30)
144
- suggestions.push("Try a wider window: days=30");
145
- else if (params.days && params.days < 90)
146
- suggestions.push("Try a wider window: days=90");
147
- if (params.mode === "semantic" && params.topic)
148
- suggestions.push("Try hybrid mode: mode='hybrid'");
149
- if (params.mode === "keyword" && params.topic)
150
- suggestions.push("Try semantic mode: mode='semantic'");
151
- if (params.speaker_name && params.topic)
152
- suggestions.push("Drop speaker_name to search all speakers");
153
- if (params.channel_name)
154
- suggestions.push(`Drop channel_name to search all channels (the topic may not appear on "${params.channel_name}")`);
230
+ if (params.fullHistory) {
231
+ suggestions.push("Try lower min_similarity (e.g. 0.3) or broader phrasing of the topic");
232
+ if (params.speaker_name)
233
+ suggestions.push("Verify the speaker name with find_speakers");
234
+ }
235
+ else {
236
+ if (params.days && params.days < 30)
237
+ suggestions.push("Try a wider window: days=30");
238
+ else if (params.days && params.days < 90)
239
+ suggestions.push("Try a wider window: days=90");
240
+ if (params.mode === "semantic" && params.topic)
241
+ suggestions.push("Try hybrid mode: mode='hybrid'");
242
+ if (params.mode === "keyword" && params.topic)
243
+ suggestions.push("Try semantic mode: mode='semantic'");
244
+ if (params.speaker_name && params.topic)
245
+ suggestions.push("Drop speaker_name to search all speakers");
246
+ if (params.channel_name)
247
+ suggestions.push(`Drop channel_name to search all channels (the topic may not appear on "${params.channel_name}")`);
248
+ }
155
249
  const query = params.topic || params.speaker_name || "this query";
156
250
  let msg = `No matching content for "${query}"`;
157
251
  if (params.channel_name)
158
252
  msg += ` on channel "${params.channel_name}"`;
159
- if (params.days)
253
+ if (params.fullHistory)
254
+ msg += ` in the full corpus history`;
255
+ else if (params.days)
160
256
  msg += ` in the last ${params.days} days`;
161
257
  msg += ".";
162
258
  if (suggestions.length)
@@ -166,111 +262,148 @@ export function emptyStateSuggestions(params) {
166
262
  // ---------------------------------------------------------------------------
167
263
  // Quotes
168
264
  // ---------------------------------------------------------------------------
169
- export function formatQuotes(data) {
265
+ export function formatQuotes(data, footer = "", offset = 0) {
170
266
  const quotes = dedupQuotes(data.quotes ?? []);
171
267
  if (!quotes.length)
172
- return "No quotes found matching your search criteria.";
268
+ return "No quotes found matching your search criteria." + footer;
173
269
  const total = data.total_results ?? quotes.length;
174
270
  const ms = data.processing_time_ms ?? 0;
175
271
  const lines = [`**${total} results** (showing ${quotes.length}, ${ms.toFixed(0)}ms)\n`];
176
272
  for (let i = 0; i < quotes.length; i++) {
177
273
  const q = quotes[i];
178
274
  const spk = q.speaker ?? {};
179
- const spkName = spk.name ?? "Unknown";
180
- const spkId = spk.identity_id;
181
- const conf = spk.confidence ?? 0;
182
- const appear = spk.appearance_type ?? "";
183
- const sim = q.similarity ?? 0;
184
- const ch = q.channel_name ?? "?";
185
- const date = fmtDate(q.episode_date);
186
- const text = (q.text ?? "").slice(0, 400);
187
- const segId = q.segment_id ?? "?";
188
- const contentId = q.content_id ?? "?";
275
+ const resolved = spk.identity_id != null;
276
+ const spkName = resolved ? (spk.name ?? `Speaker ${spk.identity_id}`) : "Unresolved speaker";
277
+ const spkToken = resolved ? `speaker:${spk.identity_id}` : "speaker:unresolved (diarization cluster)";
278
+ const sim = q.similarity;
279
+ const date = fmtDate(q.episode_date ?? q.publish_date);
280
+ const segId = q.segment_id;
189
281
  const tStart = q.timestamp_start ?? q.start_time;
190
282
  const tEnd = q.timestamp_end ?? q.end_time;
283
+ const tokens = [spkToken, confToken(spk.confidence)];
284
+ if (spk.appearance_type)
285
+ tokens.push(`appearance:${spk.appearance_type}`);
286
+ if (sim != null)
287
+ tokens.push(`sim:${sim.toFixed(2)}`);
288
+ tokens.push(`date:${date}`);
289
+ if (tStart != null)
290
+ tokens.push(fmtTimeRange(tStart, tEnd));
291
+ const lt = langToken(q.language);
292
+ if (lt)
293
+ tokens.push(lt);
294
+ if (q.channel_id != null)
295
+ tokens.push(`channel_id:${q.channel_id}`);
296
+ tokens.push(`channel:${q.channel_name ?? "n/a"}`);
297
+ lines.push(`${i + 1}. **${spkName}** — ${tokens.join(" ")}`);
298
+ lines.push(` seg:${segId ?? "n/a"} episode:${q.content_id ?? "n/a"}`);
191
299
  const clip = clipUrl(q.content_id, tStart ?? undefined, tEnd ?? undefined, q.clip_url);
192
- const tier = simTier(sim);
193
- const tierStr = tier ? ` ${tier}` : "";
194
- const idStr = spkId ? ` id:${spkId}` : "";
195
- const clipTag = appear !== "featured" && appear ? " **[played]**" : "";
196
- const timeStr = tStart != null ? ` ${fmtTime(tStart)}-${fmtTime(tEnd)}` : "";
197
- lines.push(`${i + 1}. **${spkName}**${clipTag} (conf:${Math.round(conf * 100)}%${idStr}) on ${ch} ` +
198
- `[${date}${timeStr}] sim:${sim.toFixed(2)}${tierStr}`);
199
- lines.push(` seg:${segId} | episode:${contentId}`);
200
- if (clip)
201
- lines.push(` 🔊 ${clip}`);
202
- lines.push(` > ${text}\n`);
203
- }
204
- if (total > quotes.length) {
205
- lines.push(`_${total - quotes.length} more results available (use offset parameter)_`);
206
- }
207
- return lines.join("\n");
300
+ lines.push(` ${clipLine(q.content_type, clip)}`);
301
+ const pointer = segId != null ? `get_segment seg:${segId}` : undefined;
302
+ lines.push(` > ${truncateWithPointer(q.text, pointer)}\n`);
303
+ }
304
+ const first = quotes[0];
305
+ const hint = nextCallHint(first?.segment_id != null ? `get_segment seg:${first.segment_id}` : undefined, first?.speaker?.identity_id != null ? `get_speaker speaker_id:${first.speaker.identity_id}` : undefined);
306
+ const paging = pagingNote({ shown: quotes.length, total, offset, countCapped: data.count_capped });
307
+ return withFooters(lines.join("\n"), footer, paging, hint);
208
308
  }
209
309
  // ---------------------------------------------------------------------------
210
310
  // Claims
211
311
  // ---------------------------------------------------------------------------
212
- export function formatClaims(data) {
312
+ /** Episode-count harmonization (spec §9): `episodes:` is the full count across
313
+ * modalities; the AV-only subset is labeled `av_episodes:` when text exists. */
314
+ function episodeTokens(c) {
315
+ let s = `episodes:${c.episode_count}`;
316
+ if (c.text_count)
317
+ s += ` av_episodes:${c.episode_count - c.text_count}`;
318
+ return s;
319
+ }
320
+ /** One compact claim line — used on EVERY claim list surface (spec §3). */
321
+ export function claimLine(c, idx, opts = {}) {
322
+ const tokens = [`claim:${c.claim_id}`];
323
+ if (c.stance)
324
+ tokens.push(`stance:${c.stance}`);
325
+ if (c.topic)
326
+ tokens.push(`topic:${c.topic}`);
327
+ if (c.similarity != null)
328
+ tokens.push(`sim:${c.similarity.toFixed(2)}`);
329
+ const lt = langToken(c.language);
330
+ if (lt)
331
+ tokens.push(lt);
332
+ const detail = [`obs:${c.observation_count}`, episodeTokens(c), `channels:${c.channel_count}`];
333
+ // Only render the split when BOTH counts are known — a lone count with the
334
+ // other defaulted to 0 would fabricate a zero (spec §4/§9 unknown ≠ zero).
335
+ if (opts.assertDeny && c.assert_count != null && c.deny_count != null) {
336
+ detail.push(`assert:${c.assert_count} deny:${c.deny_count}`);
337
+ }
338
+ if (c.first_seen)
339
+ detail.push(`first_seen:${fmtDate(c.first_seen)}`);
340
+ if (c.last_seen)
341
+ detail.push(`last_seen:${fmtDate(c.last_seen)}`);
342
+ const prefix = idx != null ? `${idx}. ` : "- ";
343
+ return `${prefix}**${c.summary}** — ${tokens.join(" ")}\n ${detail.join(" ")}`;
344
+ }
345
+ function anyNonEnglish(claims) {
346
+ return claims.some((c) => c.language && c.language !== "en");
347
+ }
348
+ function claimSpeakerParts(speakers, n = 4) {
349
+ return speakers.slice(0, n)
350
+ .map((s) => `${s.name} (speaker:${s.identity_id ?? "n/a"}, ${s.observation_count})`)
351
+ .join(", ");
352
+ }
353
+ /** Notice prepended when the backend reports the speaker_name filter did not
354
+ * resolve (speaker_resolved === false). Absent field → no notice. */
355
+ export function speakerResolutionNote(data) {
356
+ if (data.speaker_resolved !== false)
357
+ return "";
358
+ return "Note: speaker name did not resolve to a known speaker — 0 results may mean an unrecognized/variant spelling (ASR may render names phonetically), not that the speaker never said this. Try find_speakers to locate the canonical name.\n\n";
359
+ }
360
+ export function formatClaims(data, footer = "", offset = 0) {
213
361
  const claims = data.claims ?? [];
362
+ const note = speakerResolutionNote(data);
214
363
  if (!claims.length)
215
- return "No claims found matching your search criteria.";
364
+ return note + "No claims found matching your search criteria." + footer;
216
365
  const total = data.total_results ?? claims.length;
217
366
  const ms = data.processing_time_ms ?? 0;
218
- const mode = data.search_mode ?? "?";
367
+ const mode = data.search_mode ?? "n/a";
219
368
  const lines = [`**${total} claims** (showing ${claims.length}, ${mode}, ${ms.toFixed(0)}ms)\n`];
220
369
  for (let i = 0; i < claims.length; i++) {
221
370
  const c = claims[i];
222
- const sim = c.similarity;
223
- const tier = sim != null ? claimSimTier(sim) : "";
224
- const tierStr = tier ? ` ${tier}` : "";
225
- const simStr = sim != null ? ` sim:${sim.toFixed(2)}${tierStr}` : "";
226
- const stanceStr = c.stance ? ` [${c.stance}]` : "";
227
- const topicStr = c.topic ? ` (${c.topic})` : "";
228
- const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter((d) => d !== "?").join(" – ");
229
- lines.push(`${i + 1}. **${c.summary}**${stanceStr}${topicStr}${simStr}`);
230
- const av = c.episode_count - (c.text_count ?? 0);
231
- const spread = `${av} episode${av === 1 ? "" : "s"}`;
232
- lines.push(` ${c.observation_count} observations across ${spread}, ${c.channel_count} channels` +
233
- (span ? ` (${span})` : ""));
371
+ lines.push(claimLine(c, i + 1));
234
372
  if (c.entities.length) {
235
- lines.push(` Entities: ${c.entities.join(", ")}`);
373
+ lines.push(` entities:${c.entities.join(",")}`);
236
374
  }
237
375
  const speakers = c.top_speakers ?? [];
238
376
  if (speakers.length) {
239
- const parts = speakers.slice(0, 4).map((s) => {
240
- const idStr = s.identity_id ? ` id:${s.identity_id}` : "";
241
- return `${s.name}${idStr} (${s.observation_count})`;
242
- });
243
- lines.push(` Speakers: ${parts.join(", ")}`);
377
+ lines.push(` speakers: ${claimSpeakerParts(speakers)}`);
244
378
  }
245
379
  const channels = c.top_channels ?? [];
246
380
  if (channels.length) {
247
- const parts = channels.slice(0, 4).map((ch) => `${ch.name} (${ch.observation_count})`);
248
- lines.push(` Channels: ${parts.join(", ")}`);
381
+ const parts = channels.slice(0, 4).map((ch) => `${ch.name} (channel_id:${ch.channel_id}, ${ch.observation_count})`);
382
+ lines.push(` channels: ${parts.join(", ")}`);
249
383
  }
250
384
  const obs = c.sample_observations ?? [];
251
385
  if (obs.length) {
252
386
  const o = obs[0];
253
387
  const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
254
388
  const label = [o.speaker_name, o.channel_name, fmtDate(o.publish_date)].filter(Boolean).join(", ");
255
- lines.push(` Sample: "${truncate(o.chunk_text, 150)}" — ${label}`);
256
- if (o.segment_id)
257
- lines.push(` seg:${o.segment_id} | episode:${o.content_id}`);
258
- if (clip)
259
- lines.push(` 🔊 ${clip}`);
389
+ const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
390
+ lines.push(` sample: "${truncateWithPointer(o.chunk_text, pointer)}" — ${label}`);
391
+ lines.push(` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`);
260
392
  }
261
393
  lines.push("");
262
394
  }
263
- if (data.has_more) {
264
- lines.push(`_${total - claims.length} more claims available (use offset parameter)_`);
265
- }
266
- return lines.join("\n");
395
+ if (anyNonEnglish(claims))
396
+ lines.push(NON_ENGLISH_NOTE, "");
397
+ const paging = pagingNote({ shown: claims.length, total, offset });
398
+ const hint = nextCallHint(`get_claim claim:${claims[0].claim_id} include_observations:true`);
399
+ return withFooters(note + lines.join("\n"), footer, paging, hint);
267
400
  }
268
401
  // ---------------------------------------------------------------------------
269
402
  // Claims analysis (/api/v1/analysis/claims/*)
270
403
  // ---------------------------------------------------------------------------
271
- export function formatClaimsTopicAnalysis(data, topic) {
404
+ export function formatClaimsTopicAnalysis(data, topic, footer = "") {
272
405
  if (!data.themes?.length) {
273
- return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic.`;
406
+ return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic.` + footer;
274
407
  }
275
408
  const m = data.quantitative_metrics ?? {};
276
409
  const lines = [`# Claims analysis: "${topic}"`, ""];
@@ -284,52 +417,71 @@ export function formatClaimsTopicAnalysis(data, topic) {
284
417
  ].filter(Boolean);
285
418
  if (summaryParts.length)
286
419
  lines.push(summaryParts.join(" · "), "");
420
+ let firstClaimId;
287
421
  for (const theme of data.themes) {
288
422
  lines.push(`## ${theme.name} — ${theme.claim_count} claims, ${theme.total_observations} obs across ${theme.channel_count} channels`);
289
423
  if (theme.entities?.length)
290
- lines.push(`Entities: ${theme.entities.slice(0, 8).join(", ")}`);
424
+ lines.push(`entities:${theme.entities.slice(0, 8).join(",")}`);
291
425
  for (const c of theme.claims) {
292
- const stanceStr = c.stance ? ` [${c.stance}]` : "";
293
- const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter((d) => d !== "?").join(" – ");
294
- lines.push(`- **${c.summary}**${stanceStr} — ${c.observation_count} obs / ${c.channel_count} channels${span ? ` (${span})` : ""}`);
295
- const speakers = (c.top_speakers ?? []).slice(0, 3).map((s) => `${s.name} (${s.observation_count})`);
426
+ const tokens = [`claim:${c.claim_id ?? "n/a"}`];
427
+ if (firstClaimId == null && c.claim_id != null)
428
+ firstClaimId = c.claim_id;
429
+ if (c.stance)
430
+ tokens.push(`stance:${c.stance}`);
431
+ const detail = [`obs:${c.observation_count}`, `channels:${c.channel_count}`];
432
+ if (c.first_seen)
433
+ detail.push(`first_seen:${fmtDate(c.first_seen)}`);
434
+ if (c.last_seen)
435
+ detail.push(`last_seen:${fmtDate(c.last_seen)}`);
436
+ lines.push(`- **${c.summary}** — ${tokens.join(" ")}`);
437
+ lines.push(` ${detail.join(" ")}`);
438
+ const speakers = (c.top_speakers ?? []).slice(0, 3);
296
439
  if (speakers.length)
297
- lines.push(` Speakers: ${speakers.join(", ")}`);
440
+ lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
298
441
  }
299
442
  lines.push("");
300
443
  }
301
- return lines.join("\n");
444
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
445
+ return withFooters(lines.join("\n"), footer, "", hint);
302
446
  }
303
- const TREND_ARROW = {
304
- surging: "🚀", rising: "↗", steady: "→", declining: "↘", fading: "📉", new: "✨", no_data: "∅",
305
- };
306
- export function formatClaimsTrend(data, topic) {
447
+ export function formatClaimsTrend(data, topic, footer = "") {
448
+ const note = speakerResolutionNote(data);
307
449
  if (data.direction === "no_data") {
308
- return `No claim volume data for "${topic}" in the baseline window. Try a broader topic or longer baseline_days.`;
450
+ return note + `No claim volume data for "${topic}" in the baseline window. Try a broader topic or longer baseline_days.` + footer;
309
451
  }
310
- const arrow = TREND_ARROW[data.direction] ?? "";
311
- const pct = data.change_pct != null ? `${data.change_pct >= 0 ? "+" : ""}${data.change_pct.toFixed(0)}%` : "n/a";
312
452
  const lines = [
313
- `# Claim trend: "${topic}" — ${arrow} ${data.direction.toUpperCase()} (${pct})`,
453
+ `# Claim trend: "${topic}" — ${trendToken(data.direction, data.change_pct)}`,
314
454
  "",
315
- `Recent (${data.recent.days}d): ${data.recent.claims} claims` +
455
+ `recent (${data.recent.days}d): ${data.recent.claims} claims` +
316
456
  (data.recent.new_claims != null ? ` (${data.recent.new_claims} new)` : "") +
317
457
  `, ${data.recent.observations} obs, ${data.recent.per_day}/day`,
318
- `Baseline (${data.baseline.days}d): ${data.baseline.claims} claims, ${data.baseline.observations} obs, ${data.baseline.per_day}/day`,
458
+ `baseline (${data.baseline.days}d): ${data.baseline.claims} claims, ${data.baseline.observations} obs, ${data.baseline.per_day}/day`,
319
459
  ];
320
- return lines.join("\n");
460
+ const hint = nextCallHint(`search_claims topic:"${topic}"`, `get_timeline topic:"${topic}"`);
461
+ return withFooters(note + lines.join("\n"), footer, "", hint);
321
462
  }
322
- export function formatClaimsContradictions(data, speaker, topic) {
463
+ export function formatClaimsContradictions(data, speaker, topic, footer = "") {
323
464
  if (data.verdict === "no_data") {
324
- return `No claims found from ${speaker} on "${topic}". Try a longer time window (days) or check the speaker name.`;
465
+ return `No claims found from ${speaker} on "${topic}". Try a longer time window (days) or check the speaker name.` + footer;
325
466
  }
326
- const lines = [`# Contradiction check: ${speaker} on "${topic}"`, "", `Verdict: **${data.verdict.replace(/_/g, " ")}** (${data.claims_analyzed} claims analyzed)`, ""];
467
+ const lines = [
468
+ `# Contradiction check: ${speaker} on "${topic}"`, "",
469
+ `Verdict: **${data.verdict.replace(/_/g, " ")}** (${data.claims_analyzed} claims analyzed)`, "",
470
+ ];
471
+ let firstClaimId;
472
+ // Contradiction claim texts are synthesized summaries (never quote-marked)
473
+ // and are NEVER truncated (spec §4/§6).
474
+ const side = (label, s) => {
475
+ if (firstClaimId == null && s.claim_id != null)
476
+ firstClaimId = s.claim_id;
477
+ return ` • ${label} — claim:${s.claim_id ?? "n/a"} stance:${s.stance} date:${fmtDate(s.date)} obs:${s.observations}\n ${s.claim}`;
478
+ };
327
479
  if (data.contradictions.length) {
328
480
  lines.push("## Contradictions");
329
481
  for (const c of data.contradictions) {
330
482
  lines.push(`- [${c.severity}] ${c.description}`);
331
- lines.push(` • ${c.earlier.stance}: "${truncate(c.earlier.claim, 120)}" (${fmtDate(c.earlier.date)}, ${c.earlier.observations} obs)`);
332
- lines.push(` • ${c.later.stance}: "${truncate(c.later.claim, 120)}" (${fmtDate(c.later.date)}, ${c.later.observations} obs)`);
483
+ lines.push(side("earlier", c.earlier));
484
+ lines.push(side("later", c.later));
333
485
  }
334
486
  lines.push("");
335
487
  }
@@ -339,170 +491,181 @@ export function formatClaimsContradictions(data, speaker, topic) {
339
491
  if (data.consistent_positions.length) {
340
492
  lines.push("## Consistent positions");
341
493
  for (const p of data.consistent_positions) {
342
- lines.push(`- [${p.stance}] "${truncate(p.claim, 120)}" (${p.observations} obs)`);
494
+ lines.push(`- **${p.claim}** — claim:${p.claim_id ?? "n/a"} stance:${p.stance} obs:${p.observations}`);
495
+ if (firstClaimId == null && p.claim_id != null)
496
+ firstClaimId = p.claim_id;
343
497
  }
498
+ lines.push("");
344
499
  }
345
- return lines.join("\n");
500
+ lines.push("(synthesized claim — observations carry verbatim quotes)");
501
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
502
+ return withFooters(lines.join("\n"), footer, "", hint);
346
503
  }
347
- export function formatClaimsComparison(data, topic) {
504
+ export function formatClaimsComparison(data, topic, footer = "") {
348
505
  const lines = [`# Speaker comparison on claims: "${topic}"`, ""];
506
+ let firstClaimId;
349
507
  for (const sp of data.speakers) {
350
508
  lines.push(`## ${sp.name} — ${sp.claim_count} claims${sp.framing ? ` (${sp.framing})` : ""}`);
351
509
  if (sp.position_summary)
352
- lines.push(sp.position_summary);
510
+ lines.push(rewriteSegCitations(sp.position_summary));
353
511
  const sb = Object.entries(sp.stance_breakdown ?? {});
354
512
  if (sb.length)
355
- lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
513
+ lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
356
514
  for (const kc of (sp.key_claims ?? []).slice(0, 3)) {
357
- lines.push(`- "${truncate(kc.claim, 120)}"${kc.stance ? ` [${kc.stance}]` : ""} (${kc.observations} obs)`);
515
+ // Key-claim texts are synthesized and never truncated (spec §4/§6).
516
+ lines.push(`- **${kc.claim}** — claim:${kc.claim_id ?? "n/a"}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
517
+ if (firstClaimId == null && kc.claim_id != null)
518
+ firstClaimId = kc.claim_id;
358
519
  }
359
520
  lines.push("");
360
521
  }
361
522
  if (data.agreements?.length) {
362
- lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
523
+ lines.push("## Agreements", ...data.agreements.map((a) => `- ${rewriteSegCitations(a)}`), "");
363
524
  }
364
525
  if (data.disagreements?.length) {
365
- lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
526
+ lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${rewriteSegCitations(d)}`), "");
366
527
  }
367
- return lines.join("\n");
528
+ lines.push("(synthesized claim — observations carry verbatim quotes)");
529
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
530
+ return withFooters(lines.join("\n"), footer, "", hint);
368
531
  }
369
- export function formatChannelDossier(data) {
532
+ export function formatChannelDossier(data, footer = "") {
370
533
  const label = data.channels.join(", ");
371
534
  if (!data.claims_analyzed) {
372
- return `No claims found from ${label} on "${data.topic}". Try a longer time window (days) or check the channel name.`;
535
+ return `No claims found from ${label} on "${data.topic}". Try a longer time window (days) or check the channel name.` + footer;
373
536
  }
374
537
  const lines = [`# Channel dossier: ${label} on "${data.topic}"`, ""];
375
538
  lines.push(`${data.claims_analyzed} claims analyzed${data.total_claims_available ? ` (${data.total_claims_available} available)` : ""}`);
376
539
  const sb = Object.entries(data.stance_breakdown ?? {});
377
540
  if (sb.length)
378
- lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
541
+ lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
379
542
  lines.push("");
380
543
  if (data.themes.length) {
381
544
  lines.push("## Themes");
382
545
  for (const t of data.themes) {
383
546
  lines.push(`- **${t.name}** — ${t.claim_count} claims, ${t.total_observations} obs`);
384
547
  if (t.summary)
385
- lines.push(` ${truncate(t.summary, 200)}`);
548
+ lines.push(` ${rewriteSegCitations(t.summary)}`);
386
549
  }
387
550
  lines.push("");
388
551
  }
389
552
  if (data.key_speakers.length) {
390
- lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (${s.claim_count})`).join(", ")}`, "");
553
+ lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (speaker:${s.speaker_id ?? "n/a"}, ${s.claim_count})`).join(", ")}`, "");
391
554
  }
555
+ let firstClaimId;
392
556
  if (data.key_claims.length) {
393
557
  lines.push("## Key claims");
394
558
  for (const kc of data.key_claims) {
395
- lines.push(`- "${truncate(kc.claim, 140)}"${kc.stance ? ` [${kc.stance}]` : ""} (id:${kc.claim_id}, ${kc.observations} obs)`);
559
+ if (firstClaimId == null)
560
+ firstClaimId = kc.claim_id;
561
+ lines.push(`- **${kc.claim}** — claim:${kc.claim_id}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
396
562
  }
397
563
  lines.push("");
398
564
  }
399
565
  if (data.trajectory.length > 1) {
400
- lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}`);
566
+ lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}`, "");
401
567
  }
402
- return lines.join("\n");
568
+ lines.push("(synthesized claim — observations carry verbatim quotes)");
569
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
570
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
571
+ return withFooters(lines.join("\n"), footer, "", hint);
403
572
  }
404
- export function formatChannelClaimsComparison(data, topic) {
573
+ export function formatChannelClaimsComparison(data, topic, footer = "") {
405
574
  const lines = [`# Channel comparison on claims: "${topic}"`, ""];
575
+ let firstClaimId;
406
576
  for (const ch of data.channels) {
407
577
  lines.push(`## ${ch.name} — ${ch.claim_count} claims${ch.framing ? ` (${ch.framing})` : ""}`);
408
578
  if (ch.position_summary)
409
- lines.push(ch.position_summary);
579
+ lines.push(rewriteSegCitations(ch.position_summary));
410
580
  const sb = Object.entries(ch.stance_breakdown ?? {});
411
581
  if (sb.length)
412
- lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
582
+ lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
413
583
  if (ch.key_speakers?.length) {
414
- lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => s.name).join(", ")}`);
584
+ lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => `${s.name} (speaker:${s.speaker_id ?? "n/a"})`).join(", ")}`);
415
585
  }
416
586
  for (const kc of (ch.key_claims ?? []).slice(0, 3)) {
417
- lines.push(`- "${truncate(kc.claim, 120)}"${kc.stance ? ` [${kc.stance}]` : ""} (${kc.observations} obs)`);
587
+ lines.push(`- **${kc.claim}** — claim:${kc.claim_id ?? "n/a"}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
588
+ if (firstClaimId == null && kc.claim_id != null)
589
+ firstClaimId = kc.claim_id;
418
590
  }
419
591
  lines.push("");
420
592
  }
421
593
  if (data.agreements?.length) {
422
- lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
594
+ lines.push("## Agreements", ...data.agreements.map((a) => `- ${rewriteSegCitations(a)}`), "");
423
595
  }
424
596
  if (data.disagreements?.length) {
425
- lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
597
+ lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${rewriteSegCitations(d)}`), "");
426
598
  }
427
599
  if (data.speaker_overlap?.length) {
428
600
  lines.push("## Shared speakers");
429
601
  for (const o of data.speaker_overlap) {
430
602
  lines.push(`- ${o.channels.join(" & ")}: ${o.shared_speakers.join(", ")}`);
431
603
  }
604
+ lines.push("");
432
605
  }
433
- return lines.join("\n");
606
+ lines.push("(synthesized claim — observations carry verbatim quotes)");
607
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
608
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
609
+ return withFooters(lines.join("\n"), footer, "", hint);
434
610
  }
435
611
  // ---------------------------------------------------------------------------
436
612
  // Claims graph (entity-centric, hierarchy/relations, leaderboard)
437
613
  // ---------------------------------------------------------------------------
438
- /**
439
- * Per-claim content breakdown. Text (substack) observations are excluded
440
- * from the tool surface, so only the AV episode count is shown.
441
- */
442
- export function contentBreakdown(c) {
443
- const av = c.episode_count - (c.text_count ?? 0);
444
- return `${av} ep`;
445
- }
446
- /** One compact line for a claim in a list (used by graph/top/entity views). */
447
- function claimLine(c, idx) {
448
- const stanceStr = c.stance ? ` [${c.stance}]` : "";
449
- const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter((d) => d !== "?").join(" – ");
450
- const sim = c.similarity;
451
- const simStr = sim != null ? ` sim:${sim.toFixed(2)}` : "";
452
- const prefix = idx != null ? `${idx}. ` : "- ";
453
- return `${prefix}**${c.summary}**${stanceStr}${simStr}\n id:${c.claim_id} · ${c.observation_count} obs / ${contentBreakdown(c)} / ${c.channel_count} ch${span ? ` (${span})` : ""}`;
454
- }
455
614
  export function formatEntitySearch(data) {
456
615
  if (!data.entities.length) {
457
616
  return `No entities found matching "${data.query}". Try a shorter or differently-spelled query.`;
458
617
  }
459
618
  const lines = [`# Entities matching "${data.query}"`, ""];
460
619
  for (const e of data.entities) {
461
- const meta = [];
620
+ const tokens = [`entity:${e.id}`];
462
621
  if (e.entity_type)
463
- meta.push(e.entity_type);
464
- meta.push(`${e.claim_count.toLocaleString()} claims`, `${e.channel_count.toLocaleString()} channels`);
465
- const span = [fmtDate(e.first_seen_date), fmtDate(e.last_seen_date)].filter((d) => d !== "?").join(" – ");
466
- if (span)
467
- meta.push(span);
468
- lines.push(`- **${e.name}** (${meta.join(" · ")})`);
622
+ tokens.push(`type:${e.entity_type}`);
623
+ tokens.push(`claims:${e.claim_count}`, `channels:${e.channel_count}`);
624
+ if (e.first_seen_date)
625
+ tokens.push(`first_seen:${fmtDate(e.first_seen_date)}`);
626
+ if (e.last_seen_date)
627
+ tokens.push(`last_seen:${fmtDate(e.last_seen_date)}`);
628
+ lines.push(`- **${e.name}** — ${tokens.join(" ")}`);
469
629
  if (e.aliases?.length)
470
- lines.push(` Aliases: ${e.aliases.slice(0, 5).join(", ")}`);
630
+ lines.push(` aliases:${e.aliases.slice(0, 5).join(",")}`);
471
631
  }
472
- lines.push("", "_Use entity_claims with the exact name for the claims themselves._");
473
- return lines.join("\n");
632
+ const hint = nextCallHint(`entity_claims entity:"${data.entities[0].name}"`);
633
+ return lines.join("\n") + hint;
474
634
  }
475
- export function formatEntityClaims(data) {
635
+ export function formatEntityClaims(data, footer = "", offset = 0) {
476
636
  if (!data.entity_id) {
477
- return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching.`;
637
+ return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching.` + footer;
478
638
  }
479
639
  const lines = [`# Claims about ${data.entity}`];
480
- const meta = [];
640
+ const tokens = [`entity:${data.entity_id}`];
481
641
  if (data.entity_type)
482
- meta.push(data.entity_type);
642
+ tokens.push(`type:${data.entity_type}`);
483
643
  if (data.claim_count_rollup != null)
484
- meta.push(`${data.claim_count_rollup.toLocaleString()} claims`);
644
+ tokens.push(`claims:${data.claim_count_rollup}`);
485
645
  if (data.channel_count != null)
486
- meta.push(`${data.channel_count.toLocaleString()} channels`);
487
- if (meta.length)
488
- lines.push(meta.join(" · "));
646
+ tokens.push(`channels:${data.channel_count}`);
647
+ lines.push(tokens.join(" "));
489
648
  if (data.aliases?.length)
490
- lines.push(`Aliases: ${data.aliases.slice(0, 6).join(", ")}`);
649
+ lines.push(`aliases:${data.aliases.slice(0, 6).join(",")}`);
491
650
  if (data.stance_distribution?.length) {
492
651
  const total = data.stance_distribution.reduce((s, x) => s + x.count, 0) || 1;
493
652
  const parts = data.stance_distribution
494
- .map((s) => `${s.stance}: ${s.count.toLocaleString()} (${Math.round((s.count / total) * 100)}%)`);
495
- lines.push(`\nStance: ${parts.join(", ")}`);
653
+ .map((s) => `${s.stance}:${s.count} (${Math.round((s.count / total) * 100)}%)`);
654
+ lines.push(`\nstance: ${parts.join(" ")}`);
496
655
  }
497
- lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims.toLocaleString()})`);
656
+ lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims})`);
498
657
  data.claims.forEach((c, i) => lines.push(claimLine(c, i + 1)));
499
- if (data.has_more)
500
- lines.push(`\n_More available (use offset)._`);
501
- return lines.join("\n");
658
+ if (anyNonEnglish(data.claims))
659
+ lines.push("", NON_ENGLISH_NOTE);
660
+ const paging = pagingNote({ shown: data.claims.length, total: data.total_claims, offset });
661
+ const hint = data.claims.length
662
+ ? nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`)
663
+ : "";
664
+ return withFooters(lines.join("\n"), footer, paging, hint);
502
665
  }
503
666
  export function formatClaimGraph(data) {
504
667
  const lines = [`# Claim graph`, "", `**${data.claim.summary}**`,
505
- `id:${data.claim.claim_id} · ${data.claim.observation_count} obs / ${data.claim.channel_count} channels`, ""];
668
+ `claim:${data.claim.claim_id} obs:${data.claim.observation_count} channels:${data.claim.channel_count}`, ""];
506
669
  if (data.parent) {
507
670
  lines.push("## Broader claim (parent)", claimLine(data.parent), "");
508
671
  }
@@ -519,21 +682,27 @@ export function formatClaimGraph(data) {
519
682
  if (!data.parent && !data.children?.length && !data.siblings?.length) {
520
683
  lines.push("_No parent, children, or sibling claims found. This claim stands alone in the hierarchy._");
521
684
  }
522
- return lines.join("\n");
685
+ const next = data.children?.[0] ?? data.siblings?.[0] ?? data.parent;
686
+ const hint = nextCallHint(`get_claim claim:${data.claim.claim_id} include_observations:true`, next ? `claim_graph claim_id:${next.claim_id}` : undefined);
687
+ return lines.join("\n") + hint;
523
688
  }
524
- export function formatTopClaims(data) {
689
+ export function formatTopClaims(data, footer = "") {
525
690
  if (!data.claims?.length) {
526
- return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations.`;
691
+ return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations.` + footer;
527
692
  }
528
- const label = data.metric === "contested" ? "Most contested claims" : "Most widely-spread claims";
693
+ const contested = data.metric === "contested";
694
+ const label = contested ? "Most contested claims" : "Most widely-spread claims";
529
695
  const lines = [`# ${label}`, ""];
530
696
  data.claims.forEach((c, i) => {
531
- lines.push(claimLine(c, i + 1));
532
- const speakers = (c.top_speakers ?? []).slice(0, 3).map((s) => `${s.name} (${s.observation_count})`);
697
+ lines.push(claimLine(c, i + 1, { assertDeny: contested }));
698
+ const speakers = (c.top_speakers ?? []).slice(0, 3);
533
699
  if (speakers.length)
534
- lines.push(` Speakers: ${speakers.join(", ")}`);
700
+ lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
535
701
  });
536
- return lines.join("\n");
702
+ if (anyNonEnglish(data.claims))
703
+ lines.push("", NON_ENGLISH_NOTE);
704
+ const hint = nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`);
705
+ return withFooters(lines.join("\n"), footer, "", hint);
537
706
  }
538
707
  // ---------------------------------------------------------------------------
539
708
  // Speaker
@@ -561,31 +730,31 @@ export function formatSpeaker(data) {
561
730
  lines.push(`**Stats:** ${stats.join(", ")}`);
562
731
  const sid = data.speaker_id ?? data.id;
563
732
  if (sid)
564
- lines.push(`Speaker ID: \`${sid}\``);
733
+ lines.push(`speaker:${sid}`);
565
734
  const topChannels = data.top_channels ?? [];
566
735
  if (topChannels.length) {
567
736
  lines.push("\n**Top Channels:**");
568
737
  for (const ch of topChannels.slice(0, 8)) {
569
- lines.push(`- ${ch.name ?? "?"} (ID: ${ch.channel_id ?? "?"}, ${ch.count ?? 0} appearances)`);
738
+ lines.push(`- ${ch.name ?? "n/a"} — channel_id:${ch.channel_id ?? "n/a"} appearances:${ch.count ?? 0}`);
570
739
  }
571
740
  }
572
741
  const related = data.related_speakers ?? [];
573
742
  if (related.length) {
574
743
  lines.push("\n**Co-appearances:**");
575
744
  for (const r of related.slice(0, 10)) {
576
- const count = r.appearance_count ?? r.co_appearances ?? "?";
577
- lines.push(`- ${r.name ?? "?"} (${count})`);
745
+ const count = r.appearance_count ?? r.co_appearances ?? "n/a";
746
+ lines.push(`- ${r.name ?? "n/a"} — speaker:${r.speaker_id ?? r.identity_id ?? "n/a"} count:${count}`);
578
747
  }
579
748
  }
580
749
  const recent = data.recent_episodes ?? [];
581
750
  if (recent.length) {
582
751
  lines.push("\n**Recent Episodes:**");
583
752
  for (const ep of recent.slice(0, 8)) {
584
- const date = fmtDate(ep.publish_date);
585
- lines.push(`- [${date}] ${ep.channel_name ?? "?"} — ${ep.title ?? "?"}`);
753
+ lines.push(`- date:${fmtDate(ep.publish_date)} episode:${ep.content_id ?? "n/a"} ${ep.channel_name ?? "n/a"} — ${ep.title ?? "n/a"}`);
586
754
  }
587
755
  }
588
- return lines.join("\n");
756
+ const hint = nextCallHint(sid ? `search_quotes speaker_id:${sid} sort:chronological` : undefined, recent[0]?.content_id ? `get_episode content_id:${recent[0].content_id}` : undefined);
757
+ return lines.join("\n") + hint;
589
758
  }
590
759
  // ---------------------------------------------------------------------------
591
760
  // Channel
@@ -602,12 +771,10 @@ export function formatChannel(data) {
602
771
  meta.push(`**Language:** ${data.language}`);
603
772
  if (meta.length)
604
773
  lines.push(meta.join(" | "));
605
- lines.push(`Channel ID: \`${data.channel_id ?? data.id ?? ""}\``);
606
- const lean = data.political_lean;
607
- if (lean != null) {
608
- const label = lean <= 0.35 ? "left" : lean <= 0.45 ? "center-left" : lean <= 0.55 ? "center" : lean <= 0.65 ? "center-right" : "right";
609
- lines.push(`**Political lean:** ${lean.toFixed(2)} (${label})`);
610
- }
774
+ const cid = data.channel_id ?? data.id;
775
+ lines.push(`channel_id:${cid ?? "n/a"}`);
776
+ if (data.political_lean != null)
777
+ lines.push(leanToken(data.political_lean));
611
778
  if (data.description)
612
779
  lines.push(`\n${data.description.slice(0, 500)}`);
613
780
  if (data.primary_url)
@@ -622,52 +789,58 @@ export function formatChannel(data) {
622
789
  if (parts.length)
623
790
  lines.push(`\n**Ranking:** ${parts.join(", ")}`);
624
791
  for (const cp of (ranking.chart_positions ?? []).slice(0, 5)) {
625
- lines.push(`- Chart: ${cp.platform ?? "?"} ${cp.country ?? "?"}/${cp.category ?? "?"} #${cp.rank ?? "?"}`);
792
+ lines.push(`- Chart: ${cp.platform ?? "n/a"} ${cp.country ?? "n/a"}/${cp.category ?? "n/a"} #${cp.rank ?? "n/a"}`);
626
793
  }
627
794
  }
628
795
  const speakers = data.regular_speakers ?? data.speakers ?? [];
629
796
  if (speakers.length) {
630
797
  lines.push("\n**Speakers:**");
631
798
  for (const s of speakers.slice(0, 10)) {
632
- const count = s.appearance_count ?? s.count ?? "?";
633
- lines.push(`- ${s.name ?? "?"} (${count} appearances)`);
799
+ const count = s.appearance_count ?? s.count ?? "n/a";
800
+ lines.push(`- ${s.name ?? "n/a"} — speaker:${s.speaker_id ?? s.identity_id ?? "n/a"} appearances:${count}`);
634
801
  }
635
802
  }
636
803
  const recent = data.recent_episodes ?? [];
637
804
  if (recent.length) {
638
805
  lines.push("\n**Recent Episodes:**");
639
806
  for (const ep of recent.slice(0, 8)) {
640
- lines.push(`- [${fmtDate(ep.publish_date)}] ${ep.title ?? "?"}`);
807
+ lines.push(`- date:${fmtDate(ep.publish_date)} episode:${ep.content_id ?? "n/a"} ${ep.title ?? "n/a"}`);
641
808
  }
642
809
  }
643
- return lines.join("\n");
810
+ const hint = nextCallHint(cid != null ? `search_quotes channel_id:${cid}` : undefined, recent[0]?.content_id ? `get_episode content_id:${recent[0].content_id}` : undefined);
811
+ return lines.join("\n") + hint;
644
812
  }
645
813
  // ---------------------------------------------------------------------------
646
814
  // Segment
647
815
  // ---------------------------------------------------------------------------
648
816
  export function formatSegment(data) {
649
- const segId = data.segment_id ?? "?";
650
- const spk = data.speaker_name ?? "Unknown";
651
- const ch = data.channel_name ?? "?";
652
- const title = data.episode_title ?? "?";
653
- const contentId = data.content_id ?? "?";
654
- const date = fmtDate(data.episode_date);
655
- const tStart = data.start_time;
656
- const tEnd = data.end_time;
657
- const clip = clipUrl(data.content_id, tStart ?? undefined, tEnd ?? undefined, data.clip_url);
817
+ const segId = data.segment_id;
818
+ const spk = data.speaker ?? {};
819
+ const spkName = data.speaker_name ?? spk.name;
820
+ const resolved = spk.identity_id != null;
821
+ const clip = clipUrl(data.content_id, data.start_time ?? undefined, data.end_time ?? undefined, data.clip_url);
822
+ const speakerTokens = [];
823
+ if (resolved)
824
+ speakerTokens.push(`speaker:${spk.identity_id}`);
825
+ else if (!spkName)
826
+ speakerTokens.push("speaker:unresolved (diarization cluster)");
827
+ if (spk.confidence !== undefined || resolved)
828
+ speakerTokens.push(confToken(spk.confidence));
829
+ if (spk.appearance_type)
830
+ speakerTokens.push(`appearance:${spk.appearance_type}`);
658
831
  const lines = [
659
- `**Segment ${segId}**`,
660
- `- **Speaker:** ${spk}`,
661
- `- **Channel:** ${ch}`,
662
- `- **Episode:** ${title} (\`${contentId}\`)`,
663
- `- **Date:** ${date}`,
832
+ `**Segment ${segId ?? "n/a"}**`,
833
+ `- **Speaker:** ${spkName ?? "unresolved (diarization cluster)"}${speakerTokens.length ? ` — ${speakerTokens.join(" ")}` : ""}`,
834
+ `- **Channel:** ${data.channel_name ?? "n/a"}${data.channel_id != null ? ` — channel_id:${data.channel_id}` : ""}`,
835
+ `- **Episode:** ${data.episode_title ?? "n/a"} — episode:${data.content_id ?? "n/a"}`,
836
+ `- **Date:** date:${fmtDate(data.episode_date)}`,
664
837
  ];
665
- if (tStart != null)
666
- lines.push(`- **Time:** ${fmtTime(tStart)} – ${fmtTime(tEnd)}`);
667
- if (clip)
668
- lines.push(`- **Audio clip:** ${clip}`);
838
+ if (data.start_time != null)
839
+ lines.push(`- **Time:** ${fmtTimeRange(data.start_time, data.end_time)}`);
840
+ lines.push(`- ${clipLine(data.content_type, clip)}`);
669
841
  lines.push(`\n> ${data.text ?? ""}`);
670
- return lines.join("\n");
842
+ const hint = nextCallHint(segId != null ? `get_thread segment_id:${segId}` : undefined, data.content_id ? `get_episode content_id:${data.content_id}` : undefined);
843
+ return lines.join("\n") + hint;
671
844
  }
672
845
  // ---------------------------------------------------------------------------
673
846
  // Analysis
@@ -683,7 +856,7 @@ export function formatAnalysis(data) {
683
856
  const lines = ["**Analysis complete**\n"];
684
857
  const centrality = q.discourse_centrality;
685
858
  if (centrality?.label) {
686
- lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "?"}/5)`);
859
+ lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "n/a"}/5)`);
687
860
  }
688
861
  const parts = [];
689
862
  if (q.total_segments)
@@ -704,19 +877,45 @@ export function formatAnalysis(data) {
704
877
  const count = (t.segment_count ?? t.count);
705
878
  const countStr = count ? ` (${count} segments)` : "";
706
879
  lines.push(`${i + 1}. ${name}${countStr}`);
707
- const desc = (t.summary ?? t.description);
880
+ const desc = rewriteSegCitations((t.summary ?? t.description));
708
881
  if (desc)
709
- lines.push(` ${desc.slice(0, 200)}`);
882
+ lines.push(` ${desc}`);
710
883
  }
711
884
  lines.push("");
712
885
  }
713
886
  if (data.summary)
714
- lines.push(data.summary);
715
- return lines.join("\n");
887
+ lines.push(rewriteSegCitations(data.summary));
888
+ const firstSeg = (data.segment_ids ?? [])[0];
889
+ const hint = nextCallHint(firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
890
+ return lines.join("\n") + hint;
716
891
  }
717
892
  // ---------------------------------------------------------------------------
718
893
  // Frame comparison (left vs right)
719
894
  // ---------------------------------------------------------------------------
895
+ // LLM theme summaries can still hit the token ceiling and stop mid-sentence or
896
+ // mid-citation ("...to leave {seg_4225"). Drop any trailing incomplete {seg_...}
897
+ // citation, then cut back to the last complete sentence and flag the trim.
898
+ function trimTruncatedSummary(text) {
899
+ if (!text)
900
+ return text;
901
+ let s = text.trimEnd();
902
+ let trimmed = false;
903
+ // Dangling open citation at the end (an unclosed "{...")
904
+ const lastOpen = s.lastIndexOf("{");
905
+ if (lastOpen !== -1 && s.indexOf("}", lastOpen) === -1) {
906
+ s = s.slice(0, lastOpen).trimEnd();
907
+ trimmed = true;
908
+ }
909
+ // Mid-sentence cut: back up to the last sentence/citation boundary.
910
+ if (!/[.!?…}"”')\]]$/.test(s)) {
911
+ const m = s.match(/^[\s\S]*[.!?…}]/);
912
+ if (m) {
913
+ s = m[0];
914
+ trimmed = true;
915
+ }
916
+ }
917
+ return trimmed ? `${s} [summary truncated]` : s;
918
+ }
720
919
  export function formatFrameComparison(left, right, topic) {
721
920
  if (left.error && right.error)
722
921
  return `Error: left=${left.error}, right=${right.error}`;
@@ -726,6 +925,7 @@ export function formatFrameComparison(left, right, topic) {
726
925
  return `No frame comparison data found for "${topic}". The topic may not appear in enough left-leaning and right-leaning channels. Try broader keywords or a wider date range (days=90).`;
727
926
  }
728
927
  const lines = [`**Frame comparison: ${topic}**\n`];
928
+ let firstSeg;
729
929
  function sideBlock(label, data) {
730
930
  if (data.error) {
731
931
  lines.push(`### ${label}\nError: ${data.error}\n`);
@@ -740,25 +940,34 @@ export function formatFrameComparison(left, right, topic) {
740
940
  if (q.unique_speakers)
741
941
  parts.push(`${q.unique_speakers} speakers`);
742
942
  lines.push(`### ${label}${parts.length ? ` (${parts.join(", ")})` : ""}`);
743
- const themes = (data.themes ?? []).slice(0, 8);
943
+ // Render EVERY theme, numbered — the synthesis prose refers to themes by
944
+ // number, so truncating the list left it citing "Themes 12–19" that were
945
+ // never displayed. Include per-theme prose and segment citations so a
946
+ // reader can verify any claim (seg:<id> works with get_segment).
947
+ const themes = data.themes ?? [];
744
948
  if (themes.length) {
745
949
  lines.push("**Key themes:**");
746
- for (const t of themes) {
950
+ themes.forEach((t, i) => {
747
951
  const name = (t.name ?? t.theme_name ?? t.label);
748
- const desc = (t.summary ?? t.description);
749
- if (name)
750
- lines.push(`- **${name}**${desc ? ": " + desc.slice(0, 150) : ""}`);
751
- }
952
+ const desc = rewriteSegCitations(trimTruncatedSummary((t.summary ?? t.description)));
953
+ const segs = t.segment_count ? ` — ${t.segment_count} segments` : "";
954
+ const cites = (t.segment_ids ?? []).slice(0, 4).map((id) => `seg:${id}`).join(" ");
955
+ if (firstSeg == null && (t.segment_ids ?? []).length)
956
+ firstSeg = t.segment_ids[0];
957
+ lines.push(`${i + 1}. **${name ?? `Theme ${i + 1}`}**${segs}${desc ? ": " + desc : ""}${cites ? ` [${cites}]` : ""}`);
958
+ });
752
959
  }
753
960
  if (data.summary) {
754
961
  lines.push("");
755
- lines.push(data.summary);
962
+ lines.push(rewriteSegCitations(data.summary));
756
963
  }
757
964
  lines.push("");
758
965
  }
759
966
  sideBlock("Left-leaning media", left);
760
967
  sideBlock("Right-leaning media", right);
761
- return lines.join("\n");
968
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
969
+ const hint = nextCallHint(firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
970
+ return lines.join("\n") + hint;
762
971
  }
763
972
  // ---------------------------------------------------------------------------
764
973
  // Comparison
@@ -773,11 +982,12 @@ export function formatComparison(data, topic) {
773
982
  const lines = [`**Speaker comparison: ${topic}**\n`];
774
983
  for (const [name, sp] of Object.entries(speakers)) {
775
984
  const count = sp.segment_count ?? 0;
776
- lines.push(`### ${name} (${count} segments)`);
985
+ const gated = sp.gated_out ? `, ${sp.gated_out} weak matches excluded` : "";
986
+ lines.push(`### ${name} (${count} on-topic segments${gated})`);
777
987
  if (sp.position_summary)
778
- lines.push(`**Position:** ${sp.position_summary}`);
988
+ lines.push(`**Position:** ${rewriteSegCitations(sp.position_summary)}`);
779
989
  if (sp.framing)
780
- lines.push(`**Framing:** ${sp.framing}`);
990
+ lines.push(`**Framing:** ${rewriteSegCitations(sp.framing)}`);
781
991
  if (sp.tone)
782
992
  lines.push(`**Tone:** ${sp.tone}`);
783
993
  const claims = sp.key_claims ?? [];
@@ -785,20 +995,45 @@ export function formatComparison(data, topic) {
785
995
  lines.push("**Key claims:**");
786
996
  for (const c of claims.slice(0, 5)) {
787
997
  const text = typeof c === "string" ? c : c.claim ?? String(c);
788
- lines.push(`- ${text}`);
998
+ const cid = typeof c === "object" && c ? c.claim_id : undefined;
999
+ const idTok = cid != null ? ` — claim:${cid}` : "";
1000
+ lines.push(`- ${rewriteSegCitations(text)}${idTok}`);
789
1001
  }
790
1002
  }
791
1003
  lines.push("");
792
1004
  }
793
1005
  if (data.comparison || data.summary) {
794
- lines.push("---");
795
- lines.push(data.comparison ?? data.summary ?? "");
1006
+ lines.push("## Synthesis");
1007
+ lines.push(rewriteSegCitations(data.comparison ?? data.summary ?? ""));
1008
+ lines.push("");
796
1009
  }
1010
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
797
1011
  return lines.join("\n");
798
1012
  }
799
1013
  // ---------------------------------------------------------------------------
800
1014
  // Dossier
801
1015
  // ---------------------------------------------------------------------------
1016
+ // Render the retrieval/provenance line shared by dossier and contradictions:
1017
+ // window searched, dates the evidence spans, match strength, gating stats.
1018
+ function pushRetrievalProvenance(lines, ret) {
1019
+ if (!ret)
1020
+ return;
1021
+ const parts = [];
1022
+ if (ret.window_days)
1023
+ parts.push(`window: last ${ret.window_days} days`);
1024
+ if (ret.date_range?.length === 2)
1025
+ parts.push(`evidence spans ${ret.date_range[0].slice(0, 10)} → ${ret.date_range[1].slice(0, 10)}`);
1026
+ if (ret.similarity_range?.length === 2)
1027
+ parts.push(`relevance ${ret.similarity_range[0].toFixed(2)}–${ret.similarity_range[1].toFixed(2)}`);
1028
+ if (ret.gated_out)
1029
+ parts.push(`${ret.gated_out} weak matches (<${(ret.min_similarity ?? 0.4).toFixed(2)}) excluded`);
1030
+ if (ret.weak_match_count)
1031
+ parts.push(`${ret.weak_match_count} weak matches (<0.40) included`);
1032
+ if (parts.length)
1033
+ lines.push(`_${parts.join(" · ")}_`);
1034
+ if (ret.thin_evidence)
1035
+ lines.push(`_⚠ Thin evidence: fewer than 5 on-topic segments met the relevance threshold — treat conclusions as provisional._`);
1036
+ }
802
1037
  export function formatDossier(data, speakerName, topic) {
803
1038
  if (data.error)
804
1039
  return `Error: ${data.error}`;
@@ -810,17 +1045,23 @@ export function formatDossier(data, speakerName, topic) {
810
1045
  if (sp.occupation)
811
1046
  lines.push(`*${sp.occupation}*`);
812
1047
  if (sp.segment_count)
813
- lines.push(`${sp.segment_count} segments analyzed\n`);
1048
+ lines.push(`${sp.segment_count} segments analyzed`);
1049
+ // Show the working: window searched, dates the evidence actually spans, and
1050
+ // match strength — a dossier built from one recent episode must not pass
1051
+ // for a longitudinal record.
1052
+ pushRetrievalProvenance(lines, sp.retrieval);
1053
+ lines.push("");
814
1054
  const contras = data.contradictions;
815
1055
  if (contras?.verdict) {
816
1056
  lines.push(`**Contradiction check:** ${contras.verdict.replace(/_/g, " ")}`);
817
1057
  for (const c of contras.contradictions ?? []) {
818
- lines.push(`- [${c.severity ?? "?"}] ${c.description ?? "?"}`);
1058
+ lines.push(`- [${c.severity ?? "n/a"}] ${c.description ?? "n/a"}`);
819
1059
  }
820
1060
  lines.push("");
821
1061
  }
822
1062
  if (data.summary)
823
- lines.push(data.summary);
1063
+ lines.push(rewriteSegCitations(data.summary), "");
1064
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
824
1065
  return lines.join("\n");
825
1066
  }
826
1067
  // ---------------------------------------------------------------------------
@@ -835,13 +1076,15 @@ export function formatContradictions(data, speakerName, topic) {
835
1076
  return `No data found for contradiction check on ${speakerName} regarding "${topic}". This speaker may not have enough content on this topic. Use find_speakers to verify the name, or try broader keywords.`;
836
1077
  }
837
1078
  const lines = [`**Contradiction check: ${speakerName} on ${topic}**\n`];
1079
+ pushRetrievalProvenance(lines, data.speaker?.retrieval);
838
1080
  lines.push(`**Verdict:** ${verdict.replace(/_/g, " ")}\n`);
1081
+ // Contradiction evidence is NEVER truncated (spec §4).
839
1082
  for (const c of contras.contradictions ?? []) {
840
- lines.push(`### [${c.severity ?? "?"}] ${c.description ?? "?"}`);
1083
+ lines.push(`### [${c.severity ?? "n/a"}] ${c.description ?? "n/a"}`);
841
1084
  if (c.earlier?.claim)
842
- lines.push(`**Earlier** (${c.earlier.date ?? "?"}): ${c.earlier.claim}`);
1085
+ lines.push(`**Earlier** (date:${fmtDate(c.earlier.date)}): ${rewriteSegCitations(c.earlier.claim)}`);
843
1086
  if (c.later?.claim)
844
- lines.push(`**Later** (${c.later.date ?? "?"}): ${c.later.claim}`);
1087
+ lines.push(`**Later** (date:${fmtDate(c.later.date)}): ${rewriteSegCitations(c.later.claim)}`);
845
1088
  if (c.possible_context)
846
1089
  lines.push(`*Context:* ${c.possible_context}`);
847
1090
  lines.push("");
@@ -850,18 +1093,22 @@ export function formatContradictions(data, speakerName, topic) {
850
1093
  if (consistent.length) {
851
1094
  lines.push("**Consistent positions:**");
852
1095
  for (const pos of consistent)
853
- lines.push(`- ${pos}`);
1096
+ lines.push(`- ${rewriteSegCitations(pos)}`);
854
1097
  lines.push("");
855
1098
  }
856
1099
  const timeline = data.stance_timeline ?? [];
857
1100
  if (timeline.length) {
858
1101
  lines.push("**Stance timeline:**");
859
1102
  for (const t of timeline.slice(0, 20)) {
860
- lines.push(`- ${t.segment_id ?? "?"}: ${t.stance ?? "?"} — ${(t.claim ?? "").slice(0, 100)}`);
1103
+ const pointer = t.segment_id != null ? `get_segment seg:${t.segment_id}` : undefined;
1104
+ lines.push(`- seg:${t.segment_id ?? "n/a"} stance:${t.stance ?? "n/a"} — ${truncateWithPointer(t.claim, pointer)}`);
861
1105
  }
1106
+ lines.push("");
862
1107
  }
863
- if (data.summary && !(contras.contradictions ?? []).length)
864
- lines.push(data.summary);
1108
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
1109
+ // Note: data.summary is deliberately NOT appended — the backend builds it
1110
+ // from the same verdict/contradictions/consistent_positions fields rendered
1111
+ // above, so it duplicated the report as a dangling stub.
865
1112
  return lines.join("\n");
866
1113
  }
867
1114
  // ---------------------------------------------------------------------------
@@ -871,13 +1118,13 @@ export function formatBriefing(data) {
871
1118
  const lines = [];
872
1119
  const stats = data.statistics ?? {};
873
1120
  if (stats.total_episodes || stats.total_channels) {
874
- lines.push(`**Briefing:** ${stats.total_episodes ?? "?"} episodes, ` +
875
- `${stats.total_channels ?? "?"} channels, ` +
1121
+ lines.push(`**Briefing:** ${stats.total_episodes ?? "n/a"} episodes, ` +
1122
+ `${stats.total_channels ?? "n/a"} channels, ` +
876
1123
  `${(stats.total_duration_hours ?? 0).toFixed(0)}h\n`);
877
1124
  }
878
1125
  for (let i = 0; i < (data.topics ?? []).length; i++) {
879
1126
  const t = data.topics[i];
880
- const title = t.title ?? t.label ?? "?";
1127
+ const title = t.title ?? t.label ?? "n/a";
881
1128
  const segs = t.segment_count ?? 0;
882
1129
  const chs = t.channel_count ?? 0;
883
1130
  const score = t.score ?? 0;
@@ -887,8 +1134,16 @@ export function formatBriefing(data) {
887
1134
  lines.push(` ${truncate(summary, 250)}`);
888
1135
  for (const c of (t.citations ?? []).slice(0, 2)) {
889
1136
  const label = [c.speaker_name, c.channel_name].filter(Boolean).join(" on ");
1137
+ const ids = [];
1138
+ if (c.segment_id != null)
1139
+ ids.push(`seg:${c.segment_id}`);
1140
+ if (c.content_id)
1141
+ ids.push(`episode:${c.content_id}`);
1142
+ if (c.publish_date)
1143
+ ids.push(`date:${fmtDate(c.publish_date)}`);
1144
+ const pointer = c.segment_id != null ? `get_segment seg:${c.segment_id}` : undefined;
890
1145
  if (c.text)
891
- lines.push(` "${truncate(c.text, 120)}" — ${label}`);
1146
+ lines.push(` "${truncateWithPointer(c.text, pointer)}" — ${label}${ids.length ? ` (${ids.join(" ")})` : ""}`);
892
1147
  }
893
1148
  lines.push("");
894
1149
  }
@@ -896,18 +1151,20 @@ export function formatBriefing(data) {
896
1151
  if (events.length) {
897
1152
  lines.push("**Key events:**");
898
1153
  for (const ev of events.slice(0, 5)) {
899
- lines.push(`- ${ev.title ?? ev.description ?? "?"}`);
1154
+ lines.push(`- ${ev.title ?? ev.description ?? "n/a"}`);
900
1155
  }
901
1156
  lines.push("");
902
1157
  }
903
1158
  const speakers = data.key_speakers ?? [];
904
1159
  if (speakers.length) {
905
- const parts = speakers.slice(0, 8).map((s) => `${s.name ?? "?"} (${s.segment_count ?? "?"} seg)`);
1160
+ const parts = speakers.slice(0, 8).map((s) => `${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"}, ${s.segment_count ?? "n/a"} seg)`);
906
1161
  lines.push(`**Key speakers:** ${parts.join(", ")}\n`);
907
1162
  }
908
1163
  if (data.synthesis)
909
- lines.push(data.synthesis);
910
- return lines.join("\n") || "No briefing data available.";
1164
+ lines.push(rewriteSegCitations(data.synthesis));
1165
+ const firstTopic = data.topics?.[0];
1166
+ const hint = nextCallHint(firstTopic?.title ? `analyze_topic query:"${firstTopic.title}"` : undefined);
1167
+ return (lines.join("\n") || "No briefing data available.") + hint;
911
1168
  }
912
1169
  export function formatDiscourseSummary(data) {
913
1170
  const lines = [];
@@ -935,7 +1192,7 @@ export function formatDiscourseSummary(data) {
935
1192
  if (speakers.length) {
936
1193
  lines.push("\n**Key Speakers:**");
937
1194
  for (const s of speakers.slice(0, 10)) {
938
- lines.push(`- ${s.name ?? "?"}`);
1195
+ lines.push(`- ${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"})`);
939
1196
  }
940
1197
  }
941
1198
  return lines.join("\n") || "No discourse data available.";
@@ -954,45 +1211,48 @@ export function formatTopics(topics, totalClusters, partition, query) {
954
1211
  return `No topics matched "${query}" above the relevance threshold.`;
955
1212
  }
956
1213
  const lines = [
957
- `**${filtered.length} of ${totalClusters ?? filtered.length} topic clusters** (${partition ?? "?"})\n`,
1214
+ `**${filtered.length} of ${totalClusters ?? filtered.length} topic clusters** (${partition ?? "n/a"})\n`,
958
1215
  ];
959
1216
  for (let i = 0; i < filtered.length; i++) {
960
1217
  const t = filtered[i];
961
- const catTag = t.category ? ` [${t.category}]` : "";
962
- const rel = t.relevance;
963
- const relTag = rel != null ? ` (${topicRelevanceTier(rel)} match)` : "";
964
- lines.push(`${i + 1}. **${t.title ?? "?"}**${catTag}${relTag}`);
1218
+ const tokens = [`cluster_id:${t.cluster_id ?? "n/a"}`];
1219
+ if (t.category)
1220
+ tokens.push(`category:${t.category}`);
1221
+ if (t.relevance != null)
1222
+ tokens.push(`sim:${t.relevance.toFixed(2)}`);
1223
+ lines.push(`${i + 1}. **${t.title ?? "n/a"}** — ${tokens.join(" ")}`);
965
1224
  lines.push(` ${t.segment_count ?? 0} segments across ${t.channel_count ?? 0} channels`);
966
1225
  const speakers = t.top_speakers ?? [];
967
1226
  if (speakers.length) {
968
- lines.push(` Voices: ${speakers.slice(0, 3).map((s) => s.name ?? "?").join(", ")}`);
1227
+ lines.push(` voices: ${speakers.slice(0, 3).map((s) => `${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"})`).join(", ")}`);
969
1228
  }
970
1229
  const topSeg = (t.top_segments ?? [])[0];
971
1230
  if (topSeg?.text) {
972
- lines.push(` "${truncate(topSeg.text, 120)}" — ${topSeg.channel_name ?? ""}`);
1231
+ const pointer = topSeg.segment_id != null ? `get_segment seg:${topSeg.segment_id}` : undefined;
1232
+ lines.push(` "${truncateWithPointer(topSeg.text, pointer)}" — ${topSeg.channel_name ?? ""} (seg:${topSeg.segment_id ?? "n/a"})`);
973
1233
  }
974
1234
  lines.push("");
975
1235
  }
976
- lines.push("---");
977
- lines.push("Use `search_quotes` to find specific quotes within a topic, or `analyze_topic` for an AI-generated summary with themes and key voices.");
978
- return lines.join("\n");
1236
+ const firstSeg = filtered.map((t) => (t.top_segments ?? [])[0]?.segment_id).find((id) => id != null);
1237
+ const hint = nextCallHint(filtered[0]?.title ? `analyze_topic query:"${filtered[0].title}"` : undefined, firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
1238
+ return lines.join("\n") + hint;
979
1239
  }
980
1240
  // ---------------------------------------------------------------------------
981
1241
  // Ad formatters
982
1242
  // ---------------------------------------------------------------------------
983
1243
  export function formatAdStats(data) {
984
1244
  const lines = ["# Ad Analytics Overview\n"];
985
- lines.push(`**${(data.total_ad_hashes ?? 0).toLocaleString()}** confirmed ad sentences`);
1245
+ lines.push(`**${data.total_ad_hashes ?? 0}** confirmed ad sentences`);
986
1246
  lines.push(`**${data.total_brands ?? 0}** advertisers (+ ${(data.total_advertisers ?? 0) - (data.total_brands ?? 0)} network promos)`);
987
- lines.push(`**${(data.ad_reads_in_window ?? 0).toLocaleString()}** ad reads in last ${data.time_window_days ?? 30}d`);
988
- lines.push(`**${(data.episodes_with_ads_in_window ?? 0).toLocaleString()}** episodes with ads`);
1247
+ lines.push(`**${data.ad_reads_in_window ?? 0}** ad reads in last ${data.time_window_days ?? 30}d`);
1248
+ lines.push(`**${data.episodes_with_ads_in_window ?? 0}** episodes with ads`);
989
1249
  lines.push(`**${data.avg_ad_read_duration_seconds ?? 0}s** avg ad read duration`);
990
1250
  const byCat = data.ads_by_category ?? data.by_category ?? {};
991
1251
  if (Object.keys(byCat).length) {
992
1252
  lines.push("\n**By Category:**");
993
1253
  const sorted = Object.entries(byCat).sort((a, b) => b[1] - a[1]);
994
1254
  for (const [cat, count] of sorted) {
995
- lines.push(`- ${cat}: ${count.toLocaleString()}`);
1255
+ lines.push(`- ${cat}: ${count}`);
996
1256
  }
997
1257
  }
998
1258
  const top = data.top_advertisers ?? [];
@@ -1000,11 +1260,13 @@ export function formatAdStats(data) {
1000
1260
  lines.push("\n**Top Advertisers:**");
1001
1261
  for (let i = 0; i < top.length; i++) {
1002
1262
  const a = top[i];
1003
- lines.push(`${i + 1}. **${a.name}** (${a.category ?? "?"}) — ` +
1004
- `${a.total_episode_count.toLocaleString()} episodes, ${a.total_channel_count} channels`);
1263
+ lines.push(`${i + 1}. **${a.name}** — advertiser:${a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
1264
+ `episodes:${a.total_episode_count} channels:${a.total_channel_count}`);
1005
1265
  }
1006
1266
  }
1007
- return lines.join("\n");
1267
+ const firstId = top.find((a) => a.id != null)?.id;
1268
+ const hint = nextCallHint(firstId != null ? `get_ad_analytics view:advertiser advertiser_id:${firstId}` : "get_ad_analytics view:advertisers");
1269
+ return lines.join("\n") + hint;
1008
1270
  }
1009
1271
  export function formatAdvertiserList(data) {
1010
1272
  const advs = data.advertisers ?? [];
@@ -1015,29 +1277,33 @@ export function formatAdvertiserList(data) {
1015
1277
  for (let i = 0; i < advs.length; i++) {
1016
1278
  const a = advs[i];
1017
1279
  const aliases = a.aliases?.length ? ` (aka ${a.aliases.slice(0, 3).join(", ")})` : "";
1018
- lines.push(`${i + 1}. **${a.name}**${aliases} [${a.category ?? "?"}] — ` +
1019
- `${a.total_episode_count.toLocaleString()} episodes, ${a.total_channel_count} channels`);
1280
+ lines.push(`${i + 1}. **${a.name}**${aliases} — advertiser:${a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
1281
+ `episodes:${a.total_episode_count} channels:${a.total_channel_count}`);
1020
1282
  }
1021
- return lines.join("\n");
1283
+ const paging = pagingNote({ shown: advs.length, total });
1284
+ const firstId = advs.find((a) => a.id != null)?.id;
1285
+ const hint = nextCallHint(firstId != null ? `get_ad_analytics view:advertiser advertiser_id:${firstId}` : undefined);
1286
+ return lines.join("\n") + paging + hint;
1022
1287
  }
1023
1288
  export function formatAdvertiserDetail(data) {
1024
1289
  if (data.error)
1025
1290
  return `Error: ${data.error}`;
1026
1291
  const lines = [`# ${data.name}`];
1292
+ lines.push(`advertiser:${data.id ?? "n/a"}`);
1027
1293
  if (data.category)
1028
1294
  lines.push(`**Category:** ${data.category}`);
1029
1295
  if (data.aliases?.length)
1030
1296
  lines.push(`**Also known as:** ${data.aliases.join(", ")}`);
1031
1297
  if (data.website)
1032
1298
  lines.push(`**Website:** ${data.website}`);
1033
- lines.push(`**Episodes:** ${data.total_episode_count.toLocaleString()} | **Channels:** ${data.total_channel_count}`);
1299
+ lines.push(`**Episodes:** ${data.total_episode_count} | **Channels:** ${data.total_channel_count}`);
1034
1300
  if (data.first_seen)
1035
- lines.push(`**Active:** ${fmtDate(data.first_seen)} — ${fmtDate(data.last_seen)}`);
1301
+ lines.push(`**Active:** date:${fmtDate(data.first_seen)} — date:${fmtDate(data.last_seen)}`);
1036
1302
  const channels = data.top_channels ?? [];
1037
1303
  if (channels.length) {
1038
1304
  lines.push("\n**Top Channels:**");
1039
1305
  for (const ch of channels.slice(0, 10)) {
1040
- lines.push(`- ${ch.name} (${ch.platform ?? "?"}) — ${ch.episode_count} episodes`);
1306
+ lines.push(`- ${ch.name} — channel_id:${ch.channel_id ?? "n/a"} platform:${ch.platform ?? "n/a"} episodes:${ch.episode_count}`);
1041
1307
  }
1042
1308
  }
1043
1309
  const samples = data.sample_reads ?? [];
@@ -1045,113 +1311,135 @@ export function formatAdvertiserDetail(data) {
1045
1311
  lines.push("\n**Recent Ad Reads:**");
1046
1312
  for (const s of samples) {
1047
1313
  const dur = s.duration_seconds ? ` (${s.duration_seconds}s)` : "";
1048
- lines.push(`- [${fmtDate(s.date)}] ${s.episode_title ?? "?"}${dur}`);
1314
+ lines.push(`- date:${fmtDate(s.date)} ${s.episode_title ?? "n/a"}${dur}`);
1315
+ // Ad-read text has no per-read retrieval tool — never cut it silently.
1049
1316
  if (s.text)
1050
- lines.push(` > ${s.text.slice(0, 200)}`);
1317
+ lines.push(` > ${s.text}`);
1051
1318
  }
1052
1319
  }
1053
- return lines.join("\n");
1320
+ const firstCh = channels.find((ch) => ch.channel_id != null)?.channel_id;
1321
+ const hint = nextCallHint(firstCh != null ? `get_ad_analytics view:channel channel_id:${firstCh}` : undefined);
1322
+ return lines.join("\n") + hint;
1054
1323
  }
1055
1324
  export function formatChannelAds(data) {
1056
1325
  if (data.error)
1057
1326
  return `Error: ${data.error}`;
1058
1327
  const chName = data.channel?.name ?? data.channel_name ?? "Channel";
1059
1328
  const lines = [`# Ads on ${chName}`];
1060
- lines.push(`**${(data.total_ad_reads ?? 0).toLocaleString()}** ad reads in last ${data.time_window_days ?? 30}d\n`);
1329
+ lines.push(`**${data.total_ad_reads ?? 0}** ad reads in last ${data.time_window_days ?? 30}d\n`);
1061
1330
  for (let i = 0; i < data.advertisers.length; i++) {
1062
1331
  const a = data.advertisers[i];
1063
1332
  const promo = a.is_network_promo ? " [network]" : "";
1064
- lines.push(`${i + 1}. **${a.name}**${promo} [${a.category ?? "?"}] — ` +
1065
- `${a.episode_count} episodes, ${a.read_count} reads`);
1333
+ lines.push(`${i + 1}. **${a.name}**${promo} — advertiser:${a.advertiser_id ?? a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
1334
+ `episodes:${a.episode_count} reads:${a.read_count}`);
1066
1335
  }
1067
- return lines.join("\n") || "No ad data for this channel.";
1336
+ const firstId = data.advertisers.find((a) => (a.advertiser_id ?? a.id) != null);
1337
+ const hint = nextCallHint(firstId ? `get_ad_analytics view:advertiser advertiser_id:${firstId.advertiser_id ?? firstId.id}` : undefined);
1338
+ return (lines.join("\n") || "No ad data for this channel.") + hint;
1068
1339
  }
1069
1340
  export function formatAdTrends(data) {
1070
1341
  if (!data.advertisers?.length)
1071
1342
  return "No trending data available.";
1072
1343
  const lines = [`# Ad Trends (${data.comparison ?? ""})\n`];
1073
1344
  for (const a of data.advertisers) {
1074
- const icon = { growing: "+", declining: "-", new: "NEW", gone: "GONE", stable: "=" }[a.trend] ?? "?";
1075
- lines.push(`[${icon}] **${a.name}** (${a.category ?? "?"}) — ` +
1076
- `${a.current_episodes} eps (was ${a.prior_episodes}, ${a.change_pct > 0 ? "+" : ""}${a.change_pct.toFixed(0)}%)`);
1345
+ lines.push(`- **${a.name}** — advertiser:${a.advertiser_id ?? a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
1346
+ `${trendToken(a.trend, a.change_pct)} episodes:${a.current_episodes} (was ${a.prior_episodes})`);
1077
1347
  }
1078
- return lines.join("\n");
1348
+ const firstId = data.advertisers.find((a) => (a.advertiser_id ?? a.id) != null);
1349
+ const hint = nextCallHint(firstId ? `get_ad_analytics view:advertiser advertiser_id:${firstId.advertiser_id ?? firstId.id}` : undefined);
1350
+ return lines.join("\n") + hint;
1079
1351
  }
1080
1352
  // ---------------------------------------------------------------------------
1081
1353
  // Claim Detail
1082
1354
  // ---------------------------------------------------------------------------
1355
+ function observationLine(o) {
1356
+ const date = fmtDate(o.publish_date);
1357
+ const meta = [o.speaker_name ?? "speaker:unresolved (diarization cluster)", o.channel_name, `date:${date}`]
1358
+ .filter(Boolean).join(", ");
1359
+ const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
1360
+ const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
1361
+ return [
1362
+ `- "${truncateWithPointer(o.chunk_text, pointer)}" — ${meta}`,
1363
+ ` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`,
1364
+ ];
1365
+ }
1083
1366
  export function formatClaimDetail(detail, observations, related) {
1084
1367
  const c = detail.claim;
1085
1368
  const lines = [];
1086
- lines.push(`# Claim #${c.claim_id}`);
1087
- lines.push(`**${c.summary}**\n`);
1088
- const stance = c.stance ?? "unknown";
1089
- const topic = c.topic ?? "uncategorized";
1090
- lines.push(`Stance: ${stance} | Category: ${topic}`);
1091
- lines.push(`${c.observation_count} observations across ${c.episode_count} episodes, ${c.channel_count} channels`);
1092
- const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter(d => d !== "?").join(" – ");
1093
- if (span)
1094
- lines.push(`Period: ${span}`);
1369
+ lines.push(`# Claim claim:${c.claim_id}`);
1370
+ lines.push(`**${c.summary}**`);
1371
+ lines.push(`(synthesized claim — observations carry verbatim quotes)\n`);
1372
+ const tokens = [`stance:${c.stance ?? "n/a"}`, `topic:${c.topic ?? "n/a"}`];
1373
+ const lt = langToken(c.language);
1374
+ if (lt)
1375
+ tokens.push(lt);
1376
+ lines.push(tokens.join(" "));
1377
+ lines.push(`obs:${c.observation_count} ${episodeTokens(c)} channels:${c.channel_count}`);
1378
+ const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter(d => d !== "n/a");
1379
+ if (span.length === 2)
1380
+ lines.push(`first_seen:${span[0]} last_seen:${span[1]}`);
1095
1381
  if (c.entities?.length) {
1096
- lines.push(`Entities: ${c.entities.join(", ")}`);
1382
+ lines.push(`entities:${c.entities.join(",")}`);
1097
1383
  }
1384
+ if (c.language && c.language !== "en")
1385
+ lines.push(NON_ENGLISH_NOTE);
1098
1386
  lines.push("");
1099
1387
  if (c.top_speakers?.length) {
1100
1388
  lines.push("**Speakers:**");
1101
1389
  for (const s of c.top_speakers.slice(0, 10)) {
1102
- const idStr = s.identity_id ? ` (id:${s.identity_id})` : "";
1103
- lines.push(`- ${s.name}${idStr} — ${s.observation_count} observations`);
1390
+ lines.push(`- ${s.name} — speaker:${s.identity_id ?? "n/a"} obs:${s.observation_count}`);
1104
1391
  }
1105
1392
  lines.push("");
1106
1393
  }
1107
1394
  if (c.top_channels?.length) {
1108
1395
  lines.push("**Channels:**");
1109
1396
  for (const ch of c.top_channels.slice(0, 10)) {
1110
- lines.push(`- ${ch.name} — ${ch.observation_count} observations`);
1397
+ lines.push(`- ${ch.name} — channel_id:${ch.channel_id} obs:${ch.observation_count}`);
1111
1398
  }
1112
1399
  lines.push("");
1113
1400
  }
1114
1401
  const obs = observations?.observations ?? c.sample_observations ?? [];
1402
+ let firstObsSeg;
1115
1403
  if (obs.length) {
1116
1404
  const label = observations ? `**Observations** (${observations.total} total)` : "**Sample Observations**";
1117
1405
  lines.push(label);
1118
1406
  for (const o of obs.slice(0, 20)) {
1119
- const date = fmtDate(o.publish_date);
1120
- const meta = [o.speaker_name, o.channel_name, date].filter(Boolean).join(", ");
1121
- lines.push(`- "${truncate(o.chunk_text, 150)}" — ${meta}`);
1122
- if (o.clip_url)
1123
- lines.push(` 🔊 ${o.clip_url}`);
1407
+ if (firstObsSeg == null && o.segment_id != null)
1408
+ firstObsSeg = o.segment_id;
1409
+ lines.push(...observationLine(o));
1410
+ }
1411
+ if (observations && observations.total > Math.min(obs.length, 20)) {
1412
+ lines.push(`showing ${Math.min(obs.length, 20)} of ${observations.total} — page:${(observations.page ?? 1) + 1} for more`);
1124
1413
  }
1125
1414
  lines.push("");
1126
1415
  }
1127
1416
  if (related?.claims?.length) {
1128
1417
  lines.push("**Related Claims:**");
1129
1418
  for (const r of related.claims) {
1130
- const sim = r.similarity != null ? ` (sim:${r.similarity.toFixed(2)})` : "";
1131
- lines.push(`- ${r.summary} — ${r.observation_count} observations${sim}`);
1419
+ lines.push(claimLine(r));
1132
1420
  }
1133
1421
  lines.push("");
1134
1422
  }
1135
- return lines.join("\n");
1423
+ const hint = nextCallHint(firstObsSeg != null ? `get_segment seg:${firstObsSeg}` : undefined, `claim_graph claim_id:${c.claim_id}`);
1424
+ return lines.join("\n") + hint;
1136
1425
  }
1137
1426
  // ---------------------------------------------------------------------------
1138
1427
  // Timeline
1139
1428
  // ---------------------------------------------------------------------------
1140
- export function formatTimeline(data) {
1429
+ /** MCP timeline: `date<TAB>count` rows (spec §5 — bar glyphs retired). */
1430
+ export function formatTimeline(data, footer = "") {
1141
1431
  const buckets = data.buckets ?? [];
1142
1432
  if (!buckets.length)
1143
- return "No data found for this topic in the given time range.";
1144
- const maxCount = Math.max(...buckets.map(b => b.count));
1145
- const barWidth = 30;
1433
+ return "No data found for this topic in the given time range." + footer;
1146
1434
  const lines = [
1147
1435
  `**"${data.topic}"** — ${data.total} segments (${data.granularity}, ${data.search_mode})\n`,
1436
+ "date\tcount",
1148
1437
  ];
1149
1438
  for (const b of buckets) {
1150
- const barLen = maxCount > 0 ? Math.round((b.count / maxCount) * barWidth) : 0;
1151
- const bar = "█".repeat(barLen);
1152
- const countStr = String(b.count).padStart(4);
1153
- lines.push(`${b.start} ${countStr} ${bar}`);
1439
+ lines.push(`${b.start}\t${b.count}`);
1154
1440
  }
1155
- return lines.join("\n");
1441
+ const peak = buckets.reduce((best, b) => (b.count > best.count ? b : best), buckets[0]);
1442
+ const hint = nextCallHint(`search_quotes topic:"${data.topic}" start_date:${peak.start.slice(0, 10)}`);
1443
+ return withFooters(lines.join("\n"), footer, "", hint);
1156
1444
  }
1157
1445
  //# sourceMappingURL=format.js.map