@cyanheads/hn-mcp-server 0.5.22 → 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 (33) hide show
  1. package/AGENTS.md +22 -9
  2. package/CLAUDE.md +22 -9
  3. package/Dockerfile +9 -3
  4. package/README.md +19 -12
  5. package/changelog/0.6.x/0.6.0.md +38 -0
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-stories.tool.js +9 -6
  10. package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
  11. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +1 -0
  12. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
  13. package/dist/mcp-server/tools/definitions/get-thread.tool.js +296 -182
  14. package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
  15. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +1 -0
  16. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
  17. package/dist/mcp-server/tools/definitions/get-user.tool.js +160 -104
  18. package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
  19. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +16 -3
  20. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
  21. package/dist/mcp-server/tools/definitions/search-content.tool.js +263 -188
  22. package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
  23. package/dist/mcp-server/tools/response-budget.d.ts +111 -0
  24. package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
  25. package/dist/mcp-server/tools/response-budget.js +204 -0
  26. package/dist/mcp-server/tools/response-budget.js.map +1 -0
  27. package/dist/services/hn/hn-service.d.ts +45 -9
  28. package/dist/services/hn/hn-service.d.ts.map +1 -1
  29. package/dist/services/hn/hn-service.js +73 -17
  30. package/dist/services/hn/hn-service.js.map +1 -1
  31. package/package.json +8 -7
  32. package/server.json +3 -3
  33. package/skills/hn-guide/SKILL.md +39 -0
@@ -5,6 +5,7 @@
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 { byteLength, cutPointer, cutToBytes, fitsBudget, largestFittingLimit, measureResponse, RESPONSE_BUDGET_TEXT, } from '../response-budget.js';
8
9
  import { servedAtCap } from '../served-at-cap.js';
9
10
  import { dateBoundToEpochMs, dateRangeToEpochSeconds, decodeHtmlEntities, extractDomain, getHnService, normalizeUrl, stripHtml, stripHtmlPreservingEm, } from '../../../services/hn/hn-service.js';
10
11
  /** Most hits one call serves; a larger `count` is served at this cap. */
@@ -236,15 +237,29 @@ function causeSentences(facts) {
236
237
  }
237
238
  return sentences;
238
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
+ }
239
249
  /**
240
250
  * Compose the one `notice` string every search writes. `notice` is last-wins,
241
251
  * so each source goes through here: the page-state sentence first, then the
242
- * cause sentences in input order — query, tags, author, storyId, count. None
243
- * replaces another. Undefined when nothing applies — the last page of an
244
- * uncapped, non-empty search.
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.
245
256
  */
246
257
  export function composeSearchNotice(facts) {
247
- const sentences = [pageStateSentence(facts), ...causeSentences(facts)].filter((sentence) => sentence != null);
258
+ const sentences = [
259
+ pageStateSentence(facts),
260
+ ...causeSentences(facts),
261
+ facts.budget && budgetSentence(facts.budget),
262
+ ].filter((sentence) => sentence != null);
248
263
  return sentences.length > 0 ? sentences.join(' ') : undefined;
249
264
  }
250
265
  /**
@@ -259,19 +274,21 @@ function decodeHighlightTitle(title) {
259
274
  .join('');
260
275
  }
261
276
  /**
262
- * Project Algolia's `_highlightResult` into a flat snippet object: the title
263
- * snippet, the body snippet (comment_text, else story_text — the order `text`
264
- * reads them in; a job's job_text is never highlighted), and the deduplicated
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
265
281
  * union of matched words across all surfaced fields. Returns undefined when
266
282
  * nothing matched.
267
283
  *
268
284
  * Titles are not HTML, though some arrive entity-encoded (`<details>`,
269
285
  * `&#34;`), so the title snippet is entity-decoded between its `<em>` markers
270
- * and never tag-stripped. The body snippet is stripped of its HTML with the
271
- * markers kept.
286
+ * and never tag-stripped. The body highlight, the whole matched body, is
287
+ * stripped of its HTML with the markers kept.
272
288
  *
273
- * `includeBody` is false under the compact projection, which drops the body
274
- * 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.
275
292
  */
276
293
  function extractHighlights(hit, includeBody) {
277
294
  const h = hit._highlightResult;
@@ -295,6 +312,174 @@ function extractHighlights(hit, includeBody) {
295
312
  matchedWords,
296
313
  };
297
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
+ }
298
483
  export const searchHn = tool('hn_search_content', {
299
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.',
300
485
  annotations: { readOnlyHint: true },
@@ -373,7 +558,7 @@ export const searchHn = tool('hn_search_content', {
373
558
  .trim()
374
559
  .min(1, 'blank after trimming whitespace — pass one or more search terms, or omit query for a filter-only search.')
375
560
  .optional()
376
- .describe('Search terms. Supports simple keywords — Algolia handles stemming and relevance, and a hit must contain every term. 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.'),
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.'),
377
562
  tags: z
378
563
  .enum(SEARCH_TAGS)
379
564
  .optional()
@@ -423,66 +608,9 @@ export const searchHn = tool('hn_search_content', {
423
608
  view: z
424
609
  .enum(['full', 'compact'])
425
610
  .default('full')
426
- .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.'),
427
- }),
428
- output: z.object({
429
- hits: z
430
- .array(z
431
- .object({
432
- id: z.number().describe('HN item ID — use with hn_get_thread to read the discussion.'),
433
- type: z
434
- .enum(HIT_TYPES)
435
- .optional()
436
- .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.'),
437
- title: z
438
- .string()
439
- .optional()
440
- .describe('Item title (present for stories, polls, and jobs).'),
441
- url: z.string().optional().describe('External link URL.'),
442
- domain: z
443
- .string()
444
- .optional()
445
- .describe('Bare hostname derived from url (e.g. "github.com", with leading "www." stripped). Absent when url is missing or unparseable.'),
446
- author: z.string().describe('Author username.'),
447
- points: z.number().optional().describe('Score/upvotes.'),
448
- numComments: z.number().optional().describe('Comment count.'),
449
- createdAt: z.string().describe('Creation time (ISO 8601).'),
450
- storyTitle: z
451
- .string()
452
- .optional()
453
- .describe('Parent story title (present for comment results).'),
454
- storyId: z
455
- .number()
456
- .optional()
457
- .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."),
458
- text: z
459
- .string()
460
- .optional()
461
- .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. Absent when the hit has no body, and always absent under view \"compact\" — call hn_get_thread with this id to read it."),
462
- highlights: z
463
- .object({
464
- title: z
465
- .string()
466
- .optional()
467
- .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.'),
468
- text: z
469
- .string()
470
- .optional()
471
- .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.'),
472
- matchedWords: z
473
- .array(z.string())
474
- .describe('Deduplicated union of matched terms across all searchable fields (title, url, author, comment_text, story_text, story_title).'),
475
- })
476
- .optional()
477
- .describe('Algolia per-field highlight metadata showing which terms matched and where. Absent when no fields produced a match.'),
478
- })
479
- .describe('A single Algolia search hit (story, comment, poll, poll option, or job).'))
480
- .describe('Search results ranked by sort order.'),
481
- query: z
482
- .string()
483
- .optional()
484
- .describe('The query that was searched — for a URL query, without its leading scheme and www. 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.`),
485
612
  }),
613
+ output: searchOutput,
486
614
  enrichment: {
487
615
  criteria: searchCriteria,
488
616
  totalHits: z
@@ -511,14 +639,16 @@ export const searchHn = tool('hn_search_content', {
511
639
  .number()
512
640
  .optional()
513
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.`),
514
646
  notice: z
515
647
  .string()
516
648
  .optional()
517
- .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. Absent when none applies.'),
518
- },
519
- enrichmentTrailer: {
520
- criteria: { render: renderCriteria },
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.`),
521
650
  },
651
+ enrichmentTrailer: searchTrailer,
522
652
  async handler(input, ctx) {
523
653
  const { dateRange } = input;
524
654
  if (dateRange) {
@@ -552,30 +682,7 @@ export const searchHn = tool('hn_search_content', {
552
682
  const hn = getHnService();
553
683
  const result = await hn.search(params, ctx);
554
684
  const includeBody = input.view === 'full';
555
- const hits = result.hits.map((hit) => {
556
- const rawText = includeBody
557
- ? (hit.comment_text ?? hit.story_text ?? hit.job_text)
558
- : undefined;
559
- const url = normalizeUrl(hit.url);
560
- const domain = extractDomain(url);
561
- const highlights = extractHighlights(hit, includeBody);
562
- const type = hitType(hit._tags);
563
- return {
564
- id: Number(hit.objectID),
565
- ...(type && { type }),
566
- title: hit.title != null ? decodeHtmlEntities(hit.title) : undefined,
567
- url,
568
- domain,
569
- author: hit.author,
570
- points: hit.points ?? undefined,
571
- numComments: hit.num_comments ?? undefined,
572
- createdAt: hit.created_at,
573
- storyTitle: hit.story_title != null ? decodeHtmlEntities(hit.story_title) : undefined,
574
- storyId: hit.story_id ?? undefined,
575
- text: rawText ? stripHtml(rawText) || undefined : undefined,
576
- ...(highlights && { highlights }),
577
- };
578
- });
685
+ const hits = result.hits.map((hit) => projectHit(hit, includeBody, includeBody));
579
686
  ctx.log.info('Search completed', {
580
687
  query: params.query,
581
688
  hits: hits.length,
@@ -589,7 +696,7 @@ export const searchHn = tool('hn_search_content', {
589
696
  ? undefined
590
697
  : (result.exhaustive?.nbHits ?? result.exhaustiveNbHits);
591
698
  const bounds = params.dateRange ? dateRangeToEpochSeconds(params.dateRange) : {};
592
- ctx.enrich({
699
+ const pageFields = {
593
700
  criteria: {
594
701
  ...(params.tags != null && { tags: params.tags }),
595
702
  ...(params.author != null && { author: params.author }),
@@ -610,10 +717,9 @@ export const searchHn = tool('hn_search_content', {
610
717
  page,
611
718
  totalPages,
612
719
  ...(totalHits > HIT_CEILING && { reachableHits: HIT_CEILING }),
613
- });
614
- if (shown > 0 && page + 1 < totalPages)
615
- ctx.enrich.truncated({ shown, cap: params.count });
616
- const notice = composeSearchNotice({
720
+ };
721
+ const morePages = shown > 0 && page + 1 < totalPages;
722
+ const noticeFacts = {
617
723
  ...params,
618
724
  droppedPrefix: searched?.droppedPrefix,
619
725
  shown,
@@ -622,93 +728,62 @@ export const searchHn = tool('hn_search_content', {
622
728
  totalHitsExact,
623
729
  totalPages,
624
730
  overCap,
625
- });
626
- if (notice)
627
- ctx.enrich.notice(notice);
628
- return {
629
- hits,
630
- ...(params.query != null && { query: params.query }),
631
- };
632
- },
633
- format: (result) => {
634
- if (result.hits.length === 0) {
635
- return [
636
- {
637
- type: 'text',
638
- text: result.query != null ? `"${escapeQuoted(result.query)}" — no results.` : 'No results.',
639
- },
640
- ];
641
- }
642
- /**
643
- * Render highlight metadata as a `> match — …` footer, surfacing each highlights
644
- * field separately so structured consumers and the LLM both see what matched.
645
- * The footer follows an unquoted blank line, which a quoted body never
646
- * produces, so a body line reading `match — …` cannot pass for it. The body
647
- * snippet comes last and continues on quoted lines of its own.
648
- */
649
- const renderHighlights = (hl) => {
650
- const parts = [];
651
- if (hl.title)
652
- parts.push(`title: ${escapeHighlight(hl.title)}`);
653
- if (hl.matchedWords.length)
654
- parts.push(`terms: ${hl.matchedWords.join(', ')}`);
655
- if (hl.text)
656
- parts.push(`body: ${hl.text}`);
657
- return parts.length ? `\n\n${quoteBody(`match — ${parts.join(' | ')}`)}` : '';
658
731
  };
659
- const lines = result.hits.map((h) => {
660
- const typeToken = h.type ? `type:${h.type}` : null;
661
- const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
662
- if (h.type === 'pollopt') {
663
- // A poll option's search record carries no text and no poll; hn_get_thread has both.
664
- const meta = [
665
- `id:${h.id}`,
666
- typeToken,
667
- h.author,
668
- h.points != null ? `${h.points} pts` : null,
669
- h.createdAt,
670
- ]
671
- .filter(Boolean)
672
- .join(' | ');
673
- return `### Poll option by ${h.author}\n${meta}\nOption text and its poll: hn_get_thread with itemId ${h.id}${hlLine}`;
674
- }
675
- if (h.title) {
676
- // Story result — Algolia returns storyId === id for stories, so suppress the parent ref unless it actually differs or a parent title is set.
677
- const parentRef = (h.storyId != null && h.storyId !== h.id) || h.storyTitle
678
- ? ` | story:"${escapeQuoted(h.storyTitle ?? '?')}"#${h.storyId ?? '?'}`
679
- : '';
680
- const meta = [
681
- `id:${h.id}`,
682
- typeToken,
683
- h.author,
684
- h.points != null ? `${h.points} pts` : null,
685
- h.numComments != null ? `${h.numComments} comments` : null,
686
- h.createdAt,
687
- ]
688
- .filter(Boolean)
689
- .join(' | ');
690
- const domain = h.domain ? ` (${h.domain})` : '';
691
- const url = h.url ? `\n${h.url}` : '';
692
- const text = h.text ? `\n${quoteBody(h.text)}` : '';
693
- 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
+ });
694
775
  }
695
- // Comment result — parent context in heading.
696
- const meta = [
697
- `id:${h.id}`,
698
- typeToken,
699
- h.author,
700
- h.points != null ? `${h.points} pts` : null,
701
- h.createdAt,
702
- ]
703
- .filter(Boolean)
704
- .join(' | ');
705
- const text = h.text ? `\n${quoteBody(h.text)}` : '';
706
- 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 }),
707
780
  });
708
- const header = result.query != null
709
- ? `## "${escapeQuoted(result.query)}" — search results`
710
- : '## Search results (filters only)';
711
- 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;
712
786
  },
787
+ format: formatSearch,
713
788
  });
714
789
  //# sourceMappingURL=search-content.tool.js.map