@cyanheads/hn-mcp-server 0.5.21 → 0.6.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 +26 -10
- package/CLAUDE.md +26 -10
- package/Dockerfile +10 -4
- package/README.md +31 -19
- package/changelog/0.5.x/0.5.22.md +45 -0
- package/changelog/0.6.x/0.6.0.md +38 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- 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 -17
- package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +4 -2
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.js +427 -220
- package/dist/mcp-server/tools/definitions/get-thread.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/get-user.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-user.tool.js +176 -106
- package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +106 -0
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.js +532 -194
- package/dist/mcp-server/tools/definitions/search-content.tool.js.map +1 -1
- package/dist/mcp-server/tools/response-budget.d.ts +111 -0
- package/dist/mcp-server/tools/response-budget.d.ts.map +1 -0
- package/dist/mcp-server/tools/response-budget.js +204 -0
- package/dist/mcp-server/tools/response-budget.js.map +1 -0
- package/dist/mcp-server/tools/served-at-cap.d.ts +14 -0
- package/dist/mcp-server/tools/served-at-cap.d.ts.map +1 -0
- package/dist/mcp-server/tools/served-at-cap.js +16 -0
- package/dist/mcp-server/tools/served-at-cap.js.map +1 -0
- package/dist/mcp-server/tools/thread-cursor.d.ts +68 -0
- package/dist/mcp-server/tools/thread-cursor.d.ts.map +1 -0
- package/dist/mcp-server/tools/thread-cursor.js +200 -0
- package/dist/mcp-server/tools/thread-cursor.js.map +1 -0
- package/dist/services/hn/hn-service.d.ts +73 -12
- package/dist/services/hn/hn-service.d.ts.map +1 -1
- package/dist/services/hn/hn-service.js +127 -38
- package/dist/services/hn/hn-service.js.map +1 -1
- package/dist/services/hn/types.d.ts +9 -0
- package/dist/services/hn/types.d.ts.map +1 -1
- package/package.json +13 -12
- package/server.json +3 -3
- package/skills/hn-guide/SKILL.md +39 -0
|
@@ -4,19 +4,32 @@
|
|
|
4
4
|
* @module mcp-server/tools/definitions/get-thread.tool
|
|
5
5
|
*/
|
|
6
6
|
import { tool, z } from '@cyanheads/mcp-ts-core';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { JsonRpcErrorCode } from '@cyanheads/mcp-ts-core/errors';
|
|
8
|
+
import { escapeMarkdownInline, markdown } from '@cyanheads/mcp-ts-core/utils';
|
|
9
9
|
import { escapeInline, quoteBody } from '../markdown-escape.js';
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
import { byteLength, enrichmentBytes, fitsBudget, longestFittingPrefix, RESPONSE_BUDGET_TEXT, } from '../response-budget.js';
|
|
11
|
+
import { servedAtCap } from '../served-at-cap.js';
|
|
12
|
+
import { decodeThreadCursor, encodeThreadCursor, } from '../thread-cursor.js';
|
|
13
|
+
import { CALL_BUDGET_MS, decodeHtmlEntities, getHnService, isDeadlineCut, isRateLimited, normalizeUrl, REQUEST_TIMEOUT_MS, stripHtml, } from '../../../services/hn/hn-service.js';
|
|
14
|
+
/** Most comments one call returns; a larger `maxComments` is served at this cap. */
|
|
15
|
+
const MAX_COMMENTS_CAP = 200;
|
|
16
|
+
/** Largest `maxComments` accepted; anything above it is rejected. */
|
|
17
|
+
const MAX_COMMENTS_CEILING = 1000;
|
|
15
18
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
19
|
+
* Most reply levels one call resolves; a larger `depth` is served at this cap.
|
|
20
|
+
* Every issued cursor carries a depth at or below it, so it also bounds the
|
|
21
|
+
* depth a cursor may claim.
|
|
18
22
|
*/
|
|
19
|
-
const
|
|
23
|
+
const DEPTH_CAP = 10;
|
|
24
|
+
/** Largest `depth` accepted; anything above it is rejected. */
|
|
25
|
+
const DEPTH_CEILING = 100;
|
|
26
|
+
/**
|
|
27
|
+
* Most dead or deleted comments a notice names when the depth limit cut their
|
|
28
|
+
* replies and a larger depth still reaches the rest. At the depth cap an ID is
|
|
29
|
+
* the only way into those replies, so every one is named — at most the items
|
|
30
|
+
* one call examines.
|
|
31
|
+
*/
|
|
32
|
+
const OMITTED_IDS_NAMED = 10;
|
|
20
33
|
/** Most comments one batch fetches — bounds the fetches a size cut leaves unused. */
|
|
21
34
|
const FETCH_CHUNK = 25;
|
|
22
35
|
/**
|
|
@@ -25,6 +38,18 @@ const FETCH_CHUNK = 25;
|
|
|
25
38
|
* them would run unbounded.
|
|
26
39
|
*/
|
|
27
40
|
const MAX_ITEMS_EXAMINED = 1_000;
|
|
41
|
+
/**
|
|
42
|
+
* Most `parent` links one call follows to find a comment root's story. The
|
|
43
|
+
* deepest comment in 22 of HN's largest threads is 32 links below its story.
|
|
44
|
+
*/
|
|
45
|
+
const MAX_STORY_HOPS = 64;
|
|
46
|
+
/**
|
|
47
|
+
* The story lookup ends this long after the call starts, its retries and
|
|
48
|
+
* backoff included, so a slow root fetch leaves it less time rather than
|
|
49
|
+
* stretching the call. It is the lookup's slice of the call's
|
|
50
|
+
* {@link CALL_BUDGET_MS}, leaving the traversal at least the rest.
|
|
51
|
+
*/
|
|
52
|
+
const STORY_LOOKUP_DEADLINE_MS = 20_000;
|
|
28
53
|
// ---------------------------------------------------------------------------
|
|
29
54
|
// Schemas
|
|
30
55
|
// ---------------------------------------------------------------------------
|
|
@@ -53,11 +78,18 @@ const ItemSchema = z
|
|
|
53
78
|
url: z.string().optional().describe('External link URL.'),
|
|
54
79
|
text: z.string().optional().describe('Body text (HTML stripped).'),
|
|
55
80
|
score: z.number().optional().describe('Upvote count.'),
|
|
56
|
-
descendants: z
|
|
81
|
+
descendants: z
|
|
82
|
+
.number()
|
|
83
|
+
.optional()
|
|
84
|
+
.describe("HN's count of live comments in the thread; dead and deleted comments are not counted."),
|
|
57
85
|
parent: z
|
|
58
86
|
.number()
|
|
59
87
|
.optional()
|
|
60
88
|
.describe('For a comment root, the ID of the story or comment it replies to — pass it as itemId for the context above.'),
|
|
89
|
+
storyId: z
|
|
90
|
+
.number()
|
|
91
|
+
.optional()
|
|
92
|
+
.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).'),
|
|
61
93
|
poll: z.number().optional().describe('For a poll-option root, the ID of its poll.'),
|
|
62
94
|
parts: z.array(z.number()).optional().describe('For a poll root, its option IDs in HN order.'),
|
|
63
95
|
deleted: z
|
|
@@ -81,19 +113,21 @@ const CommentSchema = z
|
|
|
81
113
|
time: z.number().optional().describe('Unix timestamp.'),
|
|
82
114
|
text: z.string().optional().describe('Comment text (HTML stripped).'),
|
|
83
115
|
depth: z.number().describe('Nesting level (0 = direct reply to root).'),
|
|
84
|
-
parentId: z
|
|
116
|
+
parentId: z
|
|
117
|
+
.number()
|
|
118
|
+
.describe('Parent item ID: the root or another comment. It can name a dead or deleted comment, which this list omits while still returning its replies; pass that ID as itemId to see it marked.'),
|
|
85
119
|
childCount: z
|
|
86
120
|
.number()
|
|
87
121
|
.describe('Number of direct child comments (may exceed what was resolved).'),
|
|
88
122
|
isOp: z
|
|
89
123
|
.literal(true)
|
|
90
124
|
.optional()
|
|
91
|
-
.describe('Present and `true` when the comment author
|
|
125
|
+
.describe('Present and `true` when the comment author submitted the story or poll the thread belongs to (OP replying within their own thread) — for a comment root, the story at item.storyId. Omitted otherwise, including when either author is missing. Most threads carry no OP replies, so absence usually means "not OP"; it means unknown when the thread\'s root has no author (a deleted story, for example) or when notice says replies are not marked.'),
|
|
92
126
|
})
|
|
93
127
|
.describe('A single comment in the thread with its tree position.');
|
|
94
128
|
// ---------------------------------------------------------------------------
|
|
95
|
-
// Rendering — format() and the
|
|
96
|
-
// exactly the text format() produces.
|
|
129
|
+
// Rendering — format() and the response budget share these, so the budget
|
|
130
|
+
// measures exactly the text format() produces.
|
|
97
131
|
// ---------------------------------------------------------------------------
|
|
98
132
|
const TYPE_LABELS = {
|
|
99
133
|
story: 'Story',
|
|
@@ -104,8 +138,6 @@ const TYPE_LABELS = {
|
|
|
104
138
|
};
|
|
105
139
|
/** Separator format() places between the root block and the first comment. */
|
|
106
140
|
const COMMENTS_DIVIDER = '\n---\n';
|
|
107
|
-
const utf8 = new TextEncoder();
|
|
108
|
-
const byteLength = (text) => utf8.encode(text).byteLength;
|
|
109
141
|
/** Heading that names the root's type, state, and — for a poll option — its poll. */
|
|
110
142
|
function rootHeading(item) {
|
|
111
143
|
const state = item.deleted ? '[deleted] ' : item.dead ? '[dead] ' : '';
|
|
@@ -135,6 +167,7 @@ function renderRoot(item) {
|
|
|
135
167
|
`id:${item.id}`,
|
|
136
168
|
`type:${item.type}`,
|
|
137
169
|
item.parent != null ? `parent:${item.parent}` : null,
|
|
170
|
+
item.storyId != null ? `story:${item.storyId}` : null,
|
|
138
171
|
item.poll != null ? `poll:${item.poll}` : null,
|
|
139
172
|
item.parts?.length ? `parts:${item.parts.join(',')}` : null,
|
|
140
173
|
item.score != null ? `${item.score} pts` : null,
|
|
@@ -175,75 +208,67 @@ function renderThread(item, comments) {
|
|
|
175
208
|
}
|
|
176
209
|
return parts.join('\n');
|
|
177
210
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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;
|
|
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;
|
|
230
243
|
return {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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,
|
|
236
254
|
};
|
|
237
255
|
}
|
|
238
256
|
/**
|
|
239
257
|
* Ranked BFS over the frontier: every comment at one depth before any at the
|
|
240
258
|
* next, and HN's kids order within each parent. Fetches in chunks no larger
|
|
241
259
|
* than the comments still wanted, so filtered and failed items never push a
|
|
242
|
-
* later sibling out of the page — anything unvisited stays
|
|
260
|
+
* later sibling out of the page — anything unvisited stays queued.
|
|
243
261
|
*
|
|
244
|
-
* Stops on `maxComments`, the item ceiling, the
|
|
245
|
-
*
|
|
246
|
-
*
|
|
262
|
+
* Stops on `maxComments`, the item ceiling, the response budget, a rate limit,
|
|
263
|
+
* or the call's time budget. The budget check here sizes the page alone, on
|
|
264
|
+
* both surfaces, and keeps the first comment whatever its size; the enrichment
|
|
265
|
+
* is known only once the walk stops, so the handler fits it by serving the page
|
|
266
|
+
* at an earlier mark. After a rate limit nothing more is fetched: every level
|
|
267
|
+
* below would pay its own retry ladder against a throttled upstream. No batch
|
|
268
|
+
* is sent with less than one request timeout left before `deadline`, so a fetch
|
|
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.
|
|
247
272
|
*/
|
|
248
273
|
async function walkThread(hn, ctx, args) {
|
|
249
274
|
const levels = Array.from({ length: args.depth }, () => []);
|
|
@@ -251,25 +276,61 @@ async function walkThread(hn, ctx, args) {
|
|
|
251
276
|
levels[entry.level]?.push(entry);
|
|
252
277
|
const comments = [];
|
|
253
278
|
const failed = [];
|
|
254
|
-
let {
|
|
279
|
+
let { page, rateLimit } = args;
|
|
255
280
|
let omittedDeleted = 0;
|
|
256
281
|
let omittedDead = 0;
|
|
257
|
-
let
|
|
282
|
+
let liveDepthCut = false;
|
|
283
|
+
const omittedDepthCut = [];
|
|
258
284
|
let examined = 0;
|
|
285
|
+
let timeCut = false;
|
|
259
286
|
let stop;
|
|
287
|
+
/** The level being read, and its next entry; every level above is read. */
|
|
288
|
+
let atLevel = 0;
|
|
289
|
+
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
|
+
/** Queue a visited comment's replies one level down; true when the depth limit cut them instead. */
|
|
306
|
+
const enqueueKids = (c, level) => {
|
|
307
|
+
if (!c.kids?.length)
|
|
308
|
+
return false;
|
|
309
|
+
const next = levels[level + 1];
|
|
310
|
+
if (!next)
|
|
311
|
+
return true;
|
|
312
|
+
next.push(...c.kids.map((id) => ({ id, level: level + 1, parentId: c.id })));
|
|
313
|
+
return false;
|
|
314
|
+
};
|
|
260
315
|
walk: for (const [level, queue] of levels.entries()) {
|
|
261
|
-
|
|
316
|
+
atLevel = level;
|
|
317
|
+
atHead = 0;
|
|
318
|
+
while (atHead < queue.length) {
|
|
262
319
|
if (rateLimit)
|
|
263
320
|
stop = 'rate_limited';
|
|
264
321
|
else if (comments.length >= args.maxComments)
|
|
265
322
|
stop = 'count';
|
|
266
323
|
else if (examined >= MAX_ITEMS_EXAMINED)
|
|
267
324
|
stop = 'examined';
|
|
325
|
+
else if (args.deadline - Date.now() < REQUEST_TIMEOUT_MS)
|
|
326
|
+
stop = 'time';
|
|
268
327
|
if (stop)
|
|
269
328
|
break walk;
|
|
270
|
-
const
|
|
329
|
+
const head = atHead;
|
|
330
|
+
const batch = queue.slice(head, head +
|
|
331
|
+
Math.min(args.maxComments - comments.length, FETCH_CHUNK, MAX_ITEMS_EXAMINED - examined));
|
|
271
332
|
examined += batch.length;
|
|
272
|
-
const slots = await hn.fetchItems(batch.map((entry) => entry.id), ctx);
|
|
333
|
+
const slots = await hn.fetchItems(batch.map((entry) => entry.id), ctx, args.deadline);
|
|
273
334
|
let visited = 0;
|
|
274
335
|
for (const [i, entry] of batch.entries()) {
|
|
275
336
|
const slot = slots[i];
|
|
@@ -285,57 +346,97 @@ async function walkThread(hn, ctx, args) {
|
|
|
285
346
|
depth: level,
|
|
286
347
|
parentId: entry.parentId,
|
|
287
348
|
childCount: c.kids?.length ?? 0,
|
|
288
|
-
...(c.by != null && c.by === args.
|
|
349
|
+
...(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),
|
|
289
358
|
};
|
|
290
|
-
|
|
291
|
-
(comments.length === 0
|
|
292
|
-
? byteLength(`\n${COMMENTS_DIVIDER}\n`)
|
|
293
|
-
: byteLength(COMMENT_SEPARATOR));
|
|
294
|
-
if (comments.length > 0 && usedBytes + bytes > RESPONSE_BUDGET_BYTES) {
|
|
359
|
+
if (!first && !fitsBudget(grown)) {
|
|
295
360
|
stop = 'size';
|
|
296
361
|
break;
|
|
297
362
|
}
|
|
298
363
|
comments.push(comment);
|
|
299
|
-
|
|
300
|
-
if (c
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
else
|
|
305
|
-
depthCut = true;
|
|
306
|
-
}
|
|
364
|
+
page = grown;
|
|
365
|
+
if (enqueueKids(c, level))
|
|
366
|
+
liveDepthCut = true;
|
|
367
|
+
atHead = head + i + 1;
|
|
368
|
+
marks.push(mark());
|
|
307
369
|
}
|
|
308
370
|
else if (slot.kind === 'failed') {
|
|
309
371
|
failed.push(entry);
|
|
310
372
|
if (isRateLimited(slot.error))
|
|
311
373
|
rateLimit ??= slot.error;
|
|
374
|
+
else if (isDeadlineCut(slot.error))
|
|
375
|
+
timeCut = true;
|
|
312
376
|
}
|
|
313
377
|
else if (slot.kind === 'item') {
|
|
378
|
+
/** Omitted, but its live replies are still owed at their true depth. */
|
|
314
379
|
if (slot.item.deleted)
|
|
315
380
|
omittedDeleted++;
|
|
316
381
|
else
|
|
317
382
|
omittedDead++;
|
|
383
|
+
if (enqueueKids(slot.item, level))
|
|
384
|
+
omittedDepthCut.push(slot.item.id);
|
|
318
385
|
}
|
|
319
386
|
visited++;
|
|
320
387
|
}
|
|
321
|
-
|
|
388
|
+
atHead = head + visited;
|
|
322
389
|
if (stop)
|
|
323
390
|
break walk;
|
|
324
391
|
}
|
|
325
392
|
}
|
|
326
|
-
/** A rate limit on the last batch still leaves its failed entries to resume. */
|
|
393
|
+
/** A rate limit or a time cut on the last batch still leaves its failed entries to resume. */
|
|
327
394
|
if (!stop && rateLimit && failed.length > 0)
|
|
328
395
|
stop = 'rate_limited';
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
396
|
+
else if (!stop && timeCut)
|
|
397
|
+
stop = 'time';
|
|
398
|
+
return { comments, end: mark(), failed, levels, marks, omittedDepthCut, stop };
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Find the story or poll a comment root belongs to by following `parent` links
|
|
402
|
+
* up from it, one request per link, all bounded by one `deadline`. A failed,
|
|
403
|
+
* missing, or parentless ancestor, the hop ceiling, or the deadline ends the
|
|
404
|
+
* lookup with a `gap` instead of failing the call; a cancellation propagates.
|
|
405
|
+
*/
|
|
406
|
+
async function findStory(hn, ctx, root, deadline) {
|
|
407
|
+
const outOfTime = 'the lookup ran out of time';
|
|
408
|
+
let next = root.parent;
|
|
409
|
+
let below = 'it';
|
|
410
|
+
let hops = 0;
|
|
411
|
+
while (next != null) {
|
|
412
|
+
if (hops === MAX_STORY_HOPS) {
|
|
413
|
+
return { hops, gap: `it sits more than ${MAX_STORY_HOPS} levels below its story` };
|
|
414
|
+
}
|
|
415
|
+
if (Date.now() >= deadline)
|
|
416
|
+
return { hops, gap: outOfTime, transient: true };
|
|
417
|
+
hops++;
|
|
418
|
+
const [slot] = await hn.fetchItems([next], ctx, deadline);
|
|
419
|
+
if (slot?.kind === 'failed') {
|
|
420
|
+
if (isRateLimited(slot.error)) {
|
|
421
|
+
return {
|
|
422
|
+
hops,
|
|
423
|
+
gap: 'the HN API is rate-limiting this server',
|
|
424
|
+
rateLimit: slot.error,
|
|
425
|
+
transient: true,
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
const gap = Date.now() >= deadline ? outOfTime : `ancestor ${next} failed to load`;
|
|
429
|
+
return { hops, gap, transient: true };
|
|
430
|
+
}
|
|
431
|
+
if (slot?.kind !== 'item')
|
|
432
|
+
return { hops, gap: `ancestor ${next} does not exist` };
|
|
433
|
+
if (slot.item.type !== 'comment') {
|
|
434
|
+
return { hops, story: { id: slot.item.id, by: slot.item.by } };
|
|
435
|
+
}
|
|
436
|
+
below = `ancestor ${slot.item.id}`;
|
|
437
|
+
next = slot.item.parent;
|
|
438
|
+
}
|
|
439
|
+
return { hops, gap: `HN lists no parent for ${below}` };
|
|
339
440
|
}
|
|
340
441
|
function projectOption(option) {
|
|
341
442
|
return {
|
|
@@ -346,12 +447,17 @@ function projectOption(option) {
|
|
|
346
447
|
...(option.dead && { dead: true }),
|
|
347
448
|
};
|
|
348
449
|
}
|
|
349
|
-
/**
|
|
450
|
+
/**
|
|
451
|
+
* A 429's `Retry-After`, worded for a notice. A notice is authored Markdown and
|
|
452
|
+
* the header is upstream text, so an HTTP-date (or anything else) is escaped.
|
|
453
|
+
*/
|
|
350
454
|
function waitFor(rateLimit) {
|
|
351
455
|
const retryAfter = rateLimit?.data?.retryAfter;
|
|
352
456
|
if (typeof retryAfter !== 'string')
|
|
353
457
|
return 'several seconds';
|
|
354
|
-
return /^\d+$/.test(retryAfter)
|
|
458
|
+
return /^\d+$/.test(retryAfter)
|
|
459
|
+
? `${retryAfter} seconds`
|
|
460
|
+
: `until ${escapeMarkdownInline(retryAfter)}`;
|
|
355
461
|
}
|
|
356
462
|
const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many);
|
|
357
463
|
// ---------------------------------------------------------------------------
|
|
@@ -371,8 +477,8 @@ export const getThread = tool('hn_get_thread', {
|
|
|
371
477
|
{
|
|
372
478
|
reason: 'invalid_cursor',
|
|
373
479
|
code: JsonRpcErrorCode.InvalidParams,
|
|
374
|
-
when: 'The cursor was issued for a different itemId than the one passed with it.',
|
|
375
|
-
recovery: 'Pass
|
|
480
|
+
when: 'The cursor is not one this tool issued, was altered, or was issued for a different itemId than the one passed with it.',
|
|
481
|
+
recovery: 'Pass nextCursor unchanged, with the same itemId it was returned for, or omit cursor to start this item from the top.',
|
|
376
482
|
severity: 'notice',
|
|
377
483
|
},
|
|
378
484
|
{
|
|
@@ -424,16 +530,16 @@ export const getThread = tool('hn_get_thread', {
|
|
|
424
530
|
.number()
|
|
425
531
|
.int()
|
|
426
532
|
.min(0)
|
|
427
|
-
.max(
|
|
533
|
+
.max(DEPTH_CEILING)
|
|
428
534
|
.default(3)
|
|
429
|
-
.describe(`How many levels of replies to resolve. 0 = just the item, no comments. 1 = direct replies only. Replies below
|
|
535
|
+
.describe(`How many levels of replies to resolve, at most ${DEPTH_CAP} per call. 0 = just the item, no comments. 1 = direct replies only. A larger value is served at ${DEPTH_CAP}. Replies below the depth served are never fetched — to read them, raise depth up to ${DEPTH_CAP}, or call again with a comment from the deepest level returned, or one notice names, as itemId to drill into its subtree. Ignored when cursor is set: the depth the cursor was issued with applies.`),
|
|
430
536
|
maxComments: z
|
|
431
537
|
.number()
|
|
432
538
|
.int()
|
|
433
539
|
.min(1)
|
|
434
|
-
.max(
|
|
540
|
+
.max(MAX_COMMENTS_CEILING)
|
|
435
541
|
.default(50)
|
|
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
|
|
542
|
+
.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. A response also stops between comments to stay within a fixed ${RESPONSE_BUDGET_TEXT}-byte budget on each surface — all content[] text, nextCursor and the rest of the trailer included, and the structuredContent JSON — though its first comment is always returned whole. When either limit stops the traversal, nextCursor continues it.`),
|
|
437
543
|
cursor: z
|
|
438
544
|
.string()
|
|
439
545
|
.optional()
|
|
@@ -452,47 +558,64 @@ export const getThread = tool('hn_get_thread', {
|
|
|
452
558
|
totalAvailable: z
|
|
453
559
|
.number()
|
|
454
560
|
.optional()
|
|
455
|
-
.describe("HN's
|
|
561
|
+
.describe("HN's count of live comments in the thread (descendants); dead and deleted comments are not counted. Absent for comment and job roots."),
|
|
456
562
|
truncated: z
|
|
457
563
|
.boolean()
|
|
458
564
|
.optional()
|
|
459
|
-
.describe('True when comments remain unread: reachable through nextCursor (truncationReason count, size, or
|
|
565
|
+
.describe('True when comments remain unread: reachable through nextCursor (truncationReason count, size, 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.'),
|
|
460
566
|
truncationReason: z
|
|
461
|
-
.enum(['count', 'size', 'depth', 'rate_limited'])
|
|
567
|
+
.enum(['count', 'size', 'depth', 'rate_limited', 'time'])
|
|
462
568
|
.optional()
|
|
463
|
-
.describe(
|
|
569
|
+
.describe(`Why the traversal stopped short: count = maxComments (or the 1,000-item ceiling per call) was reached; size = the page stopped between comments to keep content[] and structuredContent each within the ${RESPONSE_BUDGET_TEXT}-byte response budget; 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.`),
|
|
464
570
|
nextCursor: z
|
|
465
571
|
.string()
|
|
466
572
|
.optional()
|
|
467
|
-
.describe('Pass as cursor, with the same itemId, to continue from the next unseen comment. Present when truncationReason is count, size, or
|
|
573
|
+
.describe('Pass as cursor, with the same itemId, to continue from the next unseen comment. Present when truncationReason is count, size, rate_limited, or time; absent on the last page.'),
|
|
468
574
|
shown: z.number().optional().describe('Number of comments returned.'),
|
|
469
|
-
cap: z
|
|
575
|
+
cap: z
|
|
576
|
+
.number()
|
|
577
|
+
.optional()
|
|
578
|
+
.describe(`The maxComments value served: as passed, or ${MAX_COMMENTS_CAP} when maxComments was above that cap.`),
|
|
470
579
|
failedIds: z
|
|
471
580
|
.array(z.number())
|
|
472
581
|
.optional()
|
|
473
|
-
.describe(
|
|
582
|
+
.describe(`Comment and poll-option IDs whose fetch failed after retries or ran past the call's ${CALL_BUDGET_MS / 1000}-second time budget, 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.`),
|
|
474
583
|
notice: z
|
|
475
584
|
.string()
|
|
476
585
|
.optional()
|
|
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,
|
|
478
|
-
},
|
|
479
|
-
enrichmentTrailer: {
|
|
480
|
-
failedIds: { render: (ids) => `**Failed to load:** ${(ids ?? []).join(', ')}` },
|
|
586
|
+
.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, 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.'),
|
|
481
587
|
},
|
|
588
|
+
enrichmentTrailer: threadTrailer,
|
|
482
589
|
async handler(input, ctx) {
|
|
483
|
-
const
|
|
590
|
+
const startedAt = Date.now();
|
|
591
|
+
/** Bounds every request below — root, story lookup, poll options, every batch. */
|
|
592
|
+
const deadline = startedAt + CALL_BUDGET_MS;
|
|
593
|
+
const resume = input.cursor ? decodeThreadCursor(input.cursor, DEPTH_CAP) : undefined;
|
|
594
|
+
if (input.cursor && !resume) {
|
|
595
|
+
throw ctx.fail('invalid_cursor', 'cursor is not one hn_get_thread issued, or it was altered.', { itemId: input.itemId });
|
|
596
|
+
}
|
|
484
597
|
if (resume && resume.itemId !== input.itemId) {
|
|
485
598
|
throw ctx.fail('invalid_cursor', `This cursor continues item ${resume.itemId}, not item ${input.itemId}.`, { itemId: input.itemId });
|
|
486
599
|
}
|
|
487
600
|
const hn = getHnService();
|
|
488
|
-
const root = await hn.fetchItem(input.itemId, ctx);
|
|
601
|
+
const root = await hn.fetchItem(input.itemId, ctx, deadline);
|
|
489
602
|
if (!root) {
|
|
490
603
|
throw ctx.fail('item_not_found', `Item ${input.itemId} not found`, { itemId: input.itemId });
|
|
491
604
|
}
|
|
605
|
+
/**
|
|
606
|
+
* OP on a comment root means the author of the story above it. A cursor
|
|
607
|
+
* carries the story an earlier page found; one issued before it was found
|
|
608
|
+
* looks again.
|
|
609
|
+
*/
|
|
610
|
+
const lookup = root.type !== 'comment'
|
|
611
|
+
? undefined
|
|
612
|
+
: resume?.story
|
|
613
|
+
? { hops: 0, story: resume.story }
|
|
614
|
+
: await findStory(hn, ctx, root, startedAt + STORY_LOOKUP_DEADLINE_MS);
|
|
492
615
|
const options = [];
|
|
493
616
|
const failedOptionIds = [];
|
|
494
617
|
let optionRateLimit;
|
|
495
|
-
for (const slot of root.parts?.length ? await hn.fetchItems(root.parts, ctx) : []) {
|
|
618
|
+
for (const slot of root.parts?.length ? await hn.fetchItems(root.parts, ctx, deadline) : []) {
|
|
496
619
|
if (slot.kind === 'item') {
|
|
497
620
|
options.push(projectOption(slot.item));
|
|
498
621
|
}
|
|
@@ -518,110 +641,194 @@ export const getThread = tool('hn_get_thread', {
|
|
|
518
641
|
score: root.score,
|
|
519
642
|
descendants: root.descendants,
|
|
520
643
|
...(root.parent != null && { parent: root.parent }),
|
|
644
|
+
...(lookup?.story && { storyId: lookup.story.id }),
|
|
521
645
|
...(root.poll != null && { poll: root.poll }),
|
|
522
646
|
...(root.parts && root.parts.length > 0 && { parts: root.parts }),
|
|
523
647
|
...(root.deleted && { deleted: true }),
|
|
524
648
|
...(root.dead && { dead: true }),
|
|
525
649
|
...(options.length > 0 && { options }),
|
|
526
650
|
};
|
|
527
|
-
const depth = resume?.depth ?? input.depth;
|
|
651
|
+
const depth = resume?.depth ?? Math.min(input.depth, DEPTH_CAP);
|
|
652
|
+
const maxComments = Math.min(input.maxComments, MAX_COMMENTS_CAP);
|
|
528
653
|
const seed = resume?.frontier ??
|
|
529
654
|
(depth > 0 ? (root.kids ?? []).map((id) => ({ id, level: 0, parentId: root.id })) : []);
|
|
530
|
-
const
|
|
655
|
+
const traversal = await walkThread(hn, ctx, {
|
|
656
|
+
deadline,
|
|
531
657
|
depth,
|
|
532
|
-
maxComments
|
|
533
|
-
|
|
534
|
-
|
|
658
|
+
maxComments,
|
|
659
|
+
opAuthor: lookup ? lookup.story?.by : root.by,
|
|
660
|
+
page: {
|
|
661
|
+
contentBytes: byteLength(renderRoot(item)),
|
|
662
|
+
structuredBytes: byteLength(JSON.stringify({ item, comments: [] })),
|
|
663
|
+
},
|
|
664
|
+
rateLimit: optionRateLimit ?? lookup?.rateLimit,
|
|
535
665
|
seed,
|
|
536
|
-
usedBytes: byteLength(renderRoot(item)),
|
|
537
666
|
});
|
|
538
|
-
const
|
|
667
|
+
const totalAvailable = root.descendants;
|
|
668
|
+
/** The enrichment serving `walk`, every field in the order it is written. */
|
|
669
|
+
const enrichmentFor = (walk) => {
|
|
670
|
+
const { comments, stop } = walk;
|
|
671
|
+
const totalLoaded = comments.length;
|
|
672
|
+
/** Comments returned across every page so far, this one included. */
|
|
673
|
+
const loadedSoFar = (resume?.offset ?? 0) + totalLoaded;
|
|
674
|
+
const failedCommentIds = walk.failed.map((entry) => entry.id);
|
|
675
|
+
const failedIds = [...failedCommentIds, ...failedOptionIds];
|
|
676
|
+
const omittedCut = walk.omittedDepthCut;
|
|
677
|
+
const depthCut = walk.liveDepthCut || omittedCut.length > 0 || resume?.depthCut === true;
|
|
678
|
+
/** Failed comments lead the cursor, so continuing retries them rather than skipping them. */
|
|
679
|
+
const nextCursor = stop &&
|
|
680
|
+
encodeThreadCursor({
|
|
681
|
+
offset: loadedSoFar,
|
|
682
|
+
itemId: input.itemId,
|
|
683
|
+
depth,
|
|
684
|
+
frontier: [...walk.failed, ...walk.rest],
|
|
685
|
+
depthCut,
|
|
686
|
+
...(lookup?.story && { story: lookup.story }),
|
|
687
|
+
});
|
|
688
|
+
const noticeParts = [];
|
|
689
|
+
if (lookup?.gap) {
|
|
690
|
+
const retry = !lookup.transient
|
|
691
|
+
? ''
|
|
692
|
+
: nextCursor
|
|
693
|
+
? ' Continuing with nextCursor retries the lookup.'
|
|
694
|
+
: lookup.rateLimit
|
|
695
|
+
? ` Wait ${waitFor(lookup.rateLimit)}, then call again.`
|
|
696
|
+
: ' Call again to retry.';
|
|
697
|
+
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}`);
|
|
698
|
+
}
|
|
699
|
+
else if (lookup?.story && !lookup.story.by) {
|
|
700
|
+
noticeParts.push(`The story this comment belongs to (id ${lookup.story.id}) has no author on record, so no reply is marked isOp.`);
|
|
701
|
+
}
|
|
702
|
+
if (walk.omittedDeleted > 0 || walk.omittedDead > 0) {
|
|
703
|
+
noticeParts.push(`${walk.omittedDeleted} deleted, ${walk.omittedDead} dead — omitted from this view.`);
|
|
704
|
+
}
|
|
705
|
+
if (failedCommentIds.length > 0) {
|
|
706
|
+
const n = failedCommentIds.length;
|
|
707
|
+
const retry = nextCursor
|
|
708
|
+
? `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.`
|
|
709
|
+
: 'Call again to retry, or pass an id as itemId to fetch that comment and its replies.';
|
|
710
|
+
noticeParts.push(`Could not fetch ${n} ${plural(n, 'comment')} (${plural(n, 'id')} ${failedCommentIds.join(', ')}); ${plural(n, 'its', 'their')} replies were not reached either. ${retry}`);
|
|
711
|
+
}
|
|
712
|
+
if (failedOptionIds.length > 0) {
|
|
713
|
+
const n = failedOptionIds.length;
|
|
714
|
+
noticeParts.push(`Could not fetch ${n} poll ${plural(n, 'option')} (${plural(n, 'id')} ${failedOptionIds.join(', ')}). Call again to retry.`);
|
|
715
|
+
}
|
|
716
|
+
const loaded = totalAvailable != null ? ` with ${loadedSoFar}/${totalAvailable} comments loaded` : '';
|
|
717
|
+
const resumeHint = 'Pass nextCursor as cursor, with the same itemId, to continue.';
|
|
718
|
+
/** Below the depth cap a larger depth reaches every omitted subtree, so a few names do. */
|
|
719
|
+
const named = depth < DEPTH_CAP ? omittedCut.slice(0, OMITTED_IDS_NAMED) : omittedCut;
|
|
720
|
+
const unnamed = omittedCut.length - named.length;
|
|
721
|
+
const omittedIds = `${plural(omittedCut.length, 'id')} ${named.join(', ')}${unnamed > 0 ? `, and ${unnamed} more` : ''}`;
|
|
722
|
+
switch (stop) {
|
|
723
|
+
case 'count':
|
|
724
|
+
noticeParts.push(`Stopped at maxComments ${maxComments}${loaded}.`, ...(input.maxComments > MAX_COMMENTS_CAP
|
|
725
|
+
? [servedAtCap('maxComments', input.maxComments, MAX_COMMENTS_CAP)]
|
|
726
|
+
: []), resumeHint);
|
|
727
|
+
break;
|
|
728
|
+
case 'examined':
|
|
729
|
+
noticeParts.push(`Stopped after examining ${MAX_ITEMS_EXAMINED.toLocaleString('en-US')} items in one call${loaded}. ${resumeHint}`);
|
|
730
|
+
break;
|
|
731
|
+
case 'size':
|
|
732
|
+
noticeParts.push(`Stopped at the ${RESPONSE_BUDGET_TEXT}-byte response budget after ${totalLoaded} ${plural(totalLoaded, 'comment')}${loaded}. ${resumeHint}`);
|
|
733
|
+
break;
|
|
734
|
+
case 'rate_limited':
|
|
735
|
+
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.`);
|
|
736
|
+
break;
|
|
737
|
+
case 'time':
|
|
738
|
+
noticeParts.push(`Stopped early to stay within this call's ${CALL_BUDGET_MS / 1000}-second time budget${loaded}. ${resumeHint}`);
|
|
739
|
+
break;
|
|
740
|
+
case undefined:
|
|
741
|
+
if (depthCut) {
|
|
742
|
+
const raise = depth < DEPTH_CAP ? `Raise depth (max ${DEPTH_CAP}), or pass` : 'Pass';
|
|
743
|
+
/** With a cursor its depth applies; the cap is named only when it is what was served. */
|
|
744
|
+
const overCap = input.depth > DEPTH_CAP && depth === DEPTH_CAP;
|
|
745
|
+
/**
|
|
746
|
+
* Name only drill-in targets this page can vouch for. A cut carried from
|
|
747
|
+
* an earlier page may have fallen below a comment shown there or one
|
|
748
|
+
* named there as omitted, and this page cannot tell which.
|
|
749
|
+
*/
|
|
750
|
+
const targets = [
|
|
751
|
+
walk.liveDepthCut
|
|
752
|
+
? `a depth-${depth - 1} comment that shows replies`
|
|
753
|
+
: resume?.depthCut
|
|
754
|
+
? `a depth-${depth - 1} comment with replies on an earlier page (shown there, or named as omitted)`
|
|
755
|
+
: undefined,
|
|
756
|
+
omittedCut.length === 1
|
|
757
|
+
? `omitted comment ${omittedCut[0]}`
|
|
758
|
+
: omittedCut.length > 1
|
|
759
|
+
? `an omitted comment with replies (${omittedIds})`
|
|
760
|
+
: undefined,
|
|
761
|
+
].filter((target) => target != null);
|
|
762
|
+
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.`);
|
|
763
|
+
}
|
|
764
|
+
else if (resume && totalLoaded === 0 && noticeParts.length === 0) {
|
|
765
|
+
noticeParts.push('No comments remain past this cursor; the thread has been read to the end.');
|
|
766
|
+
}
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
/** A page nextCursor continues reports the depth cut later, by flag alone, so it names them now. */
|
|
770
|
+
if (stop && omittedCut.length > 0) {
|
|
771
|
+
const n = omittedCut.length;
|
|
772
|
+
const raiseAll = unnamed > 0 ? `, or raise depth (max ${DEPTH_CAP}) to read them all` : '';
|
|
773
|
+
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}.`);
|
|
774
|
+
}
|
|
775
|
+
const notice = noticeParts.join(' ');
|
|
776
|
+
const capped = stop === 'count' || stop === 'examined' || stop === 'size';
|
|
777
|
+
return {
|
|
778
|
+
totalLoaded,
|
|
779
|
+
...(totalAvailable != null && { totalAvailable }),
|
|
780
|
+
...(failedIds.length > 0 && { failedIds }),
|
|
781
|
+
...((capped || stop === 'rate_limited' || stop === 'time' || depthCut) && {
|
|
782
|
+
truncated: true,
|
|
783
|
+
}),
|
|
784
|
+
...(capped && { shown: totalLoaded, cap: maxComments }),
|
|
785
|
+
...(notice.length > 0 && { notice }),
|
|
786
|
+
...((stop || depthCut) && {
|
|
787
|
+
truncationReason: stop === 'examined' ? 'count' : (stop ?? 'depth'),
|
|
788
|
+
...(nextCursor && { nextCursor }),
|
|
789
|
+
}),
|
|
790
|
+
};
|
|
791
|
+
};
|
|
792
|
+
/**
|
|
793
|
+
* Serve the longest page that fits the response budget on both surfaces,
|
|
794
|
+
* enrichment included: the walk's own, or one cut back to an earlier
|
|
795
|
+
* comment. The first comment stays whatever its size.
|
|
796
|
+
*/
|
|
797
|
+
const served = new Map();
|
|
798
|
+
const serve = (index) => {
|
|
799
|
+
let at = served.get(index);
|
|
800
|
+
if (!at) {
|
|
801
|
+
const page = pageAt(traversal, index);
|
|
802
|
+
at = { page, enrichment: enrichmentFor(page) };
|
|
803
|
+
served.set(index, at);
|
|
804
|
+
}
|
|
805
|
+
return at;
|
|
806
|
+
};
|
|
807
|
+
const { page, enrichment } = serve(longestFittingPrefix(traversal.marks.length + 1, (index) => {
|
|
808
|
+
const { page, enrichment } = serve(index);
|
|
809
|
+
const added = enrichmentBytes(enrichment, threadTrailer);
|
|
810
|
+
return {
|
|
811
|
+
contentBytes: page.pageBytes.contentBytes + added.contentBytes,
|
|
812
|
+
structuredBytes: page.pageBytes.structuredBytes + added.structuredBytes,
|
|
813
|
+
};
|
|
814
|
+
}));
|
|
539
815
|
ctx.log.info('Resolved thread', {
|
|
540
816
|
itemId: input.itemId,
|
|
541
|
-
comments: comments.length,
|
|
542
|
-
failed:
|
|
543
|
-
stop,
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
const totalAvailable = root.descendants;
|
|
549
|
-
const failedCommentIds = walk.failed.map((entry) => entry.id);
|
|
550
|
-
const failedIds = [...failedCommentIds, ...failedOptionIds];
|
|
551
|
-
ctx.enrich({
|
|
552
|
-
totalLoaded,
|
|
553
|
-
...(totalAvailable != null && { totalAvailable }),
|
|
554
|
-
...(failedIds.length > 0 && { failedIds }),
|
|
817
|
+
comments: page.comments.length,
|
|
818
|
+
failed: page.failed.length + failedOptionIds.length,
|
|
819
|
+
stop: page.stop,
|
|
820
|
+
...(page.comments.length < traversal.comments.length && {
|
|
821
|
+
fittedFrom: traversal.comments.length,
|
|
822
|
+
}),
|
|
823
|
+
...(lookup && { storyId: lookup.story?.id, storyHops: lookup.hops }),
|
|
555
824
|
});
|
|
556
|
-
const
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
encodeThreadCursor({
|
|
560
|
-
offset: loadedSoFar,
|
|
561
|
-
limit: input.maxComments,
|
|
562
|
-
itemId: input.itemId,
|
|
563
|
-
depth,
|
|
564
|
-
frontier: [...walk.failed, ...walk.rest],
|
|
565
|
-
depthCut,
|
|
566
|
-
});
|
|
567
|
-
const noticeParts = [];
|
|
568
|
-
if (walk.omittedDeleted > 0 || walk.omittedDead > 0) {
|
|
569
|
-
noticeParts.push(`${walk.omittedDeleted} deleted, ${walk.omittedDead} dead — omitted from this view.`);
|
|
570
|
-
}
|
|
571
|
-
if (failedCommentIds.length > 0) {
|
|
572
|
-
const n = failedCommentIds.length;
|
|
573
|
-
const retry = nextCursor
|
|
574
|
-
? `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.`
|
|
575
|
-
: 'Call again to retry, or pass an id as itemId to fetch that comment and its replies.';
|
|
576
|
-
noticeParts.push(`Could not fetch ${n} ${plural(n, 'comment')} (${plural(n, 'id')} ${failedCommentIds.join(', ')}); ${plural(n, 'its', 'their')} replies were not reached either. ${retry}`);
|
|
577
|
-
}
|
|
578
|
-
if (failedOptionIds.length > 0) {
|
|
579
|
-
const n = failedOptionIds.length;
|
|
580
|
-
noticeParts.push(`Could not fetch ${n} poll ${plural(n, 'option')} (${plural(n, 'id')} ${failedOptionIds.join(', ')}). Call again to retry.`);
|
|
581
|
-
}
|
|
582
|
-
const loaded = totalAvailable != null ? ` with ${loadedSoFar}/${totalAvailable} comments loaded` : '';
|
|
583
|
-
const resumeHint = 'Pass nextCursor as cursor, with the same itemId, to continue.';
|
|
584
|
-
switch (stop) {
|
|
585
|
-
case 'count':
|
|
586
|
-
noticeParts.push(`Stopped at maxComments ${input.maxComments}${loaded}. ${resumeHint}`);
|
|
587
|
-
break;
|
|
588
|
-
case 'examined':
|
|
589
|
-
noticeParts.push(`Stopped after examining ${MAX_ITEMS_EXAMINED.toLocaleString('en-US')} items in one call${loaded}. ${resumeHint}`);
|
|
590
|
-
break;
|
|
591
|
-
case 'size':
|
|
592
|
-
noticeParts.push(`Stopped at the ${RESPONSE_BUDGET_BYTES.toLocaleString('en-US')}-byte response budget after ${totalLoaded} ${plural(totalLoaded, 'comment')}${loaded}. ${resumeHint}`);
|
|
593
|
-
break;
|
|
594
|
-
case 'rate_limited':
|
|
595
|
-
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.`);
|
|
596
|
-
break;
|
|
597
|
-
case undefined:
|
|
598
|
-
if (depthCut) {
|
|
599
|
-
const raise = depth < MAX_DEPTH ? `Raise depth (max ${MAX_DEPTH}), or pass` : 'Pass';
|
|
600
|
-
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.`);
|
|
601
|
-
}
|
|
602
|
-
else if (resume && totalLoaded === 0 && noticeParts.length === 0) {
|
|
603
|
-
noticeParts.push('No comments remain past this cursor; the thread has been read to the end.');
|
|
604
|
-
}
|
|
605
|
-
break;
|
|
606
|
-
}
|
|
607
|
-
const notice = noticeParts.join(' ');
|
|
608
|
-
if (stop === 'count' || stop === 'examined' || stop === 'size') {
|
|
609
|
-
ctx.enrich.truncated({ shown: totalLoaded, cap: input.maxComments, guidance: notice });
|
|
610
|
-
}
|
|
611
|
-
else if (stop === 'rate_limited' || depthCut) {
|
|
612
|
-
ctx.enrich({ truncated: true });
|
|
613
|
-
ctx.enrich.notice(notice);
|
|
614
|
-
}
|
|
615
|
-
else if (notice) {
|
|
825
|
+
const { notice, truncationReason, nextCursor, ...fields } = enrichment;
|
|
826
|
+
ctx.enrich(fields);
|
|
827
|
+
if (notice)
|
|
616
828
|
ctx.enrich.notice(notice);
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
truncationReason: stop === 'examined' ? 'count' : (stop ?? 'depth'),
|
|
621
|
-
...(nextCursor && { nextCursor }),
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
return { item, comments };
|
|
829
|
+
if (truncationReason)
|
|
830
|
+
ctx.enrich({ truncationReason, ...(nextCursor && { nextCursor }) });
|
|
831
|
+
return { item, comments: page.comments };
|
|
625
832
|
},
|
|
626
833
|
format: (result) => [{ type: 'text', text: renderThread(result.item, result.comments) }],
|
|
627
834
|
});
|