@cyanheads/hn-mcp-server 0.5.17 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/AGENTS.md +5 -3
  2. package/CLAUDE.md +5 -3
  3. package/README.md +21 -12
  4. package/changelog/0.5.x/0.5.18.md +24 -0
  5. package/changelog/0.5.x/0.5.19.md +19 -0
  6. package/dist/index.js +1 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts +2 -1
  9. package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts.map +1 -1
  10. package/dist/mcp-server/tools/definitions/get-stories.tool.js +48 -14
  11. package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
  12. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +30 -2
  13. package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
  14. package/dist/mcp-server/tools/definitions/get-thread.tool.js +513 -161
  15. package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
  16. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +4 -1
  17. package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
  18. package/dist/mcp-server/tools/definitions/get-user.tool.js +55 -22
  19. package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
  20. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +24 -3
  21. package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
  22. package/dist/mcp-server/tools/definitions/search-content.tool.js +172 -46
  23. package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
  24. package/dist/services/hn/hn-service.d.ts +69 -10
  25. package/dist/services/hn/hn-service.d.ts.map +1 -1
  26. package/dist/services/hn/hn-service.js +101 -23
  27. package/dist/services/hn/hn-service.js.map +1 -1
  28. package/package.json +2 -2
  29. package/server.json +3 -3
@@ -1,12 +1,359 @@
1
1
  /**
2
- * @fileoverview Fetch an HN item and its comment tree via ranked BFS traversal.
2
+ * @fileoverview Fetch an HN item and its comment tree via ranked BFS traversal,
3
+ * resumable through a cursor that carries the unvisited frontier.
3
4
  * @module mcp-server/tools/definitions/get-thread.tool
4
5
  */
5
6
  import { tool, z } from '@cyanheads/mcp-ts-core';
6
- import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
7
- import { getHnService, normalizeUrl, stripHtml } from '../../../services/hn/hn-service.js';
7
+ import { invalidParams, JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
8
+ import { decodeCursor, encodeCursor } from '@cyanheads/mcp-ts-core/utils';
9
+ import { getHnService, isRateLimited, normalizeUrl, stripHtml, } from '../../../services/hn/hn-service.js';
10
+ /** Largest `maxComments` accepts, mirrored in the input schema. */
11
+ const MAX_COMMENTS = 200;
12
+ /** Largest `depth` accepts, mirrored in the input schema. */
13
+ const MAX_DEPTH = 10;
14
+ /**
15
+ * Byte budget for the rendered `format()` text. Checked between comments, so a
16
+ * page never ends mid-comment, and the first comment on a page is always kept.
17
+ */
18
+ const RESPONSE_BUDGET_BYTES = 64_000;
19
+ /** Most comments one batch fetches — bounds the fetches a size cut leaves unused. */
20
+ const FETCH_CHUNK = 25;
21
+ /**
22
+ * Most frontier items one call examines. Dead, deleted, and missing items cost
23
+ * a fetch without filling the page, so without a ceiling a frontier made of
24
+ * them would run unbounded.
25
+ */
26
+ const MAX_ITEMS_EXAMINED = 1_000;
27
+ // ---------------------------------------------------------------------------
28
+ // Schemas
29
+ // ---------------------------------------------------------------------------
30
+ const OptionSchema = z
31
+ .object({
32
+ id: z.number().describe('Poll option ID.'),
33
+ text: z.string().optional().describe('Option text (HTML stripped). Absent once deleted.'),
34
+ score: z.number().optional().describe('Votes for this option.'),
35
+ deleted: z
36
+ .literal(true)
37
+ .optional()
38
+ .describe('Present and `true` when the option was deleted. Omitted otherwise.'),
39
+ dead: z
40
+ .literal(true)
41
+ .optional()
42
+ .describe('Present and `true` when the option is dead (flagged or killed). Omitted otherwise.'),
43
+ })
44
+ .describe('One poll option.');
45
+ const ItemSchema = z
46
+ .object({
47
+ id: z.number().describe('Item ID.'),
48
+ type: z.string().describe('Item type: story | comment | job | poll | pollopt.'),
49
+ by: z.string().optional().describe('Author username.'),
50
+ time: z.number().optional().describe('Unix timestamp.'),
51
+ title: z.string().optional().describe('Story/job/poll title.'),
52
+ url: z.string().optional().describe('External link URL.'),
53
+ text: z.string().optional().describe('Body text (HTML stripped).'),
54
+ score: z.number().optional().describe('Upvote count.'),
55
+ descendants: z.number().optional().describe('Total comment count.'),
56
+ parent: z
57
+ .number()
58
+ .optional()
59
+ .describe('For a comment root, the ID of the story or comment it replies to — pass it as itemId for the context above.'),
60
+ poll: z.number().optional().describe('For a poll-option root, the ID of its poll.'),
61
+ parts: z.array(z.number()).optional().describe('For a poll root, its option IDs in HN order.'),
62
+ deleted: z
63
+ .literal(true)
64
+ .optional()
65
+ .describe('Present and `true` when HN reports the root deleted; its author and text are gone, but its replies are still walked. Omitted otherwise.'),
66
+ dead: z
67
+ .literal(true)
68
+ .optional()
69
+ .describe('Present and `true` when the root is dead (flagged or killed); its replies are still walked. Omitted otherwise.'),
70
+ options: z
71
+ .array(OptionSchema)
72
+ .optional()
73
+ .describe('For a poll root, its options with text and votes in parts order, resolved at every depth including 0. Deleted and dead options are kept and marked; an option whose fetch failed is listed in failedIds instead. Options do not count toward maxComments or totalLoaded.'),
74
+ })
75
+ .describe('The root item: a story, comment, job, poll, or poll option.');
76
+ const CommentSchema = z
77
+ .object({
78
+ id: z.number().describe('Comment ID.'),
79
+ by: z.string().optional().describe('Author username.'),
80
+ time: z.number().optional().describe('Unix timestamp.'),
81
+ text: z.string().optional().describe('Comment text (HTML stripped).'),
82
+ depth: z.number().describe('Nesting level (0 = direct reply to root).'),
83
+ parentId: z.number().describe('Parent item ID.'),
84
+ childCount: z
85
+ .number()
86
+ .describe('Number of direct child comments (may exceed what was resolved).'),
87
+ isOp: z
88
+ .literal(true)
89
+ .optional()
90
+ .describe('Present and `true` when the comment author matches the root item author (OP replying within their own thread). Omitted otherwise — including when either author is missing. Most threads carry no OP replies, so absence is the common case; treat missing as "not OP" rather than unknown.'),
91
+ })
92
+ .describe('A single comment in the thread with its tree position.');
93
+ // ---------------------------------------------------------------------------
94
+ // Rendering — format() and the size budget share these, so the budget measures
95
+ // exactly the text format() produces.
96
+ // ---------------------------------------------------------------------------
97
+ const TYPE_LABELS = {
98
+ story: 'Story',
99
+ comment: 'Comment',
100
+ job: 'Job',
101
+ poll: 'Poll',
102
+ pollopt: 'Poll option',
103
+ };
104
+ /** Separator format() places between the root block and the first comment. */
105
+ const COMMENTS_DIVIDER = '\n---\n';
106
+ const utf8 = new TextEncoder();
107
+ const byteLength = (text) => utf8.encode(text).byteLength;
108
+ /** Heading that names the root's type, state, and — for a poll option — its poll. */
109
+ function rootHeading(item) {
110
+ const state = item.deleted ? '[deleted] ' : item.dead ? '[dead] ' : '';
111
+ if (item.title)
112
+ return `${state}${item.title}`;
113
+ const label = TYPE_LABELS[item.type] ?? item.type;
114
+ const author = item.deleted ? '' : ` by ${item.by ?? 'unknown'}`;
115
+ const poll = item.poll != null ? ` on poll ${item.poll}` : '';
116
+ return `${state}${label}${author}${poll}`;
117
+ }
118
+ function renderOption(option) {
119
+ const meta = [
120
+ `id:${option.id}`,
121
+ option.deleted ? '[deleted]' : null,
122
+ option.dead ? '[dead]' : null,
123
+ option.score != null ? `${option.score} pts` : null,
124
+ ]
125
+ .filter(Boolean)
126
+ .join(' | ');
127
+ return `- ${meta}${option.text ? `: ${option.text}` : ''}`;
128
+ }
129
+ function renderRoot(item) {
130
+ const date = item.time
131
+ ? `${new Date(item.time * 1000).toISOString().slice(0, 10)} (t:${item.time})`
132
+ : '';
133
+ const meta = [
134
+ `id:${item.id}`,
135
+ `type:${item.type}`,
136
+ item.parent != null ? `parent:${item.parent}` : null,
137
+ item.poll != null ? `poll:${item.poll}` : null,
138
+ item.parts?.length ? `parts:${item.parts.join(',')}` : null,
139
+ item.score != null ? `${item.score} pts` : null,
140
+ item.by ? `by ${item.by}` : null,
141
+ item.descendants != null ? `${item.descendants} comments` : null,
142
+ date,
143
+ ]
144
+ .filter(Boolean)
145
+ .join(' | ');
146
+ const lines = [`## ${rootHeading(item)}\n${meta}`];
147
+ if (item.url)
148
+ lines.push(item.url);
149
+ if (item.text)
150
+ lines.push(item.text);
151
+ if (item.options?.length) {
152
+ lines.push(`**Options:**\n${item.options.map(renderOption).join('\n')}`);
153
+ }
154
+ return lines.join('\n');
155
+ }
156
+ function renderComment(c) {
157
+ // Cap visual indent at 10 levels — the depth value itself is rendered explicitly below.
158
+ const indent = ' '.repeat(Math.min(c.depth, 10));
159
+ const author = c.by ? (c.isOp ? `${c.by} (OP)` : c.by) : '[deleted]';
160
+ const date = c.time
161
+ ? `${new Date(c.time * 1000).toISOString().slice(0, 16).replace('T', ' ')} (t:${c.time})`
162
+ : '';
163
+ const replies = c.childCount > 0 ? ` | ${c.childCount} replies` : '';
164
+ const opTag = c.isOp ? ' | isOp:true' : '';
165
+ const header = `${indent}**${author}** (id:${c.id} | depth:${c.depth} | parent:${c.parentId}${opTag}${replies} | ${date})`;
166
+ return c.text ? `${header}\n${indent}${c.text.replace(/\n/g, `\n${indent}`)}` : header;
167
+ }
168
+ function renderThread(item, comments) {
169
+ const parts = [renderRoot(item)];
170
+ if (comments.length > 0)
171
+ parts.push(COMMENTS_DIVIDER, ...comments.map(renderComment));
172
+ return parts.join('\n');
173
+ }
174
+ const CursorStateSchema = z
175
+ .object({
176
+ offset: z.number().int().min(0),
177
+ limit: z.number().int().min(1),
178
+ itemId: z.number().int(),
179
+ depth: z.number().int().min(1).max(MAX_DEPTH),
180
+ /** Each group is `[level, parentId, ...ids]`. */
181
+ frontier: z.array(z.array(z.number().int().min(0)).min(3)).min(1),
182
+ depthCut: z.literal(true).optional(),
183
+ })
184
+ .refine((state) => state.frontier.every(([level = state.depth]) => level < state.depth));
185
+ function encodeThreadCursor(state) {
186
+ const groups = [];
187
+ let group;
188
+ for (const { id, level, parentId } of state.frontier) {
189
+ if (group?.[0] === level && group[1] === parentId) {
190
+ group.push(id);
191
+ }
192
+ else {
193
+ group = [level, parentId, id];
194
+ groups.push(group);
195
+ }
196
+ }
197
+ return encodeCursor({
198
+ offset: state.offset,
199
+ limit: state.limit,
200
+ itemId: state.itemId,
201
+ depth: state.depth,
202
+ frontier: groups,
203
+ ...(state.depthCut && { depthCut: true }),
204
+ });
205
+ }
206
+ function malformedCursor(cause) {
207
+ return invalidParams('cursor is not one hn_get_thread issued, or it was altered.', {
208
+ recovery: {
209
+ hint: 'Pass nextCursor exactly as a previous hn_get_thread call returned it, or omit cursor to start the thread from the top.',
210
+ },
211
+ }, { cause });
212
+ }
213
+ /** Decode and validate a cursor; anything this tool did not issue is an input error. */
214
+ function readCursor(cursor, ctx) {
215
+ let decoded;
216
+ try {
217
+ decoded = decodeCursor(cursor, ctx);
218
+ }
219
+ catch (error) {
220
+ throw malformedCursor(error);
221
+ }
222
+ const parsed = CursorStateSchema.safeParse(decoded);
223
+ if (!parsed.success)
224
+ throw malformedCursor(parsed.error);
225
+ const { offset, itemId, depth, frontier, depthCut } = parsed.data;
226
+ return {
227
+ offset,
228
+ itemId,
229
+ depth,
230
+ depthCut: depthCut === true,
231
+ frontier: frontier.flatMap(([level = 0, parentId = 0, ...ids]) => ids.map((id) => ({ id, level, parentId }))),
232
+ };
233
+ }
234
+ /**
235
+ * Ranked BFS over the frontier: every comment at one depth before any at the
236
+ * next, and HN's kids order within each parent. Fetches in chunks no larger
237
+ * than the comments still wanted, so filtered and failed items never push a
238
+ * later sibling out of the page — anything unvisited stays in `rest`.
239
+ *
240
+ * Stops on `maxComments`, the item ceiling, the byte budget, or a rate limit.
241
+ * After a rate limit nothing more is fetched: every level below would pay its
242
+ * own retry ladder against a throttled upstream.
243
+ */
244
+ async function walkThread(hn, ctx, args) {
245
+ const levels = Array.from({ length: args.depth }, () => []);
246
+ for (const entry of args.seed)
247
+ levels[entry.level]?.push(entry);
248
+ const comments = [];
249
+ const failed = [];
250
+ let { rateLimit, usedBytes } = args;
251
+ let omittedDeleted = 0;
252
+ let omittedDead = 0;
253
+ let depthCut = false;
254
+ let examined = 0;
255
+ let stop;
256
+ walk: for (const [level, queue] of levels.entries()) {
257
+ while (queue.length > 0) {
258
+ if (rateLimit)
259
+ stop = 'rate_limited';
260
+ else if (comments.length >= args.maxComments)
261
+ stop = 'count';
262
+ else if (examined >= MAX_ITEMS_EXAMINED)
263
+ stop = 'examined';
264
+ if (stop)
265
+ break walk;
266
+ const batch = queue.slice(0, Math.min(args.maxComments - comments.length, FETCH_CHUNK, MAX_ITEMS_EXAMINED - examined));
267
+ examined += batch.length;
268
+ const slots = await hn.fetchItems(batch.map((entry) => entry.id), ctx);
269
+ let visited = 0;
270
+ for (const [i, entry] of batch.entries()) {
271
+ const slot = slots[i];
272
+ if (!slot)
273
+ break;
274
+ if (slot.kind === 'item' && !slot.item.deleted && !slot.item.dead) {
275
+ const c = slot.item;
276
+ const comment = {
277
+ id: c.id,
278
+ by: c.by,
279
+ time: c.time,
280
+ text: c.text ? stripHtml(c.text) : undefined,
281
+ depth: level,
282
+ parentId: entry.parentId,
283
+ childCount: c.kids?.length ?? 0,
284
+ ...(c.by != null && c.by === args.rootBy && { isOp: true }),
285
+ };
286
+ const bytes = byteLength(renderComment(comment)) +
287
+ 1 +
288
+ (comments.length === 0 ? byteLength(COMMENTS_DIVIDER) + 1 : 0);
289
+ if (comments.length > 0 && usedBytes + bytes > RESPONSE_BUDGET_BYTES) {
290
+ stop = 'size';
291
+ break;
292
+ }
293
+ comments.push(comment);
294
+ usedBytes += bytes;
295
+ if (c.kids?.length) {
296
+ const next = levels[level + 1];
297
+ if (next)
298
+ next.push(...c.kids.map((id) => ({ id, level: level + 1, parentId: c.id })));
299
+ else
300
+ depthCut = true;
301
+ }
302
+ }
303
+ else if (slot.kind === 'failed') {
304
+ failed.push(entry);
305
+ if (isRateLimited(slot.error))
306
+ rateLimit ??= slot.error;
307
+ }
308
+ else if (slot.kind === 'item') {
309
+ if (slot.item.deleted)
310
+ omittedDeleted++;
311
+ else
312
+ omittedDead++;
313
+ }
314
+ visited++;
315
+ }
316
+ queue.splice(0, visited);
317
+ if (stop)
318
+ break walk;
319
+ }
320
+ }
321
+ /** A rate limit on the last batch still leaves its failed entries to resume. */
322
+ if (!stop && rateLimit && failed.length > 0)
323
+ stop = 'rate_limited';
324
+ return {
325
+ comments,
326
+ depthCut,
327
+ failed,
328
+ omittedDead,
329
+ omittedDeleted,
330
+ rateLimit,
331
+ rest: levels.flat(),
332
+ stop,
333
+ };
334
+ }
335
+ function projectOption(option) {
336
+ return {
337
+ id: option.id,
338
+ ...(option.text != null && { text: stripHtml(option.text) }),
339
+ ...(option.score != null && { score: option.score }),
340
+ ...(option.deleted && { deleted: true }),
341
+ ...(option.dead && { dead: true }),
342
+ };
343
+ }
344
+ /** A 429's `Retry-After`, worded for a notice. */
345
+ function waitFor(rateLimit) {
346
+ const retryAfter = rateLimit?.data?.retryAfter;
347
+ if (typeof retryAfter !== 'string')
348
+ return 'several seconds';
349
+ return /^\d+$/.test(retryAfter) ? `${retryAfter} seconds` : `until ${retryAfter}`;
350
+ }
351
+ const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many);
352
+ // ---------------------------------------------------------------------------
353
+ // Tool
354
+ // ---------------------------------------------------------------------------
8
355
  export const getThread = tool('hn_get_thread', {
9
- description: 'Get an item and its comment tree as a threaded discussion, with child comments resolved recursively. Use depth 0 for an item-only lookup.',
356
+ description: 'Get an item and its comment tree as a threaded discussion, with child comments resolved recursively. Use depth 0 for an item-only lookup. A long thread comes back in pages: pass the returned nextCursor as cursor to continue.',
10
357
  annotations: { readOnlyHint: true },
11
358
  errors: [
12
359
  {
@@ -16,6 +363,13 @@ export const getThread = tool('hn_get_thread', {
16
363
  recovery: 'Verify the itemId via hn_search_content or a feed listing from hn_get_stories.',
17
364
  severity: 'notice',
18
365
  },
366
+ {
367
+ reason: 'invalid_cursor',
368
+ code: JsonRpcErrorCode.InvalidParams,
369
+ when: 'The cursor was issued for a different itemId than the one passed with it.',
370
+ recovery: 'Pass the cursor with the same itemId it was returned for, or omit cursor to start this item from the top.',
371
+ severity: 'notice',
372
+ },
19
373
  {
20
374
  reason: 'upstream_rejected',
21
375
  code: JsonRpcErrorCode.InvalidParams,
@@ -27,7 +381,7 @@ export const getThread = tool('hn_get_thread', {
27
381
  reason: 'upstream_rate_limited',
28
382
  code: JsonRpcErrorCode.RateLimited,
29
383
  when: 'The HN API answered with HTTP 429.',
30
- recovery: 'Wait several seconds before retrying, and call this tool less often.',
384
+ recovery: 'Wait the interval in retryAfter when the error carries one, otherwise several seconds, then retry and call this tool less often.',
31
385
  retryable: true,
32
386
  thrownBy: 'service',
33
387
  },
@@ -57,74 +411,74 @@ export const getThread = tool('hn_get_thread', {
57
411
  },
58
412
  ],
59
413
  input: z.object({
60
- itemId: z.number().int().describe('ID of the story, comment, or poll to fetch the thread for.'),
414
+ itemId: z
415
+ .number()
416
+ .int()
417
+ .describe('ID of the story, comment, job, poll, or poll option to fetch the thread for.'),
61
418
  depth: z
62
419
  .number()
63
420
  .int()
64
421
  .min(0)
65
- .max(10)
422
+ .max(MAX_DEPTH)
66
423
  .default(3)
67
- .describe(`How many levels of replies to resolve. 0 = just the item, no comments. 1 = direct replies only. Popular stories often have more top-level comments than maxComments — to see nesting, raise maxComments together with depth, or call again with a specific comment's itemId to drill into a subtree.`),
424
+ .describe(`How many levels of replies to resolve. 0 = just the item, no comments. 1 = direct replies only. Replies below this depth are never fetched — to read them, raise depth or call again with a specific comment's itemId to drill into its subtree. Ignored when cursor is set: the depth the cursor was issued with applies.`),
68
425
  maxComments: z
69
426
  .number()
70
427
  .int()
71
428
  .min(1)
72
- .max(200)
429
+ .max(MAX_COMMENTS)
73
430
  .default(50)
74
- .describe(`Maximum total comments to include across all depth levels. Highest-ranked top-level comments resolve first; replies fill in only after the level above is exhausted.`),
431
+ .describe(`Maximum comments in one response, across all depth levels. Highest-ranked top-level comments resolve first; replies fill in only after the level above is exhausted. A response also stops at a fixed 64,000-byte text budget. When either limit stops the traversal, nextCursor continues it.`),
432
+ cursor: z
433
+ .string()
434
+ .optional()
435
+ .describe('nextCursor from a previous call, passed with the same itemId, to continue the traversal at the next unseen comment without repeating or skipping any. Omit to start from the top.'),
75
436
  }),
76
437
  output: z.object({
77
- item: z
78
- .object({
79
- id: z.number().describe('Item ID.'),
80
- type: z.string().describe('Item type: story | comment | job | poll | pollopt.'),
81
- by: z.string().optional().describe('Author username.'),
82
- time: z.number().optional().describe('Unix timestamp.'),
83
- title: z.string().optional().describe('Story/job title.'),
84
- url: z.string().optional().describe('External link URL.'),
85
- text: z.string().optional().describe('Body text (HTML stripped).'),
86
- score: z.number().optional().describe('Upvote count.'),
87
- descendants: z.number().optional().describe('Total comment count.'),
88
- })
89
- .describe('The root item (story, comment, or poll).'),
438
+ item: ItemSchema,
90
439
  comments: z
91
- .array(z
92
- .object({
93
- id: z.number().describe('Comment ID.'),
94
- by: z.string().optional().describe('Author username.'),
95
- time: z.number().optional().describe('Unix timestamp.'),
96
- text: z.string().optional().describe('Comment text (HTML stripped).'),
97
- depth: z.number().describe('Nesting level (0 = direct reply to root).'),
98
- parentId: z.number().describe('Parent item ID.'),
99
- childCount: z
100
- .number()
101
- .describe('Number of direct child comments (may exceed what was resolved).'),
102
- isOp: z
103
- .literal(true)
104
- .optional()
105
- .describe('Present and `true` when the comment author matches the root item author (OP replying within their own thread). Omitted otherwise — including when either author is missing. Most threads carry no OP replies, so absence is the common case; treat missing as "not OP" rather than unknown.'),
106
- })
107
- .describe('A single comment in the thread with its tree position.'))
108
- .describe('Flat comment list ordered breadth-first by rank: highest-ranked top-level comments first, then their replies. Use depth/parentId to reconstruct nesting.'),
440
+ .array(CommentSchema)
441
+ .describe('Flat comment list ordered breadth-first by rank: highest-ranked top-level comments first, then their replies. Use depth/parentId to reconstruct nesting. With cursor, the page continues the same order.'),
109
442
  }),
110
443
  enrichment: {
111
- totalLoaded: z.number().describe('Number of comments actually fetched and included.'),
444
+ totalLoaded: z
445
+ .number()
446
+ .describe('Number of comments in this response (this page, when paging with cursor).'),
112
447
  totalAvailable: z
113
448
  .number()
114
449
  .optional()
115
- .describe('Total comment count from the root item. If totalLoaded < totalAvailable, raise maxComments (and depth, if you want nested replies) and call again.'),
450
+ .describe("HN's total comment count for the root (descendants), which also counts dead and deleted comments. Absent for comment and job roots."),
116
451
  truncated: z
117
452
  .boolean()
118
453
  .optional()
119
- .describe('True when the comment list was capped by maxComments.'),
454
+ .describe('True when comments remain unread: reachable through nextCursor (truncationReason count, size, or rate_limited), or below the depth limit (depth). Absent on a terminal page — including a thread that loads in full at exactly maxComments.'),
455
+ truncationReason: z
456
+ .enum(['count', 'size', 'depth', 'rate_limited'])
457
+ .optional()
458
+ .describe('Why the traversal stopped short: count = maxComments (or the 1,000-item ceiling per call) was reached; size = the 64,000-byte response budget was reached; rate_limited = the HN API throttled the fetches; depth = replies lie below the depth limit, which nextCursor does not reach — raise depth or pass a comment id as itemId. When a cursor reason and a depth cut both apply, the cursor reason is reported.'),
459
+ nextCursor: z
460
+ .string()
461
+ .optional()
462
+ .describe('Pass as cursor, with the same itemId, to continue from the next unseen comment. Present when truncationReason is count, size, or rate_limited; absent on the last page.'),
120
463
  shown: z.number().optional().describe('Number of comments returned.'),
121
464
  cap: z.number().optional().describe('The maxComments cap that was applied.'),
465
+ failedIds: z
466
+ .array(z.number())
467
+ .optional()
468
+ .describe('Comment and poll-option IDs whose fetch failed after retries, so neither they nor their replies are in this response. They are not deleted or missing and may load on a later call. Failed comments ride in nextCursor and are retried when you continue; without one, call again, or pass an ID as itemId to fetch that comment and its replies. Absent when everything the traversal reached loaded.'),
122
469
  notice: z
123
470
  .string()
124
471
  .optional()
125
- .describe('Truncation context: counts of deleted/dead comments dropped during traversal, or pagination hint when totalLoaded < totalAvailable. Absent when no comments were dropped and all available comments were loaded.'),
472
+ .describe('Traversal context: counts of deleted/dead comments dropped, the IDs that failed to load and how to retry them, then what stopped the traversal and how to continue — nextCursor for a count, size, or rate-limit stop, or a larger depth or a comment id as itemId for a depth cut. Absent when nothing was dropped, failed, or left unread.'),
473
+ },
474
+ enrichmentTrailer: {
475
+ failedIds: { render: (ids) => `**Failed to load:** ${(ids ?? []).join(', ')}` },
126
476
  },
127
477
  async handler(input, ctx) {
478
+ const resume = input.cursor ? readCursor(input.cursor, ctx) : undefined;
479
+ if (resume && resume.itemId !== input.itemId) {
480
+ throw ctx.fail('invalid_cursor', `This cursor continues item ${resume.itemId}, not item ${input.itemId}.`, { itemId: input.itemId, ...ctx.recoveryFor('invalid_cursor') });
481
+ }
128
482
  const hn = getHnService();
129
483
  const root = await hn.fetchItem(input.itemId, ctx);
130
484
  if (!root) {
@@ -133,6 +487,24 @@ export const getThread = tool('hn_get_thread', {
133
487
  ...ctx.recoveryFor('item_not_found'),
134
488
  });
135
489
  }
490
+ const options = [];
491
+ const failedOptionIds = [];
492
+ let optionRateLimit;
493
+ for (const slot of root.parts?.length ? await hn.fetchItems(root.parts, ctx) : []) {
494
+ if (slot.kind === 'item') {
495
+ options.push(projectOption(slot.item));
496
+ }
497
+ else if (slot.kind === 'failed') {
498
+ failedOptionIds.push(slot.id);
499
+ if (isRateLimited(slot.error))
500
+ optionRateLimit ??= slot.error;
501
+ }
502
+ }
503
+ /**
504
+ * Relationship and state keys are written only when HN sets them, and
505
+ * `totalAvailable` below only when HN reports `descendants`: a key carried
506
+ * with an `undefined` value renders as a literal "undefined" in content[].
507
+ */
136
508
  const item = {
137
509
  id: root.id,
138
510
  type: root.type,
@@ -143,132 +515,112 @@ export const getThread = tool('hn_get_thread', {
143
515
  text: root.text ? stripHtml(root.text) : undefined,
144
516
  score: root.score,
145
517
  descendants: root.descendants,
518
+ ...(root.parent != null && { parent: root.parent }),
519
+ ...(root.poll != null && { poll: root.poll }),
520
+ ...(root.parts && root.parts.length > 0 && { parts: root.parts }),
521
+ ...(root.deleted && { deleted: true }),
522
+ ...(root.dead && { dead: true }),
523
+ ...(options.length > 0 && { options }),
146
524
  };
147
- /**
148
- * `totalAvailable` is only keyed when HN actually reports `descendants` —
149
- * comment and job roots omit it. Passing the key with an `undefined` value
150
- * survives the optional schema and renders as a literal "undefined" in the
151
- * framework's content[] trailer, diverging from structuredContent.
152
- */
153
- if (input.depth === 0 || !root.kids?.length) {
154
- ctx.enrich({
155
- totalLoaded: 0,
156
- ...(root.descendants != null && { totalAvailable: root.descendants }),
525
+ const depth = resume?.depth ?? input.depth;
526
+ const seed = resume?.frontier ??
527
+ (depth > 0 ? (root.kids ?? []).map((id) => ({ id, level: 0, parentId: root.id })) : []);
528
+ const walk = await walkThread(hn, ctx, {
529
+ depth,
530
+ maxComments: input.maxComments,
531
+ rateLimit: optionRateLimit,
532
+ rootBy: root.by,
533
+ seed,
534
+ usedBytes: byteLength(renderRoot(item)),
535
+ });
536
+ const { comments, stop } = walk;
537
+ ctx.log.info('Resolved thread', {
538
+ itemId: input.itemId,
539
+ comments: comments.length,
540
+ failed: walk.failed.length + failedOptionIds.length,
541
+ stop,
542
+ });
543
+ const totalLoaded = comments.length;
544
+ /** Comments returned across every page so far, this one included. */
545
+ const loadedSoFar = (resume?.offset ?? 0) + totalLoaded;
546
+ const totalAvailable = root.descendants;
547
+ const failedCommentIds = walk.failed.map((entry) => entry.id);
548
+ const failedIds = [...failedCommentIds, ...failedOptionIds];
549
+ ctx.enrich({
550
+ totalLoaded,
551
+ ...(totalAvailable != null && { totalAvailable }),
552
+ ...(failedIds.length > 0 && { failedIds }),
553
+ });
554
+ const depthCut = walk.depthCut || resume?.depthCut === true;
555
+ /** Failed comments lead the cursor, so continuing retries them rather than skipping them. */
556
+ const nextCursor = stop &&
557
+ encodeThreadCursor({
558
+ offset: loadedSoFar,
559
+ limit: input.maxComments,
560
+ itemId: input.itemId,
561
+ depth,
562
+ frontier: [...walk.failed, ...walk.rest],
563
+ depthCut,
157
564
  });
158
- return {
159
- item,
160
- comments: [],
161
- };
565
+ const noticeParts = [];
566
+ if (walk.omittedDeleted > 0 || walk.omittedDead > 0) {
567
+ noticeParts.push(`${walk.omittedDeleted} deleted, ${walk.omittedDead} dead — omitted from this view.`);
162
568
  }
163
- // Ranked BFS: process all comments at depth d before depth d+1.
164
- // Within each depth, order follows HN's kids ranking.
165
- const comments = [];
166
- let omittedDeleted = 0;
167
- let omittedDead = 0;
168
- let currentLevel = root.kids.map((id) => ({
169
- id,
170
- parentId: root.id,
171
- }));
172
- for (let d = 0; d < input.depth && currentLevel.length > 0 && comments.length < input.maxComments; d++) {
173
- const remaining = input.maxComments - comments.length;
174
- // Fetch a batch with buffer for dead/deleted items
175
- const batch = currentLevel.slice(0, remaining + 20);
176
- const items = await hn.fetchItems(batch.map((b) => b.id), ctx);
177
- const nextLevel = [];
178
- for (let i = 0; i < items.length && comments.length < input.maxComments; i++) {
179
- const c = items[i];
180
- const parent = batch[i];
181
- if (!c || !parent)
182
- continue;
183
- if (c.deleted) {
184
- omittedDeleted++;
185
- continue;
186
- }
187
- if (c.dead) {
188
- omittedDead++;
189
- continue;
569
+ if (failedCommentIds.length > 0) {
570
+ const n = failedCommentIds.length;
571
+ const retry = nextCursor
572
+ ? `nextCursor carries ${plural(n, 'it', 'them')}, so continuing retries ${plural(n, 'it', 'them')}; or pass an id as itemId to fetch that comment and its replies.`
573
+ : 'Call again to retry, or pass an id as itemId to fetch that comment and its replies.';
574
+ noticeParts.push(`Could not fetch ${n} ${plural(n, 'comment')} (${plural(n, 'id')} ${failedCommentIds.join(', ')}); ${plural(n, 'its', 'their')} replies were not reached either. ${retry}`);
575
+ }
576
+ if (failedOptionIds.length > 0) {
577
+ const n = failedOptionIds.length;
578
+ noticeParts.push(`Could not fetch ${n} poll ${plural(n, 'option')} (${plural(n, 'id')} ${failedOptionIds.join(', ')}). Call again to retry.`);
579
+ }
580
+ const loaded = totalAvailable != null ? ` with ${loadedSoFar}/${totalAvailable} comments loaded` : '';
581
+ const resumeHint = 'Pass nextCursor as cursor, with the same itemId, to continue.';
582
+ switch (stop) {
583
+ case 'count':
584
+ noticeParts.push(`Stopped at maxComments ${input.maxComments}${loaded}. ${resumeHint}`);
585
+ break;
586
+ case 'examined':
587
+ noticeParts.push(`Stopped after examining ${MAX_ITEMS_EXAMINED.toLocaleString('en-US')} items in one call${loaded}. ${resumeHint}`);
588
+ break;
589
+ case 'size':
590
+ noticeParts.push(`Stopped at the ${RESPONSE_BUDGET_BYTES.toLocaleString('en-US')}-byte response budget after ${totalLoaded} ${plural(totalLoaded, 'comment')}${loaded}. ${resumeHint}`);
591
+ break;
592
+ case 'rate_limited':
593
+ noticeParts.push(`Stopped early: the HN API is rate-limiting this server${loaded}. Wait ${waitFor(walk.rateLimit)}, then pass nextCursor as cursor, with the same itemId, to resume; comments that failed to load are retried first.`);
594
+ break;
595
+ case undefined:
596
+ if (depthCut) {
597
+ const raise = depth < MAX_DEPTH ? `Raise depth (max ${MAX_DEPTH}), or pass` : 'Pass';
598
+ noticeParts.push(`The depth limit (${depth}) left replies unread${loaded}. ${raise} the id of a depth-${depth - 1} comment that shows replies as itemId to read its subtree.`);
190
599
  }
191
- comments.push({
192
- id: c.id,
193
- by: c.by,
194
- time: c.time,
195
- text: c.text ? stripHtml(c.text) : undefined,
196
- depth: d,
197
- parentId: parent.parentId,
198
- childCount: c.kids?.length ?? 0,
199
- ...(c.by != null && c.by === root.by && { isOp: true }),
200
- });
201
- if (c.kids) {
202
- for (const kid of c.kids) {
203
- nextLevel.push({ id: kid, parentId: c.id });
204
- }
600
+ else if (resume && totalLoaded === 0 && noticeParts.length === 0) {
601
+ noticeParts.push('No comments remain past this cursor; the thread has been read to the end.');
205
602
  }
206
- }
207
- currentLevel = nextLevel;
208
- }
209
- ctx.log.info('Resolved thread', { itemId: input.itemId, comments: comments.length });
210
- const totalLoaded = comments.length;
211
- const totalAvailable = root.descendants;
212
- ctx.enrich({ totalLoaded, ...(totalAvailable != null && { totalAvailable }) });
213
- if (totalLoaded >= input.maxComments) {
214
- ctx.enrich.truncated({ shown: totalLoaded, cap: input.maxComments });
603
+ break;
215
604
  }
216
- const noticeParts = [];
217
- if (omittedDeleted > 0 || omittedDead > 0) {
218
- noticeParts.push(`${omittedDeleted} deleted, ${omittedDead} dead — omitted from this view.`);
605
+ const notice = noticeParts.join(' ');
606
+ if (stop === 'count' || stop === 'examined' || stop === 'size') {
607
+ ctx.enrich.truncated({ shown: totalLoaded, cap: input.maxComments, guidance: notice });
219
608
  }
220
- if (totalAvailable != null && totalLoaded < totalAvailable) {
221
- noticeParts.push(`${totalLoaded}/${totalAvailable} comments loaded — raise maxComments or depth for more.`);
609
+ else if (stop === 'rate_limited' || depthCut) {
610
+ ctx.enrich({ truncated: true });
611
+ ctx.enrich.notice(notice);
222
612
  }
223
- if (noticeParts.length > 0) {
224
- ctx.enrich.notice(noticeParts.join(' '));
613
+ else if (notice) {
614
+ ctx.enrich.notice(notice);
225
615
  }
226
- return {
227
- item,
228
- comments,
229
- };
230
- },
231
- format: (result) => {
232
- const { item, comments } = result;
233
- const lines = [];
234
- // Root item
235
- const title = item.title ?? `Comment by ${item.by ?? 'unknown'}`;
236
- const date = item.time
237
- ? `${new Date(item.time * 1000).toISOString().slice(0, 10)} (t:${item.time})`
238
- : '';
239
- const meta = [
240
- `id:${item.id}`,
241
- `type:${item.type}`,
242
- item.score != null ? `${item.score} pts` : null,
243
- item.by ? `by ${item.by}` : null,
244
- item.descendants != null ? `${item.descendants} comments` : null,
245
- date,
246
- ]
247
- .filter(Boolean)
248
- .join(' | ');
249
- lines.push(`## ${title}\n${meta}`);
250
- if (item.url)
251
- lines.push(item.url);
252
- if (item.text)
253
- lines.push(item.text);
254
- // Comment tree
255
- if (comments.length > 0) {
256
- lines.push('\n---\n');
257
- for (const c of comments) {
258
- // Cap visual indent at 10 levels — the depth value itself is rendered explicitly below.
259
- const indent = ' '.repeat(Math.min(c.depth, 10));
260
- const author = c.by ? (c.isOp ? `${c.by} (OP)` : c.by) : '[deleted]';
261
- const cDate = c.time
262
- ? `${new Date(c.time * 1000).toISOString().slice(0, 16).replace('T', ' ')} (t:${c.time})`
263
- : '';
264
- const replies = c.childCount > 0 ? ` | ${c.childCount} replies` : '';
265
- const opTag = c.isOp ? ' | isOp:true' : '';
266
- lines.push(`${indent}**${author}** (id:${c.id} | depth:${c.depth} | parent:${c.parentId}${opTag}${replies} | ${cDate})`);
267
- if (c.text)
268
- lines.push(`${indent}${c.text.replace(/\n/g, `\n${indent}`)}`);
269
- }
616
+ if (stop || depthCut) {
617
+ ctx.enrich({
618
+ truncationReason: stop === 'examined' ? 'count' : (stop ?? 'depth'),
619
+ ...(nextCursor && { nextCursor }),
620
+ });
270
621
  }
271
- return [{ type: 'text', text: lines.join('\n') }];
622
+ return { item, comments };
272
623
  },
624
+ format: (result) => [{ type: 'text', text: renderThread(result.item, result.comments) }],
273
625
  });
274
626
  //# sourceMappingURL=get-thread.tool.js.map