@cyanheads/hn-mcp-server 0.5.21 → 0.5.22
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 +7 -4
- package/CLAUDE.md +7 -4
- package/Dockerfile +4 -4
- package/README.md +17 -12
- package/changelog/0.5.x/0.5.22.md +45 -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 +23 -12
- package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +3 -2
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.js +192 -99
- package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -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 +22 -8
- package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +93 -0
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.js +322 -59
- package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
- package/dist/mcp-server/tools/served-at-cap.d.ts +14 -0
- package/dist/mcp-server/tools/served-at-cap.d.ts.map +1 -0
- package/dist/mcp-server/tools/served-at-cap.js +16 -0
- package/dist/mcp-server/tools/served-at-cap.js.map +1 -0
- package/dist/mcp-server/tools/thread-cursor.d.ts +68 -0
- package/dist/mcp-server/tools/thread-cursor.d.ts.map +1 -0
- package/dist/mcp-server/tools/thread-cursor.js +200 -0
- package/dist/mcp-server/tools/thread-cursor.js.map +1 -0
- package/dist/services/hn/hn-service.d.ts +29 -4
- package/dist/services/hn/hn-service.d.ts.map +1 -1
- package/dist/services/hn/hn-service.js +58 -25
- package/dist/services/hn/hn-service.js.map +1 -1
- package/dist/services/hn/types.d.ts +9 -0
- package/dist/services/hn/types.d.ts.map +1 -1
- package/package.json +9 -9
- package/server.json +3 -3
|
@@ -5,9 +5,45 @@
|
|
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
import { servedAtCap } from '../served-at-cap.js';
|
|
9
|
+
import { dateBoundToEpochMs, dateRangeToEpochSeconds, decodeHtmlEntities, extractDomain, getHnService, normalizeUrl, stripHtml, stripHtmlPreservingEm, } from '../../../services/hn/hn-service.js';
|
|
10
|
+
/** Most hits one call serves; a larger `count` is served at this cap. */
|
|
11
|
+
const COUNT_CAP = 50;
|
|
12
|
+
/** Largest `count` accepted; anything above it is rejected. */
|
|
13
|
+
const COUNT_CEILING = 1000;
|
|
14
|
+
/**
|
|
15
|
+
* The recovery for a first hit no page of 50 begins on: the largest count up to
|
|
16
|
+
* the cap whose pages include one starting at that hit, so a caller can resume
|
|
17
|
+
* from exactly there. When that count is under half the cap, covering a cap's
|
|
18
|
+
* worth of hits from there takes three or more calls where pages of 50 take
|
|
19
|
+
* two, so the page of 50 holding the hit is named as well.
|
|
20
|
+
*/
|
|
21
|
+
function unalignedHint(firstHit) {
|
|
22
|
+
let size = COUNT_CAP;
|
|
23
|
+
while (firstHit % size !== 0)
|
|
24
|
+
size--;
|
|
25
|
+
const exact = `Pass count: ${size}, page: ${firstHit / size} to start at the same hit`;
|
|
26
|
+
if (size * 2 >= COUNT_CAP)
|
|
27
|
+
return `${exact}.`;
|
|
28
|
+
const page = Math.floor(firstHit / COUNT_CAP);
|
|
29
|
+
const start = page * COUNT_CAP;
|
|
30
|
+
return `${exact}, or count: ${COUNT_CAP}, page: ${page} for hits ${start}–${start + COUNT_CAP - 1}, which include hit ${firstHit}.`;
|
|
31
|
+
}
|
|
32
|
+
/** Algolia serves at most this many hits per search, however many match. */
|
|
33
|
+
const HIT_CEILING = 1000;
|
|
34
|
+
/** The content types `tags` accepts. */
|
|
35
|
+
const SEARCH_TAGS = ['story', 'comment', 'poll', 'job', 'ask_hn', 'show_hn', 'front_page'];
|
|
36
|
+
/** The item types a hit's `_tags` can name. `ask_hn`, `show_hn`, and `front_page` are further tags on a story. */
|
|
37
|
+
const HIT_TYPES = ['story', 'comment', 'poll', 'pollopt', 'job'];
|
|
38
|
+
/**
|
|
39
|
+
* The first `_tags` entry that names an item type, or undefined when none does.
|
|
40
|
+
* The first match, not the first entry, so a reordered tag list cannot mistype
|
|
41
|
+
* a hit. A poll option's `story_<id>` tag, where present, names the option
|
|
42
|
+
* itself, so no parent is read from it.
|
|
43
|
+
*/
|
|
44
|
+
function hitType(tags) {
|
|
45
|
+
return tags?.find((tag) => HIT_TYPES.includes(tag));
|
|
46
|
+
}
|
|
11
47
|
/**
|
|
12
48
|
* ISO 8601 calendar forms accepted for a date bound: `YYYY`, `YYYY-MM`,
|
|
13
49
|
* `YYYY-MM-DD`, or `YYYY-MM-DDThh:mm[:ss[.sss]]` with an optional `Z` or
|
|
@@ -46,6 +82,70 @@ const dateBound = () => z
|
|
|
46
82
|
.regex(DATE_BOUND_PATTERN, 'must be an ISO 8601 date or date-time — YYYY, YYYY-MM, YYYY-MM-DD, or YYYY-MM-DDThh:mm[:ss[.sss]] with an optional Z or ±hh:mm offset.')
|
|
47
83
|
.refine(isCalendarValid, 'is not a real calendar date or time — check the month, day, hour, minute, second, and offset ranges.')
|
|
48
84
|
.optional();
|
|
85
|
+
/** A URL's scheme and `www.`, or a bare leading `www.`. Anchored, so it reads a fixed-length prefix at most. */
|
|
86
|
+
const URL_PREFIX = /^(?:https?:\/\/(?:www\.)?|www\.)/i;
|
|
87
|
+
/**
|
|
88
|
+
* Drop the scheme and `www.` from a query that is a URL alone. Algolia requires
|
|
89
|
+
* every token, so `https` and `www` would otherwise have to appear in the
|
|
90
|
+
* submitted URL, and the same page submitted under the other scheme or without
|
|
91
|
+
* `www.` would drop out. A query with whitespace is searched as typed, and so
|
|
92
|
+
* is one the prefix would leave empty.
|
|
93
|
+
*/
|
|
94
|
+
export function stripUrlPrefix(query) {
|
|
95
|
+
if (/\s/.test(query))
|
|
96
|
+
return { query };
|
|
97
|
+
const prefix = URL_PREFIX.exec(query)?.[0];
|
|
98
|
+
if (prefix == null || prefix.length === query.length)
|
|
99
|
+
return { query };
|
|
100
|
+
return { query: query.slice(prefix.length), droppedPrefix: prefix };
|
|
101
|
+
}
|
|
102
|
+
/** The search as run: the filters set, then sort, count, and view with their defaults. */
|
|
103
|
+
const searchCriteria = z
|
|
104
|
+
.object({
|
|
105
|
+
tags: z.enum(SEARCH_TAGS).optional().describe('Content-type filter.'),
|
|
106
|
+
author: z.string().optional().describe('Author filter — the username matched, trimmed.'),
|
|
107
|
+
storyId: z.number().optional().describe('Discussion filter: a story or poll root id.'),
|
|
108
|
+
minPoints: z.number().optional().describe('Minimum score.'),
|
|
109
|
+
dateRange: z
|
|
110
|
+
.object({
|
|
111
|
+
start: z
|
|
112
|
+
.string()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe('Items match only when created strictly after this instant: the start bound in ISO 8601 UTC, rounded down to the whole second.'),
|
|
115
|
+
end: z
|
|
116
|
+
.string()
|
|
117
|
+
.optional()
|
|
118
|
+
.describe('Items match only when created strictly before this instant: the end bound in ISO 8601 UTC, rounded up to the whole second.'),
|
|
119
|
+
})
|
|
120
|
+
.optional()
|
|
121
|
+
.describe('Creation-time window as applied, with only the bounds that were set.'),
|
|
122
|
+
sort: z.enum(['relevance', 'date']).describe('Sort order used.'),
|
|
123
|
+
count: z
|
|
124
|
+
.number()
|
|
125
|
+
.describe('Hits per page sent: count as passed, or 50 when count was above that cap.'),
|
|
126
|
+
view: z.enum(['full', 'compact']).describe('Projection applied to each hit.'),
|
|
127
|
+
})
|
|
128
|
+
.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.');
|
|
129
|
+
/** A whole Unix second as ISO 8601 UTC, e.g. `2024-05-05T08:00:00Z`. */
|
|
130
|
+
const isoSeconds = (seconds) => new Date(seconds * 1000).toISOString().replace('.000Z', 'Z');
|
|
131
|
+
/** One trailer line for the criteria; `author`, the one free-text value, is escaped inside its quotes. */
|
|
132
|
+
function renderCriteria(c) {
|
|
133
|
+
const bounds = c.dateRange && [
|
|
134
|
+
c.dateRange.start != null ? `after ${c.dateRange.start}` : null,
|
|
135
|
+
c.dateRange.end != null ? `before ${c.dateRange.end}` : null,
|
|
136
|
+
];
|
|
137
|
+
const parts = [
|
|
138
|
+
c.tags != null ? `tags: ${c.tags}` : null,
|
|
139
|
+
c.author != null ? `author: "${escapeQuoted(c.author)}"` : null,
|
|
140
|
+
c.storyId != null ? `storyId: ${c.storyId}` : null,
|
|
141
|
+
c.minPoints != null ? `minPoints: ${c.minPoints}` : null,
|
|
142
|
+
bounds ? `dateRange: ${bounds.filter(Boolean).join(', ')}` : null,
|
|
143
|
+
`sort: ${c.sort}`,
|
|
144
|
+
`count: ${c.count}`,
|
|
145
|
+
`view: ${c.view}`,
|
|
146
|
+
];
|
|
147
|
+
return `**criteria:** ${parts.filter(Boolean).join(' | ')}`;
|
|
148
|
+
}
|
|
49
149
|
/** The filters a caller set, in the order notices name them. `sort`, `count`, `page`, and `view` are not filters. */
|
|
50
150
|
function appliedFilters(input) {
|
|
51
151
|
const filters = [];
|
|
@@ -62,25 +162,90 @@ function appliedFilters(input) {
|
|
|
62
162
|
return filters;
|
|
63
163
|
}
|
|
64
164
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
165
|
+
* The page-state sentence. A non-empty page names the next page while more
|
|
166
|
+
* remain, and the last page of a search matching more than Algolia serves names
|
|
167
|
+
* that ceiling. An empty page past the end names the last valid page when
|
|
168
|
+
* Algolia reports one; past the ceiling Algolia answers `nbPages: 0`, so the
|
|
169
|
+
* last page is unknown and the notice resets to page 0. Only a first page with
|
|
170
|
+
* no hits is a zero-match search, which names what to relax. After a count
|
|
171
|
+
* above the cap, a page lever also names the count it counts in.
|
|
69
172
|
*/
|
|
70
|
-
function
|
|
173
|
+
function pageStateSentence(facts) {
|
|
174
|
+
const { shown, page, totalHits, totalPages } = facts;
|
|
175
|
+
const pageLever = (n) => `${facts.overCap ? `count: ${facts.count}, ` : ''}page: ${n}`;
|
|
176
|
+
if (shown > 0) {
|
|
177
|
+
const total = `${facts.totalHitsExact === false ? 'about ' : ''}${totalHits}`;
|
|
178
|
+
if (page + 1 < totalPages) {
|
|
179
|
+
const raiseCount = facts.count < COUNT_CAP ? `, or raise count (max ${COUNT_CAP})` : '';
|
|
180
|
+
return `Showing ${shown} of ${total} hits (page ${page} of ${totalPages}). Pass ${pageLever(page + 1)} for more${raiseCount}.`;
|
|
181
|
+
}
|
|
182
|
+
return totalHits > HIT_CEILING
|
|
183
|
+
? `Page ${page} is the last page Algolia serves: a search returns at most 1,000 of its matches, and this one matches ${total}. Split it into narrower dateRange windows to reach the rest.`
|
|
184
|
+
: undefined;
|
|
185
|
+
}
|
|
71
186
|
if (page > 0) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
187
|
+
if (totalPages === 0) {
|
|
188
|
+
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.`;
|
|
189
|
+
}
|
|
190
|
+
/** With one page, the last page is page 0, so one lever covers both. */
|
|
191
|
+
const lever = totalPages === 1
|
|
192
|
+
? `Pass ${pageLever(0)} for that page.`
|
|
193
|
+
: `Pass ${pageLever(totalPages - 1)} for the last page, or page: 0 to start over.`;
|
|
194
|
+
return `Page ${page} is past the last page of results (${totalHits} hits across ${totalPages} page${totalPages === 1 ? '' : 's'}). ${lever}`;
|
|
195
|
+
}
|
|
196
|
+
const filters = appliedFilters(facts);
|
|
197
|
+
if (facts.query == null) {
|
|
198
|
+
return `No items matched these filters: ${filters.join(', ')}. Relax or change them.`;
|
|
199
|
+
}
|
|
200
|
+
return filters.length
|
|
201
|
+
? `Try broader keywords, or relax these filters: ${filters.join(', ')}.`
|
|
202
|
+
: 'Try broader keywords or different terms.';
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Sentences an input calls for, in input order: query, tags, author, storyId,
|
|
206
|
+
* then count when it was above the per-call cap — said when hits remain past
|
|
207
|
+
* the served page, or when the served page is numbered differently from the
|
|
208
|
+
* one requested.
|
|
209
|
+
*/
|
|
210
|
+
function causeSentences(facts) {
|
|
211
|
+
const emptyFirstPage = facts.shown === 0 && facts.page === 0;
|
|
212
|
+
const sentences = [];
|
|
213
|
+
if (facts.droppedPrefix != null) {
|
|
214
|
+
sentences.push(`The leading \`${facts.droppedPrefix}\` was dropped from the query, so the URL matches submissions under either scheme, with or without www.`);
|
|
215
|
+
if (emptyFirstPage) {
|
|
216
|
+
sentences.push('Submissions often omit #fragments and tracking parameters such as utm_source; search the URL without them.');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (facts.tags === 'front_page' &&
|
|
220
|
+
(emptyFirstPage || facts.dateRange?.start || facts.dateRange?.end)) {
|
|
221
|
+
sentences.push('front_page matches only the stories on the HN front page right now (about 30), never past front pages; for an earlier window, search tags "story" with dateRange and minPoints instead.');
|
|
75
222
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
223
|
+
if (emptyFirstPage && facts.author) {
|
|
224
|
+
sentences.push("author must match the username's exact HN casing; hn_get_user answers user_not_found for a mis-cased name.");
|
|
225
|
+
}
|
|
226
|
+
if (emptyFirstPage && facts.storyId != null) {
|
|
227
|
+
sentences.push("storyId must be a story or poll root id — take it from hits[].storyId, a poll hit's own id (poll hits carry no storyId), or hn_get_thread: the root item's id, or its item.storyId when the root is a comment. A comment id matches nothing.");
|
|
228
|
+
}
|
|
229
|
+
const { overCap } = facts;
|
|
230
|
+
const morePages = facts.shown > 0 && facts.page + 1 < facts.totalPages;
|
|
231
|
+
if (overCap && (morePages || overCap.page > 0)) {
|
|
232
|
+
sentences.push(servedAtCap('count', overCap.count, facts.count));
|
|
233
|
+
if (overCap.page > 0) {
|
|
234
|
+
sentences.push(`Page ${overCap.page} at count ${overCap.count} starts at the same hit as page ${facts.page} at count ${facts.count}, the page served.`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return sentences;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Compose the one `notice` string every search writes. `notice` is last-wins,
|
|
241
|
+
* so each source goes through here: the page-state sentence first, then the
|
|
242
|
+
* cause sentences in input order — query, tags, author, storyId, count. None
|
|
243
|
+
* replaces another. Undefined when nothing applies — the last page of an
|
|
244
|
+
* uncapped, non-empty search.
|
|
245
|
+
*/
|
|
246
|
+
export function composeSearchNotice(facts) {
|
|
247
|
+
const sentences = [pageStateSentence(facts), ...causeSentences(facts)].filter((sentence) => sentence != null);
|
|
248
|
+
return sentences.length > 0 ? sentences.join(' ') : undefined;
|
|
84
249
|
}
|
|
85
250
|
/**
|
|
86
251
|
* Entity-decode a title highlight between its `<em>` markers. A decoded
|
|
@@ -95,9 +260,10 @@ function decodeHighlightTitle(title) {
|
|
|
95
260
|
}
|
|
96
261
|
/**
|
|
97
262
|
* Project Algolia's `_highlightResult` into a flat snippet object: the title
|
|
98
|
-
* snippet, the body snippet (
|
|
99
|
-
*
|
|
100
|
-
* surfaced fields. Returns undefined when
|
|
263
|
+
* snippet, the body snippet (comment_text, else story_text — the order `text`
|
|
264
|
+
* reads them in; a job's job_text is never highlighted), and the deduplicated
|
|
265
|
+
* union of matched words across all surfaced fields. Returns undefined when
|
|
266
|
+
* nothing matched.
|
|
101
267
|
*
|
|
102
268
|
* Titles are not HTML, though some arrive entity-encoded (`<details>`,
|
|
103
269
|
* `"`), so the title snippet is entity-decoded between its `<em>` markers
|
|
@@ -154,6 +320,13 @@ export const searchHn = tool('hn_search_content', {
|
|
|
154
320
|
recovery: 'Drop minPoints to search comments or jobs, or search a scored type (story, poll, ask_hn, show_hn, front_page) to filter by points.',
|
|
155
321
|
severity: 'notice',
|
|
156
322
|
},
|
|
323
|
+
{
|
|
324
|
+
reason: 'count_page_unaligned',
|
|
325
|
+
code: JsonRpcErrorCode.InvalidParams,
|
|
326
|
+
when: 'count was above the per-call cap of 50 with a page whose first hit (page × count) begins no page of 50 hits, so the requested page cannot be served from its start at the cap.',
|
|
327
|
+
recovery: 'Pass a count of 50 or less with page counted in that count; the error names a count and page that start at the same hit and, when that count is under 25, the page of 50 that holds the hit.',
|
|
328
|
+
severity: 'notice',
|
|
329
|
+
},
|
|
157
330
|
{
|
|
158
331
|
reason: 'upstream_rejected',
|
|
159
332
|
code: JsonRpcErrorCode.InvalidParams,
|
|
@@ -200,23 +373,23 @@ export const searchHn = tool('hn_search_content', {
|
|
|
200
373
|
.trim()
|
|
201
374
|
.min(1, 'blank after trimming whitespace — pass one or more search terms, or omit query for a filter-only search.')
|
|
202
375
|
.optional()
|
|
203
|
-
.describe('Search terms. Supports simple keywords — Algolia handles stemming and relevance. Trimmed before searching; blank or whitespace-only input is rejected. Omit for a filter-only search, which needs at least one of tags, author, storyId, minPoints, or a dateRange bound.'),
|
|
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.'),
|
|
204
377
|
tags: z
|
|
205
|
-
.enum(
|
|
378
|
+
.enum(SEARCH_TAGS)
|
|
206
379
|
.optional()
|
|
207
|
-
.describe('Filter results by content type: "story", "comment", "poll", or "job", or the story subsets "ask_hn", "show_hn", and "front_page". Omit to search all types.'),
|
|
380
|
+
.describe('Filter results by content type: "story", "comment", "poll", or "job", or the story subsets "ask_hn", "show_hn", and "front_page". "front_page" is the HN front page right now (about 30 stories); no filter selects stories from past front pages. The nearest proxy is "story" with dateRange and minPoints — without a query, the default relevance sort ranks by points. Omit to search all types, which also returns poll options (type "pollopt"): records with only an id, author, vote count, and date — hn_get_thread reads an option\'s text and poll.'),
|
|
208
381
|
author: z
|
|
209
382
|
.string()
|
|
210
383
|
.trim()
|
|
211
384
|
.min(1, 'blank after trimming whitespace — omit author to search all authors.')
|
|
212
385
|
.optional()
|
|
213
|
-
.describe(`Filter results to a specific author
|
|
386
|
+
.describe(`Filter results to a specific author — useful for finding a user's posts on a topic. Matches the HN username exactly and is case-sensitive, so a re-cased name finds nothing. Trimmed before filtering; omit the field to search all authors rather than passing a blank string.`),
|
|
214
387
|
storyId: z
|
|
215
388
|
.number()
|
|
216
389
|
.int()
|
|
217
390
|
.positive()
|
|
218
391
|
.optional()
|
|
219
|
-
.describe('Restrict results to one discussion: the id of a story or poll root, combined with the other filters. Pair with tags "comment" to search within a thread. Take it from hits[].storyId or the root item
|
|
392
|
+
.describe('Restrict results to one discussion: the id of a story or poll root, combined with the other filters. Pair with tags "comment" to search within a thread. Take it from hits[].storyId, from a poll hit\'s own id (poll hits carry no storyId), or from hn_get_thread — the root item\'s id, or its item.storyId when the root is a comment. A comment id matches nothing.'),
|
|
220
393
|
sort: z
|
|
221
394
|
.enum(['relevance', 'date'])
|
|
222
395
|
.default('relevance')
|
|
@@ -238,10 +411,15 @@ export const searchHn = tool('hn_search_content', {
|
|
|
238
411
|
.number()
|
|
239
412
|
.int()
|
|
240
413
|
.min(1)
|
|
241
|
-
.max(
|
|
414
|
+
.max(COUNT_CEILING)
|
|
242
415
|
.default(30)
|
|
243
|
-
.describe(
|
|
244
|
-
page: z
|
|
416
|
+
.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.`),
|
|
417
|
+
page: z
|
|
418
|
+
.number()
|
|
419
|
+
.int()
|
|
420
|
+
.min(0)
|
|
421
|
+
.default(0)
|
|
422
|
+
.describe('Page number for pagination (0-indexed), counted in pages of count results.'),
|
|
245
423
|
view: z
|
|
246
424
|
.enum(['full', 'compact'])
|
|
247
425
|
.default('full')
|
|
@@ -252,6 +430,10 @@ export const searchHn = tool('hn_search_content', {
|
|
|
252
430
|
.array(z
|
|
253
431
|
.object({
|
|
254
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.'),
|
|
255
437
|
title: z
|
|
256
438
|
.string()
|
|
257
439
|
.optional()
|
|
@@ -272,11 +454,11 @@ export const searchHn = tool('hn_search_content', {
|
|
|
272
454
|
storyId: z
|
|
273
455
|
.number()
|
|
274
456
|
.optional()
|
|
275
|
-
.describe(
|
|
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."),
|
|
276
458
|
text: z
|
|
277
459
|
.string()
|
|
278
460
|
.optional()
|
|
279
|
-
.describe('
|
|
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."),
|
|
280
462
|
highlights: z
|
|
281
463
|
.object({
|
|
282
464
|
title: z
|
|
@@ -294,29 +476,48 @@ export const searchHn = tool('hn_search_content', {
|
|
|
294
476
|
.optional()
|
|
295
477
|
.describe('Algolia per-field highlight metadata showing which terms matched and where. Absent when no fields produced a match.'),
|
|
296
478
|
})
|
|
297
|
-
.describe('A single Algolia search hit (story, comment, poll, or job).'))
|
|
479
|
+
.describe('A single Algolia search hit (story, comment, poll, poll option, or job).'))
|
|
298
480
|
.describe('Search results ranked by sort order.'),
|
|
299
481
|
query: z
|
|
300
482
|
.string()
|
|
301
483
|
.optional()
|
|
302
|
-
.describe('The query that was searched. Absent for a filter-only search.'),
|
|
484
|
+
.describe('The query that was searched — for a URL query, without its leading scheme and www. Absent for a filter-only search.'),
|
|
303
485
|
}),
|
|
304
486
|
enrichment: {
|
|
305
|
-
|
|
306
|
-
|
|
487
|
+
criteria: searchCriteria,
|
|
488
|
+
totalHits: z
|
|
489
|
+
.number()
|
|
490
|
+
.describe('Matching results across all pages, as Algolia counts them. An estimate when totalHitsExact is false — broad searches can be off by orders of magnitude — and paging reaches only the first 1,000 (see reachableHits).'),
|
|
491
|
+
totalHitsExact: z
|
|
492
|
+
.boolean()
|
|
493
|
+
.optional()
|
|
494
|
+
.describe('True when totalHits is an exact count, false when it is an estimate, as it is for keyword searches and broad filter-only searches. Absent when Algolia does not say, and on a page past the 1,000-hit ceiling, where totalHits reads 0.'),
|
|
495
|
+
page: z
|
|
496
|
+
.number()
|
|
497
|
+
.describe('Current page number (0-indexed), in pages of criteria.count hits. When count was above the per-call cap, the page of 50 where the requested page starts.'),
|
|
307
498
|
totalPages: z
|
|
308
499
|
.number()
|
|
309
|
-
.describe('Number of pages Algolia will actually serve for this query. Not derived from totalHits — broad queries report a totalHits far larger than the reachable page range, so paginate against this value.'),
|
|
500
|
+
.describe('Number of pages of criteria.count hits Algolia will actually serve for this query, covering at most the first 1,000 hits. Not derived from totalHits — broad queries report a totalHits far larger than the reachable page range, so paginate against this value.'),
|
|
501
|
+
reachableHits: z
|
|
502
|
+
.number()
|
|
503
|
+
.optional()
|
|
504
|
+
.describe('How many of totalHits paging can reach: Algolia serves at most 1,000 hits per search. Present only when totalHits exceeds that; split the search into narrower dateRange windows to reach the rest.'),
|
|
310
505
|
truncated: z
|
|
311
506
|
.boolean()
|
|
312
507
|
.optional()
|
|
313
508
|
.describe('True when more pages remain after this one (page + 1 < totalPages). Absent on the last page Algolia serves.'),
|
|
314
509
|
shown: z.number().optional().describe('Number of hits returned.'),
|
|
315
|
-
cap: z
|
|
510
|
+
cap: z
|
|
511
|
+
.number()
|
|
512
|
+
.optional()
|
|
513
|
+
.describe(`The count value served: as passed, or ${COUNT_CAP} when count was above that cap.`),
|
|
316
514
|
notice: z
|
|
317
515
|
.string()
|
|
318
516
|
.optional()
|
|
319
|
-
.describe('Agent guidance: the next page to request while more pages remain, the last valid page when the requested page is past the end, or the filters to relax when a first page comes back empty.
|
|
517
|
+
.describe('Agent guidance, one string. It opens with the page state: the next page to request while more pages remain, the 1,000-hit ceiling on the last page of a search that matches more, the last valid page when the requested page is past the end, or the filters to relax when a first page comes back empty. Notes on the inputs follow it. Absent when none applies.'),
|
|
518
|
+
},
|
|
519
|
+
enrichmentTrailer: {
|
|
520
|
+
criteria: { render: renderCriteria },
|
|
320
521
|
},
|
|
321
522
|
async handler(input, ctx) {
|
|
322
523
|
const { dateRange } = input;
|
|
@@ -336,16 +537,32 @@ export const searchHn = tool('hn_search_content', {
|
|
|
336
537
|
if (input.query == null && filters.length === 0) {
|
|
337
538
|
throw ctx.fail('missing_query_or_filter', 'No query and no filter were supplied, so there is nothing to search by.');
|
|
338
539
|
}
|
|
540
|
+
/** Above the cap, the search runs at the cap from the hit where the requested page starts. */
|
|
541
|
+
const overCap = input.count > COUNT_CAP ? { count: input.count, page: input.page } : undefined;
|
|
542
|
+
const firstHit = input.page * input.count;
|
|
543
|
+
if (overCap && firstHit % COUNT_CAP !== 0) {
|
|
544
|
+
throw ctx.fail('count_page_unaligned', `count ${input.count} is above the per-call cap of ${COUNT_CAP}, and page ${input.page} at count ${input.count} starts after ${firstHit} hits, where no page of ${COUNT_CAP} hits begins.`, { count: input.count, page: input.page, recovery: { hint: unalignedHint(firstHit) } });
|
|
545
|
+
}
|
|
546
|
+
const searched = input.query == null ? undefined : stripUrlPrefix(input.query);
|
|
547
|
+
const params = {
|
|
548
|
+
...input,
|
|
549
|
+
...(searched && { query: searched.query }),
|
|
550
|
+
...(overCap && { count: COUNT_CAP, page: firstHit / COUNT_CAP }),
|
|
551
|
+
};
|
|
339
552
|
const hn = getHnService();
|
|
340
|
-
const result = await hn.search(
|
|
553
|
+
const result = await hn.search(params, ctx);
|
|
341
554
|
const includeBody = input.view === 'full';
|
|
342
555
|
const hits = result.hits.map((hit) => {
|
|
343
|
-
const rawText = includeBody
|
|
556
|
+
const rawText = includeBody
|
|
557
|
+
? (hit.comment_text ?? hit.story_text ?? hit.job_text)
|
|
558
|
+
: undefined;
|
|
344
559
|
const url = normalizeUrl(hit.url);
|
|
345
560
|
const domain = extractDomain(url);
|
|
346
561
|
const highlights = extractHighlights(hit, includeBody);
|
|
562
|
+
const type = hitType(hit._tags);
|
|
347
563
|
return {
|
|
348
564
|
id: Number(hit.objectID),
|
|
565
|
+
...(type && { type }),
|
|
349
566
|
title: hit.title != null ? decodeHtmlEntities(hit.title) : undefined,
|
|
350
567
|
url,
|
|
351
568
|
domain,
|
|
@@ -360,26 +577,57 @@ export const searchHn = tool('hn_search_content', {
|
|
|
360
577
|
};
|
|
361
578
|
});
|
|
362
579
|
ctx.log.info('Search completed', {
|
|
363
|
-
query:
|
|
580
|
+
query: params.query,
|
|
364
581
|
hits: hits.length,
|
|
365
582
|
totalHits: result.nbHits,
|
|
366
583
|
});
|
|
367
584
|
const { nbHits: totalHits, nbPages: totalPages, page } = result;
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
585
|
+
const shown = hits.length;
|
|
586
|
+
/** Past the ceiling Algolia flags its 0 as exact, though it says nothing about the search. */
|
|
587
|
+
const pastCeiling = shown === 0 && page > 0 && totalPages === 0;
|
|
588
|
+
const totalHitsExact = pastCeiling
|
|
589
|
+
? undefined
|
|
590
|
+
: (result.exhaustive?.nbHits ?? result.exhaustiveNbHits);
|
|
591
|
+
const bounds = params.dateRange ? dateRangeToEpochSeconds(params.dateRange) : {};
|
|
592
|
+
ctx.enrich({
|
|
593
|
+
criteria: {
|
|
594
|
+
...(params.tags != null && { tags: params.tags }),
|
|
595
|
+
...(params.author != null && { author: params.author }),
|
|
596
|
+
...(params.storyId != null && { storyId: params.storyId }),
|
|
597
|
+
...(params.minPoints != null && { minPoints: params.minPoints }),
|
|
598
|
+
...((bounds.start != null || bounds.end != null) && {
|
|
599
|
+
dateRange: {
|
|
600
|
+
...(bounds.start != null && { start: isoSeconds(bounds.start) }),
|
|
601
|
+
...(bounds.end != null && { end: isoSeconds(bounds.end) }),
|
|
602
|
+
},
|
|
603
|
+
}),
|
|
604
|
+
sort: params.sort,
|
|
605
|
+
count: params.count,
|
|
606
|
+
view: params.view,
|
|
607
|
+
},
|
|
608
|
+
totalHits,
|
|
609
|
+
...(totalHitsExact != null && { totalHitsExact }),
|
|
610
|
+
page,
|
|
611
|
+
totalPages,
|
|
612
|
+
...(totalHits > HIT_CEILING && { reachableHits: HIT_CEILING }),
|
|
613
|
+
});
|
|
614
|
+
if (shown > 0 && page + 1 < totalPages)
|
|
615
|
+
ctx.enrich.truncated({ shown, cap: params.count });
|
|
616
|
+
const notice = composeSearchNotice({
|
|
617
|
+
...params,
|
|
618
|
+
droppedPrefix: searched?.droppedPrefix,
|
|
619
|
+
shown,
|
|
620
|
+
page,
|
|
621
|
+
totalHits,
|
|
622
|
+
totalHitsExact,
|
|
623
|
+
totalPages,
|
|
624
|
+
overCap,
|
|
625
|
+
});
|
|
626
|
+
if (notice)
|
|
627
|
+
ctx.enrich.notice(notice);
|
|
380
628
|
return {
|
|
381
629
|
hits,
|
|
382
|
-
...(
|
|
630
|
+
...(params.query != null && { query: params.query }),
|
|
383
631
|
};
|
|
384
632
|
},
|
|
385
633
|
format: (result) => {
|
|
@@ -387,7 +635,7 @@ export const searchHn = tool('hn_search_content', {
|
|
|
387
635
|
return [
|
|
388
636
|
{
|
|
389
637
|
type: 'text',
|
|
390
|
-
text: result.query != null ? `"${result.query}" — no results.` : 'No results.',
|
|
638
|
+
text: result.query != null ? `"${escapeQuoted(result.query)}" — no results.` : 'No results.',
|
|
391
639
|
},
|
|
392
640
|
];
|
|
393
641
|
}
|
|
@@ -409,6 +657,21 @@ export const searchHn = tool('hn_search_content', {
|
|
|
409
657
|
return parts.length ? `\n\n${quoteBody(`match — ${parts.join(' | ')}`)}` : '';
|
|
410
658
|
};
|
|
411
659
|
const lines = result.hits.map((h) => {
|
|
660
|
+
const typeToken = h.type ? `type:${h.type}` : null;
|
|
661
|
+
const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
|
|
662
|
+
if (h.type === 'pollopt') {
|
|
663
|
+
// A poll option's search record carries no text and no poll; hn_get_thread has both.
|
|
664
|
+
const meta = [
|
|
665
|
+
`id:${h.id}`,
|
|
666
|
+
typeToken,
|
|
667
|
+
h.author,
|
|
668
|
+
h.points != null ? `${h.points} pts` : null,
|
|
669
|
+
h.createdAt,
|
|
670
|
+
]
|
|
671
|
+
.filter(Boolean)
|
|
672
|
+
.join(' | ');
|
|
673
|
+
return `### Poll option by ${h.author}\n${meta}\nOption text and its poll: hn_get_thread with itemId ${h.id}${hlLine}`;
|
|
674
|
+
}
|
|
412
675
|
if (h.title) {
|
|
413
676
|
// Story result — Algolia returns storyId === id for stories, so suppress the parent ref unless it actually differs or a parent title is set.
|
|
414
677
|
const parentRef = (h.storyId != null && h.storyId !== h.id) || h.storyTitle
|
|
@@ -416,6 +679,7 @@ export const searchHn = tool('hn_search_content', {
|
|
|
416
679
|
: '';
|
|
417
680
|
const meta = [
|
|
418
681
|
`id:${h.id}`,
|
|
682
|
+
typeToken,
|
|
419
683
|
h.author,
|
|
420
684
|
h.points != null ? `${h.points} pts` : null,
|
|
421
685
|
h.numComments != null ? `${h.numComments} comments` : null,
|
|
@@ -426,12 +690,12 @@ export const searchHn = tool('hn_search_content', {
|
|
|
426
690
|
const domain = h.domain ? ` (${h.domain})` : '';
|
|
427
691
|
const url = h.url ? `\n${h.url}` : '';
|
|
428
692
|
const text = h.text ? `\n${quoteBody(h.text)}` : '';
|
|
429
|
-
const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
|
|
430
693
|
return `### ${escapeInline(h.title)}${domain}\n${meta}${parentRef}${url}${text}${hlLine}`;
|
|
431
694
|
}
|
|
432
695
|
// Comment result — parent context in heading.
|
|
433
696
|
const meta = [
|
|
434
697
|
`id:${h.id}`,
|
|
698
|
+
typeToken,
|
|
435
699
|
h.author,
|
|
436
700
|
h.points != null ? `${h.points} pts` : null,
|
|
437
701
|
h.createdAt,
|
|
@@ -439,11 +703,10 @@ export const searchHn = tool('hn_search_content', {
|
|
|
439
703
|
.filter(Boolean)
|
|
440
704
|
.join(' | ');
|
|
441
705
|
const text = h.text ? `\n${quoteBody(h.text)}` : '';
|
|
442
|
-
const hlLine = h.highlights ? renderHighlights(h.highlights) : '';
|
|
443
706
|
return `### Comment on "${escapeQuoted(h.storyTitle ?? 'unknown')}" (story id:${h.storyId ?? '?'})\n${meta}${text}${hlLine}`;
|
|
444
707
|
});
|
|
445
708
|
const header = result.query != null
|
|
446
|
-
? `## "${result.query}" — search results`
|
|
709
|
+
? `## "${escapeQuoted(result.query)}" — search results`
|
|
447
710
|
: '## Search results (filters only)';
|
|
448
711
|
return [{ type: 'text', text: `${header}\n\n${lines.join('\n\n')}` }];
|
|
449
712
|
},
|