@cyanheads/hn-mcp-server 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -2
- package/CLAUDE.md +2 -2
- package/README.md +17 -18
- package/changelog/0.7.x/0.7.0.md +32 -0
- package/dist/mcp-server/tools/definitions/get-stories.tool.d.ts +1 -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 +31 -24
- package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +3 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.js +227 -277
- package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +0 -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 +27 -73
- package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +3 -16
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.js +62 -127
- package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
- package/dist/mcp-server/tools/username-input.d.ts +23 -0
- package/dist/mcp-server/tools/username-input.d.ts.map +1 -0
- package/dist/mcp-server/tools/username-input.js +30 -0
- package/dist/mcp-server/tools/username-input.js.map +1 -0
- package/dist/services/hn/hn-service.d.ts +49 -8
- package/dist/services/hn/hn-service.d.ts.map +1 -1
- package/dist/services/hn/hn-service.js +55 -9
- package/dist/services/hn/hn-service.js.map +1 -1
- package/package.json +2 -2
- package/server.json +3 -3
- package/skills/hn-guide/SKILL.md +4 -4
- package/dist/mcp-server/tools/response-budget.d.ts +0 -111
- package/dist/mcp-server/tools/response-budget.d.ts.map +0 -1
- package/dist/mcp-server/tools/response-budget.js +0 -204
- package/dist/mcp-server/tools/response-budget.js.map +0 -1
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
7
7
|
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
8
|
-
import { escapeMarkdownInline
|
|
9
|
-
import { escapeInline, quoteBody } from '../markdown-escape.js';
|
|
10
|
-
import { byteLength, enrichmentBytes, fitsBudget, longestFittingPrefix, RESPONSE_BUDGET_TEXT, } from '../response-budget.js';
|
|
8
|
+
import { escapeMarkdownInline } from '@cyanheads/mcp-ts-core/utils';
|
|
9
|
+
import { escapeInline, escapeQuoted, quoteBody } from '../markdown-escape.js';
|
|
11
10
|
import { servedAtCap } from '../served-at-cap.js';
|
|
12
11
|
import { decodeThreadCursor, encodeThreadCursor, } from '../thread-cursor.js';
|
|
13
12
|
import { CALL_BUDGET_MS, decodeHtmlEntities, getHnService, isDeadlineCut, isRateLimited, normalizeUrl, REQUEST_TIMEOUT_MS, stripHtml, } from '../../../services/hn/hn-service.js';
|
|
@@ -30,7 +29,11 @@ const DEPTH_CEILING = 100;
|
|
|
30
29
|
* one call examines.
|
|
31
30
|
*/
|
|
32
31
|
const OMITTED_IDS_NAMED = 10;
|
|
33
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Most comments one batch fetches. The walk checks its stops — a rate limit,
|
|
34
|
+
* the item ceiling, the time budget — between batches, so this bounds the
|
|
35
|
+
* fetches sent after one is reached.
|
|
36
|
+
*/
|
|
34
37
|
const FETCH_CHUNK = 25;
|
|
35
38
|
/**
|
|
36
39
|
* Most frontier items one call examines. Dead, deleted, and missing items cost
|
|
@@ -82,6 +85,9 @@ const ItemSchema = z
|
|
|
82
85
|
.number()
|
|
83
86
|
.optional()
|
|
84
87
|
.describe("HN's count of live comments in the thread; dead and deleted comments are not counted."),
|
|
88
|
+
childCount: z
|
|
89
|
+
.number()
|
|
90
|
+
.describe("Number of direct replies to the root, dead and deleted ones included (may exceed what was resolved). A poll's options are not replies."),
|
|
85
91
|
parent: z
|
|
86
92
|
.number()
|
|
87
93
|
.optional()
|
|
@@ -90,6 +96,14 @@ const ItemSchema = z
|
|
|
90
96
|
.number()
|
|
91
97
|
.optional()
|
|
92
98
|
.describe('For a comment root, the ID of the story or poll its discussion belongs to — pass it as itemId for the whole thread, or as storyId to hn_search_content. Absent for other roots, and when the story could not be found (notice says why).'),
|
|
99
|
+
storyTitle: z
|
|
100
|
+
.string()
|
|
101
|
+
.optional()
|
|
102
|
+
.describe('For a comment root, the title of the story or poll at storyId. Absent for other roots, when storyId is absent, when that story has no title (deleted, for example), and when it could not be loaded.'),
|
|
103
|
+
storyUrl: z
|
|
104
|
+
.string()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe("External link URL of a comment root's story. Absent when that story is a text post with no link or could not be loaded, and on every root that is not a comment."),
|
|
93
107
|
poll: z.number().optional().describe('For a poll-option root, the ID of its poll.'),
|
|
94
108
|
parts: z.array(z.number()).optional().describe('For a poll root, its option IDs in HN order.'),
|
|
95
109
|
deleted: z
|
|
@@ -126,8 +140,7 @@ const CommentSchema = z
|
|
|
126
140
|
})
|
|
127
141
|
.describe('A single comment in the thread with its tree position.');
|
|
128
142
|
// ---------------------------------------------------------------------------
|
|
129
|
-
// Rendering
|
|
130
|
-
// measures exactly the text format() produces.
|
|
143
|
+
// Rendering
|
|
131
144
|
// ---------------------------------------------------------------------------
|
|
132
145
|
const TYPE_LABELS = {
|
|
133
146
|
story: 'Story',
|
|
@@ -167,12 +180,17 @@ function renderRoot(item) {
|
|
|
167
180
|
`id:${item.id}`,
|
|
168
181
|
`type:${item.type}`,
|
|
169
182
|
item.parent != null ? `parent:${item.parent}` : null,
|
|
170
|
-
item.storyId
|
|
183
|
+
item.storyId == null
|
|
184
|
+
? null
|
|
185
|
+
: item.storyTitle != null
|
|
186
|
+
? `story:"${escapeQuoted(item.storyTitle)}"#${item.storyId}`
|
|
187
|
+
: `story:${item.storyId}`,
|
|
171
188
|
item.poll != null ? `poll:${item.poll}` : null,
|
|
172
189
|
item.parts?.length ? `parts:${item.parts.join(',')}` : null,
|
|
173
190
|
item.score != null ? `${item.score} pts` : null,
|
|
174
191
|
item.by ? `by ${item.by}` : null,
|
|
175
192
|
item.descendants != null ? `${item.descendants} comments` : null,
|
|
193
|
+
item.childCount > 0 ? `${item.childCount} replies` : null,
|
|
176
194
|
date,
|
|
177
195
|
]
|
|
178
196
|
.filter(Boolean)
|
|
@@ -180,6 +198,8 @@ function renderRoot(item) {
|
|
|
180
198
|
const lines = [`## ${rootHeading(item)}\n${meta}`];
|
|
181
199
|
if (item.url)
|
|
182
200
|
lines.push(item.url);
|
|
201
|
+
if (item.storyUrl)
|
|
202
|
+
lines.push(item.storyUrl);
|
|
183
203
|
if (item.text)
|
|
184
204
|
lines.push(quoteBody(item.text));
|
|
185
205
|
if (item.options?.length) {
|
|
@@ -208,67 +228,19 @@ function renderThread(item, comments) {
|
|
|
208
228
|
}
|
|
209
229
|
return parts.join('\n');
|
|
210
230
|
}
|
|
211
|
-
/** Bytes format() adds before the first comment, and between two comments. */
|
|
212
|
-
const DIVIDER_BYTES = byteLength(`\n${COMMENTS_DIVIDER}\n`);
|
|
213
|
-
const SEPARATOR_BYTES = byteLength(COMMENT_SEPARATOR);
|
|
214
|
-
/** The content[] trailer's string fields render here, so the response budget sizes them as sent. */
|
|
215
|
-
const codeSpan = (value) => markdown()
|
|
216
|
-
.inlineCode(value ?? '')
|
|
217
|
-
.build();
|
|
218
|
-
const threadTrailer = {
|
|
219
|
-
failedIds: { render: (ids) => `**Failed to load:** ${(ids ?? []).join(', ')}` },
|
|
220
|
-
truncationReason: { render: (reason) => `**truncationReason:** ${codeSpan(reason)}` },
|
|
221
|
-
nextCursor: { render: (cursor) => `**nextCursor:** ${codeSpan(cursor)}` },
|
|
222
|
-
};
|
|
223
|
-
/**
|
|
224
|
-
* The page `walk` serves at `index`: `marks[index]`, or its end at
|
|
225
|
-
* `marks.length`. The end stops as the walk did. A page cut back to an earlier
|
|
226
|
-
* mark leaves everything after it unvisited, so it stops on `size` — or on
|
|
227
|
-
* `rate_limited` when a rate limit had already failed a fetch by then.
|
|
228
|
-
*/
|
|
229
|
-
function pageAt(walk, index) {
|
|
230
|
-
const mark = walk.marks[index] ?? walk.end;
|
|
231
|
-
const rest = walk.levels.flatMap((queue, level) => level < mark.level
|
|
232
|
-
? []
|
|
233
|
-
: queue.slice(level === mark.level ? mark.head : 0, mark.lengths[level]));
|
|
234
|
-
const stop = mark === walk.end
|
|
235
|
-
? walk.stop
|
|
236
|
-
: mark.rateLimit
|
|
237
|
-
? 'rate_limited'
|
|
238
|
-
: rest.length > 0
|
|
239
|
-
? 'size'
|
|
240
|
-
: mark.timeCut
|
|
241
|
-
? 'time'
|
|
242
|
-
: undefined;
|
|
243
|
-
return {
|
|
244
|
-
comments: walk.comments.slice(0, mark.comments),
|
|
245
|
-
failed: walk.failed.slice(0, mark.failed),
|
|
246
|
-
liveDepthCut: mark.liveDepthCut,
|
|
247
|
-
omittedDead: mark.omittedDead,
|
|
248
|
-
omittedDeleted: mark.omittedDeleted,
|
|
249
|
-
omittedDepthCut: walk.omittedDepthCut.slice(0, mark.omittedDepthCut),
|
|
250
|
-
pageBytes: mark.pageBytes,
|
|
251
|
-
rateLimit: mark.rateLimit,
|
|
252
|
-
rest,
|
|
253
|
-
stop,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
231
|
/**
|
|
257
232
|
* Ranked BFS over the frontier: every comment at one depth before any at the
|
|
258
233
|
* next, and HN's kids order within each parent. Fetches in chunks no larger
|
|
259
234
|
* than the comments still wanted, so filtered and failed items never push a
|
|
260
235
|
* later sibling out of the page — anything unvisited stays queued.
|
|
261
236
|
*
|
|
262
|
-
* Stops on `maxComments`, the item ceiling,
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
* the deadline cuts has always had a full attempt to answer; an entry held back
|
|
270
|
-
* stays queued. A batch the deadline cut, the last one included, stops the walk
|
|
271
|
-
* on `time`, so its failed entries ride a cursor.
|
|
237
|
+
* Stops on `maxComments`, the item ceiling, a rate limit, or the call's time
|
|
238
|
+
* budget. After a rate limit nothing more is fetched: every level below would
|
|
239
|
+
* pay its own retry ladder against a throttled upstream. No batch is sent with
|
|
240
|
+
* less than one request timeout left before `deadline`, so a fetch the deadline
|
|
241
|
+
* cuts has always had a full attempt to answer; an entry held back stays
|
|
242
|
+
* queued. A batch the deadline cut, the last one included, stops the walk on
|
|
243
|
+
* `time`, so its failed entries ride a cursor.
|
|
272
244
|
*/
|
|
273
245
|
async function walkThread(hn, ctx, args) {
|
|
274
246
|
const levels = Array.from({ length: args.depth }, () => []);
|
|
@@ -276,7 +248,7 @@ async function walkThread(hn, ctx, args) {
|
|
|
276
248
|
levels[entry.level]?.push(entry);
|
|
277
249
|
const comments = [];
|
|
278
250
|
const failed = [];
|
|
279
|
-
let {
|
|
251
|
+
let { rateLimit } = args;
|
|
280
252
|
let omittedDeleted = 0;
|
|
281
253
|
let omittedDead = 0;
|
|
282
254
|
let liveDepthCut = false;
|
|
@@ -287,21 +259,6 @@ async function walkThread(hn, ctx, args) {
|
|
|
287
259
|
/** The level being read, and its next entry; every level above is read. */
|
|
288
260
|
let atLevel = 0;
|
|
289
261
|
let atHead = 0;
|
|
290
|
-
const marks = [];
|
|
291
|
-
const mark = () => ({
|
|
292
|
-
comments: comments.length,
|
|
293
|
-
failed: failed.length,
|
|
294
|
-
head: atHead,
|
|
295
|
-
lengths: levels.map((queue) => queue.length),
|
|
296
|
-
level: atLevel,
|
|
297
|
-
liveDepthCut,
|
|
298
|
-
omittedDead,
|
|
299
|
-
omittedDeleted,
|
|
300
|
-
omittedDepthCut: omittedDepthCut.length,
|
|
301
|
-
pageBytes: page,
|
|
302
|
-
rateLimit,
|
|
303
|
-
timeCut,
|
|
304
|
-
});
|
|
305
262
|
/** Queue a visited comment's replies one level down; true when the depth limit cut them instead. */
|
|
306
263
|
const enqueueKids = (c, level) => {
|
|
307
264
|
if (!c.kids?.length)
|
|
@@ -326,8 +283,7 @@ async function walkThread(hn, ctx, args) {
|
|
|
326
283
|
stop = 'time';
|
|
327
284
|
if (stop)
|
|
328
285
|
break walk;
|
|
329
|
-
const
|
|
330
|
-
const batch = queue.slice(head, head +
|
|
286
|
+
const batch = queue.slice(atHead, atHead +
|
|
331
287
|
Math.min(args.maxComments - comments.length, FETCH_CHUNK, MAX_ITEMS_EXAMINED - examined));
|
|
332
288
|
examined += batch.length;
|
|
333
289
|
const slots = await hn.fetchItems(batch.map((entry) => entry.id), ctx, args.deadline);
|
|
@@ -338,7 +294,7 @@ async function walkThread(hn, ctx, args) {
|
|
|
338
294
|
break;
|
|
339
295
|
if (slot.kind === 'item' && !slot.item.deleted && !slot.item.dead) {
|
|
340
296
|
const c = slot.item;
|
|
341
|
-
|
|
297
|
+
comments.push({
|
|
342
298
|
id: c.id,
|
|
343
299
|
by: c.by,
|
|
344
300
|
time: c.time,
|
|
@@ -347,25 +303,9 @@ async function walkThread(hn, ctx, args) {
|
|
|
347
303
|
parentId: entry.parentId,
|
|
348
304
|
childCount: c.kids?.length ?? 0,
|
|
349
305
|
...(c.by != null && c.by === args.opAuthor && { isOp: true }),
|
|
350
|
-
};
|
|
351
|
-
/** The comment's rendered block and its JSON array element, each with its separator. */
|
|
352
|
-
const first = comments.length === 0;
|
|
353
|
-
const grown = {
|
|
354
|
-
contentBytes: page.contentBytes +
|
|
355
|
-
byteLength(renderComment(comment)) +
|
|
356
|
-
(first ? DIVIDER_BYTES : SEPARATOR_BYTES),
|
|
357
|
-
structuredBytes: page.structuredBytes + byteLength(JSON.stringify(comment)) + (first ? 0 : 1),
|
|
358
|
-
};
|
|
359
|
-
if (!first && !fitsBudget(grown)) {
|
|
360
|
-
stop = 'size';
|
|
361
|
-
break;
|
|
362
|
-
}
|
|
363
|
-
comments.push(comment);
|
|
364
|
-
page = grown;
|
|
306
|
+
});
|
|
365
307
|
if (enqueueKids(c, level))
|
|
366
308
|
liveDepthCut = true;
|
|
367
|
-
atHead = head + i + 1;
|
|
368
|
-
marks.push(mark());
|
|
369
309
|
}
|
|
370
310
|
else if (slot.kind === 'failed') {
|
|
371
311
|
failed.push(entry);
|
|
@@ -385,9 +325,7 @@ async function walkThread(hn, ctx, args) {
|
|
|
385
325
|
}
|
|
386
326
|
visited++;
|
|
387
327
|
}
|
|
388
|
-
atHead
|
|
389
|
-
if (stop)
|
|
390
|
-
break walk;
|
|
328
|
+
atHead += visited;
|
|
391
329
|
}
|
|
392
330
|
}
|
|
393
331
|
/** A rate limit or a time cut on the last batch still leaves its failed entries to resume. */
|
|
@@ -395,7 +333,26 @@ async function walkThread(hn, ctx, args) {
|
|
|
395
333
|
stop = 'rate_limited';
|
|
396
334
|
else if (!stop && timeCut)
|
|
397
335
|
stop = 'time';
|
|
398
|
-
|
|
336
|
+
const rest = levels.flatMap((queue, level) => level < atLevel ? [] : queue.slice(level === atLevel ? atHead : 0));
|
|
337
|
+
return {
|
|
338
|
+
comments,
|
|
339
|
+
failed,
|
|
340
|
+
liveDepthCut,
|
|
341
|
+
omittedDead,
|
|
342
|
+
omittedDeleted,
|
|
343
|
+
omittedDepthCut,
|
|
344
|
+
rateLimit,
|
|
345
|
+
rest,
|
|
346
|
+
stop,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/** A story item's title and link, decoded and keyed only when HN sets them. */
|
|
350
|
+
function storyTitleAndUrl(story) {
|
|
351
|
+
const storyUrl = normalizeUrl(story.url);
|
|
352
|
+
return {
|
|
353
|
+
...(story.title && { storyTitle: decodeHtmlEntities(story.title) }),
|
|
354
|
+
...(storyUrl && { storyUrl }),
|
|
355
|
+
};
|
|
399
356
|
}
|
|
400
357
|
/**
|
|
401
358
|
* Find the story or poll a comment root belongs to by following `parent` links
|
|
@@ -431,13 +388,33 @@ async function findStory(hn, ctx, root, deadline) {
|
|
|
431
388
|
if (slot?.kind !== 'item')
|
|
432
389
|
return { hops, gap: `ancestor ${next} does not exist` };
|
|
433
390
|
if (slot.item.type !== 'comment') {
|
|
434
|
-
return {
|
|
391
|
+
return {
|
|
392
|
+
hops,
|
|
393
|
+
story: { id: slot.item.id, by: slot.item.by },
|
|
394
|
+
...storyTitleAndUrl(slot.item),
|
|
395
|
+
};
|
|
435
396
|
}
|
|
436
397
|
below = `ancestor ${slot.item.id}`;
|
|
437
398
|
next = slot.item.parent;
|
|
438
399
|
}
|
|
439
400
|
return { hops, gap: `HN lists no parent for ${below}` };
|
|
440
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* Fetch the title and link of the story a cursor names, the one request a
|
|
404
|
+
* continuation page spends on it, bounded by the lookup's `deadline`. The
|
|
405
|
+
* story's ID and author stay the cursor's, so a refetch never re-keys OP marks.
|
|
406
|
+
* A missing story or a failed fetch leaves the title and link out; a 429 ends
|
|
407
|
+
* the page as one during the lookup does; a cancellation propagates.
|
|
408
|
+
*/
|
|
409
|
+
async function reloadStory(hn, ctx, story, deadline) {
|
|
410
|
+
const [slot] = await hn.fetchItems([story.id], ctx, deadline);
|
|
411
|
+
if (slot?.kind === 'item')
|
|
412
|
+
return { hops: 0, story, ...storyTitleAndUrl(slot.item) };
|
|
413
|
+
if (slot?.kind === 'failed' && isRateLimited(slot.error)) {
|
|
414
|
+
return { hops: 0, story, rateLimit: slot.error };
|
|
415
|
+
}
|
|
416
|
+
return { hops: 0, story };
|
|
417
|
+
}
|
|
441
418
|
function projectOption(option) {
|
|
442
419
|
return {
|
|
443
420
|
id: option.id,
|
|
@@ -546,8 +523,8 @@ export const getThread = tool('hn_get_thread', {
|
|
|
546
523
|
.int()
|
|
547
524
|
.min(1)
|
|
548
525
|
.max(MAX_COMMENTS_CEILING)
|
|
549
|
-
.default(
|
|
550
|
-
.describe(`Maximum comments in one response, across all depth levels, at most ${MAX_COMMENTS_CAP} per call. A larger value is served at ${MAX_COMMENTS_CAP}, and nextCursor continues from there. Highest-ranked top-level comments resolve first; replies fill in only after the level above is exhausted.
|
|
526
|
+
.default(25)
|
|
527
|
+
.describe(`Maximum comments in one response, across all depth levels, at most ${MAX_COMMENTS_CAP} per call. A larger value is served at ${MAX_COMMENTS_CAP}, and nextCursor continues from there. Highest-ranked top-level comments resolve first; replies fill in only after the level above is exhausted. When this limit stops the traversal, nextCursor continues it.`),
|
|
551
528
|
cursor: z
|
|
552
529
|
.string()
|
|
553
530
|
.optional()
|
|
@@ -570,15 +547,15 @@ export const getThread = tool('hn_get_thread', {
|
|
|
570
547
|
truncated: z
|
|
571
548
|
.boolean()
|
|
572
549
|
.optional()
|
|
573
|
-
.describe('True when comments remain unread: reachable through nextCursor (truncationReason count,
|
|
550
|
+
.describe('True when comments remain unread: reachable through nextCursor (truncationReason count, rate_limited, or time), or below the depth limit (depth). Absent on a terminal page — including a thread that loads in full at exactly maxComments.'),
|
|
574
551
|
truncationReason: z
|
|
575
|
-
.enum(['count', '
|
|
552
|
+
.enum(['count', 'depth', 'rate_limited', 'time'])
|
|
576
553
|
.optional()
|
|
577
|
-
.describe(`Why the traversal stopped short: count = maxComments (or the 1,000-item ceiling per call) was reached;
|
|
554
|
+
.describe(`Why the traversal stopped short: count = maxComments (or the 1,000-item ceiling per call) was reached; rate_limited = the HN API throttled the fetches; time = the call's ${CALL_BUDGET_MS / 1000}-second time budget ran short because the HN API answered slowly or not at all; 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.`),
|
|
578
555
|
nextCursor: z
|
|
579
556
|
.string()
|
|
580
557
|
.optional()
|
|
581
|
-
.describe('Pass as cursor, with the same itemId, to continue from the next unseen comment. Present when truncationReason is count,
|
|
558
|
+
.describe('Pass as cursor, with the same itemId, to continue from the next unseen comment. Present when truncationReason is count, rate_limited, or time; absent on the last page.'),
|
|
582
559
|
shown: z.number().optional().describe('Number of comments returned.'),
|
|
583
560
|
cap: z
|
|
584
561
|
.number()
|
|
@@ -591,9 +568,11 @@ export const getThread = tool('hn_get_thread', {
|
|
|
591
568
|
notice: z
|
|
592
569
|
.string()
|
|
593
570
|
.optional()
|
|
594
|
-
.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,
|
|
571
|
+
.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, rate-limit, or time-budget stop, or a larger depth or a comment id as itemId for a depth cut — noting when the maxComments or depth that stopped it was served at its per-call cap. Dead or deleted comments whose replies the depth limit cut are named by id, to pass as itemId, on the page that reaches them, since comments omits them. Absent when nothing was dropped, failed, or left unread.'),
|
|
572
|
+
},
|
|
573
|
+
enrichmentTrailer: {
|
|
574
|
+
failedIds: { render: (ids) => `**Failed to load:** ${(ids ?? []).join(', ')}` },
|
|
595
575
|
},
|
|
596
|
-
enrichmentTrailer: threadTrailer,
|
|
597
576
|
async handler(input, ctx) {
|
|
598
577
|
const startedAt = Date.now();
|
|
599
578
|
/** Bounds every request below — root, story lookup, poll options, every batch. */
|
|
@@ -612,14 +591,15 @@ export const getThread = tool('hn_get_thread', {
|
|
|
612
591
|
}
|
|
613
592
|
/**
|
|
614
593
|
* OP on a comment root means the author of the story above it. A cursor
|
|
615
|
-
* carries the story an earlier page found
|
|
616
|
-
* looks again.
|
|
594
|
+
* carries the story an earlier page found, and that story alone is fetched
|
|
595
|
+
* again for its title and link; one issued before it was found looks again.
|
|
617
596
|
*/
|
|
597
|
+
const lookupDeadline = startedAt + STORY_LOOKUP_DEADLINE_MS;
|
|
618
598
|
const lookup = root.type !== 'comment'
|
|
619
599
|
? undefined
|
|
620
600
|
: resume?.story
|
|
621
|
-
?
|
|
622
|
-
: await findStory(hn, ctx, root,
|
|
601
|
+
? await reloadStory(hn, ctx, resume.story, lookupDeadline)
|
|
602
|
+
: await findStory(hn, ctx, root, lookupDeadline);
|
|
623
603
|
const options = [];
|
|
624
604
|
const failedOptionIds = [];
|
|
625
605
|
let optionRateLimit;
|
|
@@ -648,8 +628,11 @@ export const getThread = tool('hn_get_thread', {
|
|
|
648
628
|
text: root.text ? stripHtml(root.text) : undefined,
|
|
649
629
|
score: root.score,
|
|
650
630
|
descendants: root.descendants,
|
|
631
|
+
childCount: root.kids?.length ?? 0,
|
|
651
632
|
...(root.parent != null && { parent: root.parent }),
|
|
652
633
|
...(lookup?.story && { storyId: lookup.story.id }),
|
|
634
|
+
...(lookup?.storyTitle != null && { storyTitle: lookup.storyTitle }),
|
|
635
|
+
...(lookup?.storyUrl != null && { storyUrl: lookup.storyUrl }),
|
|
653
636
|
...(root.poll != null && { poll: root.poll }),
|
|
654
637
|
...(root.parts && root.parts.length > 0 && { parts: root.parts }),
|
|
655
638
|
...(root.deleted && { deleted: true }),
|
|
@@ -665,178 +648,145 @@ export const getThread = tool('hn_get_thread', {
|
|
|
665
648
|
depth,
|
|
666
649
|
maxComments,
|
|
667
650
|
opAuthor: lookup ? lookup.story?.by : root.by,
|
|
668
|
-
page: {
|
|
669
|
-
contentBytes: byteLength(renderRoot(item)),
|
|
670
|
-
structuredBytes: byteLength(JSON.stringify({ item, comments: [] })),
|
|
671
|
-
},
|
|
672
651
|
rateLimit: optionRateLimit ?? lookup?.rateLimit,
|
|
673
652
|
seed,
|
|
674
653
|
});
|
|
654
|
+
const { comments, stop } = traversal;
|
|
675
655
|
const totalAvailable = root.descendants;
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
})
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
return {
|
|
786
|
-
totalLoaded,
|
|
787
|
-
...(totalAvailable != null && { totalAvailable }),
|
|
788
|
-
...(failedIds.length > 0 && { failedIds }),
|
|
789
|
-
...((capped || stop === 'rate_limited' || stop === 'time' || depthCut) && {
|
|
790
|
-
truncated: true,
|
|
791
|
-
}),
|
|
792
|
-
...(capped && { shown: totalLoaded, cap: maxComments }),
|
|
793
|
-
...(notice.length > 0 && { notice }),
|
|
794
|
-
...((stop || depthCut) && {
|
|
795
|
-
truncationReason: stop === 'examined' ? 'count' : (stop ?? 'depth'),
|
|
796
|
-
...(nextCursor && { nextCursor }),
|
|
797
|
-
}),
|
|
798
|
-
};
|
|
799
|
-
};
|
|
800
|
-
/**
|
|
801
|
-
* Serve the longest page that fits the response budget on both surfaces,
|
|
802
|
-
* enrichment included: the walk's own, or one cut back to an earlier
|
|
803
|
-
* comment. The first comment stays whatever its size.
|
|
804
|
-
*/
|
|
805
|
-
const served = new Map();
|
|
806
|
-
const serve = (index) => {
|
|
807
|
-
let at = served.get(index);
|
|
808
|
-
if (!at) {
|
|
809
|
-
const page = pageAt(traversal, index);
|
|
810
|
-
at = { page, enrichment: enrichmentFor(page) };
|
|
811
|
-
served.set(index, at);
|
|
812
|
-
}
|
|
813
|
-
return at;
|
|
814
|
-
};
|
|
815
|
-
const { page, enrichment } = serve(longestFittingPrefix(traversal.marks.length + 1, (index) => {
|
|
816
|
-
const { page, enrichment } = serve(index);
|
|
817
|
-
const added = enrichmentBytes(enrichment, threadTrailer);
|
|
818
|
-
return {
|
|
819
|
-
contentBytes: page.pageBytes.contentBytes + added.contentBytes,
|
|
820
|
-
structuredBytes: page.pageBytes.structuredBytes + added.structuredBytes,
|
|
821
|
-
};
|
|
822
|
-
}));
|
|
656
|
+
const totalLoaded = comments.length;
|
|
657
|
+
/** Comments returned across every page so far, this one included. */
|
|
658
|
+
const loadedSoFar = (resume?.offset ?? 0) + totalLoaded;
|
|
659
|
+
const failedCommentIds = traversal.failed.map((entry) => entry.id);
|
|
660
|
+
const failedIds = [...failedCommentIds, ...failedOptionIds];
|
|
661
|
+
const omittedCut = traversal.omittedDepthCut;
|
|
662
|
+
/** Depth 0 walks nothing, so the root's own replies are what its limit leaves unread. */
|
|
663
|
+
const depthCut = (depth === 0 && item.childCount > 0) ||
|
|
664
|
+
traversal.liveDepthCut ||
|
|
665
|
+
omittedCut.length > 0 ||
|
|
666
|
+
resume?.depthCut === true;
|
|
667
|
+
/** Failed comments lead the cursor, so continuing retries them rather than skipping them. */
|
|
668
|
+
const nextCursor = stop &&
|
|
669
|
+
encodeThreadCursor({
|
|
670
|
+
offset: loadedSoFar,
|
|
671
|
+
itemId: input.itemId,
|
|
672
|
+
depth,
|
|
673
|
+
frontier: [...traversal.failed, ...traversal.rest],
|
|
674
|
+
depthCut,
|
|
675
|
+
...(lookup?.story && { story: lookup.story }),
|
|
676
|
+
});
|
|
677
|
+
const noticeParts = [];
|
|
678
|
+
if (lookup?.gap) {
|
|
679
|
+
const retry = !lookup.transient
|
|
680
|
+
? ''
|
|
681
|
+
: nextCursor
|
|
682
|
+
? ' Continuing with nextCursor retries the lookup.'
|
|
683
|
+
: lookup.rateLimit
|
|
684
|
+
? ` Wait ${waitFor(lookup.rateLimit)}, then call again.`
|
|
685
|
+
: ' Call again to retry.';
|
|
686
|
+
noticeParts.push(`Could not find the story this comment belongs to: ${lookup.gap}. storyId is absent and replies by the story's author are not marked isOp.${retry}`);
|
|
687
|
+
}
|
|
688
|
+
else if (lookup?.story && !lookup.story.by) {
|
|
689
|
+
noticeParts.push(`The story this comment belongs to (id ${lookup.story.id}) has no author on record, so no reply is marked isOp.`);
|
|
690
|
+
}
|
|
691
|
+
if (traversal.omittedDeleted > 0 || traversal.omittedDead > 0) {
|
|
692
|
+
noticeParts.push(`${traversal.omittedDeleted} deleted, ${traversal.omittedDead} dead — omitted from this view.`);
|
|
693
|
+
}
|
|
694
|
+
if (failedCommentIds.length > 0) {
|
|
695
|
+
const n = failedCommentIds.length;
|
|
696
|
+
const retry = nextCursor
|
|
697
|
+
? `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.`
|
|
698
|
+
: 'Call again to retry, or pass an id as itemId to fetch that comment and its replies.';
|
|
699
|
+
noticeParts.push(`Could not fetch ${n} ${plural(n, 'comment')} (${plural(n, 'id')} ${failedCommentIds.join(', ')}); ${plural(n, 'its', 'their')} replies were not reached either. ${retry}`);
|
|
700
|
+
}
|
|
701
|
+
if (failedOptionIds.length > 0) {
|
|
702
|
+
const n = failedOptionIds.length;
|
|
703
|
+
noticeParts.push(`Could not fetch ${n} poll ${plural(n, 'option')} (${plural(n, 'id')} ${failedOptionIds.join(', ')}). Call again to retry.`);
|
|
704
|
+
}
|
|
705
|
+
const loaded = totalAvailable != null ? ` with ${loadedSoFar}/${totalAvailable} comments loaded` : '';
|
|
706
|
+
const resumeHint = 'Pass nextCursor as cursor, with the same itemId, to continue.';
|
|
707
|
+
/** Below the depth cap a larger depth reaches every omitted subtree, so a few names do. */
|
|
708
|
+
const named = depth < DEPTH_CAP ? omittedCut.slice(0, OMITTED_IDS_NAMED) : omittedCut;
|
|
709
|
+
const unnamed = omittedCut.length - named.length;
|
|
710
|
+
const omittedIds = `${plural(omittedCut.length, 'id')} ${named.join(', ')}${unnamed > 0 ? `, and ${unnamed} more` : ''}`;
|
|
711
|
+
switch (stop) {
|
|
712
|
+
case 'count':
|
|
713
|
+
noticeParts.push(`Stopped at maxComments ${maxComments}${loaded}.`, ...(input.maxComments > MAX_COMMENTS_CAP
|
|
714
|
+
? [servedAtCap('maxComments', input.maxComments, MAX_COMMENTS_CAP)]
|
|
715
|
+
: []), resumeHint);
|
|
716
|
+
break;
|
|
717
|
+
case 'examined':
|
|
718
|
+
noticeParts.push(`Stopped after examining ${MAX_ITEMS_EXAMINED.toLocaleString('en-US')} items in one call${loaded}. ${resumeHint}`);
|
|
719
|
+
break;
|
|
720
|
+
case 'rate_limited':
|
|
721
|
+
noticeParts.push(`Stopped early: the HN API is rate-limiting this server${loaded}. Wait ${waitFor(traversal.rateLimit)}, then pass nextCursor as cursor, with the same itemId, to resume; comments that failed to load are retried first.`);
|
|
722
|
+
break;
|
|
723
|
+
case 'time':
|
|
724
|
+
noticeParts.push(`Stopped early to stay within this call's ${CALL_BUDGET_MS / 1000}-second time budget${loaded}. ${resumeHint}`);
|
|
725
|
+
break;
|
|
726
|
+
case undefined:
|
|
727
|
+
if (depthCut && depth === 0) {
|
|
728
|
+
/** No comment was returned to drill into, so a larger depth is the only way in. */
|
|
729
|
+
noticeParts.push(`The depth limit (${depth}) left replies unread${loaded}. Raise depth (max ${DEPTH_CAP}) to read them.`);
|
|
730
|
+
}
|
|
731
|
+
else if (depthCut) {
|
|
732
|
+
const raise = depth < DEPTH_CAP ? `Raise depth (max ${DEPTH_CAP}), or pass` : 'Pass';
|
|
733
|
+
/** With a cursor its depth applies; the cap is named only when it is what was served. */
|
|
734
|
+
const overCap = input.depth > DEPTH_CAP && depth === DEPTH_CAP;
|
|
735
|
+
/**
|
|
736
|
+
* Name only drill-in targets this page can vouch for. A cut carried from
|
|
737
|
+
* an earlier page may have fallen below a comment shown there or one
|
|
738
|
+
* named there as omitted, and this page cannot tell which.
|
|
739
|
+
*/
|
|
740
|
+
const targets = [
|
|
741
|
+
traversal.liveDepthCut
|
|
742
|
+
? `a depth-${depth - 1} comment that shows replies`
|
|
743
|
+
: resume?.depthCut
|
|
744
|
+
? `a depth-${depth - 1} comment with replies on an earlier page (shown there, or named as omitted)`
|
|
745
|
+
: undefined,
|
|
746
|
+
omittedCut.length === 1
|
|
747
|
+
? `omitted comment ${omittedCut[0]}`
|
|
748
|
+
: omittedCut.length > 1
|
|
749
|
+
? `an omitted comment with replies (${omittedIds})`
|
|
750
|
+
: undefined,
|
|
751
|
+
].filter((target) => target != null);
|
|
752
|
+
noticeParts.push(`The depth limit (${depth}) left replies unread${loaded}.`, ...(overCap ? [servedAtCap('depth', input.depth, DEPTH_CAP)] : []), `${raise} the id of ${targets.join(', or of ')}${targets.length > 1 ? ',' : ''} as itemId to read its subtree.`);
|
|
753
|
+
}
|
|
754
|
+
else if (resume && totalLoaded === 0 && noticeParts.length === 0) {
|
|
755
|
+
noticeParts.push('No comments remain past this cursor; the thread has been read to the end.');
|
|
756
|
+
}
|
|
757
|
+
break;
|
|
758
|
+
}
|
|
759
|
+
/** A page nextCursor continues reports the depth cut later, by flag alone, so it names them now. */
|
|
760
|
+
if (stop && omittedCut.length > 0) {
|
|
761
|
+
const n = omittedCut.length;
|
|
762
|
+
const raiseAll = unnamed > 0 ? `, or raise depth (max ${DEPTH_CAP}) to read them all` : '';
|
|
763
|
+
noticeParts.push(`${n} omitted ${plural(n, 'comment')} (${omittedIds}) ${plural(n, 'has', 'have')} replies below the depth limit, which nextCursor does not reach; pass ${plural(n, 'its id', 'an id')} as itemId to read ${plural(n, 'its', "that comment's")} subtree${raiseAll}.`);
|
|
764
|
+
}
|
|
823
765
|
ctx.log.info('Resolved thread', {
|
|
824
766
|
itemId: input.itemId,
|
|
825
|
-
comments:
|
|
826
|
-
failed:
|
|
827
|
-
stop
|
|
828
|
-
...(page.comments.length < traversal.comments.length && {
|
|
829
|
-
fittedFrom: traversal.comments.length,
|
|
830
|
-
}),
|
|
767
|
+
comments: totalLoaded,
|
|
768
|
+
failed: failedIds.length,
|
|
769
|
+
stop,
|
|
831
770
|
...(lookup && { storyId: lookup.story?.id, storyHops: lookup.hops }),
|
|
832
771
|
});
|
|
833
|
-
const
|
|
834
|
-
ctx.enrich(
|
|
772
|
+
const capped = stop === 'count' || stop === 'examined';
|
|
773
|
+
ctx.enrich({
|
|
774
|
+
totalLoaded,
|
|
775
|
+
...(totalAvailable != null && { totalAvailable }),
|
|
776
|
+
...(failedIds.length > 0 && { failedIds }),
|
|
777
|
+
...((stop || depthCut) && { truncated: true }),
|
|
778
|
+
...(capped && { shown: totalLoaded, cap: maxComments }),
|
|
779
|
+
});
|
|
780
|
+
const notice = noticeParts.join(' ');
|
|
835
781
|
if (notice)
|
|
836
782
|
ctx.enrich.notice(notice);
|
|
837
|
-
if (
|
|
838
|
-
ctx.enrich({
|
|
839
|
-
|
|
783
|
+
if (stop || depthCut) {
|
|
784
|
+
ctx.enrich({
|
|
785
|
+
truncationReason: stop === 'examined' ? 'count' : (stop ?? 'depth'),
|
|
786
|
+
...(nextCursor && { nextCursor }),
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
return { item, comments };
|
|
840
790
|
},
|
|
841
791
|
format: (result) => [{ type: 'text', text: renderThread(result.item, result.comments) }],
|
|
842
792
|
});
|