@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.
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 +582 -98
  5. package/dist/cli.js.map +1 -1
  6. package/dist/client.d.ts +18 -0
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +53 -7
  9. package/dist/client.js.map +1 -1
  10. package/dist/format.d.ts +67 -18
  11. package/dist/format.d.ts.map +1 -1
  12. package/dist/format.js +637 -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 +57 -17
  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 +53 -31
  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 +60 -37
  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 +55 -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");
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Footers (spec §7): provenance footer → paging note → next-call hint
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;
75
177
  }
76
178
  // ---------------------------------------------------------------------------
77
- // Quote dedup
179
+ // Quote dedup — episode + timestamp + full-text hash (spec / audit M8)
78
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,140 @@ 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
+ export function formatClaims(data, footer = "", offset = 0) {
213
354
  const claims = data.claims ?? [];
214
355
  if (!claims.length)
215
- return "No claims found matching your search criteria.";
356
+ return "No claims found matching your search criteria." + footer;
216
357
  const total = data.total_results ?? claims.length;
217
358
  const ms = data.processing_time_ms ?? 0;
218
- const mode = data.search_mode ?? "?";
359
+ const mode = data.search_mode ?? "n/a";
219
360
  const lines = [`**${total} claims** (showing ${claims.length}, ${mode}, ${ms.toFixed(0)}ms)\n`];
220
361
  for (let i = 0; i < claims.length; i++) {
221
362
  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})` : ""));
363
+ lines.push(claimLine(c, i + 1));
234
364
  if (c.entities.length) {
235
- lines.push(` Entities: ${c.entities.join(", ")}`);
365
+ lines.push(` entities:${c.entities.join(",")}`);
236
366
  }
237
367
  const speakers = c.top_speakers ?? [];
238
368
  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(", ")}`);
369
+ lines.push(` speakers: ${claimSpeakerParts(speakers)}`);
244
370
  }
245
371
  const channels = c.top_channels ?? [];
246
372
  if (channels.length) {
247
- const parts = channels.slice(0, 4).map((ch) => `${ch.name} (${ch.observation_count})`);
248
- lines.push(` Channels: ${parts.join(", ")}`);
373
+ const parts = channels.slice(0, 4).map((ch) => `${ch.name} (channel_id:${ch.channel_id}, ${ch.observation_count})`);
374
+ lines.push(` channels: ${parts.join(", ")}`);
249
375
  }
250
376
  const obs = c.sample_observations ?? [];
251
377
  if (obs.length) {
252
378
  const o = obs[0];
253
379
  const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
254
380
  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}`);
381
+ const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
382
+ lines.push(` sample: "${truncateWithPointer(o.chunk_text, pointer)}" — ${label}`);
383
+ lines.push(` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`);
260
384
  }
261
385
  lines.push("");
262
386
  }
263
- if (data.has_more) {
264
- lines.push(`_${total - claims.length} more claims available (use offset parameter)_`);
265
- }
266
- return lines.join("\n");
387
+ if (anyNonEnglish(claims))
388
+ lines.push(NON_ENGLISH_NOTE, "");
389
+ const paging = pagingNote({ shown: claims.length, total, offset });
390
+ const hint = nextCallHint(`get_claim claim:${claims[0].claim_id} include_observations:true`);
391
+ return withFooters(lines.join("\n"), footer, paging, hint);
267
392
  }
268
393
  // ---------------------------------------------------------------------------
269
394
  // Claims analysis (/api/v1/analysis/claims/*)
270
395
  // ---------------------------------------------------------------------------
271
- export function formatClaimsTopicAnalysis(data, topic) {
396
+ export function formatClaimsTopicAnalysis(data, topic, footer = "") {
272
397
  if (!data.themes?.length) {
273
- return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic.`;
398
+ return `No claims found for "${topic}". Try a wider time window (days), lower min_observations, or a broader topic.` + footer;
274
399
  }
275
400
  const m = data.quantitative_metrics ?? {};
276
401
  const lines = [`# Claims analysis: "${topic}"`, ""];
@@ -284,52 +409,70 @@ export function formatClaimsTopicAnalysis(data, topic) {
284
409
  ].filter(Boolean);
285
410
  if (summaryParts.length)
286
411
  lines.push(summaryParts.join(" · "), "");
412
+ let firstClaimId;
287
413
  for (const theme of data.themes) {
288
414
  lines.push(`## ${theme.name} — ${theme.claim_count} claims, ${theme.total_observations} obs across ${theme.channel_count} channels`);
289
415
  if (theme.entities?.length)
290
- lines.push(`Entities: ${theme.entities.slice(0, 8).join(", ")}`);
416
+ lines.push(`entities:${theme.entities.slice(0, 8).join(",")}`);
291
417
  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})`);
418
+ const tokens = [`claim:${c.claim_id ?? "n/a"}`];
419
+ if (firstClaimId == null && c.claim_id != null)
420
+ firstClaimId = c.claim_id;
421
+ if (c.stance)
422
+ tokens.push(`stance:${c.stance}`);
423
+ const detail = [`obs:${c.observation_count}`, `channels:${c.channel_count}`];
424
+ if (c.first_seen)
425
+ detail.push(`first_seen:${fmtDate(c.first_seen)}`);
426
+ if (c.last_seen)
427
+ detail.push(`last_seen:${fmtDate(c.last_seen)}`);
428
+ lines.push(`- **${c.summary}** — ${tokens.join(" ")}`);
429
+ lines.push(` ${detail.join(" ")}`);
430
+ const speakers = (c.top_speakers ?? []).slice(0, 3);
296
431
  if (speakers.length)
297
- lines.push(` Speakers: ${speakers.join(", ")}`);
432
+ lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
298
433
  }
299
434
  lines.push("");
300
435
  }
301
- return lines.join("\n");
436
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
437
+ return withFooters(lines.join("\n"), footer, "", hint);
302
438
  }
303
- const TREND_ARROW = {
304
- surging: "🚀", rising: "↗", steady: "→", declining: "↘", fading: "📉", new: "✨", no_data: "∅",
305
- };
306
- export function formatClaimsTrend(data, topic) {
439
+ export function formatClaimsTrend(data, topic, footer = "") {
307
440
  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.`;
441
+ return `No claim volume data for "${topic}" in the baseline window. Try a broader topic or longer baseline_days.` + footer;
309
442
  }
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
443
  const lines = [
313
- `# Claim trend: "${topic}" — ${arrow} ${data.direction.toUpperCase()} (${pct})`,
444
+ `# Claim trend: "${topic}" — ${trendToken(data.direction, data.change_pct)}`,
314
445
  "",
315
- `Recent (${data.recent.days}d): ${data.recent.claims} claims` +
446
+ `recent (${data.recent.days}d): ${data.recent.claims} claims` +
316
447
  (data.recent.new_claims != null ? ` (${data.recent.new_claims} new)` : "") +
317
448
  `, ${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`,
449
+ `baseline (${data.baseline.days}d): ${data.baseline.claims} claims, ${data.baseline.observations} obs, ${data.baseline.per_day}/day`,
319
450
  ];
320
- return lines.join("\n");
451
+ const hint = nextCallHint(`search_claims topic:"${topic}"`, `get_timeline topic:"${topic}"`);
452
+ return withFooters(lines.join("\n"), footer, "", hint);
321
453
  }
322
- export function formatClaimsContradictions(data, speaker, topic) {
454
+ export function formatClaimsContradictions(data, speaker, topic, footer = "") {
323
455
  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.`;
456
+ return `No claims found from ${speaker} on "${topic}". Try a longer time window (days) or check the speaker name.` + footer;
325
457
  }
326
- const lines = [`# Contradiction check: ${speaker} on "${topic}"`, "", `Verdict: **${data.verdict.replace(/_/g, " ")}** (${data.claims_analyzed} claims analyzed)`, ""];
458
+ const lines = [
459
+ `# Contradiction check: ${speaker} on "${topic}"`, "",
460
+ `Verdict: **${data.verdict.replace(/_/g, " ")}** (${data.claims_analyzed} claims analyzed)`, "",
461
+ ];
462
+ let firstClaimId;
463
+ // Contradiction claim texts are synthesized summaries (never quote-marked)
464
+ // and are NEVER truncated (spec §4/§6).
465
+ const side = (label, s) => {
466
+ if (firstClaimId == null && s.claim_id != null)
467
+ firstClaimId = s.claim_id;
468
+ return ` • ${label} — claim:${s.claim_id ?? "n/a"} stance:${s.stance} date:${fmtDate(s.date)} obs:${s.observations}\n ${s.claim}`;
469
+ };
327
470
  if (data.contradictions.length) {
328
471
  lines.push("## Contradictions");
329
472
  for (const c of data.contradictions) {
330
473
  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)`);
474
+ lines.push(side("earlier", c.earlier));
475
+ lines.push(side("later", c.later));
333
476
  }
334
477
  lines.push("");
335
478
  }
@@ -339,170 +482,181 @@ export function formatClaimsContradictions(data, speaker, topic) {
339
482
  if (data.consistent_positions.length) {
340
483
  lines.push("## Consistent positions");
341
484
  for (const p of data.consistent_positions) {
342
- lines.push(`- [${p.stance}] "${truncate(p.claim, 120)}" (${p.observations} obs)`);
485
+ lines.push(`- **${p.claim}** — claim:${p.claim_id ?? "n/a"} stance:${p.stance} obs:${p.observations}`);
486
+ if (firstClaimId == null && p.claim_id != null)
487
+ firstClaimId = p.claim_id;
343
488
  }
489
+ lines.push("");
344
490
  }
345
- return lines.join("\n");
491
+ lines.push("(synthesized claim — observations carry verbatim quotes)");
492
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
493
+ return withFooters(lines.join("\n"), footer, "", hint);
346
494
  }
347
- export function formatClaimsComparison(data, topic) {
495
+ export function formatClaimsComparison(data, topic, footer = "") {
348
496
  const lines = [`# Speaker comparison on claims: "${topic}"`, ""];
497
+ let firstClaimId;
349
498
  for (const sp of data.speakers) {
350
499
  lines.push(`## ${sp.name} — ${sp.claim_count} claims${sp.framing ? ` (${sp.framing})` : ""}`);
351
500
  if (sp.position_summary)
352
- lines.push(sp.position_summary);
501
+ lines.push(rewriteSegCitations(sp.position_summary));
353
502
  const sb = Object.entries(sp.stance_breakdown ?? {});
354
503
  if (sb.length)
355
- lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
504
+ lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
356
505
  for (const kc of (sp.key_claims ?? []).slice(0, 3)) {
357
- lines.push(`- "${truncate(kc.claim, 120)}"${kc.stance ? ` [${kc.stance}]` : ""} (${kc.observations} obs)`);
506
+ // Key-claim texts are synthesized and never truncated (spec §4/§6).
507
+ lines.push(`- **${kc.claim}** — claim:${kc.claim_id ?? "n/a"}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
508
+ if (firstClaimId == null && kc.claim_id != null)
509
+ firstClaimId = kc.claim_id;
358
510
  }
359
511
  lines.push("");
360
512
  }
361
513
  if (data.agreements?.length) {
362
- lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
514
+ lines.push("## Agreements", ...data.agreements.map((a) => `- ${rewriteSegCitations(a)}`), "");
363
515
  }
364
516
  if (data.disagreements?.length) {
365
- lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
517
+ lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${rewriteSegCitations(d)}`), "");
366
518
  }
367
- return lines.join("\n");
519
+ lines.push("(synthesized claim — observations carry verbatim quotes)");
520
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
521
+ return withFooters(lines.join("\n"), footer, "", hint);
368
522
  }
369
- export function formatChannelDossier(data) {
523
+ export function formatChannelDossier(data, footer = "") {
370
524
  const label = data.channels.join(", ");
371
525
  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.`;
526
+ return `No claims found from ${label} on "${data.topic}". Try a longer time window (days) or check the channel name.` + footer;
373
527
  }
374
528
  const lines = [`# Channel dossier: ${label} on "${data.topic}"`, ""];
375
529
  lines.push(`${data.claims_analyzed} claims analyzed${data.total_claims_available ? ` (${data.total_claims_available} available)` : ""}`);
376
530
  const sb = Object.entries(data.stance_breakdown ?? {});
377
531
  if (sb.length)
378
- lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
532
+ lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
379
533
  lines.push("");
380
534
  if (data.themes.length) {
381
535
  lines.push("## Themes");
382
536
  for (const t of data.themes) {
383
537
  lines.push(`- **${t.name}** — ${t.claim_count} claims, ${t.total_observations} obs`);
384
538
  if (t.summary)
385
- lines.push(` ${truncate(t.summary, 200)}`);
539
+ lines.push(` ${rewriteSegCitations(t.summary)}`);
386
540
  }
387
541
  lines.push("");
388
542
  }
389
543
  if (data.key_speakers.length) {
390
- lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (${s.claim_count})`).join(", ")}`, "");
544
+ lines.push(`Key speakers: ${data.key_speakers.map((s) => `${s.name} (speaker:${s.speaker_id ?? "n/a"}, ${s.claim_count})`).join(", ")}`, "");
391
545
  }
546
+ let firstClaimId;
392
547
  if (data.key_claims.length) {
393
548
  lines.push("## Key claims");
394
549
  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)`);
550
+ if (firstClaimId == null)
551
+ firstClaimId = kc.claim_id;
552
+ lines.push(`- **${kc.claim}** — claim:${kc.claim_id}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
396
553
  }
397
554
  lines.push("");
398
555
  }
399
556
  if (data.trajectory.length > 1) {
400
- lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}`);
557
+ lines.push(`Trajectory: ${data.trajectory.map((t) => `${t.month}: ${t.claims}`).join(" · ")}`, "");
401
558
  }
402
- return lines.join("\n");
559
+ lines.push("(synthesized claim — observations carry verbatim quotes)");
560
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
561
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
562
+ return withFooters(lines.join("\n"), footer, "", hint);
403
563
  }
404
- export function formatChannelClaimsComparison(data, topic) {
564
+ export function formatChannelClaimsComparison(data, topic, footer = "") {
405
565
  const lines = [`# Channel comparison on claims: "${topic}"`, ""];
566
+ let firstClaimId;
406
567
  for (const ch of data.channels) {
407
568
  lines.push(`## ${ch.name} — ${ch.claim_count} claims${ch.framing ? ` (${ch.framing})` : ""}`);
408
569
  if (ch.position_summary)
409
- lines.push(ch.position_summary);
570
+ lines.push(rewriteSegCitations(ch.position_summary));
410
571
  const sb = Object.entries(ch.stance_breakdown ?? {});
411
572
  if (sb.length)
412
- lines.push(`Stance: ${sb.map(([k, v]) => `${k}: ${v}`).join(", ")}`);
573
+ lines.push(`stance: ${sb.map(([k, v]) => `${k}:${v}`).join(" ")}`);
413
574
  if (ch.key_speakers?.length) {
414
- lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => s.name).join(", ")}`);
575
+ lines.push(`Key speakers: ${ch.key_speakers.slice(0, 5).map((s) => `${s.name} (speaker:${s.speaker_id ?? "n/a"})`).join(", ")}`);
415
576
  }
416
577
  for (const kc of (ch.key_claims ?? []).slice(0, 3)) {
417
- lines.push(`- "${truncate(kc.claim, 120)}"${kc.stance ? ` [${kc.stance}]` : ""} (${kc.observations} obs)`);
578
+ lines.push(`- **${kc.claim}** — claim:${kc.claim_id ?? "n/a"}${kc.stance ? ` stance:${kc.stance}` : ""} obs:${kc.observations}`);
579
+ if (firstClaimId == null && kc.claim_id != null)
580
+ firstClaimId = kc.claim_id;
418
581
  }
419
582
  lines.push("");
420
583
  }
421
584
  if (data.agreements?.length) {
422
- lines.push("## Agreements", ...data.agreements.map((a) => `- ${a}`), "");
585
+ lines.push("## Agreements", ...data.agreements.map((a) => `- ${rewriteSegCitations(a)}`), "");
423
586
  }
424
587
  if (data.disagreements?.length) {
425
- lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${d}`), "");
588
+ lines.push("## Disagreements", ...data.disagreements.map((d) => `- ${rewriteSegCitations(d)}`), "");
426
589
  }
427
590
  if (data.speaker_overlap?.length) {
428
591
  lines.push("## Shared speakers");
429
592
  for (const o of data.speaker_overlap) {
430
593
  lines.push(`- ${o.channels.join(" & ")}: ${o.shared_speakers.join(", ")}`);
431
594
  }
595
+ lines.push("");
432
596
  }
433
- return lines.join("\n");
597
+ lines.push("(synthesized claim — observations carry verbatim quotes)");
598
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
599
+ const hint = nextCallHint(firstClaimId != null ? `get_claim claim:${firstClaimId} include_observations:true` : undefined);
600
+ return withFooters(lines.join("\n"), footer, "", hint);
434
601
  }
435
602
  // ---------------------------------------------------------------------------
436
603
  // Claims graph (entity-centric, hierarchy/relations, leaderboard)
437
604
  // ---------------------------------------------------------------------------
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
605
  export function formatEntitySearch(data) {
456
606
  if (!data.entities.length) {
457
607
  return `No entities found matching "${data.query}". Try a shorter or differently-spelled query.`;
458
608
  }
459
609
  const lines = [`# Entities matching "${data.query}"`, ""];
460
610
  for (const e of data.entities) {
461
- const meta = [];
611
+ const tokens = [`entity:${e.id}`];
462
612
  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(" · ")})`);
613
+ tokens.push(`type:${e.entity_type}`);
614
+ tokens.push(`claims:${e.claim_count}`, `channels:${e.channel_count}`);
615
+ if (e.first_seen_date)
616
+ tokens.push(`first_seen:${fmtDate(e.first_seen_date)}`);
617
+ if (e.last_seen_date)
618
+ tokens.push(`last_seen:${fmtDate(e.last_seen_date)}`);
619
+ lines.push(`- **${e.name}** — ${tokens.join(" ")}`);
469
620
  if (e.aliases?.length)
470
- lines.push(` Aliases: ${e.aliases.slice(0, 5).join(", ")}`);
621
+ lines.push(` aliases:${e.aliases.slice(0, 5).join(",")}`);
471
622
  }
472
- lines.push("", "_Use entity_claims with the exact name for the claims themselves._");
473
- return lines.join("\n");
623
+ const hint = nextCallHint(`entity_claims entity:"${data.entities[0].name}"`);
624
+ return lines.join("\n") + hint;
474
625
  }
475
- export function formatEntityClaims(data) {
626
+ export function formatEntityClaims(data, footer = "", offset = 0) {
476
627
  if (!data.entity_id) {
477
- return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching.`;
628
+ return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching.` + footer;
478
629
  }
479
630
  const lines = [`# Claims about ${data.entity}`];
480
- const meta = [];
631
+ const tokens = [`entity:${data.entity_id}`];
481
632
  if (data.entity_type)
482
- meta.push(data.entity_type);
633
+ tokens.push(`type:${data.entity_type}`);
483
634
  if (data.claim_count_rollup != null)
484
- meta.push(`${data.claim_count_rollup.toLocaleString()} claims`);
635
+ tokens.push(`claims:${data.claim_count_rollup}`);
485
636
  if (data.channel_count != null)
486
- meta.push(`${data.channel_count.toLocaleString()} channels`);
487
- if (meta.length)
488
- lines.push(meta.join(" · "));
637
+ tokens.push(`channels:${data.channel_count}`);
638
+ lines.push(tokens.join(" "));
489
639
  if (data.aliases?.length)
490
- lines.push(`Aliases: ${data.aliases.slice(0, 6).join(", ")}`);
640
+ lines.push(`aliases:${data.aliases.slice(0, 6).join(",")}`);
491
641
  if (data.stance_distribution?.length) {
492
642
  const total = data.stance_distribution.reduce((s, x) => s + x.count, 0) || 1;
493
643
  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(", ")}`);
644
+ .map((s) => `${s.stance}:${s.count} (${Math.round((s.count / total) * 100)}%)`);
645
+ lines.push(`\nstance: ${parts.join(" ")}`);
496
646
  }
497
- lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims.toLocaleString()})`);
647
+ lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims})`);
498
648
  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");
649
+ if (anyNonEnglish(data.claims))
650
+ lines.push("", NON_ENGLISH_NOTE);
651
+ const paging = pagingNote({ shown: data.claims.length, total: data.total_claims, offset });
652
+ const hint = data.claims.length
653
+ ? nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`)
654
+ : "";
655
+ return withFooters(lines.join("\n"), footer, paging, hint);
502
656
  }
503
657
  export function formatClaimGraph(data) {
504
658
  const lines = [`# Claim graph`, "", `**${data.claim.summary}**`,
505
- `id:${data.claim.claim_id} · ${data.claim.observation_count} obs / ${data.claim.channel_count} channels`, ""];
659
+ `claim:${data.claim.claim_id} obs:${data.claim.observation_count} channels:${data.claim.channel_count}`, ""];
506
660
  if (data.parent) {
507
661
  lines.push("## Broader claim (parent)", claimLine(data.parent), "");
508
662
  }
@@ -519,21 +673,27 @@ export function formatClaimGraph(data) {
519
673
  if (!data.parent && !data.children?.length && !data.siblings?.length) {
520
674
  lines.push("_No parent, children, or sibling claims found. This claim stands alone in the hierarchy._");
521
675
  }
522
- return lines.join("\n");
676
+ const next = data.children?.[0] ?? data.siblings?.[0] ?? data.parent;
677
+ const hint = nextCallHint(`get_claim claim:${data.claim.claim_id} include_observations:true`, next ? `claim_graph claim_id:${next.claim_id}` : undefined);
678
+ return lines.join("\n") + hint;
523
679
  }
524
- export function formatTopClaims(data) {
680
+ export function formatTopClaims(data, footer = "") {
525
681
  if (!data.claims?.length) {
526
- return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations.`;
682
+ return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations.` + footer;
527
683
  }
528
- const label = data.metric === "contested" ? "Most contested claims" : "Most widely-spread claims";
684
+ const contested = data.metric === "contested";
685
+ const label = contested ? "Most contested claims" : "Most widely-spread claims";
529
686
  const lines = [`# ${label}`, ""];
530
687
  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})`);
688
+ lines.push(claimLine(c, i + 1, { assertDeny: contested }));
689
+ const speakers = (c.top_speakers ?? []).slice(0, 3);
533
690
  if (speakers.length)
534
- lines.push(` Speakers: ${speakers.join(", ")}`);
691
+ lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
535
692
  });
536
- return lines.join("\n");
693
+ if (anyNonEnglish(data.claims))
694
+ lines.push("", NON_ENGLISH_NOTE);
695
+ const hint = nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`);
696
+ return withFooters(lines.join("\n"), footer, "", hint);
537
697
  }
538
698
  // ---------------------------------------------------------------------------
539
699
  // Speaker
@@ -561,31 +721,31 @@ export function formatSpeaker(data) {
561
721
  lines.push(`**Stats:** ${stats.join(", ")}`);
562
722
  const sid = data.speaker_id ?? data.id;
563
723
  if (sid)
564
- lines.push(`Speaker ID: \`${sid}\``);
724
+ lines.push(`speaker:${sid}`);
565
725
  const topChannels = data.top_channels ?? [];
566
726
  if (topChannels.length) {
567
727
  lines.push("\n**Top Channels:**");
568
728
  for (const ch of topChannels.slice(0, 8)) {
569
- lines.push(`- ${ch.name ?? "?"} (ID: ${ch.channel_id ?? "?"}, ${ch.count ?? 0} appearances)`);
729
+ lines.push(`- ${ch.name ?? "n/a"} — channel_id:${ch.channel_id ?? "n/a"} appearances:${ch.count ?? 0}`);
570
730
  }
571
731
  }
572
732
  const related = data.related_speakers ?? [];
573
733
  if (related.length) {
574
734
  lines.push("\n**Co-appearances:**");
575
735
  for (const r of related.slice(0, 10)) {
576
- const count = r.appearance_count ?? r.co_appearances ?? "?";
577
- lines.push(`- ${r.name ?? "?"} (${count})`);
736
+ const count = r.appearance_count ?? r.co_appearances ?? "n/a";
737
+ lines.push(`- ${r.name ?? "n/a"} — speaker:${r.speaker_id ?? r.identity_id ?? "n/a"} count:${count}`);
578
738
  }
579
739
  }
580
740
  const recent = data.recent_episodes ?? [];
581
741
  if (recent.length) {
582
742
  lines.push("\n**Recent Episodes:**");
583
743
  for (const ep of recent.slice(0, 8)) {
584
- const date = fmtDate(ep.publish_date);
585
- lines.push(`- [${date}] ${ep.channel_name ?? "?"} — ${ep.title ?? "?"}`);
744
+ lines.push(`- date:${fmtDate(ep.publish_date)} episode:${ep.content_id ?? "n/a"} ${ep.channel_name ?? "n/a"} — ${ep.title ?? "n/a"}`);
586
745
  }
587
746
  }
588
- return lines.join("\n");
747
+ 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);
748
+ return lines.join("\n") + hint;
589
749
  }
590
750
  // ---------------------------------------------------------------------------
591
751
  // Channel
@@ -602,12 +762,10 @@ export function formatChannel(data) {
602
762
  meta.push(`**Language:** ${data.language}`);
603
763
  if (meta.length)
604
764
  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
- }
765
+ const cid = data.channel_id ?? data.id;
766
+ lines.push(`channel_id:${cid ?? "n/a"}`);
767
+ if (data.political_lean != null)
768
+ lines.push(leanToken(data.political_lean));
611
769
  if (data.description)
612
770
  lines.push(`\n${data.description.slice(0, 500)}`);
613
771
  if (data.primary_url)
@@ -622,52 +780,58 @@ export function formatChannel(data) {
622
780
  if (parts.length)
623
781
  lines.push(`\n**Ranking:** ${parts.join(", ")}`);
624
782
  for (const cp of (ranking.chart_positions ?? []).slice(0, 5)) {
625
- lines.push(`- Chart: ${cp.platform ?? "?"} ${cp.country ?? "?"}/${cp.category ?? "?"} #${cp.rank ?? "?"}`);
783
+ lines.push(`- Chart: ${cp.platform ?? "n/a"} ${cp.country ?? "n/a"}/${cp.category ?? "n/a"} #${cp.rank ?? "n/a"}`);
626
784
  }
627
785
  }
628
786
  const speakers = data.regular_speakers ?? data.speakers ?? [];
629
787
  if (speakers.length) {
630
788
  lines.push("\n**Speakers:**");
631
789
  for (const s of speakers.slice(0, 10)) {
632
- const count = s.appearance_count ?? s.count ?? "?";
633
- lines.push(`- ${s.name ?? "?"} (${count} appearances)`);
790
+ const count = s.appearance_count ?? s.count ?? "n/a";
791
+ lines.push(`- ${s.name ?? "n/a"} — speaker:${s.speaker_id ?? s.identity_id ?? "n/a"} appearances:${count}`);
634
792
  }
635
793
  }
636
794
  const recent = data.recent_episodes ?? [];
637
795
  if (recent.length) {
638
796
  lines.push("\n**Recent Episodes:**");
639
797
  for (const ep of recent.slice(0, 8)) {
640
- lines.push(`- [${fmtDate(ep.publish_date)}] ${ep.title ?? "?"}`);
798
+ lines.push(`- date:${fmtDate(ep.publish_date)} episode:${ep.content_id ?? "n/a"} ${ep.title ?? "n/a"}`);
641
799
  }
642
800
  }
643
- return lines.join("\n");
801
+ const hint = nextCallHint(cid != null ? `search_quotes channel_id:${cid}` : undefined, recent[0]?.content_id ? `get_episode content_id:${recent[0].content_id}` : undefined);
802
+ return lines.join("\n") + hint;
644
803
  }
645
804
  // ---------------------------------------------------------------------------
646
805
  // Segment
647
806
  // ---------------------------------------------------------------------------
648
807
  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);
808
+ const segId = data.segment_id;
809
+ const spk = data.speaker ?? {};
810
+ const spkName = data.speaker_name ?? spk.name;
811
+ const resolved = spk.identity_id != null;
812
+ const clip = clipUrl(data.content_id, data.start_time ?? undefined, data.end_time ?? undefined, data.clip_url);
813
+ const speakerTokens = [];
814
+ if (resolved)
815
+ speakerTokens.push(`speaker:${spk.identity_id}`);
816
+ else if (!spkName)
817
+ speakerTokens.push("speaker:unresolved (diarization cluster)");
818
+ if (spk.confidence !== undefined || resolved)
819
+ speakerTokens.push(confToken(spk.confidence));
820
+ if (spk.appearance_type)
821
+ speakerTokens.push(`appearance:${spk.appearance_type}`);
658
822
  const lines = [
659
- `**Segment ${segId}**`,
660
- `- **Speaker:** ${spk}`,
661
- `- **Channel:** ${ch}`,
662
- `- **Episode:** ${title} (\`${contentId}\`)`,
663
- `- **Date:** ${date}`,
823
+ `**Segment ${segId ?? "n/a"}**`,
824
+ `- **Speaker:** ${spkName ?? "unresolved (diarization cluster)"}${speakerTokens.length ? ` — ${speakerTokens.join(" ")}` : ""}`,
825
+ `- **Channel:** ${data.channel_name ?? "n/a"}${data.channel_id != null ? ` — channel_id:${data.channel_id}` : ""}`,
826
+ `- **Episode:** ${data.episode_title ?? "n/a"} — episode:${data.content_id ?? "n/a"}`,
827
+ `- **Date:** date:${fmtDate(data.episode_date)}`,
664
828
  ];
665
- if (tStart != null)
666
- lines.push(`- **Time:** ${fmtTime(tStart)} – ${fmtTime(tEnd)}`);
667
- if (clip)
668
- lines.push(`- **Audio clip:** ${clip}`);
829
+ if (data.start_time != null)
830
+ lines.push(`- **Time:** ${fmtTimeRange(data.start_time, data.end_time)}`);
831
+ lines.push(`- ${clipLine(data.content_type, clip)}`);
669
832
  lines.push(`\n> ${data.text ?? ""}`);
670
- return lines.join("\n");
833
+ const hint = nextCallHint(segId != null ? `get_thread segment_id:${segId}` : undefined, data.content_id ? `get_episode content_id:${data.content_id}` : undefined);
834
+ return lines.join("\n") + hint;
671
835
  }
672
836
  // ---------------------------------------------------------------------------
673
837
  // Analysis
@@ -683,7 +847,7 @@ export function formatAnalysis(data) {
683
847
  const lines = ["**Analysis complete**\n"];
684
848
  const centrality = q.discourse_centrality;
685
849
  if (centrality?.label) {
686
- lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "?"}/5)`);
850
+ lines.push(`Discourse centrality: ${centrality.label} (${centrality.level ?? "n/a"}/5)`);
687
851
  }
688
852
  const parts = [];
689
853
  if (q.total_segments)
@@ -704,19 +868,45 @@ export function formatAnalysis(data) {
704
868
  const count = (t.segment_count ?? t.count);
705
869
  const countStr = count ? ` (${count} segments)` : "";
706
870
  lines.push(`${i + 1}. ${name}${countStr}`);
707
- const desc = (t.summary ?? t.description);
871
+ const desc = rewriteSegCitations((t.summary ?? t.description));
708
872
  if (desc)
709
- lines.push(` ${desc.slice(0, 200)}`);
873
+ lines.push(` ${desc}`);
710
874
  }
711
875
  lines.push("");
712
876
  }
713
877
  if (data.summary)
714
- lines.push(data.summary);
715
- return lines.join("\n");
878
+ lines.push(rewriteSegCitations(data.summary));
879
+ const firstSeg = (data.segment_ids ?? [])[0];
880
+ const hint = nextCallHint(firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
881
+ return lines.join("\n") + hint;
716
882
  }
717
883
  // ---------------------------------------------------------------------------
718
884
  // Frame comparison (left vs right)
719
885
  // ---------------------------------------------------------------------------
886
+ // LLM theme summaries can still hit the token ceiling and stop mid-sentence or
887
+ // mid-citation ("...to leave {seg_4225"). Drop any trailing incomplete {seg_...}
888
+ // citation, then cut back to the last complete sentence and flag the trim.
889
+ function trimTruncatedSummary(text) {
890
+ if (!text)
891
+ return text;
892
+ let s = text.trimEnd();
893
+ let trimmed = false;
894
+ // Dangling open citation at the end (an unclosed "{...")
895
+ const lastOpen = s.lastIndexOf("{");
896
+ if (lastOpen !== -1 && s.indexOf("}", lastOpen) === -1) {
897
+ s = s.slice(0, lastOpen).trimEnd();
898
+ trimmed = true;
899
+ }
900
+ // Mid-sentence cut: back up to the last sentence/citation boundary.
901
+ if (!/[.!?…}"”')\]]$/.test(s)) {
902
+ const m = s.match(/^[\s\S]*[.!?…}]/);
903
+ if (m) {
904
+ s = m[0];
905
+ trimmed = true;
906
+ }
907
+ }
908
+ return trimmed ? `${s} [summary truncated]` : s;
909
+ }
720
910
  export function formatFrameComparison(left, right, topic) {
721
911
  if (left.error && right.error)
722
912
  return `Error: left=${left.error}, right=${right.error}`;
@@ -726,6 +916,7 @@ export function formatFrameComparison(left, right, topic) {
726
916
  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
917
  }
728
918
  const lines = [`**Frame comparison: ${topic}**\n`];
919
+ let firstSeg;
729
920
  function sideBlock(label, data) {
730
921
  if (data.error) {
731
922
  lines.push(`### ${label}\nError: ${data.error}\n`);
@@ -740,25 +931,34 @@ export function formatFrameComparison(left, right, topic) {
740
931
  if (q.unique_speakers)
741
932
  parts.push(`${q.unique_speakers} speakers`);
742
933
  lines.push(`### ${label}${parts.length ? ` (${parts.join(", ")})` : ""}`);
743
- const themes = (data.themes ?? []).slice(0, 8);
934
+ // Render EVERY theme, numbered — the synthesis prose refers to themes by
935
+ // number, so truncating the list left it citing "Themes 12–19" that were
936
+ // never displayed. Include per-theme prose and segment citations so a
937
+ // reader can verify any claim (seg:<id> works with get_segment).
938
+ const themes = data.themes ?? [];
744
939
  if (themes.length) {
745
940
  lines.push("**Key themes:**");
746
- for (const t of themes) {
941
+ themes.forEach((t, i) => {
747
942
  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
- }
943
+ const desc = rewriteSegCitations(trimTruncatedSummary((t.summary ?? t.description)));
944
+ const segs = t.segment_count ? ` — ${t.segment_count} segments` : "";
945
+ const cites = (t.segment_ids ?? []).slice(0, 4).map((id) => `seg:${id}`).join(" ");
946
+ if (firstSeg == null && (t.segment_ids ?? []).length)
947
+ firstSeg = t.segment_ids[0];
948
+ lines.push(`${i + 1}. **${name ?? `Theme ${i + 1}`}**${segs}${desc ? ": " + desc : ""}${cites ? ` [${cites}]` : ""}`);
949
+ });
752
950
  }
753
951
  if (data.summary) {
754
952
  lines.push("");
755
- lines.push(data.summary);
953
+ lines.push(rewriteSegCitations(data.summary));
756
954
  }
757
955
  lines.push("");
758
956
  }
759
957
  sideBlock("Left-leaning media", left);
760
958
  sideBlock("Right-leaning media", right);
761
- return lines.join("\n");
959
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
960
+ const hint = nextCallHint(firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
961
+ return lines.join("\n") + hint;
762
962
  }
763
963
  // ---------------------------------------------------------------------------
764
964
  // Comparison
@@ -773,11 +973,12 @@ export function formatComparison(data, topic) {
773
973
  const lines = [`**Speaker comparison: ${topic}**\n`];
774
974
  for (const [name, sp] of Object.entries(speakers)) {
775
975
  const count = sp.segment_count ?? 0;
776
- lines.push(`### ${name} (${count} segments)`);
976
+ const gated = sp.gated_out ? `, ${sp.gated_out} weak matches excluded` : "";
977
+ lines.push(`### ${name} (${count} on-topic segments${gated})`);
777
978
  if (sp.position_summary)
778
- lines.push(`**Position:** ${sp.position_summary}`);
979
+ lines.push(`**Position:** ${rewriteSegCitations(sp.position_summary)}`);
779
980
  if (sp.framing)
780
- lines.push(`**Framing:** ${sp.framing}`);
981
+ lines.push(`**Framing:** ${rewriteSegCitations(sp.framing)}`);
781
982
  if (sp.tone)
782
983
  lines.push(`**Tone:** ${sp.tone}`);
783
984
  const claims = sp.key_claims ?? [];
@@ -785,20 +986,45 @@ export function formatComparison(data, topic) {
785
986
  lines.push("**Key claims:**");
786
987
  for (const c of claims.slice(0, 5)) {
787
988
  const text = typeof c === "string" ? c : c.claim ?? String(c);
788
- lines.push(`- ${text}`);
989
+ const cid = typeof c === "object" && c ? c.claim_id : undefined;
990
+ const idTok = cid != null ? ` — claim:${cid}` : "";
991
+ lines.push(`- ${rewriteSegCitations(text)}${idTok}`);
789
992
  }
790
993
  }
791
994
  lines.push("");
792
995
  }
793
996
  if (data.comparison || data.summary) {
794
- lines.push("---");
795
- lines.push(data.comparison ?? data.summary ?? "");
997
+ lines.push("## Synthesis");
998
+ lines.push(rewriteSegCitations(data.comparison ?? data.summary ?? ""));
999
+ lines.push("");
796
1000
  }
1001
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
797
1002
  return lines.join("\n");
798
1003
  }
799
1004
  // ---------------------------------------------------------------------------
800
1005
  // Dossier
801
1006
  // ---------------------------------------------------------------------------
1007
+ // Render the retrieval/provenance line shared by dossier and contradictions:
1008
+ // window searched, dates the evidence spans, match strength, gating stats.
1009
+ function pushRetrievalProvenance(lines, ret) {
1010
+ if (!ret)
1011
+ return;
1012
+ const parts = [];
1013
+ if (ret.window_days)
1014
+ parts.push(`window: last ${ret.window_days} days`);
1015
+ if (ret.date_range?.length === 2)
1016
+ parts.push(`evidence spans ${ret.date_range[0].slice(0, 10)} → ${ret.date_range[1].slice(0, 10)}`);
1017
+ if (ret.similarity_range?.length === 2)
1018
+ parts.push(`relevance ${ret.similarity_range[0].toFixed(2)}–${ret.similarity_range[1].toFixed(2)}`);
1019
+ if (ret.gated_out)
1020
+ parts.push(`${ret.gated_out} weak matches (<${(ret.min_similarity ?? 0.4).toFixed(2)}) excluded`);
1021
+ if (ret.weak_match_count)
1022
+ parts.push(`${ret.weak_match_count} weak matches (<0.40) included`);
1023
+ if (parts.length)
1024
+ lines.push(`_${parts.join(" · ")}_`);
1025
+ if (ret.thin_evidence)
1026
+ lines.push(`_⚠ Thin evidence: fewer than 5 on-topic segments met the relevance threshold — treat conclusions as provisional._`);
1027
+ }
802
1028
  export function formatDossier(data, speakerName, topic) {
803
1029
  if (data.error)
804
1030
  return `Error: ${data.error}`;
@@ -810,17 +1036,23 @@ export function formatDossier(data, speakerName, topic) {
810
1036
  if (sp.occupation)
811
1037
  lines.push(`*${sp.occupation}*`);
812
1038
  if (sp.segment_count)
813
- lines.push(`${sp.segment_count} segments analyzed\n`);
1039
+ lines.push(`${sp.segment_count} segments analyzed`);
1040
+ // Show the working: window searched, dates the evidence actually spans, and
1041
+ // match strength — a dossier built from one recent episode must not pass
1042
+ // for a longitudinal record.
1043
+ pushRetrievalProvenance(lines, sp.retrieval);
1044
+ lines.push("");
814
1045
  const contras = data.contradictions;
815
1046
  if (contras?.verdict) {
816
1047
  lines.push(`**Contradiction check:** ${contras.verdict.replace(/_/g, " ")}`);
817
1048
  for (const c of contras.contradictions ?? []) {
818
- lines.push(`- [${c.severity ?? "?"}] ${c.description ?? "?"}`);
1049
+ lines.push(`- [${c.severity ?? "n/a"}] ${c.description ?? "n/a"}`);
819
1050
  }
820
1051
  lines.push("");
821
1052
  }
822
1053
  if (data.summary)
823
- lines.push(data.summary);
1054
+ lines.push(rewriteSegCitations(data.summary), "");
1055
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
824
1056
  return lines.join("\n");
825
1057
  }
826
1058
  // ---------------------------------------------------------------------------
@@ -835,13 +1067,15 @@ export function formatContradictions(data, speakerName, topic) {
835
1067
  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
1068
  }
837
1069
  const lines = [`**Contradiction check: ${speakerName} on ${topic}**\n`];
1070
+ pushRetrievalProvenance(lines, data.speaker?.retrieval);
838
1071
  lines.push(`**Verdict:** ${verdict.replace(/_/g, " ")}\n`);
1072
+ // Contradiction evidence is NEVER truncated (spec §4).
839
1073
  for (const c of contras.contradictions ?? []) {
840
- lines.push(`### [${c.severity ?? "?"}] ${c.description ?? "?"}`);
1074
+ lines.push(`### [${c.severity ?? "n/a"}] ${c.description ?? "n/a"}`);
841
1075
  if (c.earlier?.claim)
842
- lines.push(`**Earlier** (${c.earlier.date ?? "?"}): ${c.earlier.claim}`);
1076
+ lines.push(`**Earlier** (date:${fmtDate(c.earlier.date)}): ${rewriteSegCitations(c.earlier.claim)}`);
843
1077
  if (c.later?.claim)
844
- lines.push(`**Later** (${c.later.date ?? "?"}): ${c.later.claim}`);
1078
+ lines.push(`**Later** (date:${fmtDate(c.later.date)}): ${rewriteSegCitations(c.later.claim)}`);
845
1079
  if (c.possible_context)
846
1080
  lines.push(`*Context:* ${c.possible_context}`);
847
1081
  lines.push("");
@@ -850,18 +1084,22 @@ export function formatContradictions(data, speakerName, topic) {
850
1084
  if (consistent.length) {
851
1085
  lines.push("**Consistent positions:**");
852
1086
  for (const pos of consistent)
853
- lines.push(`- ${pos}`);
1087
+ lines.push(`- ${rewriteSegCitations(pos)}`);
854
1088
  lines.push("");
855
1089
  }
856
1090
  const timeline = data.stance_timeline ?? [];
857
1091
  if (timeline.length) {
858
1092
  lines.push("**Stance timeline:**");
859
1093
  for (const t of timeline.slice(0, 20)) {
860
- lines.push(`- ${t.segment_id ?? "?"}: ${t.stance ?? "?"} — ${(t.claim ?? "").slice(0, 100)}`);
1094
+ const pointer = t.segment_id != null ? `get_segment seg:${t.segment_id}` : undefined;
1095
+ lines.push(`- seg:${t.segment_id ?? "n/a"} stance:${t.stance ?? "n/a"} — ${truncateWithPointer(t.claim, pointer)}`);
861
1096
  }
1097
+ lines.push("");
862
1098
  }
863
- if (data.summary && !(contras.contradictions ?? []).length)
864
- lines.push(data.summary);
1099
+ lines.push("quotes are model-extracted; verify via cited seg: before publication");
1100
+ // Note: data.summary is deliberately NOT appended — the backend builds it
1101
+ // from the same verdict/contradictions/consistent_positions fields rendered
1102
+ // above, so it duplicated the report as a dangling stub.
865
1103
  return lines.join("\n");
866
1104
  }
867
1105
  // ---------------------------------------------------------------------------
@@ -871,13 +1109,13 @@ export function formatBriefing(data) {
871
1109
  const lines = [];
872
1110
  const stats = data.statistics ?? {};
873
1111
  if (stats.total_episodes || stats.total_channels) {
874
- lines.push(`**Briefing:** ${stats.total_episodes ?? "?"} episodes, ` +
875
- `${stats.total_channels ?? "?"} channels, ` +
1112
+ lines.push(`**Briefing:** ${stats.total_episodes ?? "n/a"} episodes, ` +
1113
+ `${stats.total_channels ?? "n/a"} channels, ` +
876
1114
  `${(stats.total_duration_hours ?? 0).toFixed(0)}h\n`);
877
1115
  }
878
1116
  for (let i = 0; i < (data.topics ?? []).length; i++) {
879
1117
  const t = data.topics[i];
880
- const title = t.title ?? t.label ?? "?";
1118
+ const title = t.title ?? t.label ?? "n/a";
881
1119
  const segs = t.segment_count ?? 0;
882
1120
  const chs = t.channel_count ?? 0;
883
1121
  const score = t.score ?? 0;
@@ -887,8 +1125,16 @@ export function formatBriefing(data) {
887
1125
  lines.push(` ${truncate(summary, 250)}`);
888
1126
  for (const c of (t.citations ?? []).slice(0, 2)) {
889
1127
  const label = [c.speaker_name, c.channel_name].filter(Boolean).join(" on ");
1128
+ const ids = [];
1129
+ if (c.segment_id != null)
1130
+ ids.push(`seg:${c.segment_id}`);
1131
+ if (c.content_id)
1132
+ ids.push(`episode:${c.content_id}`);
1133
+ if (c.publish_date)
1134
+ ids.push(`date:${fmtDate(c.publish_date)}`);
1135
+ const pointer = c.segment_id != null ? `get_segment seg:${c.segment_id}` : undefined;
890
1136
  if (c.text)
891
- lines.push(` "${truncate(c.text, 120)}" — ${label}`);
1137
+ lines.push(` "${truncateWithPointer(c.text, pointer)}" — ${label}${ids.length ? ` (${ids.join(" ")})` : ""}`);
892
1138
  }
893
1139
  lines.push("");
894
1140
  }
@@ -896,18 +1142,20 @@ export function formatBriefing(data) {
896
1142
  if (events.length) {
897
1143
  lines.push("**Key events:**");
898
1144
  for (const ev of events.slice(0, 5)) {
899
- lines.push(`- ${ev.title ?? ev.description ?? "?"}`);
1145
+ lines.push(`- ${ev.title ?? ev.description ?? "n/a"}`);
900
1146
  }
901
1147
  lines.push("");
902
1148
  }
903
1149
  const speakers = data.key_speakers ?? [];
904
1150
  if (speakers.length) {
905
- const parts = speakers.slice(0, 8).map((s) => `${s.name ?? "?"} (${s.segment_count ?? "?"} seg)`);
1151
+ 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
1152
  lines.push(`**Key speakers:** ${parts.join(", ")}\n`);
907
1153
  }
908
1154
  if (data.synthesis)
909
- lines.push(data.synthesis);
910
- return lines.join("\n") || "No briefing data available.";
1155
+ lines.push(rewriteSegCitations(data.synthesis));
1156
+ const firstTopic = data.topics?.[0];
1157
+ const hint = nextCallHint(firstTopic?.title ? `analyze_topic query:"${firstTopic.title}"` : undefined);
1158
+ return (lines.join("\n") || "No briefing data available.") + hint;
911
1159
  }
912
1160
  export function formatDiscourseSummary(data) {
913
1161
  const lines = [];
@@ -935,7 +1183,7 @@ export function formatDiscourseSummary(data) {
935
1183
  if (speakers.length) {
936
1184
  lines.push("\n**Key Speakers:**");
937
1185
  for (const s of speakers.slice(0, 10)) {
938
- lines.push(`- ${s.name ?? "?"}`);
1186
+ lines.push(`- ${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"})`);
939
1187
  }
940
1188
  }
941
1189
  return lines.join("\n") || "No discourse data available.";
@@ -954,45 +1202,48 @@ export function formatTopics(topics, totalClusters, partition, query) {
954
1202
  return `No topics matched "${query}" above the relevance threshold.`;
955
1203
  }
956
1204
  const lines = [
957
- `**${filtered.length} of ${totalClusters ?? filtered.length} topic clusters** (${partition ?? "?"})\n`,
1205
+ `**${filtered.length} of ${totalClusters ?? filtered.length} topic clusters** (${partition ?? "n/a"})\n`,
958
1206
  ];
959
1207
  for (let i = 0; i < filtered.length; i++) {
960
1208
  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}`);
1209
+ const tokens = [`cluster_id:${t.cluster_id ?? "n/a"}`];
1210
+ if (t.category)
1211
+ tokens.push(`category:${t.category}`);
1212
+ if (t.relevance != null)
1213
+ tokens.push(`sim:${t.relevance.toFixed(2)}`);
1214
+ lines.push(`${i + 1}. **${t.title ?? "n/a"}** — ${tokens.join(" ")}`);
965
1215
  lines.push(` ${t.segment_count ?? 0} segments across ${t.channel_count ?? 0} channels`);
966
1216
  const speakers = t.top_speakers ?? [];
967
1217
  if (speakers.length) {
968
- lines.push(` Voices: ${speakers.slice(0, 3).map((s) => s.name ?? "?").join(", ")}`);
1218
+ lines.push(` voices: ${speakers.slice(0, 3).map((s) => `${s.name ?? "n/a"} (speaker:${s.speaker_id ?? "n/a"})`).join(", ")}`);
969
1219
  }
970
1220
  const topSeg = (t.top_segments ?? [])[0];
971
1221
  if (topSeg?.text) {
972
- lines.push(` "${truncate(topSeg.text, 120)}" — ${topSeg.channel_name ?? ""}`);
1222
+ const pointer = topSeg.segment_id != null ? `get_segment seg:${topSeg.segment_id}` : undefined;
1223
+ lines.push(` "${truncateWithPointer(topSeg.text, pointer)}" — ${topSeg.channel_name ?? ""} (seg:${topSeg.segment_id ?? "n/a"})`);
973
1224
  }
974
1225
  lines.push("");
975
1226
  }
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");
1227
+ const firstSeg = filtered.map((t) => (t.top_segments ?? [])[0]?.segment_id).find((id) => id != null);
1228
+ const hint = nextCallHint(filtered[0]?.title ? `analyze_topic query:"${filtered[0].title}"` : undefined, firstSeg != null ? `get_segment seg:${firstSeg}` : undefined);
1229
+ return lines.join("\n") + hint;
979
1230
  }
980
1231
  // ---------------------------------------------------------------------------
981
1232
  // Ad formatters
982
1233
  // ---------------------------------------------------------------------------
983
1234
  export function formatAdStats(data) {
984
1235
  const lines = ["# Ad Analytics Overview\n"];
985
- lines.push(`**${(data.total_ad_hashes ?? 0).toLocaleString()}** confirmed ad sentences`);
1236
+ lines.push(`**${data.total_ad_hashes ?? 0}** confirmed ad sentences`);
986
1237
  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`);
1238
+ lines.push(`**${data.ad_reads_in_window ?? 0}** ad reads in last ${data.time_window_days ?? 30}d`);
1239
+ lines.push(`**${data.episodes_with_ads_in_window ?? 0}** episodes with ads`);
989
1240
  lines.push(`**${data.avg_ad_read_duration_seconds ?? 0}s** avg ad read duration`);
990
1241
  const byCat = data.ads_by_category ?? data.by_category ?? {};
991
1242
  if (Object.keys(byCat).length) {
992
1243
  lines.push("\n**By Category:**");
993
1244
  const sorted = Object.entries(byCat).sort((a, b) => b[1] - a[1]);
994
1245
  for (const [cat, count] of sorted) {
995
- lines.push(`- ${cat}: ${count.toLocaleString()}`);
1246
+ lines.push(`- ${cat}: ${count}`);
996
1247
  }
997
1248
  }
998
1249
  const top = data.top_advertisers ?? [];
@@ -1000,11 +1251,13 @@ export function formatAdStats(data) {
1000
1251
  lines.push("\n**Top Advertisers:**");
1001
1252
  for (let i = 0; i < top.length; i++) {
1002
1253
  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`);
1254
+ lines.push(`${i + 1}. **${a.name}** — advertiser:${a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
1255
+ `episodes:${a.total_episode_count} channels:${a.total_channel_count}`);
1005
1256
  }
1006
1257
  }
1007
- return lines.join("\n");
1258
+ const firstId = top.find((a) => a.id != null)?.id;
1259
+ const hint = nextCallHint(firstId != null ? `get_ad_analytics view:advertiser advertiser_id:${firstId}` : "get_ad_analytics view:advertisers");
1260
+ return lines.join("\n") + hint;
1008
1261
  }
1009
1262
  export function formatAdvertiserList(data) {
1010
1263
  const advs = data.advertisers ?? [];
@@ -1015,29 +1268,33 @@ export function formatAdvertiserList(data) {
1015
1268
  for (let i = 0; i < advs.length; i++) {
1016
1269
  const a = advs[i];
1017
1270
  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`);
1271
+ lines.push(`${i + 1}. **${a.name}**${aliases} — advertiser:${a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
1272
+ `episodes:${a.total_episode_count} channels:${a.total_channel_count}`);
1020
1273
  }
1021
- return lines.join("\n");
1274
+ const paging = pagingNote({ shown: advs.length, total });
1275
+ const firstId = advs.find((a) => a.id != null)?.id;
1276
+ const hint = nextCallHint(firstId != null ? `get_ad_analytics view:advertiser advertiser_id:${firstId}` : undefined);
1277
+ return lines.join("\n") + paging + hint;
1022
1278
  }
1023
1279
  export function formatAdvertiserDetail(data) {
1024
1280
  if (data.error)
1025
1281
  return `Error: ${data.error}`;
1026
1282
  const lines = [`# ${data.name}`];
1283
+ lines.push(`advertiser:${data.id ?? "n/a"}`);
1027
1284
  if (data.category)
1028
1285
  lines.push(`**Category:** ${data.category}`);
1029
1286
  if (data.aliases?.length)
1030
1287
  lines.push(`**Also known as:** ${data.aliases.join(", ")}`);
1031
1288
  if (data.website)
1032
1289
  lines.push(`**Website:** ${data.website}`);
1033
- lines.push(`**Episodes:** ${data.total_episode_count.toLocaleString()} | **Channels:** ${data.total_channel_count}`);
1290
+ lines.push(`**Episodes:** ${data.total_episode_count} | **Channels:** ${data.total_channel_count}`);
1034
1291
  if (data.first_seen)
1035
- lines.push(`**Active:** ${fmtDate(data.first_seen)} — ${fmtDate(data.last_seen)}`);
1292
+ lines.push(`**Active:** date:${fmtDate(data.first_seen)} — date:${fmtDate(data.last_seen)}`);
1036
1293
  const channels = data.top_channels ?? [];
1037
1294
  if (channels.length) {
1038
1295
  lines.push("\n**Top Channels:**");
1039
1296
  for (const ch of channels.slice(0, 10)) {
1040
- lines.push(`- ${ch.name} (${ch.platform ?? "?"}) — ${ch.episode_count} episodes`);
1297
+ lines.push(`- ${ch.name} — channel_id:${ch.channel_id ?? "n/a"} platform:${ch.platform ?? "n/a"} episodes:${ch.episode_count}`);
1041
1298
  }
1042
1299
  }
1043
1300
  const samples = data.sample_reads ?? [];
@@ -1045,113 +1302,135 @@ export function formatAdvertiserDetail(data) {
1045
1302
  lines.push("\n**Recent Ad Reads:**");
1046
1303
  for (const s of samples) {
1047
1304
  const dur = s.duration_seconds ? ` (${s.duration_seconds}s)` : "";
1048
- lines.push(`- [${fmtDate(s.date)}] ${s.episode_title ?? "?"}${dur}`);
1305
+ lines.push(`- date:${fmtDate(s.date)} ${s.episode_title ?? "n/a"}${dur}`);
1306
+ // Ad-read text has no per-read retrieval tool — never cut it silently.
1049
1307
  if (s.text)
1050
- lines.push(` > ${s.text.slice(0, 200)}`);
1308
+ lines.push(` > ${s.text}`);
1051
1309
  }
1052
1310
  }
1053
- return lines.join("\n");
1311
+ const firstCh = channels.find((ch) => ch.channel_id != null)?.channel_id;
1312
+ const hint = nextCallHint(firstCh != null ? `get_ad_analytics view:channel channel_id:${firstCh}` : undefined);
1313
+ return lines.join("\n") + hint;
1054
1314
  }
1055
1315
  export function formatChannelAds(data) {
1056
1316
  if (data.error)
1057
1317
  return `Error: ${data.error}`;
1058
1318
  const chName = data.channel?.name ?? data.channel_name ?? "Channel";
1059
1319
  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`);
1320
+ lines.push(`**${data.total_ad_reads ?? 0}** ad reads in last ${data.time_window_days ?? 30}d\n`);
1061
1321
  for (let i = 0; i < data.advertisers.length; i++) {
1062
1322
  const a = data.advertisers[i];
1063
1323
  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`);
1324
+ lines.push(`${i + 1}. **${a.name}**${promo} — advertiser:${a.advertiser_id ?? a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
1325
+ `episodes:${a.episode_count} reads:${a.read_count}`);
1066
1326
  }
1067
- return lines.join("\n") || "No ad data for this channel.";
1327
+ const firstId = data.advertisers.find((a) => (a.advertiser_id ?? a.id) != null);
1328
+ const hint = nextCallHint(firstId ? `get_ad_analytics view:advertiser advertiser_id:${firstId.advertiser_id ?? firstId.id}` : undefined);
1329
+ return (lines.join("\n") || "No ad data for this channel.") + hint;
1068
1330
  }
1069
1331
  export function formatAdTrends(data) {
1070
1332
  if (!data.advertisers?.length)
1071
1333
  return "No trending data available.";
1072
1334
  const lines = [`# Ad Trends (${data.comparison ?? ""})\n`];
1073
1335
  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)}%)`);
1336
+ lines.push(`- **${a.name}** — advertiser:${a.advertiser_id ?? a.id ?? "n/a"} category:${a.category ?? "n/a"} ` +
1337
+ `${trendToken(a.trend, a.change_pct)} episodes:${a.current_episodes} (was ${a.prior_episodes})`);
1077
1338
  }
1078
- return lines.join("\n");
1339
+ const firstId = data.advertisers.find((a) => (a.advertiser_id ?? a.id) != null);
1340
+ const hint = nextCallHint(firstId ? `get_ad_analytics view:advertiser advertiser_id:${firstId.advertiser_id ?? firstId.id}` : undefined);
1341
+ return lines.join("\n") + hint;
1079
1342
  }
1080
1343
  // ---------------------------------------------------------------------------
1081
1344
  // Claim Detail
1082
1345
  // ---------------------------------------------------------------------------
1346
+ function observationLine(o) {
1347
+ const date = fmtDate(o.publish_date);
1348
+ const meta = [o.speaker_name ?? "speaker:unresolved (diarization cluster)", o.channel_name, `date:${date}`]
1349
+ .filter(Boolean).join(", ");
1350
+ const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
1351
+ const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
1352
+ return [
1353
+ `- "${truncateWithPointer(o.chunk_text, pointer)}" — ${meta}`,
1354
+ ` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`,
1355
+ ];
1356
+ }
1083
1357
  export function formatClaimDetail(detail, observations, related) {
1084
1358
  const c = detail.claim;
1085
1359
  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}`);
1360
+ lines.push(`# Claim claim:${c.claim_id}`);
1361
+ lines.push(`**${c.summary}**`);
1362
+ lines.push(`(synthesized claim — observations carry verbatim quotes)\n`);
1363
+ const tokens = [`stance:${c.stance ?? "n/a"}`, `topic:${c.topic ?? "n/a"}`];
1364
+ const lt = langToken(c.language);
1365
+ if (lt)
1366
+ tokens.push(lt);
1367
+ lines.push(tokens.join(" "));
1368
+ lines.push(`obs:${c.observation_count} ${episodeTokens(c)} channels:${c.channel_count}`);
1369
+ const span = [fmtDate(c.first_seen), fmtDate(c.last_seen)].filter(d => d !== "n/a");
1370
+ if (span.length === 2)
1371
+ lines.push(`first_seen:${span[0]} last_seen:${span[1]}`);
1095
1372
  if (c.entities?.length) {
1096
- lines.push(`Entities: ${c.entities.join(", ")}`);
1373
+ lines.push(`entities:${c.entities.join(",")}`);
1097
1374
  }
1375
+ if (c.language && c.language !== "en")
1376
+ lines.push(NON_ENGLISH_NOTE);
1098
1377
  lines.push("");
1099
1378
  if (c.top_speakers?.length) {
1100
1379
  lines.push("**Speakers:**");
1101
1380
  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`);
1381
+ lines.push(`- ${s.name} — speaker:${s.identity_id ?? "n/a"} obs:${s.observation_count}`);
1104
1382
  }
1105
1383
  lines.push("");
1106
1384
  }
1107
1385
  if (c.top_channels?.length) {
1108
1386
  lines.push("**Channels:**");
1109
1387
  for (const ch of c.top_channels.slice(0, 10)) {
1110
- lines.push(`- ${ch.name} — ${ch.observation_count} observations`);
1388
+ lines.push(`- ${ch.name} — channel_id:${ch.channel_id} obs:${ch.observation_count}`);
1111
1389
  }
1112
1390
  lines.push("");
1113
1391
  }
1114
1392
  const obs = observations?.observations ?? c.sample_observations ?? [];
1393
+ let firstObsSeg;
1115
1394
  if (obs.length) {
1116
1395
  const label = observations ? `**Observations** (${observations.total} total)` : "**Sample Observations**";
1117
1396
  lines.push(label);
1118
1397
  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}`);
1398
+ if (firstObsSeg == null && o.segment_id != null)
1399
+ firstObsSeg = o.segment_id;
1400
+ lines.push(...observationLine(o));
1401
+ }
1402
+ if (observations && observations.total > Math.min(obs.length, 20)) {
1403
+ lines.push(`showing ${Math.min(obs.length, 20)} of ${observations.total} — page:${(observations.page ?? 1) + 1} for more`);
1124
1404
  }
1125
1405
  lines.push("");
1126
1406
  }
1127
1407
  if (related?.claims?.length) {
1128
1408
  lines.push("**Related Claims:**");
1129
1409
  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}`);
1410
+ lines.push(claimLine(r));
1132
1411
  }
1133
1412
  lines.push("");
1134
1413
  }
1135
- return lines.join("\n");
1414
+ const hint = nextCallHint(firstObsSeg != null ? `get_segment seg:${firstObsSeg}` : undefined, `claim_graph claim_id:${c.claim_id}`);
1415
+ return lines.join("\n") + hint;
1136
1416
  }
1137
1417
  // ---------------------------------------------------------------------------
1138
1418
  // Timeline
1139
1419
  // ---------------------------------------------------------------------------
1140
- export function formatTimeline(data) {
1420
+ /** MCP timeline: `date<TAB>count` rows (spec §5 — bar glyphs retired). */
1421
+ export function formatTimeline(data, footer = "") {
1141
1422
  const buckets = data.buckets ?? [];
1142
1423
  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;
1424
+ return "No data found for this topic in the given time range." + footer;
1146
1425
  const lines = [
1147
1426
  `**"${data.topic}"** — ${data.total} segments (${data.granularity}, ${data.search_mode})\n`,
1427
+ "date\tcount",
1148
1428
  ];
1149
1429
  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}`);
1430
+ lines.push(`${b.start}\t${b.count}`);
1154
1431
  }
1155
- return lines.join("\n");
1432
+ const peak = buckets.reduce((best, b) => (b.count > best.count ? b : best), buckets[0]);
1433
+ const hint = nextCallHint(`search_quotes topic:"${data.topic}" start_date:${peak.start.slice(0, 10)}`);
1434
+ return withFooters(lines.join("\n"), footer, "", hint);
1156
1435
  }
1157
1436
  //# sourceMappingURL=format.js.map