@cyanheads/hn-mcp-server 0.6.0 → 0.7.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 (41) hide show
  1. package/AGENTS.md +6 -6
  2. package/CLAUDE.md +6 -6
  3. package/README.md +24 -22
  4. package/changelog/0.6.x/0.6.1.md +34 -0
  5. package/changelog/0.7.x/0.7.0.md +32 -0
  6. package/changelog/template.md +10 -3
  7. package/dist/index.js +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts +15 -0
  10. package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts.map +1 -1
  11. package/dist/mcp-server/tools/definitions/get-stories.tool.js +47 -24
  12. package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
  13. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +10 -1
  14. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
  15. package/dist/mcp-server/tools/definitions/get-thread.tool.js +235 -277
  16. package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
  17. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +14 -1
  18. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
  19. package/dist/mcp-server/tools/definitions/get-user.tool.js +43 -72
  20. package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
  21. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +15 -17
  22. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
  23. package/dist/mcp-server/tools/definitions/search-content.tool.js +124 -163
  24. package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
  25. package/dist/mcp-server/tools/username-input.d.ts +23 -0
  26. package/dist/mcp-server/tools/username-input.d.ts.map +1 -0
  27. package/dist/mcp-server/tools/username-input.js +30 -0
  28. package/dist/mcp-server/tools/username-input.js.map +1 -0
  29. package/dist/services/hn/hn-service.d.ts +57 -8
  30. package/dist/services/hn/hn-service.d.ts.map +1 -1
  31. package/dist/services/hn/hn-service.js +128 -16
  32. package/dist/services/hn/hn-service.js.map +1 -1
  33. package/dist/services/hn/types.d.ts +4 -0
  34. package/dist/services/hn/types.d.ts.map +1 -1
  35. package/package.json +8 -8
  36. package/server.json +3 -3
  37. package/skills/hn-guide/SKILL.md +8 -8
  38. package/dist/mcp-server/tools/response-budget.d.ts +0 -111
  39. package/dist/mcp-server/tools/response-budget.d.ts.map +0 -1
  40. package/dist/mcp-server/tools/response-budget.js +0 -204
  41. package/dist/mcp-server/tools/response-budget.js.map +0 -1
@@ -5,9 +5,11 @@
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';
9
8
  import { servedAtCap } from '../served-at-cap.js';
9
+ import { usernameInput } from '../username-input.js';
10
10
  import { dateBoundToEpochMs, dateRangeToEpochSeconds, decodeHtmlEntities, extractDomain, getHnService, normalizeUrl, stripHtml, stripHtmlPreservingEm, } from '../../../services/hn/hn-service.js';
11
+ /** `count` when the caller omits it. */
12
+ const COUNT_DEFAULT = 10;
11
13
  /** Most hits one call serves; a larger `count` is served at this cap. */
12
14
  const COUNT_CAP = 50;
13
15
  /** Largest `count` accepted; anything above it is rejected. */
@@ -104,9 +106,13 @@ export function stripUrlPrefix(query) {
104
106
  const searchCriteria = z
105
107
  .object({
106
108
  tags: z.enum(SEARCH_TAGS).optional().describe('Content-type filter.'),
107
- author: z.string().optional().describe('Author filter — the username matched, trimmed.'),
109
+ author: z
110
+ .string()
111
+ .optional()
112
+ .describe('Author filter — the username as searched: trimmed, with one leading @ dropped.'),
108
113
  storyId: z.number().optional().describe('Discussion filter: a story or poll root id.'),
109
114
  minPoints: z.number().optional().describe('Minimum score.'),
115
+ minComments: z.number().optional().describe('Minimum comment count.'),
110
116
  dateRange: z
111
117
  .object({
112
118
  start: z
@@ -126,7 +132,7 @@ const searchCriteria = z
126
132
  .describe('Hits per page sent: count as passed, or 50 when count was above that cap.'),
127
133
  view: z.enum(['full', 'compact']).describe('Projection applied to each hit.'),
128
134
  })
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.');
135
+ .describe('The criteria this search ran with: each filter that was set, as sent — author as searched (trimmed, one leading @ dropped), 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
136
  /** A whole Unix second as ISO 8601 UTC, e.g. `2024-05-05T08:00:00Z`. */
131
137
  const isoSeconds = (seconds) => new Date(seconds * 1000).toISOString().replace('.000Z', 'Z');
132
138
  /** One trailer line for the criteria; `author`, the one free-text value, is escaped inside its quotes. */
@@ -140,6 +146,7 @@ function renderCriteria(c) {
140
146
  c.author != null ? `author: "${escapeQuoted(c.author)}"` : null,
141
147
  c.storyId != null ? `storyId: ${c.storyId}` : null,
142
148
  c.minPoints != null ? `minPoints: ${c.minPoints}` : null,
149
+ c.minComments != null ? `minComments: ${c.minComments}` : null,
143
150
  bounds ? `dateRange: ${bounds.filter(Boolean).join(', ')}` : null,
144
151
  `sort: ${c.sort}`,
145
152
  `count: ${c.count}`,
@@ -158,6 +165,8 @@ function appliedFilters(input) {
158
165
  filters.push('storyId');
159
166
  if (input.minPoints != null)
160
167
  filters.push('minPoints');
168
+ if (input.minComments != null)
169
+ filters.push('minComments');
161
170
  if (input.dateRange?.start || input.dateRange?.end)
162
171
  filters.push('dateRange');
163
172
  return filters;
@@ -168,16 +177,23 @@ function appliedFilters(input) {
168
177
  * that ceiling. An empty page past the end names the last valid page when
169
178
  * Algolia reports one; past the ceiling Algolia answers `nbPages: 0`, so the
170
179
  * 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.
180
+ * no hits is a zero-match search, which names what to relax.
181
+ *
182
+ * `page` counts in pages of `count`, so every page lever names the count the
183
+ * search ran at unless that is the default — a caller who sends the lever as
184
+ * written, without count, would otherwise page at the default and repeat or
185
+ * skip hits. Raising count is offered only on page 0, where the larger page
186
+ * still starts at hit 0; on a later page it would move every page boundary.
173
187
  */
174
188
  function pageStateSentence(facts) {
175
189
  const { shown, page, totalHits, totalPages } = facts;
176
- const pageLever = (n) => `${facts.overCap ? `count: ${facts.count}, ` : ''}page: ${n}`;
190
+ const pageLever = (n) => `${facts.count === COUNT_DEFAULT ? '' : `count: ${facts.count}, `}page: ${n}`;
177
191
  if (shown > 0) {
178
192
  const total = `${facts.totalHitsExact === false ? 'about ' : ''}${totalHits}`;
179
193
  if (page + 1 < totalPages) {
180
- const raiseCount = facts.count < COUNT_CAP ? `, or raise count (max ${COUNT_CAP})` : '';
194
+ const raiseCount = page === 0 && facts.count < COUNT_CAP
195
+ ? `, or keep page: 0 and raise count (max ${COUNT_CAP})`
196
+ : '';
181
197
  return `Showing ${shown} of ${total} hits (page ${page} of ${totalPages}). Pass ${pageLever(page + 1)} for more${raiseCount}.`;
182
198
  }
183
199
  return totalHits > HIT_CEILING
@@ -186,12 +202,12 @@ function pageStateSentence(facts) {
186
202
  }
187
203
  if (page > 0) {
188
204
  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.`;
205
+ 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 ${pageLever(0)} to start over; if that also returns nothing, the search itself matches nothing.`;
190
206
  }
191
207
  /** With one page, the last page is page 0, so one lever covers both. */
192
208
  const lever = totalPages === 1
193
209
  ? `Pass ${pageLever(0)} for that page.`
194
- : `Pass ${pageLever(totalPages - 1)} for the last page, or page: 0 to start over.`;
210
+ : `Pass ${pageLever(totalPages - 1)} for the last page, or ${pageLever(0)} to start over.`;
195
211
  return `Page ${page} is past the last page of results (${totalHits} hits across ${totalPages} page${totalPages === 1 ? '' : 's'}). ${lever}`;
196
212
  }
197
213
  const filters = appliedFilters(facts);
@@ -237,29 +253,15 @@ function causeSentences(facts) {
237
253
  }
238
254
  return sentences;
239
255
  }
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
256
  /**
250
257
  * Compose the one `notice` string every search writes. `notice` is last-wins,
251
258
  * 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.
259
+ * cause sentences in input order — query, tags, author, storyId, count. None
260
+ * replaces another. Undefined when nothing applies — the last page of an
261
+ * uncapped, non-empty search.
256
262
  */
257
263
  export function composeSearchNotice(facts) {
258
- const sentences = [
259
- pageStateSentence(facts),
260
- ...causeSentences(facts),
261
- facts.budget && budgetSentence(facts.budget),
262
- ].filter((sentence) => sentence != null);
264
+ const sentences = [pageStateSentence(facts), ...causeSentences(facts)].filter((sentence) => sentence != null);
263
265
  return sentences.length > 0 ? sentences.join(' ') : undefined;
264
266
  }
265
267
  /**
@@ -279,26 +281,27 @@ function decodeHighlightTitle(title) {
279
281
  * (comment_text, else story_text — the order `text` reads them in; a job's
280
282
  * job_text is never highlighted) — and the deduplicated
281
283
  * union of matched words across all surfaced fields. Returns undefined when
282
- * nothing matched.
284
+ * Algolia highlighted no field and reported no matched word. That is Algolia's
285
+ * answer, not a finding about the hit: it marks every field `matchLevel: none`
286
+ * on some hits whose body holds every query term.
283
287
  *
284
288
  * Titles are not HTML, though some arrive entity-encoded (`&lt;details&gt;`,
285
289
  * `&#34;`), so the title snippet is entity-decoded between its `<em>` markers
286
290
  * and never tag-stripped. The body highlight, the whole matched body, is
287
291
  * stripped of its HTML with the markers kept.
288
292
  *
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.
293
+ * `includeBody` is false under the compact projection, which drops the body
294
+ * highlight — the field that repeats a hit's full body text.
292
295
  */
293
296
  function extractHighlights(hit, includeBody) {
294
297
  const h = hit._highlightResult;
295
298
  if (!h)
296
299
  return;
297
- const matched = (v) => v != null && v.matchLevel !== 'none';
298
- const title = matched(h.title) ? h.title.value : undefined;
299
- const textHl = matched(h.comment_text)
300
+ const highlighted = (v) => v != null && v.matchLevel !== 'none';
301
+ const title = highlighted(h.title) ? h.title.value : undefined;
302
+ const textHl = highlighted(h.comment_text)
300
303
  ? h.comment_text
301
- : matched(h.story_text)
304
+ : highlighted(h.story_text)
302
305
  ? h.story_text
303
306
  : undefined;
304
307
  const text = includeBody && textHl ? stripHtmlPreservingEm(textHl.value) : undefined;
@@ -334,14 +337,18 @@ const searchHit = z
334
337
  .number()
335
338
  .optional()
336
339
  .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
340
+ parentId: z
341
+ .number()
342
+ .optional()
343
+ .describe("The item a comment hit replies to: its story's id for a top-level comment, otherwise the parent comment's id — hn_get_thread with this id reads it. Present on comment hits only."),
344
+ storyUrl: z
338
345
  .string()
339
346
  .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()
347
+ .describe("External link URL of a comment hit's story. Absent when that story is a text post with no link, and on every hit that is not a comment."),
348
+ text: z
349
+ .string()
343
350
  .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.`),
351
+ .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.`),
345
352
  highlights: z
346
353
  .object({
347
354
  title: z
@@ -351,25 +358,26 @@ const searchHit = z
351
358
  text: z
352
359
  .string()
353
360
  .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.'),
361
+ .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 Algolia returned no highlight for the body, which does not mean the body lacks the query terms: Algolia leaves some bodies that hold every term unhighlighted. Always absent under view "compact", where matchedWords still lists the matched terms.'),
355
362
  matchedWords: z
356
363
  .array(z.string())
357
364
  .describe('Deduplicated union of matched terms across all searchable fields (title, url, author, comment_text, story_text, story_title).'),
358
365
  })
359
366
  .optional()
360
- .describe('Algolia per-field highlight metadata showing which terms matched and where. Absent when no fields produced a match.'),
367
+ .describe('Algolia per-field highlight metadata showing which terms matched and where. Absent when Algolia returned no highlight and no matched terms for any field, which does not mean the hit lacks the query terms: Algolia leaves some hits that hold every term unhighlighted.'),
361
368
  })
362
369
  .describe('A single Algolia search hit (story, comment, poll, poll option, or job).');
363
370
  /**
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.
371
+ * Project an Algolia hit into a search hit. Under `includeBody`, `text`
372
+ * carries its body and its highlights carry the body too — the matched body in
373
+ * full, with markers.
367
374
  */
368
- function projectHit(hit, text, bodyHighlight) {
369
- const rawText = text ? (hit.comment_text ?? hit.story_text ?? hit.job_text) : undefined;
375
+ function projectHit(hit, includeBody) {
376
+ const rawText = includeBody ? (hit.comment_text ?? hit.story_text ?? hit.job_text) : undefined;
370
377
  const url = normalizeUrl(hit.url);
371
378
  const domain = extractDomain(url);
372
- const highlights = extractHighlights(hit, bodyHighlight);
379
+ const storyUrl = normalizeUrl(hit.story_url);
380
+ const highlights = extractHighlights(hit, includeBody);
373
381
  const type = hitType(hit._tags);
374
382
  return {
375
383
  id: Number(hit.objectID),
@@ -383,6 +391,8 @@ function projectHit(hit, text, bodyHighlight) {
383
391
  createdAt: hit.created_at,
384
392
  storyTitle: hit.story_title != null ? decodeHtmlEntities(hit.story_title) : undefined,
385
393
  storyId: hit.story_id ?? undefined,
394
+ ...(hit.parent_id != null && { parentId: hit.parent_id }),
395
+ ...(storyUrl && { storyUrl }),
386
396
  text: rawText ? stripHtml(rawText) || undefined : undefined,
387
397
  ...(highlights && { highlights }),
388
398
  };
@@ -394,9 +404,6 @@ const searchOutput = z.object({
394
404
  .optional()
395
405
  .describe('The query that was searched — for a URL query, without its leading scheme and www. Absent for a filter-only search.'),
396
406
  });
397
- const searchTrailer = {
398
- criteria: { render: renderCriteria },
399
- };
400
407
  /**
401
408
  * Render highlight metadata as a `> match — …` footer, surfacing each highlights
402
409
  * field separately so structured consumers and the LLM both see what matched.
@@ -414,10 +421,31 @@ function renderHighlights(hl) {
414
421
  parts.push(`body: ${hl.text}`);
415
422
  return parts.length ? `\n\n${quoteBody(`match — ${parts.join(' | ')}`)}` : '';
416
423
  }
417
- /** A hit's quoted body, and after a cut body the unquoted pointer to the whole text, a blank line apart. */
424
+ /** A hit's body, quoted on the lines under its meta line and links. */
418
425
  function renderBody(h) {
419
- const text = h.text ? `\n${quoteBody(h.text)}` : '';
420
- return h.textTruncated ? `${text}\n\n${cutPointer(h.id)}` : text;
426
+ return h.text ? `\n${quoteBody(h.text)}` : '';
427
+ }
428
+ /**
429
+ * A hit's meta line: id, type, and the item a comment replies to, in the order
430
+ * `hn_get_thread` opens its meta line with, then author, score, comment count,
431
+ * and time. Each field renders when present, whatever the hit's type.
432
+ */
433
+ function metaLine(h) {
434
+ return [
435
+ `id:${h.id}`,
436
+ h.type ? `type:${h.type}` : null,
437
+ h.parentId != null ? `parent:${h.parentId}` : null,
438
+ h.author,
439
+ h.points != null ? `${h.points} pts` : null,
440
+ h.numComments != null ? `${h.numComments} comments` : null,
441
+ h.createdAt,
442
+ ]
443
+ .filter(Boolean)
444
+ .join(' | ');
445
+ }
446
+ /** The hit's own link, then its story's, each on a line of its own under the meta line. */
447
+ function renderLinks(h) {
448
+ return [h.url, h.storyUrl].map((link) => (link ? `\n${link}` : '')).join('');
421
449
  }
422
450
  function formatSearch(result) {
423
451
  if (result.hits.length === 0) {
@@ -429,51 +457,23 @@ function formatSearch(result) {
429
457
  ];
430
458
  }
431
459
  const lines = result.hits.map((h) => {
432
- const typeToken = h.type ? `type:${h.type}` : null;
460
+ const meta = metaLine(h);
461
+ const links = renderLinks(h);
433
462
  const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
434
463
  if (h.type === 'pollopt') {
435
464
  // 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}`;
465
+ return `### Poll option by ${h.author}\n${meta}${links}\nOption text and its poll: hn_get_thread with itemId ${h.id}${hlLine}`;
446
466
  }
447
467
  if (h.title) {
448
468
  // Story result — Algolia returns storyId === id for stories, so suppress the parent ref unless it actually differs or a parent title is set.
449
469
  const parentRef = (h.storyId != null && h.storyId !== h.id) || h.storyTitle
450
470
  ? ` | story:"${escapeQuoted(h.storyTitle ?? '?')}"#${h.storyId ?? '?'}`
451
471
  : '';
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
472
  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}`;
473
+ return `### ${escapeInline(h.title)}${domain}\n${meta}${parentRef}${links}${renderBody(h)}${hlLine}`;
465
474
  }
466
475
  // 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}`;
476
+ return `### Comment on "${escapeQuoted(h.storyTitle ?? 'unknown')}" (story id:${h.storyId ?? '?'})\n${meta}${links}${renderBody(h)}${hlLine}`;
477
477
  });
478
478
  const header = result.query != null
479
479
  ? `## "${escapeQuoted(result.query)}" — search results`
@@ -481,14 +481,14 @@ function formatSearch(result) {
481
481
  return [{ type: 'text', text: `${header}\n\n${lines.join('\n\n')}` }];
482
482
  }
483
483
  export const searchHn = tool('hn_search_content', {
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.',
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, minimum points, and minimum comment count.',
485
485
  annotations: { readOnlyHint: true },
486
486
  errors: [
487
487
  {
488
488
  reason: 'missing_query_or_filter',
489
489
  code: JsonRpcErrorCode.InvalidParams,
490
490
  when: 'Neither a query nor any filter was supplied, so there is nothing to search by.',
491
- recovery: 'Pass a query, or at least one filter: tags, author, storyId, minPoints, or a dateRange bound. sort, count, page, and view do not count as filters.',
491
+ recovery: 'Pass a query, or at least one filter: tags, author, storyId, minPoints, minComments, or a dateRange bound. sort, count, page, and view do not count as filters.',
492
492
  severity: 'notice',
493
493
  },
494
494
  {
@@ -505,6 +505,13 @@ export const searchHn = tool('hn_search_content', {
505
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.',
506
506
  severity: 'notice',
507
507
  },
508
+ {
509
+ reason: 'min_comments_unscored_type',
510
+ code: JsonRpcErrorCode.InvalidParams,
511
+ when: 'minComments was combined with tags "comment" or "job", record types Algolia stores without a comment count.',
512
+ recovery: 'Drop minComments to search comments or jobs, or search a type with a comment count (story, poll, ask_hn, show_hn, front_page) to filter by comments.',
513
+ severity: 'notice',
514
+ },
508
515
  {
509
516
  reason: 'count_page_unaligned',
510
517
  code: JsonRpcErrorCode.InvalidParams,
@@ -558,17 +565,14 @@ export const searchHn = tool('hn_search_content', {
558
565
  .trim()
559
566
  .min(1, 'blank after trimming whitespace — pass one or more search terms, or omit query for a filter-only search.')
560
567
  .optional()
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.'),
568
+ .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, minComments, or a dateRange bound.'),
562
569
  tags: z
563
570
  .enum(SEARCH_TAGS)
564
571
  .optional()
565
572
  .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.'),
566
- author: z
567
- .string()
568
- .trim()
569
- .min(1, 'blank after trimming whitespace — omit author to search all authors.')
573
+ author: usernameInput('omit author to search all authors.')
570
574
  .optional()
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.`),
575
+ .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, and one leading @ is dropped, so "@pg" searches pg; a name that still holds whitespace is rejected as a display name. Omit the field to search all authors rather than passing a blank string.`),
572
576
  storyId: z
573
577
  .number()
574
578
  .int()
@@ -592,12 +596,18 @@ export const searchHn = tool('hn_search_content', {
592
596
  .min(0)
593
597
  .optional()
594
598
  .describe('Minimum score. Applies to stories and polls, including the ask_hn, show_hn, and front_page subsets. Comments and jobs carry no points in the search index, so any minPoints excludes them — combining it with tags "comment" or "job" is rejected.'),
599
+ minComments: z
600
+ .number()
601
+ .int()
602
+ .min(0)
603
+ .optional()
604
+ .describe('Minimum comment count. Applies to stories and polls, including the ask_hn, show_hn, and front_page subsets. Any minComments, 0 included, excludes comments, jobs, and poll options, which carry no comment count in the search index, so an untagged search returns only stories and polls — combining it with tags "comment" or "job" is rejected.'),
595
605
  count: z
596
606
  .number()
597
607
  .int()
598
608
  .min(1)
599
609
  .max(COUNT_CEILING)
600
- .default(30)
610
+ .default(COUNT_DEFAULT)
601
611
  .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
612
  page: z
603
613
  .number()
@@ -608,7 +618,7 @@ export const searchHn = tool('hn_search_content', {
608
618
  view: z
609
619
  .enum(['full', 'compact'])
610
620
  .default('full')
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.`),
621
+ .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, parentId, storyUrl, 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.'),
612
622
  }),
613
623
  output: searchOutput,
614
624
  enrichment: {
@@ -639,16 +649,14 @@ export const searchHn = tool('hn_search_content', {
639
649
  .number()
640
650
  .optional()
641
651
  .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.`),
646
652
  notice: z
647
653
  .string()
648
654
  .optional()
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.`),
655
+ .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.'),
656
+ },
657
+ enrichmentTrailer: {
658
+ criteria: { render: renderCriteria },
650
659
  },
651
- enrichmentTrailer: searchTrailer,
652
660
  async handler(input, ctx) {
653
661
  const { dateRange } = input;
654
662
  if (dateRange) {
@@ -663,6 +671,9 @@ export const searchHn = tool('hn_search_content', {
663
671
  if (input.minPoints != null && (input.tags === 'comment' || input.tags === 'job')) {
664
672
  throw ctx.fail('min_points_unscored_type', `minPoints cannot match tags "${input.tags}": ${input.tags} records carry no points, so any minPoints excludes every one.`);
665
673
  }
674
+ if (input.minComments != null && (input.tags === 'comment' || input.tags === 'job')) {
675
+ throw ctx.fail('min_comments_unscored_type', `minComments cannot match tags "${input.tags}": ${input.tags} records carry no comment count, so any minComments excludes every one.`);
676
+ }
666
677
  const filters = appliedFilters(input);
667
678
  if (input.query == null && filters.length === 0) {
668
679
  throw ctx.fail('missing_query_or_filter', 'No query and no filter were supplied, so there is nothing to search by.');
@@ -681,8 +692,7 @@ export const searchHn = tool('hn_search_content', {
681
692
  };
682
693
  const hn = getHnService();
683
694
  const result = await hn.search(params, ctx);
684
- const includeBody = input.view === 'full';
685
- const hits = result.hits.map((hit) => projectHit(hit, includeBody, includeBody));
695
+ const hits = result.hits.map((hit) => projectHit(hit, input.view === 'full'));
686
696
  ctx.log.info('Search completed', {
687
697
  query: params.query,
688
698
  hits: hits.length,
@@ -696,12 +706,13 @@ export const searchHn = tool('hn_search_content', {
696
706
  ? undefined
697
707
  : (result.exhaustive?.nbHits ?? result.exhaustiveNbHits);
698
708
  const bounds = params.dateRange ? dateRangeToEpochSeconds(params.dateRange) : {};
699
- const pageFields = {
709
+ ctx.enrich({
700
710
  criteria: {
701
711
  ...(params.tags != null && { tags: params.tags }),
702
712
  ...(params.author != null && { author: params.author }),
703
713
  ...(params.storyId != null && { storyId: params.storyId }),
704
714
  ...(params.minPoints != null && { minPoints: params.minPoints }),
715
+ ...(params.minComments != null && { minComments: params.minComments }),
705
716
  ...((bounds.start != null || bounds.end != null) && {
706
717
  dateRange: {
707
718
  ...(bounds.start != null && { start: isoSeconds(bounds.start) }),
@@ -717,9 +728,10 @@ export const searchHn = tool('hn_search_content', {
717
728
  page,
718
729
  totalPages,
719
730
  ...(totalHits > HIT_CEILING && { reachableHits: HIT_CEILING }),
720
- };
721
- const morePages = shown > 0 && page + 1 < totalPages;
722
- const noticeFacts = {
731
+ });
732
+ if (shown > 0 && page + 1 < totalPages)
733
+ ctx.enrich.truncated({ shown, cap: params.count });
734
+ const notice = composeSearchNotice({
723
735
  ...params,
724
736
  droppedPrefix: searched?.droppedPrefix,
725
737
  shown,
@@ -728,61 +740,10 @@ export const searchHn = tool('hn_search_content', {
728
740
  totalHitsExact,
729
741
  totalPages,
730
742
  overCap,
731
- };
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
743
  });
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
- });
775
- }
776
- }
777
- ctx.enrich({
778
- ...pageFields,
779
- ...(response.highlightsTextOmitted && { highlightsTextOmitted: true }),
780
- });
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;
744
+ if (notice)
745
+ ctx.enrich.notice(notice);
746
+ return { hits, ...(params.query != null && { query: params.query }) };
786
747
  },
787
748
  format: formatSearch,
788
749
  });