@cyanheads/hn-mcp-server 0.5.18 → 0.5.20

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