@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.
- package/AGENTS.md +6 -6
- package/CLAUDE.md +6 -6
- package/README.md +24 -22
- package/changelog/0.6.x/0.6.1.md +34 -0
- package/changelog/0.7.x/0.7.0.md +32 -0
- package/changelog/template.md +10 -3
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts +15 -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 +47 -24
- package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +10 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.js +235 -277
- package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +14 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.js +43 -72
- package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +15 -17
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.js +124 -163
- package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
- package/dist/mcp-server/tools/username-input.d.ts +23 -0
- package/dist/mcp-server/tools/username-input.d.ts.map +1 -0
- package/dist/mcp-server/tools/username-input.js +30 -0
- package/dist/mcp-server/tools/username-input.js.map +1 -0
- package/dist/services/hn/hn-service.d.ts +57 -8
- package/dist/services/hn/hn-service.d.ts.map +1 -1
- package/dist/services/hn/hn-service.js +128 -16
- 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 +8 -8
- package/server.json +3 -3
- package/skills/hn-guide/SKILL.md +8 -8
- package/dist/mcp-server/tools/response-budget.d.ts +0 -111
- package/dist/mcp-server/tools/response-budget.d.ts.map +0 -1
- package/dist/mcp-server/tools/response-budget.js +0 -204
- 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
|
|
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.
|
|
172
|
-
*
|
|
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.
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
-
*
|
|
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 (`<details>`,
|
|
285
289
|
* `"`), 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,
|
|
290
|
-
* the
|
|
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
|
|
298
|
-
const title =
|
|
299
|
-
const textHl =
|
|
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
|
-
:
|
|
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
|
-
|
|
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(
|
|
341
|
-
|
|
342
|
-
.
|
|
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(`
|
|
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
|
|
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
|
|
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`
|
|
365
|
-
*
|
|
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,
|
|
369
|
-
const rawText =
|
|
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
|
|
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
|
|
424
|
+
/** A hit's body, quoted on the lines under its meta line and links. */
|
|
418
425
|
function renderBody(h) {
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
722
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
});
|