@cyanheads/hn-mcp-server 0.5.22 → 0.6.1
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.
- package/AGENTS.md +24 -11
- package/CLAUDE.md +24 -11
- package/Dockerfile +9 -3
- package/README.md +25 -15
- package/changelog/0.6.x/0.6.0.md +38 -0
- package/changelog/0.6.x/0.6.1.md +34 -0
- package/changelog/template.md +10 -3
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts +14 -0
- package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-stories.tool.js +25 -6
- package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +8 -0
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.js +304 -182
- package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +15 -0
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.js +177 -104
- package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +28 -4
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.js +291 -190
- package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +111 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +204 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- package/dist/services/hn/hn-service.d.ts +56 -12
- package/dist/services/hn/hn-service.d.ts.map +1 -1
- package/dist/services/hn/hn-service.js +147 -25
- package/dist/services/hn/hn-service.js.map +1 -1
- package/dist/services/hn/types.d.ts +4 -0
- package/dist/services/hn/types.d.ts.map +1 -1
- package/package.json +12 -11
- package/server.json +3 -3
- 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. */
|
|
@@ -106,6 +107,7 @@ const searchCriteria = z
|
|
|
106
107
|
author: z.string().optional().describe('Author filter — the username matched, trimmed.'),
|
|
107
108
|
storyId: z.number().optional().describe('Discussion filter: a story or poll root id.'),
|
|
108
109
|
minPoints: z.number().optional().describe('Minimum score.'),
|
|
110
|
+
minComments: z.number().optional().describe('Minimum comment count.'),
|
|
109
111
|
dateRange: z
|
|
110
112
|
.object({
|
|
111
113
|
start: z
|
|
@@ -139,6 +141,7 @@ function renderCriteria(c) {
|
|
|
139
141
|
c.author != null ? `author: "${escapeQuoted(c.author)}"` : null,
|
|
140
142
|
c.storyId != null ? `storyId: ${c.storyId}` : null,
|
|
141
143
|
c.minPoints != null ? `minPoints: ${c.minPoints}` : null,
|
|
144
|
+
c.minComments != null ? `minComments: ${c.minComments}` : null,
|
|
142
145
|
bounds ? `dateRange: ${bounds.filter(Boolean).join(', ')}` : null,
|
|
143
146
|
`sort: ${c.sort}`,
|
|
144
147
|
`count: ${c.count}`,
|
|
@@ -157,6 +160,8 @@ function appliedFilters(input) {
|
|
|
157
160
|
filters.push('storyId');
|
|
158
161
|
if (input.minPoints != null)
|
|
159
162
|
filters.push('minPoints');
|
|
163
|
+
if (input.minComments != null)
|
|
164
|
+
filters.push('minComments');
|
|
160
165
|
if (input.dateRange?.start || input.dateRange?.end)
|
|
161
166
|
filters.push('dateRange');
|
|
162
167
|
return filters;
|
|
@@ -236,15 +241,29 @@ function causeSentences(facts) {
|
|
|
236
241
|
}
|
|
237
242
|
return sentences;
|
|
238
243
|
}
|
|
244
|
+
/** The sentence disclosing how the page was fitted to the response budget. */
|
|
245
|
+
function budgetSentence({ highlightsTextOmitted, cutIds }) {
|
|
246
|
+
const budget = `To stay within the ${RESPONSE_BUDGET_TEXT}-byte response budget,`;
|
|
247
|
+
const omitted = 'highlights.text is omitted from every hit';
|
|
248
|
+
if (cutIds.length === 0)
|
|
249
|
+
return `${budget} ${omitted}; matchedWords still lists the matched terms.`;
|
|
250
|
+
const cut = `the text of ${cutIds.length === 1 ? 'hit' : 'hits'} ${cutIds.join(', ')} is cut short`;
|
|
251
|
+
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.`;
|
|
252
|
+
}
|
|
239
253
|
/**
|
|
240
254
|
* Compose the one `notice` string every search writes. `notice` is last-wins,
|
|
241
255
|
* so each source goes through here: the page-state sentence first, then the
|
|
242
|
-
* cause sentences in input order — query, tags, author, storyId, count
|
|
243
|
-
*
|
|
244
|
-
* uncapped,
|
|
256
|
+
* cause sentences in input order — query, tags, author, storyId, count — then
|
|
257
|
+
* the budget sentence for a page fitted to the response budget. None replaces
|
|
258
|
+
* another. Undefined when nothing applies — the last page of an uncapped,
|
|
259
|
+
* non-empty search that fit.
|
|
245
260
|
*/
|
|
246
261
|
export function composeSearchNotice(facts) {
|
|
247
|
-
const sentences = [
|
|
262
|
+
const sentences = [
|
|
263
|
+
pageStateSentence(facts),
|
|
264
|
+
...causeSentences(facts),
|
|
265
|
+
facts.budget && budgetSentence(facts.budget),
|
|
266
|
+
].filter((sentence) => sentence != null);
|
|
248
267
|
return sentences.length > 0 ? sentences.join(' ') : undefined;
|
|
249
268
|
}
|
|
250
269
|
/**
|
|
@@ -259,19 +278,21 @@ function decodeHighlightTitle(title) {
|
|
|
259
278
|
.join('');
|
|
260
279
|
}
|
|
261
280
|
/**
|
|
262
|
-
* Project Algolia's `_highlightResult` into a flat
|
|
263
|
-
*
|
|
264
|
-
* reads them in; a job's
|
|
281
|
+
* Project Algolia's `_highlightResult` into a flat object: the title snippet,
|
|
282
|
+
* the body highlight — the whole matched body with markers, not a snippet
|
|
283
|
+
* (comment_text, else story_text — the order `text` reads them in; a job's
|
|
284
|
+
* job_text is never highlighted) — and the deduplicated
|
|
265
285
|
* union of matched words across all surfaced fields. Returns undefined when
|
|
266
286
|
* nothing matched.
|
|
267
287
|
*
|
|
268
288
|
* Titles are not HTML, though some arrive entity-encoded (`<details>`,
|
|
269
289
|
* `"`), so the title snippet is entity-decoded between its `<em>` markers
|
|
270
|
-
* and never tag-stripped. The body
|
|
271
|
-
* markers kept.
|
|
290
|
+
* and never tag-stripped. The body highlight, the whole matched body, is
|
|
291
|
+
* stripped of its HTML with the markers kept.
|
|
272
292
|
*
|
|
273
|
-
* `includeBody` is false under the compact projection,
|
|
274
|
-
*
|
|
293
|
+
* `includeBody` is false under the compact projection, and on a full page over
|
|
294
|
+
* the response budget, both of which drop the body highlight — the field that
|
|
295
|
+
* repeats a hit's full body text.
|
|
275
296
|
*/
|
|
276
297
|
function extractHighlights(hit, includeBody) {
|
|
277
298
|
const h = hit._highlightResult;
|
|
@@ -295,15 +316,188 @@ function extractHighlights(hit, includeBody) {
|
|
|
295
316
|
matchedWords,
|
|
296
317
|
};
|
|
297
318
|
}
|
|
319
|
+
const searchHit = z
|
|
320
|
+
.object({
|
|
321
|
+
id: z.number().describe('HN item ID — use with hn_get_thread to read the discussion.'),
|
|
322
|
+
type: z
|
|
323
|
+
.enum(HIT_TYPES)
|
|
324
|
+
.optional()
|
|
325
|
+
.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.'),
|
|
326
|
+
title: z.string().optional().describe('Item title (present for stories, polls, and jobs).'),
|
|
327
|
+
url: z.string().optional().describe('External link URL.'),
|
|
328
|
+
domain: z
|
|
329
|
+
.string()
|
|
330
|
+
.optional()
|
|
331
|
+
.describe('Bare hostname derived from url (e.g. "github.com", with leading "www." stripped). Absent when url is missing or unparseable.'),
|
|
332
|
+
author: z.string().describe('Author username.'),
|
|
333
|
+
points: z.number().optional().describe('Score/upvotes.'),
|
|
334
|
+
numComments: z.number().optional().describe('Comment count.'),
|
|
335
|
+
createdAt: z.string().describe('Creation time (ISO 8601).'),
|
|
336
|
+
storyTitle: z.string().optional().describe('Parent story title (present for comment results).'),
|
|
337
|
+
storyId: z
|
|
338
|
+
.number()
|
|
339
|
+
.optional()
|
|
340
|
+
.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."),
|
|
341
|
+
parentId: z
|
|
342
|
+
.number()
|
|
343
|
+
.optional()
|
|
344
|
+
.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."),
|
|
345
|
+
storyUrl: z
|
|
346
|
+
.string()
|
|
347
|
+
.optional()
|
|
348
|
+
.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."),
|
|
349
|
+
text: z
|
|
350
|
+
.string()
|
|
351
|
+
.optional()
|
|
352
|
+
.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.`),
|
|
353
|
+
textTruncated: z
|
|
354
|
+
.boolean()
|
|
355
|
+
.optional()
|
|
356
|
+
.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.`),
|
|
357
|
+
highlights: z
|
|
358
|
+
.object({
|
|
359
|
+
title: z
|
|
360
|
+
.string()
|
|
361
|
+
.optional()
|
|
362
|
+
.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.'),
|
|
363
|
+
text: z
|
|
364
|
+
.string()
|
|
365
|
+
.optional()
|
|
366
|
+
.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.'),
|
|
367
|
+
matchedWords: z
|
|
368
|
+
.array(z.string())
|
|
369
|
+
.describe('Deduplicated union of matched terms across all searchable fields (title, url, author, comment_text, story_text, story_title).'),
|
|
370
|
+
})
|
|
371
|
+
.optional()
|
|
372
|
+
.describe('Algolia per-field highlight metadata showing which terms matched and where. Absent when no fields produced a match.'),
|
|
373
|
+
})
|
|
374
|
+
.describe('A single Algolia search hit (story, comment, poll, poll option, or job).');
|
|
375
|
+
/**
|
|
376
|
+
* Project an Algolia hit into a search hit. `text` carries its body; under
|
|
377
|
+
* `bodyHighlight` its highlights carry the body too — the matched body in full,
|
|
378
|
+
* with markers.
|
|
379
|
+
*/
|
|
380
|
+
function projectHit(hit, text, bodyHighlight) {
|
|
381
|
+
const rawText = text ? (hit.comment_text ?? hit.story_text ?? hit.job_text) : undefined;
|
|
382
|
+
const url = normalizeUrl(hit.url);
|
|
383
|
+
const domain = extractDomain(url);
|
|
384
|
+
const storyUrl = normalizeUrl(hit.story_url);
|
|
385
|
+
const highlights = extractHighlights(hit, bodyHighlight);
|
|
386
|
+
const type = hitType(hit._tags);
|
|
387
|
+
return {
|
|
388
|
+
id: Number(hit.objectID),
|
|
389
|
+
...(type && { type }),
|
|
390
|
+
title: hit.title != null ? decodeHtmlEntities(hit.title) : undefined,
|
|
391
|
+
url,
|
|
392
|
+
domain,
|
|
393
|
+
author: hit.author,
|
|
394
|
+
points: hit.points ?? undefined,
|
|
395
|
+
numComments: hit.num_comments ?? undefined,
|
|
396
|
+
createdAt: hit.created_at,
|
|
397
|
+
storyTitle: hit.story_title != null ? decodeHtmlEntities(hit.story_title) : undefined,
|
|
398
|
+
storyId: hit.story_id ?? undefined,
|
|
399
|
+
...(hit.parent_id != null && { parentId: hit.parent_id }),
|
|
400
|
+
...(storyUrl && { storyUrl }),
|
|
401
|
+
text: rawText ? stripHtml(rawText) || undefined : undefined,
|
|
402
|
+
...(highlights && { highlights }),
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
const searchOutput = z.object({
|
|
406
|
+
hits: z.array(searchHit).describe('Search results ranked by sort order.'),
|
|
407
|
+
query: z
|
|
408
|
+
.string()
|
|
409
|
+
.optional()
|
|
410
|
+
.describe('The query that was searched — for a URL query, without its leading scheme and www. Absent for a filter-only search.'),
|
|
411
|
+
});
|
|
412
|
+
const searchTrailer = {
|
|
413
|
+
criteria: { render: renderCriteria },
|
|
414
|
+
};
|
|
415
|
+
/**
|
|
416
|
+
* Render highlight metadata as a `> match — …` footer, surfacing each highlights
|
|
417
|
+
* field separately so structured consumers and the LLM both see what matched.
|
|
418
|
+
* The footer follows an unquoted blank line, which a quoted body never
|
|
419
|
+
* produces, so a body line reading `match — …` cannot pass for it. The
|
|
420
|
+
* matched body comes last and continues on quoted lines of its own.
|
|
421
|
+
*/
|
|
422
|
+
function renderHighlights(hl) {
|
|
423
|
+
const parts = [];
|
|
424
|
+
if (hl.title)
|
|
425
|
+
parts.push(`title: ${escapeHighlight(hl.title)}`);
|
|
426
|
+
if (hl.matchedWords.length)
|
|
427
|
+
parts.push(`terms: ${hl.matchedWords.join(', ')}`);
|
|
428
|
+
if (hl.text)
|
|
429
|
+
parts.push(`body: ${hl.text}`);
|
|
430
|
+
return parts.length ? `\n\n${quoteBody(`match — ${parts.join(' | ')}`)}` : '';
|
|
431
|
+
}
|
|
432
|
+
/** A hit's quoted body, and after a cut body the unquoted pointer to the whole text, a blank line apart. */
|
|
433
|
+
function renderBody(h) {
|
|
434
|
+
const text = h.text ? `\n${quoteBody(h.text)}` : '';
|
|
435
|
+
return h.textTruncated ? `${text}\n\n${cutPointer(h.id)}` : text;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* A hit's meta line: id, type, and the item a comment replies to, in the order
|
|
439
|
+
* `hn_get_thread` opens its meta line with, then author, score, comment count,
|
|
440
|
+
* and time. Each field renders when present, whatever the hit's type.
|
|
441
|
+
*/
|
|
442
|
+
function metaLine(h) {
|
|
443
|
+
return [
|
|
444
|
+
`id:${h.id}`,
|
|
445
|
+
h.type ? `type:${h.type}` : null,
|
|
446
|
+
h.parentId != null ? `parent:${h.parentId}` : null,
|
|
447
|
+
h.author,
|
|
448
|
+
h.points != null ? `${h.points} pts` : null,
|
|
449
|
+
h.numComments != null ? `${h.numComments} comments` : null,
|
|
450
|
+
h.createdAt,
|
|
451
|
+
]
|
|
452
|
+
.filter(Boolean)
|
|
453
|
+
.join(' | ');
|
|
454
|
+
}
|
|
455
|
+
/** The hit's own link, then its story's, each on a line of its own under the meta line. */
|
|
456
|
+
function renderLinks(h) {
|
|
457
|
+
return [h.url, h.storyUrl].map((link) => (link ? `\n${link}` : '')).join('');
|
|
458
|
+
}
|
|
459
|
+
function formatSearch(result) {
|
|
460
|
+
if (result.hits.length === 0) {
|
|
461
|
+
return [
|
|
462
|
+
{
|
|
463
|
+
type: 'text',
|
|
464
|
+
text: result.query != null ? `"${escapeQuoted(result.query)}" — no results.` : 'No results.',
|
|
465
|
+
},
|
|
466
|
+
];
|
|
467
|
+
}
|
|
468
|
+
const lines = result.hits.map((h) => {
|
|
469
|
+
const meta = metaLine(h);
|
|
470
|
+
const links = renderLinks(h);
|
|
471
|
+
const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
|
|
472
|
+
if (h.type === 'pollopt') {
|
|
473
|
+
// A poll option's search record carries no text and no poll; hn_get_thread has both.
|
|
474
|
+
return `### Poll option by ${h.author}\n${meta}${links}\nOption text and its poll: hn_get_thread with itemId ${h.id}${hlLine}`;
|
|
475
|
+
}
|
|
476
|
+
if (h.title) {
|
|
477
|
+
// Story result — Algolia returns storyId === id for stories, so suppress the parent ref unless it actually differs or a parent title is set.
|
|
478
|
+
const parentRef = (h.storyId != null && h.storyId !== h.id) || h.storyTitle
|
|
479
|
+
? ` | story:"${escapeQuoted(h.storyTitle ?? '?')}"#${h.storyId ?? '?'}`
|
|
480
|
+
: '';
|
|
481
|
+
const domain = h.domain ? ` (${h.domain})` : '';
|
|
482
|
+
return `### ${escapeInline(h.title)}${domain}\n${meta}${parentRef}${links}${renderBody(h)}${hlLine}`;
|
|
483
|
+
}
|
|
484
|
+
// Comment result — parent context in heading.
|
|
485
|
+
return `### Comment on "${escapeQuoted(h.storyTitle ?? 'unknown')}" (story id:${h.storyId ?? '?'})\n${meta}${links}${renderBody(h)}${hlLine}`;
|
|
486
|
+
});
|
|
487
|
+
const header = result.query != null
|
|
488
|
+
? `## "${escapeQuoted(result.query)}" — search results`
|
|
489
|
+
: '## Search results (filters only)';
|
|
490
|
+
return [{ type: 'text', text: `${header}\n\n${lines.join('\n\n')}` }];
|
|
491
|
+
}
|
|
298
492
|
export const searchHn = tool('hn_search_content', {
|
|
299
|
-
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
|
|
493
|
+
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.',
|
|
300
494
|
annotations: { readOnlyHint: true },
|
|
301
495
|
errors: [
|
|
302
496
|
{
|
|
303
497
|
reason: 'missing_query_or_filter',
|
|
304
498
|
code: JsonRpcErrorCode.InvalidParams,
|
|
305
499
|
when: 'Neither a query nor any filter was supplied, so there is nothing to search by.',
|
|
306
|
-
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.',
|
|
500
|
+
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.',
|
|
307
501
|
severity: 'notice',
|
|
308
502
|
},
|
|
309
503
|
{
|
|
@@ -320,6 +514,13 @@ export const searchHn = tool('hn_search_content', {
|
|
|
320
514
|
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.',
|
|
321
515
|
severity: 'notice',
|
|
322
516
|
},
|
|
517
|
+
{
|
|
518
|
+
reason: 'min_comments_unscored_type',
|
|
519
|
+
code: JsonRpcErrorCode.InvalidParams,
|
|
520
|
+
when: 'minComments was combined with tags "comment" or "job", record types Algolia stores without a comment count.',
|
|
521
|
+
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.',
|
|
522
|
+
severity: 'notice',
|
|
523
|
+
},
|
|
323
524
|
{
|
|
324
525
|
reason: 'count_page_unaligned',
|
|
325
526
|
code: JsonRpcErrorCode.InvalidParams,
|
|
@@ -373,7 +574,7 @@ export const searchHn = tool('hn_search_content', {
|
|
|
373
574
|
.trim()
|
|
374
575
|
.min(1, 'blank after trimming whitespace — pass one or more search terms, or omit query for a filter-only search.')
|
|
375
576
|
.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.'),
|
|
577
|
+
.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.'),
|
|
377
578
|
tags: z
|
|
378
579
|
.enum(SEARCH_TAGS)
|
|
379
580
|
.optional()
|
|
@@ -407,6 +608,12 @@ export const searchHn = tool('hn_search_content', {
|
|
|
407
608
|
.min(0)
|
|
408
609
|
.optional()
|
|
409
610
|
.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.'),
|
|
611
|
+
minComments: z
|
|
612
|
+
.number()
|
|
613
|
+
.int()
|
|
614
|
+
.min(0)
|
|
615
|
+
.optional()
|
|
616
|
+
.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.'),
|
|
410
617
|
count: z
|
|
411
618
|
.number()
|
|
412
619
|
.int()
|
|
@@ -423,66 +630,9 @@ export const searchHn = tool('hn_search_content', {
|
|
|
423
630
|
view: z
|
|
424
631
|
.enum(['full', 'compact'])
|
|
425
632
|
.default('full')
|
|
426
|
-
.describe(
|
|
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.'),
|
|
633
|
+
.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, 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.`),
|
|
485
634
|
}),
|
|
635
|
+
output: searchOutput,
|
|
486
636
|
enrichment: {
|
|
487
637
|
criteria: searchCriteria,
|
|
488
638
|
totalHits: z
|
|
@@ -511,14 +661,16 @@ export const searchHn = tool('hn_search_content', {
|
|
|
511
661
|
.number()
|
|
512
662
|
.optional()
|
|
513
663
|
.describe(`The count value served: as passed, or ${COUNT_CAP} when count was above that cap.`),
|
|
664
|
+
highlightsTextOmitted: z
|
|
665
|
+
.boolean()
|
|
666
|
+
.optional()
|
|
667
|
+
.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
668
|
notice: z
|
|
515
669
|
.string()
|
|
516
670
|
.optional()
|
|
517
|
-
.describe(
|
|
518
|
-
},
|
|
519
|
-
enrichmentTrailer: {
|
|
520
|
-
criteria: { render: renderCriteria },
|
|
671
|
+
.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
672
|
},
|
|
673
|
+
enrichmentTrailer: searchTrailer,
|
|
522
674
|
async handler(input, ctx) {
|
|
523
675
|
const { dateRange } = input;
|
|
524
676
|
if (dateRange) {
|
|
@@ -533,6 +685,9 @@ export const searchHn = tool('hn_search_content', {
|
|
|
533
685
|
if (input.minPoints != null && (input.tags === 'comment' || input.tags === 'job')) {
|
|
534
686
|
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.`);
|
|
535
687
|
}
|
|
688
|
+
if (input.minComments != null && (input.tags === 'comment' || input.tags === 'job')) {
|
|
689
|
+
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.`);
|
|
690
|
+
}
|
|
536
691
|
const filters = appliedFilters(input);
|
|
537
692
|
if (input.query == null && filters.length === 0) {
|
|
538
693
|
throw ctx.fail('missing_query_or_filter', 'No query and no filter were supplied, so there is nothing to search by.');
|
|
@@ -552,30 +707,7 @@ export const searchHn = tool('hn_search_content', {
|
|
|
552
707
|
const hn = getHnService();
|
|
553
708
|
const result = await hn.search(params, ctx);
|
|
554
709
|
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
|
-
});
|
|
710
|
+
const hits = result.hits.map((hit) => projectHit(hit, includeBody, includeBody));
|
|
579
711
|
ctx.log.info('Search completed', {
|
|
580
712
|
query: params.query,
|
|
581
713
|
hits: hits.length,
|
|
@@ -589,12 +721,13 @@ export const searchHn = tool('hn_search_content', {
|
|
|
589
721
|
? undefined
|
|
590
722
|
: (result.exhaustive?.nbHits ?? result.exhaustiveNbHits);
|
|
591
723
|
const bounds = params.dateRange ? dateRangeToEpochSeconds(params.dateRange) : {};
|
|
592
|
-
|
|
724
|
+
const pageFields = {
|
|
593
725
|
criteria: {
|
|
594
726
|
...(params.tags != null && { tags: params.tags }),
|
|
595
727
|
...(params.author != null && { author: params.author }),
|
|
596
728
|
...(params.storyId != null && { storyId: params.storyId }),
|
|
597
729
|
...(params.minPoints != null && { minPoints: params.minPoints }),
|
|
730
|
+
...(params.minComments != null && { minComments: params.minComments }),
|
|
598
731
|
...((bounds.start != null || bounds.end != null) && {
|
|
599
732
|
dateRange: {
|
|
600
733
|
...(bounds.start != null && { start: isoSeconds(bounds.start) }),
|
|
@@ -610,10 +743,9 @@ export const searchHn = tool('hn_search_content', {
|
|
|
610
743
|
page,
|
|
611
744
|
totalPages,
|
|
612
745
|
...(totalHits > HIT_CEILING && { reachableHits: HIT_CEILING }),
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
const notice = composeSearchNotice({
|
|
746
|
+
};
|
|
747
|
+
const morePages = shown > 0 && page + 1 < totalPages;
|
|
748
|
+
const noticeFacts = {
|
|
617
749
|
...params,
|
|
618
750
|
droppedPrefix: searched?.droppedPrefix,
|
|
619
751
|
shown,
|
|
@@ -622,93 +754,62 @@ export const searchHn = tool('hn_search_content', {
|
|
|
622
754
|
totalHitsExact,
|
|
623
755
|
totalPages,
|
|
624
756
|
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
757
|
};
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
758
|
+
/** The response serving `served`, with the notice and flag for how `budget` fitted it. */
|
|
759
|
+
const respond = (served, budget) => ({
|
|
760
|
+
output: { hits: served, ...(params.query != null && { query: params.query }) },
|
|
761
|
+
notice: composeSearchNotice({ ...noticeFacts, budget }),
|
|
762
|
+
highlightsTextOmitted: budget?.highlightsTextOmitted === true,
|
|
763
|
+
});
|
|
764
|
+
/** Both surfaces of a response as the caller receives it, enrichment and trailer included. */
|
|
765
|
+
const measure = (response) => measureResponse({
|
|
766
|
+
content: formatSearch(response.output),
|
|
767
|
+
result: response.output,
|
|
768
|
+
enrichment: {
|
|
769
|
+
...pageFields,
|
|
770
|
+
...(response.highlightsTextOmitted && { highlightsTextOmitted: true }),
|
|
771
|
+
...(morePages && { truncated: true, shown, cap: params.count }),
|
|
772
|
+
...(response.notice != null && { notice: response.notice }),
|
|
773
|
+
},
|
|
774
|
+
trailer: searchTrailer,
|
|
775
|
+
});
|
|
776
|
+
let response = respond(hits);
|
|
777
|
+
if (includeBody && !fitsBudget(measure(response))) {
|
|
778
|
+
/** Step 1: drop the body highlight, which repeats the matched body in full, from every hit. */
|
|
779
|
+
const highlightsTextOmitted = hits.some((hit) => hit.highlights?.text != null);
|
|
780
|
+
const lean = highlightsTextOmitted
|
|
781
|
+
? result.hits.map((hit) => projectHit(hit, true, false))
|
|
782
|
+
: hits;
|
|
783
|
+
if (highlightsTextOmitted)
|
|
784
|
+
response = respond(lean, { highlightsTextOmitted, cutIds: [] });
|
|
785
|
+
if (!highlightsTextOmitted || !fitsBudget(measure(response))) {
|
|
786
|
+
/** Step 2: cut every body longer than the largest limit they can share. */
|
|
787
|
+
const bodies = lean.map((hit) => ({ hit, bytes: hit.text ? byteLength(hit.text) : 0 }));
|
|
788
|
+
const cutAt = (limit) => {
|
|
789
|
+
const served = bodies.map(({ hit, bytes }) => hit.text && bytes > limit
|
|
790
|
+
? { ...hit, text: cutToBytes(hit.text, limit), textTruncated: true }
|
|
791
|
+
: hit);
|
|
792
|
+
const cutIds = served.filter((hit) => hit.textTruncated).map((hit) => hit.id);
|
|
793
|
+
return respond(served, { highlightsTextOmitted, cutIds });
|
|
794
|
+
};
|
|
795
|
+
const limit = largestFittingLimit(bodies.map((body) => body.bytes), (limitBytes) => measure(cutAt(limitBytes)));
|
|
796
|
+
response = cutAt(limit);
|
|
797
|
+
ctx.log.info('Fitted search page to the response budget', {
|
|
798
|
+
limitBytes: limit,
|
|
799
|
+
cutIds: response.output.hits.filter((hit) => hit.textTruncated).map((hit) => hit.id),
|
|
800
|
+
});
|
|
694
801
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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}`;
|
|
802
|
+
}
|
|
803
|
+
ctx.enrich({
|
|
804
|
+
...pageFields,
|
|
805
|
+
...(response.highlightsTextOmitted && { highlightsTextOmitted: true }),
|
|
707
806
|
});
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
807
|
+
if (morePages)
|
|
808
|
+
ctx.enrich.truncated({ shown, cap: params.count });
|
|
809
|
+
if (response.notice)
|
|
810
|
+
ctx.enrich.notice(response.notice);
|
|
811
|
+
return response.output;
|
|
712
812
|
},
|
|
813
|
+
format: formatSearch,
|
|
713
814
|
});
|
|
714
815
|
//# sourceMappingURL=search-content.tool.js.map
|