@cyanheads/hn-mcp-server 0.5.21 → 0.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 (44) hide show
  1. package/AGENTS.md +26 -10
  2. package/CLAUDE.md +26 -10
  3. package/Dockerfile +10 -4
  4. package/README.md +31 -19
  5. package/changelog/0.5.x/0.5.22.md +45 -0
  6. package/changelog/0.6.x/0.6.0.md +38 -0
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/get-stories.tool.js +31 -17
  11. package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +4 -2
  13. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/get-thread.tool.js +427 -220
  15. package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +1 -0
  17. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/get-user.tool.js +176 -106
  19. package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +106 -0
  21. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/search-content.tool.js +532 -194
  23. package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
  24. package/dist/mcp-server/tools/response-budget.d.ts +111 -0
  25. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
  26. package/dist/mcp-server/tools/response-budget.js +204 -0
  27. package/dist/mcp-server/tools/response-budget.js.map +1 -0
  28. package/dist/mcp-server/tools/served-at-cap.d.ts +14 -0
  29. package/dist/mcp-server/tools/served-at-cap.d.ts.map +1 -0
  30. package/dist/mcp-server/tools/served-at-cap.js +16 -0
  31. package/dist/mcp-server/tools/served-at-cap.js.map +1 -0
  32. package/dist/mcp-server/tools/thread-cursor.d.ts +68 -0
  33. package/dist/mcp-server/tools/thread-cursor.d.ts.map +1 -0
  34. package/dist/mcp-server/tools/thread-cursor.js +200 -0
  35. package/dist/mcp-server/tools/thread-cursor.js.map +1 -0
  36. package/dist/services/hn/hn-service.d.ts +73 -12
  37. package/dist/services/hn/hn-service.d.ts.map +1 -1
  38. package/dist/services/hn/hn-service.js +127 -38
  39. package/dist/services/hn/hn-service.js.map +1 -1
  40. package/dist/services/hn/types.d.ts +9 -0
  41. package/dist/services/hn/types.d.ts.map +1 -1
  42. package/package.json +13 -12
  43. package/server.json +3 -3
  44. package/skills/hn-guide/SKILL.md +39 -0
@@ -5,9 +5,46 @@
5
5
  import { tool, z } from '@cyanheads/mcp-ts-core';
6
6
  import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
7
7
  import { escapeHighlight, escapeInline, escapeQuoted, quoteBody, } from '../markdown-escape.js';
8
- import { dateBoundToEpochMs, decodeHtmlEntities, extractDomain, getHnService, normalizeUrl, stripHtml, stripHtmlPreservingEm, } from '../../../services/hn/hn-service.js';
9
- /** Largest page `count` accepts, mirrored in the input schema. */
10
- const MAX_COUNT = 50;
8
+ import { byteLength, cutPointer, cutToBytes, fitsBudget, largestFittingLimit, measureResponse, RESPONSE_BUDGET_TEXT, } from '../response-budget.js';
9
+ import { servedAtCap } from '../served-at-cap.js';
10
+ import { dateBoundToEpochMs, dateRangeToEpochSeconds, decodeHtmlEntities, extractDomain, getHnService, normalizeUrl, stripHtml, stripHtmlPreservingEm, } from '../../../services/hn/hn-service.js';
11
+ /** Most hits one call serves; a larger `count` is served at this cap. */
12
+ const COUNT_CAP = 50;
13
+ /** Largest `count` accepted; anything above it is rejected. */
14
+ const COUNT_CEILING = 1000;
15
+ /**
16
+ * The recovery for a first hit no page of 50 begins on: the largest count up to
17
+ * the cap whose pages include one starting at that hit, so a caller can resume
18
+ * from exactly there. When that count is under half the cap, covering a cap's
19
+ * worth of hits from there takes three or more calls where pages of 50 take
20
+ * two, so the page of 50 holding the hit is named as well.
21
+ */
22
+ function unalignedHint(firstHit) {
23
+ let size = COUNT_CAP;
24
+ while (firstHit % size !== 0)
25
+ size--;
26
+ const exact = `Pass count: ${size}, page: ${firstHit / size} to start at the same hit`;
27
+ if (size * 2 >= COUNT_CAP)
28
+ return `${exact}.`;
29
+ const page = Math.floor(firstHit / COUNT_CAP);
30
+ const start = page * COUNT_CAP;
31
+ return `${exact}, or count: ${COUNT_CAP}, page: ${page} for hits ${start}–${start + COUNT_CAP - 1}, which include hit ${firstHit}.`;
32
+ }
33
+ /** Algolia serves at most this many hits per search, however many match. */
34
+ const HIT_CEILING = 1000;
35
+ /** The content types `tags` accepts. */
36
+ const SEARCH_TAGS = ['story', 'comment', 'poll', 'job', 'ask_hn', 'show_hn', 'front_page'];
37
+ /** The item types a hit's `_tags` can name. `ask_hn`, `show_hn`, and `front_page` are further tags on a story. */
38
+ const HIT_TYPES = ['story', 'comment', 'poll', 'pollopt', 'job'];
39
+ /**
40
+ * The first `_tags` entry that names an item type, or undefined when none does.
41
+ * The first match, not the first entry, so a reordered tag list cannot mistype
42
+ * a hit. A poll option's `story_<id>` tag, where present, names the option
43
+ * itself, so no parent is read from it.
44
+ */
45
+ function hitType(tags) {
46
+ return tags?.find((tag) => HIT_TYPES.includes(tag));
47
+ }
11
48
  /**
12
49
  * ISO 8601 calendar forms accepted for a date bound: `YYYY`, `YYYY-MM`,
13
50
  * `YYYY-MM-DD`, or `YYYY-MM-DDThh:mm[:ss[.sss]]` with an optional `Z` or
@@ -46,6 +83,70 @@ const dateBound = () => z
46
83
  .regex(DATE_BOUND_PATTERN, 'must be an ISO 8601 date or date-time — YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DDThh:mm[:ss[.sss]] with an optional Z or ±hh:mm offset.')
47
84
  .refine(isCalendarValid, 'is not a real calendar date or time — check the month, day, hour, minute, second, and offset ranges.')
48
85
  .optional();
86
+ /** A URL's scheme and `www.`, or a bare leading `www.`. Anchored, so it reads a fixed-length prefix at most. */
87
+ const URL_PREFIX = /^(?:https?:\/\/(?:www\.)?|www\.)/i;
88
+ /**
89
+ * Drop the scheme and `www.` from a query that is a URL alone. Algolia requires
90
+ * every token, so `https` and `www` would otherwise have to appear in the
91
+ * submitted URL, and the same page submitted under the other scheme or without
92
+ * `www.` would drop out. A query with whitespace is searched as typed, and so
93
+ * is one the prefix would leave empty.
94
+ */
95
+ export function stripUrlPrefix(query) {
96
+ if (/\s/.test(query))
97
+ return { query };
98
+ const prefix = URL_PREFIX.exec(query)?.[0];
99
+ if (prefix == null || prefix.length === query.length)
100
+ return { query };
101
+ return { query: query.slice(prefix.length), droppedPrefix: prefix };
102
+ }
103
+ /** The search as run: the filters set, then sort, count, and view with their defaults. */
104
+ const searchCriteria = z
105
+ .object({
106
+ tags: z.enum(SEARCH_TAGS).optional().describe('Content-type filter.'),
107
+ author: z.string().optional().describe('Author filter — the username matched, trimmed.'),
108
+ storyId: z.number().optional().describe('Discussion filter: a story or poll root id.'),
109
+ minPoints: z.number().optional().describe('Minimum score.'),
110
+ dateRange: z
111
+ .object({
112
+ start: z
113
+ .string()
114
+ .optional()
115
+ .describe('Items match only when created strictly after this instant: the start bound in ISO 8601 UTC, rounded down to the whole second.'),
116
+ end: z
117
+ .string()
118
+ .optional()
119
+ .describe('Items match only when created strictly before this instant: the end bound in ISO 8601 UTC, rounded up to the whole second.'),
120
+ })
121
+ .optional()
122
+ .describe('Creation-time window as applied, with only the bounds that were set.'),
123
+ sort: z.enum(['relevance', 'date']).describe('Sort order used.'),
124
+ count: z
125
+ .number()
126
+ .describe('Hits per page sent: count as passed, or 50 when count was above that cap.'),
127
+ view: z.enum(['full', 'compact']).describe('Projection applied to each hit.'),
128
+ })
129
+ .describe('The criteria this search ran with: each filter that was set, as sent — author trimmed, dateRange bounds as the exclusive UTC instants applied — then sort, count, and view, defaults included. Unset filters are absent. The searched query is in query.');
130
+ /** A whole Unix second as ISO 8601 UTC, e.g. `2024-05-05T08:00:00Z`. */
131
+ const isoSeconds = (seconds) => new Date(seconds * 1000).toISOString().replace('.000Z', 'Z');
132
+ /** One trailer line for the criteria; `author`, the one free-text value, is escaped inside its quotes. */
133
+ function renderCriteria(c) {
134
+ const bounds = c.dateRange && [
135
+ c.dateRange.start != null ? `after ${c.dateRange.start}` : null,
136
+ c.dateRange.end != null ? `before ${c.dateRange.end}` : null,
137
+ ];
138
+ const parts = [
139
+ c.tags != null ? `tags: ${c.tags}` : null,
140
+ c.author != null ? `author: "${escapeQuoted(c.author)}"` : null,
141
+ c.storyId != null ? `storyId: ${c.storyId}` : null,
142
+ c.minPoints != null ? `minPoints: ${c.minPoints}` : null,
143
+ bounds ? `dateRange: ${bounds.filter(Boolean).join(', ')}` : null,
144
+ `sort: ${c.sort}`,
145
+ `count: ${c.count}`,
146
+ `view: ${c.view}`,
147
+ ];
148
+ return `**criteria:** ${parts.filter(Boolean).join(' | ')}`;
149
+ }
49
150
  /** The filters a caller set, in the order notices name them. `sort`, `count`, `page`, and `view` are not filters. */
50
151
  function appliedFilters(input) {
51
152
  const filters = [];
@@ -62,25 +163,104 @@ function appliedFilters(input) {
62
163
  return filters;
63
164
  }
64
165
  /**
65
- * Notice for a page that came back empty. A page past the end names the last
66
- * valid page when Algolia reports one; past its 1,000-hit ceiling Algolia
67
- * answers `nbPages: 0`, so the last page is unknown and the notice resets to
68
- * page 0. Only a first page with no hits is a zero-match search.
166
+ * The page-state sentence. A non-empty page names the next page while more
167
+ * remain, and the last page of a search matching more than Algolia serves names
168
+ * that ceiling. An empty page past the end names the last valid page when
169
+ * Algolia reports one; past the ceiling Algolia answers `nbPages: 0`, so the
170
+ * last page is unknown and the notice resets to page 0. Only a first page with
171
+ * no hits is a zero-match search, which names what to relax. After a count
172
+ * above the cap, a page lever also names the count it counts in.
69
173
  */
70
- function emptyPageNotice(input, filters, page, totalHits, totalPages) {
174
+ function pageStateSentence(facts) {
175
+ const { shown, page, totalHits, totalPages } = facts;
176
+ const pageLever = (n) => `${facts.overCap ? `count: ${facts.count}, ` : ''}page: ${n}`;
177
+ if (shown > 0) {
178
+ const total = `${facts.totalHitsExact === false ? 'about ' : ''}${totalHits}`;
179
+ if (page + 1 < totalPages) {
180
+ const raiseCount = facts.count < COUNT_CAP ? `, or raise count (max ${COUNT_CAP})` : '';
181
+ return `Showing ${shown} of ${total} hits (page ${page} of ${totalPages}). Pass ${pageLever(page + 1)} for more${raiseCount}.`;
182
+ }
183
+ return totalHits > HIT_CEILING
184
+ ? `Page ${page} is the last page Algolia serves: a search returns at most 1,000 of its matches, and this one matches ${total}. Split it into narrower dateRange windows to reach the rest.`
185
+ : undefined;
186
+ }
71
187
  if (page > 0) {
72
- return totalPages > 0
73
- ? `Page ${page} is past the last page of results (${totalHits} hits across ${totalPages} page${totalPages === 1 ? '' : 's'}). Pass page: ${totalPages - 1} for the last page, or page: 0 to start over.`
74
- : `Page ${page} is past the last page Algolia serves for this search (at most 1,000 hits per search), where totalHits and totalPages read 0. Pass page: 0 to start over; if that also returns nothing, the search itself matches nothing.`;
188
+ if (totalPages === 0) {
189
+ return `Page ${page} is past the last page Algolia serves for this search (at most 1,000 hits per search), where totalHits and totalPages read 0. Pass page: 0 to start over; if that also returns nothing, the search itself matches nothing.`;
190
+ }
191
+ /** With one page, the last page is page 0, so one lever covers both. */
192
+ const lever = totalPages === 1
193
+ ? `Pass ${pageLever(0)} for that page.`
194
+ : `Pass ${pageLever(totalPages - 1)} for the last page, or page: 0 to start over.`;
195
+ return `Page ${page} is past the last page of results (${totalHits} hits across ${totalPages} page${totalPages === 1 ? '' : 's'}). ${lever}`;
196
+ }
197
+ const filters = appliedFilters(facts);
198
+ if (facts.query == null) {
199
+ return `No items matched these filters: ${filters.join(', ')}. Relax or change them.`;
200
+ }
201
+ return filters.length
202
+ ? `Try broader keywords, or relax these filters: ${filters.join(', ')}.`
203
+ : 'Try broader keywords or different terms.';
204
+ }
205
+ /**
206
+ * Sentences an input calls for, in input order: query, tags, author, storyId,
207
+ * then count when it was above the per-call cap — said when hits remain past
208
+ * the served page, or when the served page is numbered differently from the
209
+ * one requested.
210
+ */
211
+ function causeSentences(facts) {
212
+ const emptyFirstPage = facts.shown === 0 && facts.page === 0;
213
+ const sentences = [];
214
+ if (facts.droppedPrefix != null) {
215
+ sentences.push(`The leading \`${facts.droppedPrefix}\` was dropped from the query, so the URL matches submissions under either scheme, with or without www.`);
216
+ if (emptyFirstPage) {
217
+ sentences.push('Submissions often omit #fragments and tracking parameters such as utm_source; search the URL without them.');
218
+ }
219
+ }
220
+ if (facts.tags === 'front_page' &&
221
+ (emptyFirstPage || facts.dateRange?.start || facts.dateRange?.end)) {
222
+ sentences.push('front_page matches only the stories on the HN front page right now (about 30), never past front pages; for an earlier window, search tags "story" with dateRange and minPoints instead.');
223
+ }
224
+ if (emptyFirstPage && facts.author) {
225
+ sentences.push("author must match the username's exact HN casing; hn_get_user answers user_not_found for a mis-cased name.");
226
+ }
227
+ if (emptyFirstPage && facts.storyId != null) {
228
+ sentences.push("storyId must be a story or poll root id — take it from hits[].storyId, a poll hit's own id (poll hits carry no storyId), or hn_get_thread: the root item's id, or its item.storyId when the root is a comment. A comment id matches nothing.");
75
229
  }
76
- const base = input.query == null
77
- ? `No items matched these filters: ${filters.join(', ')}. Relax or change them.`
78
- : filters.length
79
- ? `Try broader keywords, or relax these filters: ${filters.join(', ')}.`
80
- : 'Try broader keywords or different terms.';
81
- return input.storyId == null
82
- ? base
83
- : `${base} storyId must be a story or poll root id — take it from hits[].storyId or the hn_get_thread root; a comment id matches nothing.`;
230
+ const { overCap } = facts;
231
+ const morePages = facts.shown > 0 && facts.page + 1 < facts.totalPages;
232
+ if (overCap && (morePages || overCap.page > 0)) {
233
+ sentences.push(servedAtCap('count', overCap.count, facts.count));
234
+ if (overCap.page > 0) {
235
+ sentences.push(`Page ${overCap.page} at count ${overCap.count} starts at the same hit as page ${facts.page} at count ${facts.count}, the page served.`);
236
+ }
237
+ }
238
+ return sentences;
239
+ }
240
+ /** The sentence disclosing how the page was fitted to the response budget. */
241
+ function budgetSentence({ highlightsTextOmitted, cutIds }) {
242
+ const budget = `To stay within the ${RESPONSE_BUDGET_TEXT}-byte response budget,`;
243
+ const omitted = 'highlights.text is omitted from every hit';
244
+ if (cutIds.length === 0)
245
+ return `${budget} ${omitted}; matchedWords still lists the matched terms.`;
246
+ const cut = `the text of ${cutIds.length === 1 ? 'hit' : 'hits'} ${cutIds.join(', ')} is cut short`;
247
+ return `${budget} ${highlightsTextOmitted ? `${omitted} and ` : ''}${cut}; hn_get_thread with a cut hit's id returns its full text, and view: "compact" or a smaller count avoids the cut.`;
248
+ }
249
+ /**
250
+ * Compose the one `notice` string every search writes. `notice` is last-wins,
251
+ * so each source goes through here: the page-state sentence first, then the
252
+ * cause sentences in input order — query, tags, author, storyId, count — then
253
+ * the budget sentence for a page fitted to the response budget. None replaces
254
+ * another. Undefined when nothing applies — the last page of an uncapped,
255
+ * non-empty search that fit.
256
+ */
257
+ export function composeSearchNotice(facts) {
258
+ const sentences = [
259
+ pageStateSentence(facts),
260
+ ...causeSentences(facts),
261
+ facts.budget && budgetSentence(facts.budget),
262
+ ].filter((sentence) => sentence != null);
263
+ return sentences.length > 0 ? sentences.join(' ') : undefined;
84
264
  }
85
265
  /**
86
266
  * Entity-decode a title highlight between its `<em>` markers. A decoded
@@ -94,18 +274,21 @@ function decodeHighlightTitle(title) {
94
274
  .join('');
95
275
  }
96
276
  /**
97
- * Project Algolia's `_highlightResult` into a flat snippet object: the title
98
- * snippet, the body snippet (preferring comment_text over story_text to match
99
- * the `text` mapping), and the deduplicated union of matched words across all
100
- * surfaced fields. Returns undefined when nothing matched.
277
+ * Project Algolia's `_highlightResult` into a flat object: the title snippet,
278
+ * the body highlight — the whole matched body with markers, not a snippet
279
+ * (comment_text, else story_text — the order `text` reads them in; a job's
280
+ * job_text is never highlighted) — and the deduplicated
281
+ * union of matched words across all surfaced fields. Returns undefined when
282
+ * nothing matched.
101
283
  *
102
284
  * Titles are not HTML, though some arrive entity-encoded (`&lt;details&gt;`,
103
285
  * `&#34;`), so the title snippet is entity-decoded between its `<em>` markers
104
- * and never tag-stripped. The body snippet is stripped of its HTML with the
105
- * markers kept.
286
+ * and never tag-stripped. The body highlight, the whole matched body, is
287
+ * stripped of its HTML with the markers kept.
106
288
  *
107
- * `includeBody` is false under the compact projection, which drops the body
108
- * snippet — the field that duplicates a hit's full comment text.
289
+ * `includeBody` is false under the compact projection, and on a full page over
290
+ * the response budget, both of which drop the body highlight — the field that
291
+ * repeats a hit's full body text.
109
292
  */
110
293
  function extractHighlights(hit, includeBody) {
111
294
  const h = hit._highlightResult;
@@ -129,6 +312,174 @@ function extractHighlights(hit, includeBody) {
129
312
  matchedWords,
130
313
  };
131
314
  }
315
+ const searchHit = z
316
+ .object({
317
+ id: z.number().describe('HN item ID — use with hn_get_thread to read the discussion.'),
318
+ type: z
319
+ .enum(HIT_TYPES)
320
+ .optional()
321
+ .describe('Item type: story (Ask HN, Show HN, and front-page stories included), comment, poll, pollopt (one option of a poll), or job. Absent when the search record names no type.'),
322
+ title: z.string().optional().describe('Item title (present for stories, polls, and jobs).'),
323
+ url: z.string().optional().describe('External link URL.'),
324
+ domain: z
325
+ .string()
326
+ .optional()
327
+ .describe('Bare hostname derived from url (e.g. "github.com", with leading "www." stripped). Absent when url is missing or unparseable.'),
328
+ author: z.string().describe('Author username.'),
329
+ points: z.number().optional().describe('Score/upvotes.'),
330
+ numComments: z.number().optional().describe('Comment count.'),
331
+ createdAt: z.string().describe('Creation time (ISO 8601).'),
332
+ storyTitle: z.string().optional().describe('Parent story title (present for comment results).'),
333
+ storyId: z
334
+ .number()
335
+ .optional()
336
+ .describe("Parent story ID for comment hits; equals `id` for story hits. Absent for polls, poll options, and jobs — a poll's own `id` is its discussion root."),
337
+ text: z
338
+ .string()
339
+ .optional()
340
+ .describe(`Body text, HTML stripped: a comment's text, a text post's body, or a job's description. A job description is returned but not keyword-searched or highlighted. Cut short when the page would otherwise exceed the ${RESPONSE_BUDGET_TEXT}-byte response budget, with textTruncated set. Absent when the hit has no body, and always absent under view "compact" — call hn_get_thread with this id to read it.`),
341
+ textTruncated: z
342
+ .boolean()
343
+ .optional()
344
+ .describe(`True when text is a prefix of the body, cut short to keep the page within the ${RESPONSE_BUDGET_TEXT}-byte response budget — hn_get_thread with this id returns it whole. Absent when text is whole.`),
345
+ highlights: z
346
+ .object({
347
+ title: z
348
+ .string()
349
+ .optional()
350
+ .describe('Title snippet with matched terms wrapped in `<em>…</em>`. Titles are not HTML, though some arrive entity-encoded; entities are decoded and no tags are stripped, so a literal `<em>` in the title is indistinguishable from a marker. Absent when the title did not match.'),
351
+ text: z
352
+ .string()
353
+ .optional()
354
+ .describe('The matched body in full, not a snippet (comment_text or story_text), HTML stripped, with matched terms wrapped in `<em>…</em>` — the same text as `text` plus its markers. A literal `<em>` typed into the body is indistinguishable from a marker. Absent when the body did not match, when highlightsTextOmitted is true, and always under view "compact" — matchedWords still lists what matched.'),
355
+ matchedWords: z
356
+ .array(z.string())
357
+ .describe('Deduplicated union of matched terms across all searchable fields (title, url, author, comment_text, story_text, story_title).'),
358
+ })
359
+ .optional()
360
+ .describe('Algolia per-field highlight metadata showing which terms matched and where. Absent when no fields produced a match.'),
361
+ })
362
+ .describe('A single Algolia search hit (story, comment, poll, poll option, or job).');
363
+ /**
364
+ * Project an Algolia hit into a search hit. `text` carries its body; under
365
+ * `bodyHighlight` its highlights carry the body too — the matched body in full,
366
+ * with markers.
367
+ */
368
+ function projectHit(hit, text, bodyHighlight) {
369
+ const rawText = text ? (hit.comment_text ?? hit.story_text ?? hit.job_text) : undefined;
370
+ const url = normalizeUrl(hit.url);
371
+ const domain = extractDomain(url);
372
+ const highlights = extractHighlights(hit, bodyHighlight);
373
+ const type = hitType(hit._tags);
374
+ return {
375
+ id: Number(hit.objectID),
376
+ ...(type && { type }),
377
+ title: hit.title != null ? decodeHtmlEntities(hit.title) : undefined,
378
+ url,
379
+ domain,
380
+ author: hit.author,
381
+ points: hit.points ?? undefined,
382
+ numComments: hit.num_comments ?? undefined,
383
+ createdAt: hit.created_at,
384
+ storyTitle: hit.story_title != null ? decodeHtmlEntities(hit.story_title) : undefined,
385
+ storyId: hit.story_id ?? undefined,
386
+ text: rawText ? stripHtml(rawText) || undefined : undefined,
387
+ ...(highlights && { highlights }),
388
+ };
389
+ }
390
+ const searchOutput = z.object({
391
+ hits: z.array(searchHit).describe('Search results ranked by sort order.'),
392
+ query: z
393
+ .string()
394
+ .optional()
395
+ .describe('The query that was searched — for a URL query, without its leading scheme and www. Absent for a filter-only search.'),
396
+ });
397
+ const searchTrailer = {
398
+ criteria: { render: renderCriteria },
399
+ };
400
+ /**
401
+ * Render highlight metadata as a `> match — …` footer, surfacing each highlights
402
+ * field separately so structured consumers and the LLM both see what matched.
403
+ * The footer follows an unquoted blank line, which a quoted body never
404
+ * produces, so a body line reading `match — …` cannot pass for it. The
405
+ * matched body comes last and continues on quoted lines of its own.
406
+ */
407
+ function renderHighlights(hl) {
408
+ const parts = [];
409
+ if (hl.title)
410
+ parts.push(`title: ${escapeHighlight(hl.title)}`);
411
+ if (hl.matchedWords.length)
412
+ parts.push(`terms: ${hl.matchedWords.join(', ')}`);
413
+ if (hl.text)
414
+ parts.push(`body: ${hl.text}`);
415
+ return parts.length ? `\n\n${quoteBody(`match — ${parts.join(' | ')}`)}` : '';
416
+ }
417
+ /** A hit's quoted body, and after a cut body the unquoted pointer to the whole text, a blank line apart. */
418
+ function renderBody(h) {
419
+ const text = h.text ? `\n${quoteBody(h.text)}` : '';
420
+ return h.textTruncated ? `${text}\n\n${cutPointer(h.id)}` : text;
421
+ }
422
+ function formatSearch(result) {
423
+ if (result.hits.length === 0) {
424
+ return [
425
+ {
426
+ type: 'text',
427
+ text: result.query != null ? `"${escapeQuoted(result.query)}" — no results.` : 'No results.',
428
+ },
429
+ ];
430
+ }
431
+ const lines = result.hits.map((h) => {
432
+ const typeToken = h.type ? `type:${h.type}` : null;
433
+ const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
434
+ if (h.type === 'pollopt') {
435
+ // A poll option's search record carries no text and no poll; hn_get_thread has both.
436
+ const meta = [
437
+ `id:${h.id}`,
438
+ typeToken,
439
+ h.author,
440
+ h.points != null ? `${h.points} pts` : null,
441
+ h.createdAt,
442
+ ]
443
+ .filter(Boolean)
444
+ .join(' | ');
445
+ return `### Poll option by ${h.author}\n${meta}\nOption text and its poll: hn_get_thread with itemId ${h.id}${hlLine}`;
446
+ }
447
+ if (h.title) {
448
+ // Story result — Algolia returns storyId === id for stories, so suppress the parent ref unless it actually differs or a parent title is set.
449
+ const parentRef = (h.storyId != null && h.storyId !== h.id) || h.storyTitle
450
+ ? ` | story:"${escapeQuoted(h.storyTitle ?? '?')}"#${h.storyId ?? '?'}`
451
+ : '';
452
+ const meta = [
453
+ `id:${h.id}`,
454
+ typeToken,
455
+ h.author,
456
+ h.points != null ? `${h.points} pts` : null,
457
+ h.numComments != null ? `${h.numComments} comments` : null,
458
+ h.createdAt,
459
+ ]
460
+ .filter(Boolean)
461
+ .join(' | ');
462
+ const domain = h.domain ? ` (${h.domain})` : '';
463
+ const url = h.url ? `\n${h.url}` : '';
464
+ return `### ${escapeInline(h.title)}${domain}\n${meta}${parentRef}${url}${renderBody(h)}${hlLine}`;
465
+ }
466
+ // Comment result — parent context in heading.
467
+ const meta = [
468
+ `id:${h.id}`,
469
+ typeToken,
470
+ h.author,
471
+ h.points != null ? `${h.points} pts` : null,
472
+ h.createdAt,
473
+ ]
474
+ .filter(Boolean)
475
+ .join(' | ');
476
+ return `### Comment on "${escapeQuoted(h.storyTitle ?? 'unknown')}" (story id:${h.storyId ?? '?'})\n${meta}${renderBody(h)}${hlLine}`;
477
+ });
478
+ const header = result.query != null
479
+ ? `## "${escapeQuoted(result.query)}" — search results`
480
+ : '## Search results (filters only)';
481
+ return [{ type: 'text', text: `${header}\n\n${lines.join('\n\n')}` }];
482
+ }
132
483
  export const searchHn = tool('hn_search_content', {
133
484
  description: 'Search Hacker News stories, comments, polls, and jobs via Algolia — by keyword, by filters alone, or both. Filterable by content type, author, parent story, date range, and minimum points.',
134
485
  annotations: { readOnlyHint: true },
@@ -154,6 +505,13 @@ export const searchHn = tool('hn_search_content', {
154
505
  recovery: 'Drop minPoints to search comments or jobs, or search a scored type (story, poll, ask_hn, show_hn, front_page) to filter by points.',
155
506
  severity: 'notice',
156
507
  },
508
+ {
509
+ reason: 'count_page_unaligned',
510
+ code: JsonRpcErrorCode.InvalidParams,
511
+ when: 'count was above the per-call cap of 50 with a page whose first hit (page × count) begins no page of 50 hits, so the requested page cannot be served from its start at the cap.',
512
+ recovery: 'Pass a count of 50 or less with page counted in that count; the error names a count and page that start at the same hit and, when that count is under 25, the page of 50 that holds the hit.',
513
+ severity: 'notice',
514
+ },
157
515
  {
158
516
  reason: 'upstream_rejected',
159
517
  code: JsonRpcErrorCode.InvalidParams,
@@ -200,23 +558,23 @@ export const searchHn = tool('hn_search_content', {
200
558
  .trim()
201
559
  .min(1, 'blank after trimming whitespace — pass one or more search terms, or omit query for a filter-only search.')
202
560
  .optional()
203
- .describe('Search terms. Supports simple keywords — Algolia handles stemming and relevance. Trimmed before searching; blank or whitespace-only input is rejected. Omit for a filter-only search, which needs at least one of tags, author, storyId, minPoints, or a dateRange bound.'),
561
+ .describe('Search terms. Supports simple keywords — Algolia handles stemming and relevance, and a hit must contain every term. Matching is typo-tolerant and the last term also matches as a prefix (zig matches Zigbee); wrap a term in double quotes to match it exactly ("zig"), or put - before one to exclude it ("zig" -devlog). Trimmed before searching; blank or whitespace-only input is rejected. To find discussions of a page, pass its URL alone: a query with no spaces that starts with http://, https://, or www. is searched without that prefix, so submissions under either scheme, with or without www., all match. Omit for a filter-only search, which needs at least one of tags, author, storyId, minPoints, or a dateRange bound.'),
204
562
  tags: z
205
- .enum(['story', 'comment', 'poll', 'job', 'ask_hn', 'show_hn', 'front_page'])
563
+ .enum(SEARCH_TAGS)
206
564
  .optional()
207
- .describe('Filter results by content type: "story", "comment", "poll", or "job", or the story subsets "ask_hn", "show_hn", and "front_page". Omit to search all types.'),
565
+ .describe('Filter results by content type: "story", "comment", "poll", or "job", or the story subsets "ask_hn", "show_hn", and "front_page". "front_page" is the HN front page right now (about 30 stories); no filter selects stories from past front pages. The nearest proxy is "story" with dateRange and minPoints — without a query, the default relevance sort ranks by points. Omit to search all types, which also returns poll options (type "pollopt"): records with only an id, author, vote count, and date — hn_get_thread reads an option\'s text and poll.'),
208
566
  author: z
209
567
  .string()
210
568
  .trim()
211
569
  .min(1, 'blank after trimming whitespace — omit author to search all authors.')
212
570
  .optional()
213
- .describe(`Filter results to a specific author. Useful for finding a user's posts on a topic (hn_get_user only shows recent submissions). Trimmed before filtering; omit the field to search all authors rather than passing a blank string.`),
571
+ .describe(`Filter results to a specific author — useful for finding a user's posts on a topic. Matches the HN username exactly and is case-sensitive, so a re-cased name finds nothing. Trimmed before filtering; omit the field to search all authors rather than passing a blank string.`),
214
572
  storyId: z
215
573
  .number()
216
574
  .int()
217
575
  .positive()
218
576
  .optional()
219
- .describe('Restrict results to one discussion: the id of a story or poll root, combined with the other filters. Pair with tags "comment" to search within a thread. Take it from hits[].storyId or the root item of hn_get_thread — a comment id matches nothing.'),
577
+ .describe('Restrict results to one discussion: the id of a story or poll root, combined with the other filters. Pair with tags "comment" to search within a thread. Take it from hits[].storyId, from a poll hit\'s own id (poll hits carry no storyId), or from hn_get_thread — the root item\'s id, or its item.storyId when the root is a comment. A comment id matches nothing.'),
220
578
  sort: z
221
579
  .enum(['relevance', 'date'])
222
580
  .default('relevance')
@@ -238,86 +596,59 @@ export const searchHn = tool('hn_search_content', {
238
596
  .number()
239
597
  .int()
240
598
  .min(1)
241
- .max(MAX_COUNT)
599
+ .max(COUNT_CEILING)
242
600
  .default(30)
243
- .describe('Number of results to return.'),
244
- page: z.number().int().min(0).default(0).describe('Page number for pagination (0-indexed).'),
601
+ .describe(`Number of results to return, at most ${COUNT_CAP} per call. A larger value is served at ${COUNT_CAP}: the search runs at count ${COUNT_CAP} from the hit where the requested page starts, and the notice names the count and page to pass next. With page above 0, that hit (page × count) must begin a page of ${COUNT_CAP} hits.`),
602
+ page: z
603
+ .number()
604
+ .int()
605
+ .min(0)
606
+ .default(0)
607
+ .describe('Page number for pagination (0-indexed), counted in pages of count results.'),
245
608
  view: z
246
609
  .enum(['full', 'compact'])
247
610
  .default('full')
248
- .describe('How much of each hit to return. "full" includes every field. "compact" omits the two body-text fields — `text` and `highlights.text` — which together can repeat a long comment twice per hit; everything else (id, title, url, domain, author, points, comment count, timestamp, parent story, title highlight, matchedWords) is unchanged. Use "compact" to scan many results, then pass a hit id to hn_get_thread to read the body you skipped.'),
249
- }),
250
- output: z.object({
251
- hits: z
252
- .array(z
253
- .object({
254
- id: z.number().describe('HN item ID — use with hn_get_thread to read the discussion.'),
255
- title: z
256
- .string()
257
- .optional()
258
- .describe('Item title (present for stories, polls, and jobs).'),
259
- url: z.string().optional().describe('External link URL.'),
260
- domain: z
261
- .string()
262
- .optional()
263
- .describe('Bare hostname derived from url (e.g. "github.com", with leading "www." stripped). Absent when url is missing or unparseable.'),
264
- author: z.string().describe('Author username.'),
265
- points: z.number().optional().describe('Score/upvotes.'),
266
- numComments: z.number().optional().describe('Comment count.'),
267
- createdAt: z.string().describe('Creation time (ISO 8601).'),
268
- storyTitle: z
269
- .string()
270
- .optional()
271
- .describe('Parent story title (present for comment results).'),
272
- storyId: z
273
- .number()
274
- .optional()
275
- .describe('Parent story ID for comment hits; equals `id` for story hits.'),
276
- text: z
277
- .string()
278
- .optional()
279
- .describe('Comment or story body text (HTML stripped). Absent when the hit has no body, and always absent under view "compact" — call hn_get_thread with this id to read it.'),
280
- highlights: z
281
- .object({
282
- title: z
283
- .string()
284
- .optional()
285
- .describe('Title snippet with matched terms wrapped in `<em>…</em>`. Titles are not HTML, though some arrive entity-encoded; entities are decoded and no tags are stripped, so a literal `<em>` in the title is indistinguishable from a marker. Absent when the title did not match.'),
286
- text: z
287
- .string()
288
- .optional()
289
- .describe('Body snippet (comment_text or story_text), HTML stripped, with matched terms wrapped in `<em>…</em>`. A literal `<em>` typed into the body is indistinguishable from a marker. Absent when the body did not match, and always absent under view "compact" — matchedWords still lists what matched.'),
290
- matchedWords: z
291
- .array(z.string())
292
- .describe('Deduplicated union of matched terms across all searchable fields (title, url, author, comment_text, story_text, story_title).'),
293
- })
294
- .optional()
295
- .describe('Algolia per-field highlight metadata showing which terms matched and where. Absent when no fields produced a match.'),
296
- })
297
- .describe('A single Algolia search hit (story, comment, poll, or job).'))
298
- .describe('Search results ranked by sort order.'),
299
- query: z
300
- .string()
301
- .optional()
302
- .describe('The query that was searched. Absent for a filter-only search.'),
611
+ .describe(`How much of each hit to return. "full" includes every field, within a ${RESPONSE_BUDGET_TEXT}-byte budget on each response surface: a page over it drops highlights.text from every hit (highlightsTextOmitted), and if still over, cuts the longest text bodies to one shared length (textTruncated) — every hit stays on the page. "compact" omits the two body-text fields — \`text\` and \`highlights.text\` — which together can repeat a long comment twice per hit; everything else (id, title, url, domain, author, points, comment count, timestamp, parent story, title highlight, matchedWords) is unchanged. Use "compact" to scan many results, then pass a hit id to hn_get_thread to read the body you skipped.`),
303
612
  }),
613
+ output: searchOutput,
304
614
  enrichment: {
305
- totalHits: z.number().describe('Total matching results across all pages.'),
306
- page: z.number().describe('Current page number (0-indexed).'),
615
+ criteria: searchCriteria,
616
+ totalHits: z
617
+ .number()
618
+ .describe('Matching results across all pages, as Algolia counts them. An estimate when totalHitsExact is false — broad searches can be off by orders of magnitude — and paging reaches only the first 1,000 (see reachableHits).'),
619
+ totalHitsExact: z
620
+ .boolean()
621
+ .optional()
622
+ .describe('True when totalHits is an exact count, false when it is an estimate, as it is for keyword searches and broad filter-only searches. Absent when Algolia does not say, and on a page past the 1,000-hit ceiling, where totalHits reads 0.'),
623
+ page: z
624
+ .number()
625
+ .describe('Current page number (0-indexed), in pages of criteria.count hits. When count was above the per-call cap, the page of 50 where the requested page starts.'),
307
626
  totalPages: z
308
627
  .number()
309
- .describe('Number of pages Algolia will actually serve for this query. Not derived from totalHits — broad queries report a totalHits far larger than the reachable page range, so paginate against this value.'),
628
+ .describe('Number of pages of criteria.count hits Algolia will actually serve for this query, covering at most the first 1,000 hits. Not derived from totalHits — broad queries report a totalHits far larger than the reachable page range, so paginate against this value.'),
629
+ reachableHits: z
630
+ .number()
631
+ .optional()
632
+ .describe('How many of totalHits paging can reach: Algolia serves at most 1,000 hits per search. Present only when totalHits exceeds that; split the search into narrower dateRange windows to reach the rest.'),
310
633
  truncated: z
311
634
  .boolean()
312
635
  .optional()
313
636
  .describe('True when more pages remain after this one (page + 1 < totalPages). Absent on the last page Algolia serves.'),
314
637
  shown: z.number().optional().describe('Number of hits returned.'),
315
- cap: z.number().optional().describe('The count cap that was applied.'),
638
+ cap: z
639
+ .number()
640
+ .optional()
641
+ .describe(`The count value served: as passed, or ${COUNT_CAP} when count was above that cap.`),
642
+ highlightsTextOmitted: z
643
+ .boolean()
644
+ .optional()
645
+ .describe(`True when highlights.text was dropped from every hit to keep this view "full" page within the ${RESPONSE_BUDGET_TEXT}-byte response budget; highlights.title and matchedWords remain. Absent otherwise.`),
316
646
  notice: z
317
647
  .string()
318
648
  .optional()
319
- .describe('Agent guidance: the next page to request while more pages remain, the last valid page when the requested page is past the end, or the filters to relax when a first page comes back empty. Absent on the last page of a non-empty result.'),
649
+ .describe(`Agent guidance, one string. It opens with the page state: the next page to request while more pages remain, the 1,000-hit ceiling on the last page of a search that matches more, the last valid page when the requested page is past the end, or the filters to relax when a first page comes back empty. Notes on the inputs follow it, then, on a view "full" page fitted to the ${RESPONSE_BUDGET_TEXT}-byte response budget, a sentence naming what was omitted and the ids of hits whose text was cut. Absent when none applies.`),
320
650
  },
651
+ enrichmentTrailer: searchTrailer,
321
652
  async handler(input, ctx) {
322
653
  const { dateRange } = input;
323
654
  if (dateRange) {
@@ -336,116 +667,123 @@ export const searchHn = tool('hn_search_content', {
336
667
  if (input.query == null && filters.length === 0) {
337
668
  throw ctx.fail('missing_query_or_filter', 'No query and no filter were supplied, so there is nothing to search by.');
338
669
  }
670
+ /** Above the cap, the search runs at the cap from the hit where the requested page starts. */
671
+ const overCap = input.count > COUNT_CAP ? { count: input.count, page: input.page } : undefined;
672
+ const firstHit = input.page * input.count;
673
+ if (overCap && firstHit % COUNT_CAP !== 0) {
674
+ throw ctx.fail('count_page_unaligned', `count ${input.count} is above the per-call cap of ${COUNT_CAP}, and page ${input.page} at count ${input.count} starts after ${firstHit} hits, where no page of ${COUNT_CAP} hits begins.`, { count: input.count, page: input.page, recovery: { hint: unalignedHint(firstHit) } });
675
+ }
676
+ const searched = input.query == null ? undefined : stripUrlPrefix(input.query);
677
+ const params = {
678
+ ...input,
679
+ ...(searched && { query: searched.query }),
680
+ ...(overCap && { count: COUNT_CAP, page: firstHit / COUNT_CAP }),
681
+ };
339
682
  const hn = getHnService();
340
- const result = await hn.search(input, ctx);
683
+ const result = await hn.search(params, ctx);
341
684
  const includeBody = input.view === 'full';
342
- const hits = result.hits.map((hit) => {
343
- const rawText = includeBody ? (hit.comment_text ?? hit.story_text) : undefined;
344
- const url = normalizeUrl(hit.url);
345
- const domain = extractDomain(url);
346
- const highlights = extractHighlights(hit, includeBody);
347
- return {
348
- id: Number(hit.objectID),
349
- title: hit.title != null ? decodeHtmlEntities(hit.title) : undefined,
350
- url,
351
- domain,
352
- author: hit.author,
353
- points: hit.points ?? undefined,
354
- numComments: hit.num_comments ?? undefined,
355
- createdAt: hit.created_at,
356
- storyTitle: hit.story_title != null ? decodeHtmlEntities(hit.story_title) : undefined,
357
- storyId: hit.story_id ?? undefined,
358
- text: rawText ? stripHtml(rawText) || undefined : undefined,
359
- ...(highlights && { highlights }),
360
- };
361
- });
685
+ const hits = result.hits.map((hit) => projectHit(hit, includeBody, includeBody));
362
686
  ctx.log.info('Search completed', {
363
- query: input.query,
687
+ query: params.query,
364
688
  hits: hits.length,
365
689
  totalHits: result.nbHits,
366
690
  });
367
691
  const { nbHits: totalHits, nbPages: totalPages, page } = result;
368
- ctx.enrich({ totalHits, page, totalPages });
369
- if (hits.length === 0) {
370
- ctx.enrich.notice(emptyPageNotice(input, filters, page, totalHits, totalPages));
371
- }
372
- else if (page + 1 < totalPages) {
373
- const raiseCount = input.count < MAX_COUNT ? `, or raise count (max ${MAX_COUNT})` : '';
374
- ctx.enrich.truncated({
375
- shown: hits.length,
376
- cap: input.count,
377
- guidance: `Showing ${hits.length} of ${totalHits} hits (page ${page} of ${totalPages}). Pass page: ${page + 1} for more${raiseCount}.`,
378
- });
379
- }
380
- return {
381
- hits,
382
- ...(input.query != null && { query: input.query }),
692
+ const shown = hits.length;
693
+ /** Past the ceiling Algolia flags its 0 as exact, though it says nothing about the search. */
694
+ const pastCeiling = shown === 0 && page > 0 && totalPages === 0;
695
+ const totalHitsExact = pastCeiling
696
+ ? undefined
697
+ : (result.exhaustive?.nbHits ?? result.exhaustiveNbHits);
698
+ const bounds = params.dateRange ? dateRangeToEpochSeconds(params.dateRange) : {};
699
+ const pageFields = {
700
+ criteria: {
701
+ ...(params.tags != null && { tags: params.tags }),
702
+ ...(params.author != null && { author: params.author }),
703
+ ...(params.storyId != null && { storyId: params.storyId }),
704
+ ...(params.minPoints != null && { minPoints: params.minPoints }),
705
+ ...((bounds.start != null || bounds.end != null) && {
706
+ dateRange: {
707
+ ...(bounds.start != null && { start: isoSeconds(bounds.start) }),
708
+ ...(bounds.end != null && { end: isoSeconds(bounds.end) }),
709
+ },
710
+ }),
711
+ sort: params.sort,
712
+ count: params.count,
713
+ view: params.view,
714
+ },
715
+ totalHits,
716
+ ...(totalHitsExact != null && { totalHitsExact }),
717
+ page,
718
+ totalPages,
719
+ ...(totalHits > HIT_CEILING && { reachableHits: HIT_CEILING }),
383
720
  };
384
- },
385
- format: (result) => {
386
- if (result.hits.length === 0) {
387
- return [
388
- {
389
- type: 'text',
390
- text: result.query != null ? `"${result.query}" — no results.` : 'No results.',
391
- },
392
- ];
393
- }
394
- /**
395
- * Render highlight metadata as a `> match — …` footer, surfacing each highlights
396
- * field separately so structured consumers and the LLM both see what matched.
397
- * The footer follows an unquoted blank line, which a quoted body never
398
- * produces, so a body line reading `match — …` cannot pass for it. The body
399
- * snippet comes last and continues on quoted lines of its own.
400
- */
401
- const renderHighlights = (hl) => {
402
- const parts = [];
403
- if (hl.title)
404
- parts.push(`title: ${escapeHighlight(hl.title)}`);
405
- if (hl.matchedWords.length)
406
- parts.push(`terms: ${hl.matchedWords.join(', ')}`);
407
- if (hl.text)
408
- parts.push(`body: ${hl.text}`);
409
- return parts.length ? `\n\n${quoteBody(`match — ${parts.join(' | ')}`)}` : '';
721
+ const morePages = shown > 0 && page + 1 < totalPages;
722
+ const noticeFacts = {
723
+ ...params,
724
+ droppedPrefix: searched?.droppedPrefix,
725
+ shown,
726
+ page,
727
+ totalHits,
728
+ totalHitsExact,
729
+ totalPages,
730
+ overCap,
410
731
  };
411
- const lines = result.hits.map((h) => {
412
- if (h.title) {
413
- // Story result — Algolia returns storyId === id for stories, so suppress the parent ref unless it actually differs or a parent title is set.
414
- const parentRef = (h.storyId != null && h.storyId !== h.id) || h.storyTitle
415
- ? ` | story:"${escapeQuoted(h.storyTitle ?? '?')}"#${h.storyId ?? '?'}`
416
- : '';
417
- const meta = [
418
- `id:${h.id}`,
419
- h.author,
420
- h.points != null ? `${h.points} pts` : null,
421
- h.numComments != null ? `${h.numComments} comments` : null,
422
- h.createdAt,
423
- ]
424
- .filter(Boolean)
425
- .join(' | ');
426
- const domain = h.domain ? ` (${h.domain})` : '';
427
- const url = h.url ? `\n${h.url}` : '';
428
- const text = h.text ? `\n${quoteBody(h.text)}` : '';
429
- const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
430
- return `### ${escapeInline(h.title)}${domain}\n${meta}${parentRef}${url}${text}${hlLine}`;
732
+ /** The response serving `served`, with the notice and flag for how `budget` fitted it. */
733
+ const respond = (served, budget) => ({
734
+ output: { hits: served, ...(params.query != null && { query: params.query }) },
735
+ notice: composeSearchNotice({ ...noticeFacts, budget }),
736
+ highlightsTextOmitted: budget?.highlightsTextOmitted === true,
737
+ });
738
+ /** Both surfaces of a response as the caller receives it, enrichment and trailer included. */
739
+ const measure = (response) => measureResponse({
740
+ content: formatSearch(response.output),
741
+ result: response.output,
742
+ enrichment: {
743
+ ...pageFields,
744
+ ...(response.highlightsTextOmitted && { highlightsTextOmitted: true }),
745
+ ...(morePages && { truncated: true, shown, cap: params.count }),
746
+ ...(response.notice != null && { notice: response.notice }),
747
+ },
748
+ trailer: searchTrailer,
749
+ });
750
+ let response = respond(hits);
751
+ if (includeBody && !fitsBudget(measure(response))) {
752
+ /** Step 1: drop the body highlight, which repeats the matched body in full, from every hit. */
753
+ const highlightsTextOmitted = hits.some((hit) => hit.highlights?.text != null);
754
+ const lean = highlightsTextOmitted
755
+ ? result.hits.map((hit) => projectHit(hit, true, false))
756
+ : hits;
757
+ if (highlightsTextOmitted)
758
+ response = respond(lean, { highlightsTextOmitted, cutIds: [] });
759
+ if (!highlightsTextOmitted || !fitsBudget(measure(response))) {
760
+ /** Step 2: cut every body longer than the largest limit they can share. */
761
+ const bodies = lean.map((hit) => ({ hit, bytes: hit.text ? byteLength(hit.text) : 0 }));
762
+ const cutAt = (limit) => {
763
+ const served = bodies.map(({ hit, bytes }) => hit.text && bytes > limit
764
+ ? { ...hit, text: cutToBytes(hit.text, limit), textTruncated: true }
765
+ : hit);
766
+ const cutIds = served.filter((hit) => hit.textTruncated).map((hit) => hit.id);
767
+ return respond(served, { highlightsTextOmitted, cutIds });
768
+ };
769
+ const limit = largestFittingLimit(bodies.map((body) => body.bytes), (limitBytes) => measure(cutAt(limitBytes)));
770
+ response = cutAt(limit);
771
+ ctx.log.info('Fitted search page to the response budget', {
772
+ limitBytes: limit,
773
+ cutIds: response.output.hits.filter((hit) => hit.textTruncated).map((hit) => hit.id),
774
+ });
431
775
  }
432
- // Comment result — parent context in heading.
433
- const meta = [
434
- `id:${h.id}`,
435
- h.author,
436
- h.points != null ? `${h.points} pts` : null,
437
- h.createdAt,
438
- ]
439
- .filter(Boolean)
440
- .join(' | ');
441
- const text = h.text ? `\n${quoteBody(h.text)}` : '';
442
- const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
443
- return `### Comment on "${escapeQuoted(h.storyTitle ?? 'unknown')}" (story id:${h.storyId ?? '?'})\n${meta}${text}${hlLine}`;
776
+ }
777
+ ctx.enrich({
778
+ ...pageFields,
779
+ ...(response.highlightsTextOmitted && { highlightsTextOmitted: true }),
444
780
  });
445
- const header = result.query != null
446
- ? `## "${result.query}" — search results`
447
- : '## Search results (filters only)';
448
- return [{ type: 'text', text: `${header}\n\n${lines.join('\n\n')}` }];
781
+ if (morePages)
782
+ ctx.enrich.truncated({ shown, cap: params.count });
783
+ if (response.notice)
784
+ ctx.enrich.notice(response.notice);
785
+ return response.output;
449
786
  },
787
+ format: formatSearch,
450
788
  });
451
789
  //# sourceMappingURL=search-content.tool.js.map