@signal4/s4 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +60 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +407 -196
  4. package/dist/cli.js.map +1 -1
  5. package/dist/cli_args.d.ts +24 -0
  6. package/dist/cli_args.d.ts.map +1 -0
  7. package/dist/cli_args.js +130 -0
  8. package/dist/cli_args.js.map +1 -0
  9. package/dist/client.d.ts +103 -3
  10. package/dist/client.d.ts.map +1 -1
  11. package/dist/client.js +195 -23
  12. package/dist/client.js.map +1 -1
  13. package/dist/format.d.ts +103 -4
  14. package/dist/format.d.ts.map +1 -1
  15. package/dist/format.js +299 -64
  16. package/dist/format.js.map +1 -1
  17. package/dist/handler_types.d.ts +10 -0
  18. package/dist/handler_types.d.ts.map +1 -1
  19. package/dist/handler_types.js +25 -0
  20. package/dist/handler_types.js.map +1 -1
  21. package/dist/handlers/ads.js +4 -4
  22. package/dist/handlers/ads.js.map +1 -1
  23. package/dist/handlers/analyze.d.ts.map +1 -1
  24. package/dist/handlers/analyze.js +22 -6
  25. package/dist/handlers/analyze.js.map +1 -1
  26. package/dist/handlers/bookmarks.js +2 -2
  27. package/dist/handlers/bookmarks.js.map +1 -1
  28. package/dist/handlers/claim_detail.d.ts.map +1 -1
  29. package/dist/handlers/claim_detail.js +18 -10
  30. package/dist/handlers/claim_detail.js.map +1 -1
  31. package/dist/handlers/claims.d.ts.map +1 -1
  32. package/dist/handlers/claims.js +72 -27
  33. package/dist/handlers/claims.js.map +1 -1
  34. package/dist/handlers/count.d.ts +14 -0
  35. package/dist/handlers/count.d.ts.map +1 -0
  36. package/dist/handlers/count.js +158 -0
  37. package/dist/handlers/count.js.map +1 -0
  38. package/dist/handlers/explore.d.ts.map +1 -1
  39. package/dist/handlers/explore.js +56 -14
  40. package/dist/handlers/explore.js.map +1 -1
  41. package/dist/handlers/registry.d.ts.map +1 -1
  42. package/dist/handlers/registry.js +5 -1
  43. package/dist/handlers/registry.js.map +1 -1
  44. package/dist/handlers/reports.d.ts +6 -0
  45. package/dist/handlers/reports.d.ts.map +1 -1
  46. package/dist/handlers/reports.js +140 -26
  47. package/dist/handlers/reports.js.map +1 -1
  48. package/dist/handlers/search.d.ts.map +1 -1
  49. package/dist/handlers/search.js +80 -23
  50. package/dist/handlers/search.js.map +1 -1
  51. package/dist/handlers/timeline.d.ts.map +1 -1
  52. package/dist/handlers/timeline.js +14 -8
  53. package/dist/handlers/timeline.js.map +1 -1
  54. package/dist/http.js +25 -3
  55. package/dist/http.js.map +1 -1
  56. package/dist/index.js +2 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/provenance.d.ts +29 -2
  59. package/dist/provenance.d.ts.map +1 -1
  60. package/dist/provenance.js +63 -4
  61. package/dist/provenance.js.map +1 -1
  62. package/dist/server_core.d.ts +8 -1
  63. package/dist/server_core.d.ts.map +1 -1
  64. package/dist/server_core.js +90 -8
  65. package/dist/server_core.js.map +1 -1
  66. package/dist/speed.d.ts +40 -0
  67. package/dist/speed.d.ts.map +1 -0
  68. package/dist/speed.js +214 -0
  69. package/dist/speed.js.map +1 -0
  70. package/dist/surface.d.ts +7 -0
  71. package/dist/surface.d.ts.map +1 -0
  72. package/dist/surface.js +19 -0
  73. package/dist/surface.js.map +1 -0
  74. package/dist/types.d.ts +249 -15
  75. package/dist/types.d.ts.map +1 -1
  76. package/package.json +3 -2
package/dist/format.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * any port. Canonical tokens, truncation markers, and footers all live
7
7
  * here — do not invent per-tool vocabulary in handlers.
8
8
  */
9
+ import { hint as surfaceHint, getSurface } from "./surface.js";
9
10
  const PUBLIC_URL = "https://api.signal4.ca";
10
11
  // ---------------------------------------------------------------------------
11
12
  // Political lean — single canonical bucket table (spec §5), shared with the
@@ -99,6 +100,65 @@ export function confToken(confidence) {
99
100
  return "conf:unverified";
100
101
  return `conf:${Math.round(confidence * 100)}%`;
101
102
  }
103
+ // ---------------------------------------------------------------------------
104
+ // Speaker attribution
105
+ // ---------------------------------------------------------------------------
106
+ /**
107
+ * How a speaker renders, gated on the episode's host/guest roster (core#559).
108
+ *
109
+ * There are four states, and a consumer must be able to tell them apart:
110
+ *
111
+ * corroborated **Glenn Beck** — the roster backs the name
112
+ * unconfirmed **Unconfirmed — possibly Glenn Beck** — roster exists, name absent
113
+ * unchecked **Unconfirmed — possibly Glenn Beck** — no roster to check against
114
+ * no identity **Unresolved speaker** — a distinct voice, no name
115
+ *
116
+ * The hedge word leads so that a skim reads it. Putting the name first and the
117
+ * caveat in a token loses the caveat: an LLM consumer summarising the result
118
+ * writes "Glenn Beck said X" either way.
119
+ *
120
+ * The two hedged tiers deliberately share a label and differ in their token.
121
+ * They are the same instruction to the reader ("do not cite this name") for
122
+ * two different reasons, and the reason is machine-readable where it belongs.
123
+ *
124
+ * Fails closed: a backend that emits `identity_id` without `attribution` (any
125
+ * build older than core#559) is treated as `unchecked`. A stale deploy must not
126
+ * be able to turn assertions back on.
127
+ */
128
+ export function speakerAttribution(spk) {
129
+ const s = spk ?? {};
130
+ const id = s.identity_id;
131
+ const name = s.name ?? s.primary_name;
132
+ if (id == null) {
133
+ // No identity — a diarization cluster. Any name string sitting here is a
134
+ // placeholder ("Unknown", "SPEAKER_01"), never something to print as a
135
+ // speaker: nothing links it to a person we can name.
136
+ return {
137
+ label: "Unresolved speaker",
138
+ tokens: ["speaker:unresolved (diarization cluster)"],
139
+ hedged: false,
140
+ };
141
+ }
142
+ const shown = name ?? `Speaker ${id}`;
143
+ const attribution = s.attribution ?? "unchecked";
144
+ if (attribution === "corroborated") {
145
+ const by = s.corroborated_by ? ` (${s.corroborated_by})` : "";
146
+ return { label: shown, tokens: [`speaker:${id}`, `attribution:corroborated${by}`], hedged: false };
147
+ }
148
+ return {
149
+ label: `Unconfirmed — possibly ${shown}`,
150
+ tokens: [`speaker:${id}`, `attribution:${attribution}`],
151
+ hedged: true,
152
+ };
153
+ }
154
+ /** Footer shown whenever a result set contains a hedged speaker name.
155
+ * Without it, "Unconfirmed — possibly X" is a label with no defined meaning. */
156
+ export const ATTRIBUTION_NOTE = "\n\n_Speaker names: `attribution:corroborated` = the identity is on that " +
157
+ "episode's host/guest list — safe to cite. `unconfirmed` = the episode has a " +
158
+ "host/guest list and this name is not on it. `unchecked` = no list exists for " +
159
+ "that episode. Do not cite an Unconfirmed name as the speaker; check it with " +
160
+ "`get_episode` (returns hosts/guests) or `get_segment_sentences`. `confidence` " +
161
+ "is not calibrated and is not a substitute for this._";
102
162
  /** `trend:surging (+340%)` / `trend:new` / `trend:steady (n/a)` (spec §5). */
103
163
  export function trendToken(direction, changePct) {
104
164
  if (direction === "new")
@@ -143,7 +203,7 @@ export function truncateWithPointer(text, pointer, maxlen = QUOTE_BUDGET) {
143
203
  const cut = clean.slice(0, maxlen);
144
204
  const last = cut.lastIndexOf(" ");
145
205
  const body = last > maxlen * 0.5 ? cut.slice(0, last) : cut;
146
- return `${body}…[truncated — full text: ${pointer}]`;
206
+ return `${body}…[truncated — full text: ${surfaceHint(pointer)}]`;
147
207
  }
148
208
  /** Rewrite LLM `{seg_N}` / `{{seg_N}}` citations to the spec token `seg:N`. */
149
209
  export function rewriteSegCitations(text) {
@@ -157,10 +217,10 @@ export function rewriteSegCitations(text) {
157
217
  /** `showing 20 of 249 — offset:20 for more` (spec §7.2). */
158
218
  export function pagingNote(opts) {
159
219
  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`;
220
+ return surfaceHint(`\nshowing ${opts.shown} of ${opts.total ?? "n/a"} (count_capped — true total unknown) — offset:${(opts.offset ?? 0) + opts.shown} for more`);
161
221
  }
162
222
  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`;
223
+ return surfaceHint(`\nshowing ${opts.shown} of ${opts.total} — offset:${(opts.offset ?? 0) + opts.shown} for more`);
164
224
  }
165
225
  return "";
166
226
  }
@@ -169,7 +229,7 @@ export function nextCallHint(...calls) {
169
229
  const real = calls.filter((c) => !!c);
170
230
  if (!real.length)
171
231
  return "";
172
- return `\n→ ${real.join(" · ")}`;
232
+ return surfaceHint(`\n→ ${real.join(" · ")}`);
173
233
  }
174
234
  /** Assemble body + footers in spec order. */
175
235
  function withFooters(body, footer, paging, hint) {
@@ -225,6 +285,11 @@ function clipLine(contentType, url) {
225
285
  // ---------------------------------------------------------------------------
226
286
  // Empty state
227
287
  // ---------------------------------------------------------------------------
288
+ /** An empty page past the end of a list that had results: not "no matches" (signal4#329). */
289
+ export function pastEndMessage(offset, noun, depthNote = "") {
290
+ return `No more ${noun} past offset ${offset.toLocaleString("en-US")}: the list ends before it. `
291
+ + `Earlier pages (offset 0 up) hold the matches${depthNote ? `; ${depthNote}` : ""}.`;
292
+ }
228
293
  export function emptyStateSuggestions(params) {
229
294
  const suggestions = [];
230
295
  if (params.fullHistory) {
@@ -233,7 +298,11 @@ export function emptyStateSuggestions(params) {
233
298
  suggestions.push("Verify the speaker name with find_speakers");
234
299
  }
235
300
  else {
236
- if (params.days && params.days < 30)
301
+ // A caller who named their own dates does not want to hear about `days` —
302
+ // suggesting it implies we ignored the dates they passed (#554).
303
+ if (params.explicitRange)
304
+ suggestions.push("Try an earlier start_date, or full_history=true with a topic + speaker filter for the whole archive");
305
+ else if (params.days && params.days < 30)
237
306
  suggestions.push("Try a wider window: days=30");
238
307
  else if (params.days && params.days < 90)
239
308
  suggestions.push("Try a wider window: days=90");
@@ -250,8 +319,13 @@ export function emptyStateSuggestions(params) {
250
319
  let msg = `No matching content for "${query}"`;
251
320
  if (params.channel_name)
252
321
  msg += ` on channel "${params.channel_name}"`;
322
+ // Say the window we actually searched. "in the last 30 days" printed under a
323
+ // query that passed start_date=2025-01-01 is a lie about the search, and the
324
+ // one that made a client re-run correct searches (#554).
253
325
  if (params.fullHistory)
254
326
  msg += ` in the full corpus history`;
327
+ else if (params.explicitRange && params.startDate)
328
+ msg += ` between ${params.startDate} and ${params.endDate ?? "today"}`;
255
329
  else if (params.days)
256
330
  msg += ` in the last ${params.days} days`;
257
331
  msg += ".";
@@ -262,49 +336,86 @@ export function emptyStateSuggestions(params) {
262
336
  // ---------------------------------------------------------------------------
263
337
  // Quotes
264
338
  // ---------------------------------------------------------------------------
339
+ /** Quote excerpt budget in the compact search view. */
340
+ export const SEARCH_QUOTE_BUDGET = 300;
341
+ /** Real total if the API gave one; null/undefined means "unknown" and is never printed as a count. */
342
+ export function realTotal(data) {
343
+ return typeof data.total_results === "number" ? data.total_results : null;
344
+ }
345
+ /** `Showing 10 of 249 · sim ≥0.35 · more: --offset 10` — honest about unknown totals. */
346
+ export function resultsHeader(opts) {
347
+ const more = opts.hasMore ?? (opts.total != null && opts.total > opts.offset + opts.consumed);
348
+ const range = opts.offset > 0 ? `${opts.offset + 1}–${opts.offset + opts.consumed}` : `${opts.shown}`;
349
+ const parts = [`Showing ${range}${opts.total != null ? ` of ${opts.total.toLocaleString("en-US")}` : ""}${opts.noun ? ` ${opts.noun}` : ""}`];
350
+ if (opts.minSimilarity != null)
351
+ parts.push(`sim ≥${opts.minSimilarity}`);
352
+ if (more)
353
+ parts.push(surfaceHint(`more: offset:${opts.offset + opts.consumed}`));
354
+ else if (opts.depthReached)
355
+ parts.push(depthNote(opts.rankedDepth));
356
+ return parts.join(" · ");
357
+ }
358
+ /** A ranked search served its last page: more matches exist past it, but no deeper page. */
359
+ export function depthNote(rankedDepth) {
360
+ const at = rankedDepth ? ` at rank ${rankedDepth.toLocaleString("en-US")}` : "";
361
+ return `ranked results end${at}; mode:keyword or count_mentions group_by:episode list every match`;
362
+ }
363
+ /** One paging line for list tools: "showing 21–40 of 312 · next: offset:40".
364
+ * `param` names the offset parameter (observations_offset on get_claim). Empty when the
365
+ * whole list fits on the first page. (signal4#329) */
366
+ export function pageLine(opts) {
367
+ const param = opts.param ?? "offset";
368
+ if (!opts.hasMore && opts.offset === 0)
369
+ return "";
370
+ const range = opts.shown ? `${opts.offset + 1}–${opts.offset + opts.shown}` : `none past ${opts.offset}`;
371
+ const of = opts.total != null ? ` of ${opts.total.toLocaleString("en-US")}` : "";
372
+ const noun = opts.noun ? ` ${opts.noun}` : "";
373
+ const tail = opts.hasMore ? ` · next: ${param}:${opts.offset + opts.shown}`
374
+ : opts.depthNote ? ` · ${opts.depthNote}` : " · last page";
375
+ return surfaceHint(`\nshowing ${range}${of}${noun}${tail}`);
376
+ }
265
377
  export function formatQuotes(data, footer = "", offset = 0) {
266
378
  const quotes = dedupQuotes(data.quotes ?? []);
267
379
  if (!quotes.length)
268
- return "No quotes found matching your search criteria." + footer;
269
- const total = data.total_results ?? quotes.length;
270
- const ms = data.processing_time_ms ?? 0;
271
- const lines = [`**${total} results** (showing ${quotes.length}, ${ms.toFixed(0)}ms)\n`];
380
+ return (offset > 0 ? pastEndMessage(offset, "quotes") : "No quotes found matching your search criteria.") + footer;
381
+ const lines = [resultsHeader({
382
+ shown: quotes.length, total: realTotal(data), offset, consumed: (data.quotes ?? []).length,
383
+ hasMore: data.has_more, minSimilarity: data.min_similarity,
384
+ depthReached: data.depth_reached, rankedDepth: data.ranked_depth,
385
+ }) + "\n"];
386
+ // Set when any quote renders a hedged name, so the footer explaining what
387
+ // "Unconfirmed — possibly X" means appears only where it is needed.
388
+ let anyHedged = false;
272
389
  for (let i = 0; i < quotes.length; i++) {
273
390
  const q = quotes[i];
274
391
  const spk = q.speaker ?? {};
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)";
392
+ const attr = speakerAttribution(spk);
393
+ if (attr.hedged)
394
+ anyHedged = true;
278
395
  const sim = q.similarity;
279
396
  const date = fmtDate(q.episode_date ?? q.publish_date);
280
397
  const segId = q.segment_id;
281
398
  const tStart = q.timestamp_start ?? q.start_time;
282
- const tEnd = q.timestamp_end ?? q.end_time;
283
- const tokens = [spkToken, confToken(spk.confidence)];
399
+ const tokens = [...attr.tokens, confToken(spk.confidence)];
284
400
  if (spk.appearance_type)
285
401
  tokens.push(`appearance:${spk.appearance_type}`);
286
402
  if (sim != null)
287
403
  tokens.push(`sim:${sim.toFixed(2)}`);
288
404
  tokens.push(`date:${date}`);
289
405
  if (tStart != null)
290
- tokens.push(fmtTimeRange(tStart, tEnd));
406
+ tokens.push(`@${fmtTime(tStart)}`);
291
407
  const lt = langToken(q.language);
292
408
  if (lt)
293
409
  tokens.push(lt);
294
- if (q.channel_id != null)
295
- tokens.push(`channel_id:${q.channel_id}`);
296
410
  tokens.push(`channel:${q.channel_name ?? "n/a"}`);
297
- lines.push(`${i + 1}. **${spkName}** — ${tokens.join(" ")}`);
411
+ lines.push(`${i + 1}. **${attr.label}** — ${tokens.join(" ")}`);
298
412
  lines.push(` seg:${segId ?? "n/a"} episode:${q.content_id ?? "n/a"}`);
299
- const clip = clipUrl(q.content_id, tStart ?? undefined, tEnd ?? undefined, q.clip_url);
300
- lines.push(` ${clipLine(q.content_type, clip)}`);
301
413
  const pointer = segId != null ? `get_segment seg:${segId}` : undefined;
302
- lines.push(` > ${truncateWithPointer(q.text, pointer)}\n`);
414
+ lines.push(` > ${truncateWithPointer(q.text, pointer, SEARCH_QUOTE_BUDGET)}\n`);
303
415
  }
304
416
  const first = quotes[0];
305
417
  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);
418
+ return withFooters(lines.join("\n"), footer, "", hint) + (anyHedged ? ATTRIBUTION_NOTE : "");
308
419
  }
309
420
  // ---------------------------------------------------------------------------
310
421
  // Claims
@@ -357,15 +468,42 @@ export function speakerResolutionNote(data) {
357
468
  return "";
358
469
  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
470
  }
471
+ /** "12,345" or "100,000+" for a capped count. */
472
+ function countLabel(n, capped) {
473
+ return `${n.toLocaleString("en-US")}${capped ? "+" : ""}`;
474
+ }
475
+ /** What an entity name matched: the label, the other names folded in, how it was found. */
476
+ export function entityMatchLine(m) {
477
+ const parts = [`entity:${m.entity_id} "${m.name}"`];
478
+ if (m.how === "similar")
479
+ parts.push(`closest name to "${m.query}"`);
480
+ else if (m.how === "alias")
481
+ parts.push(`matched "${m.query}" as an alias`);
482
+ if (m.entity_ids.length > 1) {
483
+ const also = m.other_names.length ? `: ${m.other_names.slice(0, 3).map((n) => `"${n}"`).join(", ")}` : "";
484
+ parts.push(`${m.entity_ids.length} entity rows${also}`);
485
+ }
486
+ parts.push(`linked claims:${countLabel(m.linked_claims, m.linked_claims_capped)}`);
487
+ return parts.join(" · ");
488
+ }
489
+ /** Notice for an entity filter: what it matched, or that it matched nothing. */
490
+ export function entityResolutionNote(data) {
491
+ if (data.entity_resolved === false) {
492
+ return "Note: the entity name matched no known entity — 0 results means an unknown name, not that nothing was claimed. Try find_entities for the canonical name.\n\n";
493
+ }
494
+ return data.entity_match ? `entity filter: ${entityMatchLine(data.entity_match)}\n` : "";
495
+ }
360
496
  export function formatClaims(data, footer = "", offset = 0) {
361
497
  const claims = data.claims ?? [];
362
- const note = speakerResolutionNote(data);
498
+ const note = speakerResolutionNote(data) + entityResolutionNote(data);
363
499
  if (!claims.length)
364
- return note + "No claims found matching your search criteria." + footer;
365
- const total = data.total_results ?? claims.length;
366
- const ms = data.processing_time_ms ?? 0;
367
- const mode = data.search_mode ?? "n/a";
368
- const lines = [`**${total} claims** (showing ${claims.length}, ${mode}, ${ms.toFixed(0)}ms)\n`];
500
+ return note + (offset > 0 ? pastEndMessage(offset, "claims") : "No claims found matching your search criteria.") + footer;
501
+ const total = realTotal(data);
502
+ const lines = [resultsHeader({
503
+ shown: claims.length, total, offset, consumed: claims.length,
504
+ hasMore: data.has_more, minSimilarity: data.min_similarity, noun: "claims",
505
+ depthReached: data.depth_reached, rankedDepth: data.ranked_depth,
506
+ }) + "\n"];
369
507
  for (let i = 0; i < claims.length; i++) {
370
508
  const c = claims[i];
371
509
  lines.push(claimLine(c, i + 1));
@@ -384,19 +522,17 @@ export function formatClaims(data, footer = "", offset = 0) {
384
522
  const obs = c.sample_observations ?? [];
385
523
  if (obs.length) {
386
524
  const o = obs[0];
387
- const clip = clipUrl(o.content_id, o.start_time ?? undefined, o.end_time ?? undefined, o.clip_url);
388
525
  const label = [o.speaker_name, o.channel_name, fmtDate(o.publish_date)].filter(Boolean).join(", ");
389
526
  const pointer = o.segment_id != null ? `get_segment seg:${o.segment_id}` : undefined;
390
527
  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)}`);
528
+ lines.push(` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"}`);
392
529
  }
393
530
  lines.push("");
394
531
  }
395
532
  if (anyNonEnglish(claims))
396
533
  lines.push(NON_ENGLISH_NOTE, "");
397
- const paging = pagingNote({ shown: claims.length, total, offset });
398
534
  const hint = nextCallHint(`get_claim claim:${claims[0].claim_id} include_observations:true`);
399
- return withFooters(note + lines.join("\n"), footer, paging, hint);
535
+ return withFooters(note + lines.join("\n"), footer, "", hint);
400
536
  }
401
537
  // ---------------------------------------------------------------------------
402
538
  // Claims analysis (/api/v1/analysis/claims/*)
@@ -620,40 +756,51 @@ export function formatEntitySearch(data) {
620
756
  const tokens = [`entity:${e.id}`];
621
757
  if (e.entity_type)
622
758
  tokens.push(`type:${e.entity_type}`);
623
- tokens.push(`claims:${e.claim_count}`, `channels:${e.channel_count}`);
759
+ tokens.push(`claims:${countLabel(e.claim_count, e.claim_count_capped)}`);
624
760
  if (e.first_seen_date)
625
761
  tokens.push(`first_seen:${fmtDate(e.first_seen_date)}`);
626
762
  if (e.last_seen_date)
627
763
  tokens.push(`last_seen:${fmtDate(e.last_seen_date)}`);
628
764
  lines.push(`- **${e.name}** — ${tokens.join(" ")}`);
765
+ if (e.matched_ids && e.matched_ids.length > 1) {
766
+ const also = e.also_named?.length ? ` (also named ${e.also_named.slice(0, 3).map((n) => `"${n}"`).join(", ")})` : "";
767
+ lines.push(` "${data.query}" covers ${e.matched_ids.length} entity rows${also}; entity_claims uses them all`);
768
+ }
629
769
  if (e.aliases?.length)
630
770
  lines.push(` aliases:${e.aliases.slice(0, 5).join(",")}`);
631
771
  }
632
- const hint = nextCallHint(`entity_claims entity:"${data.entities[0].name}"`);
633
- return lines.join("\n") + hint;
772
+ lines.push("", "_claims = claims linked to the entity, single mentions included._");
773
+ const paging = pageLine({ offset: data.offset ?? 0, shown: data.entities.length, hasMore: !!data.has_more });
774
+ const hint = nextCallHint(`entity_claims entity:"${data.query}"`);
775
+ return lines.join("\n") + paging + hint;
634
776
  }
635
777
  export function formatEntityClaims(data, footer = "", offset = 0) {
636
778
  if (!data.entity_id) {
637
- return `No entity found matching "${data.entity}". Try a different spelling, or use search_claims?entity= for fuzzy matching.` + footer;
779
+ if (data.timed_out) {
780
+ return `Timed out looking up "${data.entity}" — this is not "no claims". Retry (a repeat call is usually fast), or use search_claims with a topic and entity:"${data.entity}".` + footer;
781
+ }
782
+ return `No entity found matching "${data.entity}". Try find_entities for the canonical name, or search_claims with a topic.` + footer;
638
783
  }
639
784
  const lines = [`# Claims about ${data.entity}`];
640
785
  const tokens = [`entity:${data.entity_id}`];
641
786
  if (data.entity_type)
642
787
  tokens.push(`type:${data.entity_type}`);
643
- if (data.claim_count_rollup != null)
644
- tokens.push(`claims:${data.claim_count_rollup}`);
645
- if (data.channel_count != null)
646
- tokens.push(`channels:${data.channel_count}`);
647
788
  lines.push(tokens.join(" "));
789
+ if (data.match)
790
+ lines.push(`matched: ${entityMatchLine(data.match)}`);
648
791
  if (data.aliases?.length)
649
792
  lines.push(`aliases:${data.aliases.slice(0, 6).join(",")}`);
793
+ if (data.timed_out) {
794
+ lines.push("", `Timed out before the claims were ranked: ${data.entity} has too many linked claims to rank in the time limit with these filters. This is not "no claims". Retry, or narrow with collection or stance, or use search_claims with a topic and entity:"${data.entity}".`);
795
+ return lines.join("\n") + footer;
796
+ }
650
797
  if (data.stance_distribution?.length) {
651
798
  const total = data.stance_distribution.reduce((s, x) => s + x.count, 0) || 1;
652
799
  const parts = data.stance_distribution
653
800
  .map((s) => `${s.stance}:${s.count} (${Math.round((s.count / total) * 100)}%)`);
654
801
  lines.push(`\nstance: ${parts.join(" ")}`);
655
802
  }
656
- lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims})`);
803
+ lines.push(`\n## Top claims (${data.claims.length} of ${data.total_claims.toLocaleString("en-US")} observed at least twice)`);
657
804
  data.claims.forEach((c, i) => lines.push(claimLine(c, i + 1)));
658
805
  if (anyNonEnglish(data.claims))
659
806
  lines.push("", NON_ENGLISH_NOTE);
@@ -686,23 +833,27 @@ export function formatClaimGraph(data) {
686
833
  const hint = nextCallHint(`get_claim claim:${data.claim.claim_id} include_observations:true`, next ? `claim_graph claim_id:${next.claim_id}` : undefined);
687
834
  return lines.join("\n") + hint;
688
835
  }
689
- export function formatTopClaims(data, footer = "") {
836
+ export function formatTopClaims(data, footer = "", offset = 0) {
837
+ const start = data.offset ?? offset;
690
838
  if (!data.claims?.length) {
839
+ if (start > 0)
840
+ return `No ${data.metric} claims past rank ${start} (the leaderboard is ${data.ranked_depth ?? 100} deep).` + footer;
691
841
  return `No ${data.metric} claims found in the window. Try a wider days range or lower min_observations.` + footer;
692
842
  }
693
843
  const contested = data.metric === "contested";
694
844
  const label = contested ? "Most contested claims" : "Most widely-spread claims";
695
845
  const lines = [`# ${label}`, ""];
696
846
  data.claims.forEach((c, i) => {
697
- lines.push(claimLine(c, i + 1, { assertDeny: contested }));
847
+ lines.push(claimLine(c, start + i + 1, { assertDeny: contested }));
698
848
  const speakers = (c.top_speakers ?? []).slice(0, 3);
699
849
  if (speakers.length)
700
850
  lines.push(` speakers: ${claimSpeakerParts(speakers, 3)}`);
701
851
  });
702
852
  if (anyNonEnglish(data.claims))
703
853
  lines.push("", NON_ENGLISH_NOTE);
854
+ const paging = pageLine({ offset: start, shown: data.claims.length, hasMore: !!data.has_more });
704
855
  const hint = nextCallHint(`get_claim claim:${data.claims[0].claim_id} include_observations:true`);
705
- return withFooters(lines.join("\n"), footer, "", hint);
856
+ return withFooters(lines.join("\n"), footer, paging, hint);
706
857
  }
707
858
  // ---------------------------------------------------------------------------
708
859
  // Speaker
@@ -816,21 +967,28 @@ export function formatChannel(data) {
816
967
  export function formatSegment(data) {
817
968
  const segId = data.segment_id;
818
969
  const spk = data.speaker ?? {};
819
- const spkName = data.speaker_name ?? spk.name;
820
- const resolved = spk.identity_id != null;
970
+ // get_segment returns the primary speaker flat (speaker_name /
971
+ // canonical_speaker_id / attribution) and the per-speaker list separately;
972
+ // other producers of SegmentDetail nest it. Accept both, preferring the flat
973
+ // fields, which is the precedence formatSegment already had for the name.
974
+ const primary = {
975
+ ...spk,
976
+ name: data.speaker_name ?? spk.name,
977
+ identity_id: spk.identity_id ?? data.canonical_speaker_id,
978
+ attribution: spk.attribution ?? data.attribution,
979
+ corroborated_by: spk.corroborated_by ?? data.corroborated_by,
980
+ };
981
+ const attr = speakerAttribution(primary);
982
+ const resolved = primary.identity_id != null;
821
983
  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)");
984
+ const speakerTokens = [...attr.tokens];
827
985
  if (spk.confidence !== undefined || resolved)
828
986
  speakerTokens.push(confToken(spk.confidence));
829
987
  if (spk.appearance_type)
830
988
  speakerTokens.push(`appearance:${spk.appearance_type}`);
831
989
  const lines = [
832
990
  `**Segment ${segId ?? "n/a"}**`,
833
- `- **Speaker:** ${spkName ?? "unresolved (diarization cluster)"}${speakerTokens.length ? ` — ${speakerTokens.join(" ")}` : ""}`,
991
+ `- **Speaker:** ${attr.label}${speakerTokens.length ? ` — ${speakerTokens.join(" ")}` : ""}`,
834
992
  `- **Channel:** ${data.channel_name ?? "n/a"}${data.channel_id != null ? ` — channel_id:${data.channel_id}` : ""}`,
835
993
  `- **Episode:** ${data.episode_title ?? "n/a"} — episode:${data.content_id ?? "n/a"}`,
836
994
  `- **Date:** date:${fmtDate(data.episode_date)}`,
@@ -840,7 +998,7 @@ export function formatSegment(data) {
840
998
  lines.push(`- ${clipLine(data.content_type, clip)}`);
841
999
  lines.push(`\n> ${data.text ?? ""}`);
842
1000
  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;
1001
+ return lines.join("\n") + hint + (attr.hedged ? ATTRIBUTION_NOTE : "");
844
1002
  }
845
1003
  // ---------------------------------------------------------------------------
846
1004
  // Analysis
@@ -1363,7 +1521,7 @@ function observationLine(o) {
1363
1521
  ` seg:${o.segment_id ?? "n/a"} episode:${o.content_id ?? "n/a"} ${clipLine(o.content_type, clip)}`,
1364
1522
  ];
1365
1523
  }
1366
- export function formatClaimDetail(detail, observations, related) {
1524
+ export function formatClaimDetail(detail, observations, related, obsOffset = 0) {
1367
1525
  const c = detail.claim;
1368
1526
  const lines = [];
1369
1527
  lines.push(`# Claim claim:${c.claim_id}`);
@@ -1401,18 +1559,28 @@ export function formatClaimDetail(detail, observations, related) {
1401
1559
  const obs = observations?.observations ?? c.sample_observations ?? [];
1402
1560
  let firstObsSeg;
1403
1561
  if (obs.length) {
1404
- const label = observations ? `**Observations** (${observations.total} total)` : "**Sample Observations**";
1562
+ const label = observations ? `**Observations** (${observations.total.toLocaleString("en-US")} total)` : "**Sample Observations**";
1405
1563
  lines.push(label);
1406
- for (const o of obs.slice(0, 20)) {
1564
+ for (const o of observations ? obs : obs.slice(0, 20)) {
1407
1565
  if (firstObsSeg == null && o.segment_id != null)
1408
1566
  firstObsSeg = o.segment_id;
1409
1567
  lines.push(...observationLine(o));
1410
1568
  }
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`);
1569
+ // The hint used to say "page:N", a parameter get_claim never had (signal4#329).
1570
+ if (observations) {
1571
+ const page = pageLine({
1572
+ offset: obsOffset, shown: obs.length, total: observations.total,
1573
+ hasMore: obsOffset + obs.length < observations.total,
1574
+ param: "observations_offset", noun: "observations",
1575
+ }).trim();
1576
+ if (page)
1577
+ lines.push(page);
1413
1578
  }
1414
1579
  lines.push("");
1415
1580
  }
1581
+ else if (observations && obsOffset > 0) {
1582
+ lines.push(`**Observations**: none past ${obsOffset.toLocaleString("en-US")} (${observations.total.toLocaleString("en-US")} total)`, "");
1583
+ }
1416
1584
  if (related?.claims?.length) {
1417
1585
  lines.push("**Related Claims:**");
1418
1586
  for (const r of related.claims) {
@@ -1420,26 +1588,93 @@ export function formatClaimDetail(detail, observations, related) {
1420
1588
  }
1421
1589
  lines.push("");
1422
1590
  }
1591
+ else if (related?.method === "timed_out" || related?.method === "unavailable") {
1592
+ lines.push("_Related claims: lookup timed out; the claim above is unaffected. Retry, or use search_claims with its summary as the topic._", "");
1593
+ }
1423
1594
  const hint = nextCallHint(firstObsSeg != null ? `get_segment seg:${firstObsSeg}` : undefined, `claim_graph claim_id:${c.claim_id}`);
1424
1595
  return lines.join("\n") + hint;
1425
1596
  }
1426
1597
  // ---------------------------------------------------------------------------
1427
1598
  // Timeline
1428
1599
  // ---------------------------------------------------------------------------
1429
- /** MCP timeline: `date<TAB>count` rows (spec §5 — bar glyphs retired). */
1600
+ export const TIMELINE_MAX_ROWS = 60;
1601
+ function isoWeekStart(d) {
1602
+ const t = new Date(d.slice(0, 10) + "T00:00:00Z");
1603
+ const dow = (t.getUTCDay() + 6) % 7; // Monday = 0
1604
+ t.setUTCDate(t.getUTCDate() - dow);
1605
+ return t.toISOString().slice(0, 10);
1606
+ }
1607
+ /** Aggregate day buckets to week, then month, until at most `max` rows remain. */
1608
+ export function aggregateBuckets(buckets, granularity, max = TIMELINE_MAX_ROWS) {
1609
+ let rows = buckets.map((b) => ({ start: b.start.slice(0, 10), count: b.count }));
1610
+ let g = granularity;
1611
+ const fold = (key) => {
1612
+ const m = new Map();
1613
+ for (const r of rows) {
1614
+ const k = key(r.start);
1615
+ m.set(k, (m.get(k) ?? 0) + r.count);
1616
+ }
1617
+ rows = [...m.entries()].map(([start, count]) => ({ start, count })).sort((a, b) => a.start.localeCompare(b.start));
1618
+ };
1619
+ if (rows.length > max && g === "day") {
1620
+ fold(isoWeekStart);
1621
+ g = "week";
1622
+ }
1623
+ // Only fine buckets fold. month/quarter/year come bucketed by the server on calendar
1624
+ // boundaries; re-keying them here would relabel a quarter as a month.
1625
+ if (rows.length > max && g === "week") {
1626
+ fold((d) => d.slice(0, 7));
1627
+ g = "month";
1628
+ }
1629
+ return { rows, granularity: g };
1630
+ }
1631
+ /** MCP timeline: one summary line, then `date<TAB>count` rows (zero runs collapsed, ≤60 rows). */
1430
1632
  export function formatTimeline(data, footer = "") {
1431
- const buckets = data.buckets ?? [];
1432
- if (!buckets.length)
1633
+ const raw = data.buckets ?? [];
1634
+ if (!raw.length)
1433
1635
  return "No data found for this topic in the given time range." + footer;
1636
+ const { rows: buckets, granularity } = aggregateBuckets(raw, data.granularity);
1637
+ const total = buckets.reduce((n, b) => n + b.count, 0);
1638
+ const peak = buckets.reduce((best, b) => (b.count > best.count ? b : best), buckets[0]);
1639
+ const mean = total / buckets.length;
1434
1640
  const lines = [
1435
- `**"${data.topic}"** — ${data.total} segments (${data.granularity}, ${data.search_mode})\n`,
1641
+ `**"${data.topic}"** — ${data.total ?? total} segments${data.episodes != null ? ` · ${data.episodes} episodes · ${data.channels ?? 0} channels` : ""} · peak ${peak.start} (${peak.count}) · mean ${mean.toFixed(1)}/${granularity} · by ${granularity}${granularity !== data.granularity ? ` (aggregated from ${data.granularity})` : ""} · semantic estimate at ≥${data.min_similarity ?? 0.35}\n`,
1436
1642
  "date\tcount",
1437
1643
  ];
1644
+ let zeroRun = [];
1645
+ const flush = () => {
1646
+ if (zeroRun.length > 3)
1647
+ lines.push(`${zeroRun[0].start}..${zeroRun[zeroRun.length - 1].start}\t0 (${zeroRun.length} empty)`);
1648
+ else
1649
+ for (const z of zeroRun)
1650
+ lines.push(`${z.start}\t0`);
1651
+ zeroRun = [];
1652
+ };
1438
1653
  for (const b of buckets) {
1654
+ if (b.count === 0) {
1655
+ zeroRun.push(b);
1656
+ continue;
1657
+ }
1658
+ flush();
1439
1659
  lines.push(`${b.start}\t${b.count}`);
1440
1660
  }
1441
- const peak = buckets.reduce((best, b) => (b.count > best.count ? b : best), buckets[0]);
1661
+ flush();
1662
+ for (const note of data.notes ?? [])
1663
+ lines.push(`_Note: ${note}._`);
1442
1664
  const hint = nextCallHint(`search_quotes topic:"${data.topic}" start_date:${peak.start.slice(0, 10)}`);
1443
1665
  return withFooters(lines.join("\n"), footer, "", hint);
1444
1666
  }
1667
+ /** Token-light collection listing: header, `key | channels | description`, aliases. */
1668
+ export function formatCollections(data) {
1669
+ const cols = (data.collections ?? []).filter((c) => c.enabled !== false);
1670
+ const surface = getSurface();
1671
+ const flag = surface === "cli" ? "--collection" : "collection";
1672
+ const lines = [`${cols.length} collections · omit ${flag} to search ${data.default ?? "everything"}`];
1673
+ for (const c of cols)
1674
+ lines.push(`${c.key} | ${c.channels ?? "?"} channels | ${truncate(c.description ?? c.label ?? "", 90)}`);
1675
+ const al = Object.entries(data.aliases ?? {});
1676
+ if (al.length)
1677
+ lines.push(`aliases: ${al.map(([a, k]) => `${a}=${k}`).join(", ")}`);
1678
+ return lines.join("\n");
1679
+ }
1445
1680
  //# sourceMappingURL=format.js.map