@cyanheads/hn-mcp-server 0.5.22 → 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 +22 -9
- package/CLAUDE.md +22 -9
- package/Dockerfile +9 -3
- package/README.md +19 -12
- 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 +9 -6
- package/dist/mcp-server/tools/definitions/get-stories.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts +1 -0
- package/dist/mcp-server/tools/definitions/get-thread.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/get-thread.tool.js +296 -182
- 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 +160 -104
- package/dist/mcp-server/tools/definitions/get-user.tool.js.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts +16 -3
- package/dist/mcp-server/tools/definitions/search-content.tool.d.ts.map +1 -1
- package/dist/mcp-server/tools/definitions/search-content.tool.js +263 -188
- 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/services/hn/hn-service.d.ts +45 -9
- package/dist/services/hn/hn-service.d.ts.map +1 -1
- package/dist/services/hn/hn-service.js +73 -17
- package/dist/services/hn/hn-service.js.map +1 -1
- package/package.json +8 -7
- package/server.json +3 -3
- package/skills/hn-guide/SKILL.md +39 -0
|
@@ -5,10 +5,12 @@
|
|
|
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, markdown } from '@cyanheads/mcp-ts-core/utils';
|
|
8
9
|
import { escapeInline, quoteBody } from '../markdown-escape.js';
|
|
10
|
+
import { byteLength, enrichmentBytes, fitsBudget, longestFittingPrefix, RESPONSE_BUDGET_TEXT, } from '../response-budget.js';
|
|
9
11
|
import { servedAtCap } from '../served-at-cap.js';
|
|
10
12
|
import { decodeThreadCursor, encodeThreadCursor, } from '../thread-cursor.js';
|
|
11
|
-
import { decodeHtmlEntities, getHnService, isRateLimited, normalizeUrl, stripHtml, } from '../../../services/hn/hn-service.js';
|
|
13
|
+
import { CALL_BUDGET_MS, decodeHtmlEntities, getHnService, isDeadlineCut, isRateLimited, normalizeUrl, REQUEST_TIMEOUT_MS, stripHtml, } from '../../../services/hn/hn-service.js';
|
|
12
14
|
/** Most comments one call returns; a larger `maxComments` is served at this cap. */
|
|
13
15
|
const MAX_COMMENTS_CAP = 200;
|
|
14
16
|
/** Largest `maxComments` accepted; anything above it is rejected. */
|
|
@@ -28,11 +30,6 @@ const DEPTH_CEILING = 100;
|
|
|
28
30
|
* one call examines.
|
|
29
31
|
*/
|
|
30
32
|
const OMITTED_IDS_NAMED = 10;
|
|
31
|
-
/**
|
|
32
|
-
* Byte budget for the rendered `format()` text. Checked between comments, so a
|
|
33
|
-
* page never ends mid-comment, and the first comment on a page is always kept.
|
|
34
|
-
*/
|
|
35
|
-
const RESPONSE_BUDGET_BYTES = 64_000;
|
|
36
33
|
/** Most comments one batch fetches — bounds the fetches a size cut leaves unused. */
|
|
37
34
|
const FETCH_CHUNK = 25;
|
|
38
35
|
/**
|
|
@@ -49,7 +46,8 @@ const MAX_STORY_HOPS = 64;
|
|
|
49
46
|
/**
|
|
50
47
|
* The story lookup ends this long after the call starts, its retries and
|
|
51
48
|
* backoff included, so a slow root fetch leaves it less time rather than
|
|
52
|
-
* stretching the call.
|
|
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.
|
|
53
51
|
*/
|
|
54
52
|
const STORY_LOOKUP_DEADLINE_MS = 20_000;
|
|
55
53
|
// ---------------------------------------------------------------------------
|
|
@@ -128,8 +126,8 @@ const CommentSchema = z
|
|
|
128
126
|
})
|
|
129
127
|
.describe('A single comment in the thread with its tree position.');
|
|
130
128
|
// ---------------------------------------------------------------------------
|
|
131
|
-
// Rendering — format() and the
|
|
132
|
-
// exactly the text format() produces.
|
|
129
|
+
// Rendering — format() and the response budget share these, so the budget
|
|
130
|
+
// measures exactly the text format() produces.
|
|
133
131
|
// ---------------------------------------------------------------------------
|
|
134
132
|
const TYPE_LABELS = {
|
|
135
133
|
story: 'Story',
|
|
@@ -140,8 +138,6 @@ const TYPE_LABELS = {
|
|
|
140
138
|
};
|
|
141
139
|
/** Separator format() places between the root block and the first comment. */
|
|
142
140
|
const COMMENTS_DIVIDER = '\n---\n';
|
|
143
|
-
const utf8 = new TextEncoder();
|
|
144
|
-
const byteLength = (text) => utf8.encode(text).byteLength;
|
|
145
141
|
/** Heading that names the root's type, state, and — for a poll option — its poll. */
|
|
146
142
|
function rootHeading(item) {
|
|
147
143
|
const state = item.deleted ? '[deleted] ' : item.dead ? '[dead] ' : '';
|
|
@@ -212,15 +208,67 @@ function renderThread(item, comments) {
|
|
|
212
208
|
}
|
|
213
209
|
return parts.join('\n');
|
|
214
210
|
}
|
|
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
|
+
}
|
|
215
256
|
/**
|
|
216
257
|
* Ranked BFS over the frontier: every comment at one depth before any at the
|
|
217
258
|
* next, and HN's kids order within each parent. Fetches in chunks no larger
|
|
218
259
|
* than the comments still wanted, so filtered and failed items never push a
|
|
219
|
-
* later sibling out of the page — anything unvisited stays
|
|
260
|
+
* later sibling out of the page — anything unvisited stays queued.
|
|
220
261
|
*
|
|
221
|
-
* Stops on `maxComments`, the item ceiling, the
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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.
|
|
224
272
|
*/
|
|
225
273
|
async function walkThread(hn, ctx, args) {
|
|
226
274
|
const levels = Array.from({ length: args.depth }, () => []);
|
|
@@ -228,13 +276,32 @@ async function walkThread(hn, ctx, args) {
|
|
|
228
276
|
levels[entry.level]?.push(entry);
|
|
229
277
|
const comments = [];
|
|
230
278
|
const failed = [];
|
|
231
|
-
let {
|
|
279
|
+
let { page, rateLimit } = args;
|
|
232
280
|
let omittedDeleted = 0;
|
|
233
281
|
let omittedDead = 0;
|
|
234
282
|
let liveDepthCut = false;
|
|
235
283
|
const omittedDepthCut = [];
|
|
236
284
|
let examined = 0;
|
|
285
|
+
let timeCut = false;
|
|
237
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
|
+
});
|
|
238
305
|
/** Queue a visited comment's replies one level down; true when the depth limit cut them instead. */
|
|
239
306
|
const enqueueKids = (c, level) => {
|
|
240
307
|
if (!c.kids?.length)
|
|
@@ -246,18 +313,24 @@ async function walkThread(hn, ctx, args) {
|
|
|
246
313
|
return false;
|
|
247
314
|
};
|
|
248
315
|
walk: for (const [level, queue] of levels.entries()) {
|
|
249
|
-
|
|
316
|
+
atLevel = level;
|
|
317
|
+
atHead = 0;
|
|
318
|
+
while (atHead < queue.length) {
|
|
250
319
|
if (rateLimit)
|
|
251
320
|
stop = 'rate_limited';
|
|
252
321
|
else if (comments.length >= args.maxComments)
|
|
253
322
|
stop = 'count';
|
|
254
323
|
else if (examined >= MAX_ITEMS_EXAMINED)
|
|
255
324
|
stop = 'examined';
|
|
325
|
+
else if (args.deadline - Date.now() < REQUEST_TIMEOUT_MS)
|
|
326
|
+
stop = 'time';
|
|
256
327
|
if (stop)
|
|
257
328
|
break walk;
|
|
258
|
-
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));
|
|
259
332
|
examined += batch.length;
|
|
260
|
-
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);
|
|
261
334
|
let visited = 0;
|
|
262
335
|
for (const [i, entry] of batch.entries()) {
|
|
263
336
|
const slot = slots[i];
|
|
@@ -275,23 +348,31 @@ async function walkThread(hn, ctx, args) {
|
|
|
275
348
|
childCount: c.kids?.length ?? 0,
|
|
276
349
|
...(c.by != null && c.by === args.opAuthor && { isOp: true }),
|
|
277
350
|
};
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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)) {
|
|
283
360
|
stop = 'size';
|
|
284
361
|
break;
|
|
285
362
|
}
|
|
286
363
|
comments.push(comment);
|
|
287
|
-
|
|
364
|
+
page = grown;
|
|
288
365
|
if (enqueueKids(c, level))
|
|
289
366
|
liveDepthCut = true;
|
|
367
|
+
atHead = head + i + 1;
|
|
368
|
+
marks.push(mark());
|
|
290
369
|
}
|
|
291
370
|
else if (slot.kind === 'failed') {
|
|
292
371
|
failed.push(entry);
|
|
293
372
|
if (isRateLimited(slot.error))
|
|
294
373
|
rateLimit ??= slot.error;
|
|
374
|
+
else if (isDeadlineCut(slot.error))
|
|
375
|
+
timeCut = true;
|
|
295
376
|
}
|
|
296
377
|
else if (slot.kind === 'item') {
|
|
297
378
|
/** Omitted, but its live replies are still owed at their true depth. */
|
|
@@ -304,25 +385,17 @@ async function walkThread(hn, ctx, args) {
|
|
|
304
385
|
}
|
|
305
386
|
visited++;
|
|
306
387
|
}
|
|
307
|
-
|
|
388
|
+
atHead = head + visited;
|
|
308
389
|
if (stop)
|
|
309
390
|
break walk;
|
|
310
391
|
}
|
|
311
392
|
}
|
|
312
|
-
/** 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. */
|
|
313
394
|
if (!stop && rateLimit && failed.length > 0)
|
|
314
395
|
stop = 'rate_limited';
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
liveDepthCut,
|
|
319
|
-
omittedDead,
|
|
320
|
-
omittedDepthCut,
|
|
321
|
-
omittedDeleted,
|
|
322
|
-
rateLimit,
|
|
323
|
-
rest: levels.flat(),
|
|
324
|
-
stop,
|
|
325
|
-
};
|
|
396
|
+
else if (!stop && timeCut)
|
|
397
|
+
stop = 'time';
|
|
398
|
+
return { comments, end: mark(), failed, levels, marks, omittedDepthCut, stop };
|
|
326
399
|
}
|
|
327
400
|
/**
|
|
328
401
|
* Find the story or poll a comment root belongs to by following `parent` links
|
|
@@ -374,12 +447,17 @@ function projectOption(option) {
|
|
|
374
447
|
...(option.dead && { dead: true }),
|
|
375
448
|
};
|
|
376
449
|
}
|
|
377
|
-
/**
|
|
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
|
+
*/
|
|
378
454
|
function waitFor(rateLimit) {
|
|
379
455
|
const retryAfter = rateLimit?.data?.retryAfter;
|
|
380
456
|
if (typeof retryAfter !== 'string')
|
|
381
457
|
return 'several seconds';
|
|
382
|
-
return /^\d+$/.test(retryAfter)
|
|
458
|
+
return /^\d+$/.test(retryAfter)
|
|
459
|
+
? `${retryAfter} seconds`
|
|
460
|
+
: `until ${escapeMarkdownInline(retryAfter)}`;
|
|
383
461
|
}
|
|
384
462
|
const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many);
|
|
385
463
|
// ---------------------------------------------------------------------------
|
|
@@ -461,7 +539,7 @@ export const getThread = tool('hn_get_thread', {
|
|
|
461
539
|
.min(1)
|
|
462
540
|
.max(MAX_COMMENTS_CEILING)
|
|
463
541
|
.default(50)
|
|
464
|
-
.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
|
|
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.`),
|
|
465
543
|
cursor: z
|
|
466
544
|
.string()
|
|
467
545
|
.optional()
|
|
@@ -484,15 +562,15 @@ export const getThread = tool('hn_get_thread', {
|
|
|
484
562
|
truncated: z
|
|
485
563
|
.boolean()
|
|
486
564
|
.optional()
|
|
487
|
-
.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.'),
|
|
488
566
|
truncationReason: z
|
|
489
|
-
.enum(['count', 'size', 'depth', 'rate_limited'])
|
|
567
|
+
.enum(['count', 'size', 'depth', 'rate_limited', 'time'])
|
|
490
568
|
.optional()
|
|
491
|
-
.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.`),
|
|
492
570
|
nextCursor: z
|
|
493
571
|
.string()
|
|
494
572
|
.optional()
|
|
495
|
-
.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.'),
|
|
496
574
|
shown: z.number().optional().describe('Number of comments returned.'),
|
|
497
575
|
cap: z
|
|
498
576
|
.number()
|
|
@@ -501,17 +579,17 @@ export const getThread = tool('hn_get_thread', {
|
|
|
501
579
|
failedIds: z
|
|
502
580
|
.array(z.number())
|
|
503
581
|
.optional()
|
|
504
|
-
.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.`),
|
|
505
583
|
notice: z
|
|
506
584
|
.string()
|
|
507
585
|
.optional()
|
|
508
|
-
.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,
|
|
509
|
-
},
|
|
510
|
-
enrichmentTrailer: {
|
|
511
|
-
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.'),
|
|
512
587
|
},
|
|
588
|
+
enrichmentTrailer: threadTrailer,
|
|
513
589
|
async handler(input, ctx) {
|
|
514
590
|
const startedAt = Date.now();
|
|
591
|
+
/** Bounds every request below — root, story lookup, poll options, every batch. */
|
|
592
|
+
const deadline = startedAt + CALL_BUDGET_MS;
|
|
515
593
|
const resume = input.cursor ? decodeThreadCursor(input.cursor, DEPTH_CAP) : undefined;
|
|
516
594
|
if (input.cursor && !resume) {
|
|
517
595
|
throw ctx.fail('invalid_cursor', 'cursor is not one hn_get_thread issued, or it was altered.', { itemId: input.itemId });
|
|
@@ -520,7 +598,7 @@ export const getThread = tool('hn_get_thread', {
|
|
|
520
598
|
throw ctx.fail('invalid_cursor', `This cursor continues item ${resume.itemId}, not item ${input.itemId}.`, { itemId: input.itemId });
|
|
521
599
|
}
|
|
522
600
|
const hn = getHnService();
|
|
523
|
-
const root = await hn.fetchItem(input.itemId, ctx);
|
|
601
|
+
const root = await hn.fetchItem(input.itemId, ctx, deadline);
|
|
524
602
|
if (!root) {
|
|
525
603
|
throw ctx.fail('item_not_found', `Item ${input.itemId} not found`, { itemId: input.itemId });
|
|
526
604
|
}
|
|
@@ -537,7 +615,7 @@ export const getThread = tool('hn_get_thread', {
|
|
|
537
615
|
const options = [];
|
|
538
616
|
const failedOptionIds = [];
|
|
539
617
|
let optionRateLimit;
|
|
540
|
-
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) : []) {
|
|
541
619
|
if (slot.kind === 'item') {
|
|
542
620
|
options.push(projectOption(slot.item));
|
|
543
621
|
}
|
|
@@ -574,147 +652,183 @@ export const getThread = tool('hn_get_thread', {
|
|
|
574
652
|
const maxComments = Math.min(input.maxComments, MAX_COMMENTS_CAP);
|
|
575
653
|
const seed = resume?.frontier ??
|
|
576
654
|
(depth > 0 ? (root.kids ?? []).map((id) => ({ id, level: 0, parentId: root.id })) : []);
|
|
577
|
-
const
|
|
655
|
+
const traversal = await walkThread(hn, ctx, {
|
|
656
|
+
deadline,
|
|
578
657
|
depth,
|
|
579
658
|
maxComments,
|
|
580
659
|
opAuthor: lookup ? lookup.story?.by : root.by,
|
|
660
|
+
page: {
|
|
661
|
+
contentBytes: byteLength(renderRoot(item)),
|
|
662
|
+
structuredBytes: byteLength(JSON.stringify({ item, comments: [] })),
|
|
663
|
+
},
|
|
581
664
|
rateLimit: optionRateLimit ?? lookup?.rateLimit,
|
|
582
665
|
seed,
|
|
583
|
-
usedBytes: byteLength(renderRoot(item)),
|
|
584
666
|
});
|
|
585
|
-
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
|
+
}));
|
|
586
815
|
ctx.log.info('Resolved thread', {
|
|
587
816
|
itemId: input.itemId,
|
|
588
|
-
comments: comments.length,
|
|
589
|
-
failed:
|
|
590
|
-
stop,
|
|
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
|
+
}),
|
|
591
823
|
...(lookup && { storyId: lookup.story?.id, storyHops: lookup.hops }),
|
|
592
824
|
});
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const totalAvailable = root.descendants;
|
|
597
|
-
const failedCommentIds = walk.failed.map((entry) => entry.id);
|
|
598
|
-
const failedIds = [...failedCommentIds, ...failedOptionIds];
|
|
599
|
-
ctx.enrich({
|
|
600
|
-
totalLoaded,
|
|
601
|
-
...(totalAvailable != null && { totalAvailable }),
|
|
602
|
-
...(failedIds.length > 0 && { failedIds }),
|
|
603
|
-
});
|
|
604
|
-
const omittedCut = walk.omittedDepthCut;
|
|
605
|
-
const depthCut = walk.liveDepthCut || omittedCut.length > 0 || resume?.depthCut === true;
|
|
606
|
-
/** Failed comments lead the cursor, so continuing retries them rather than skipping them. */
|
|
607
|
-
const nextCursor = stop &&
|
|
608
|
-
encodeThreadCursor({
|
|
609
|
-
offset: loadedSoFar,
|
|
610
|
-
itemId: input.itemId,
|
|
611
|
-
depth,
|
|
612
|
-
frontier: [...walk.failed, ...walk.rest],
|
|
613
|
-
depthCut,
|
|
614
|
-
...(lookup?.story && { story: lookup.story }),
|
|
615
|
-
});
|
|
616
|
-
const noticeParts = [];
|
|
617
|
-
if (lookup?.gap) {
|
|
618
|
-
const retry = !lookup.transient
|
|
619
|
-
? ''
|
|
620
|
-
: nextCursor
|
|
621
|
-
? ' Continuing with nextCursor retries the lookup.'
|
|
622
|
-
: lookup.rateLimit
|
|
623
|
-
? ` Wait ${waitFor(lookup.rateLimit)}, then call again.`
|
|
624
|
-
: ' Call again to retry.';
|
|
625
|
-
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}`);
|
|
626
|
-
}
|
|
627
|
-
else if (lookup?.story && !lookup.story.by) {
|
|
628
|
-
noticeParts.push(`The story this comment belongs to (id ${lookup.story.id}) has no author on record, so no reply is marked isOp.`);
|
|
629
|
-
}
|
|
630
|
-
if (walk.omittedDeleted > 0 || walk.omittedDead > 0) {
|
|
631
|
-
noticeParts.push(`${walk.omittedDeleted} deleted, ${walk.omittedDead} dead — omitted from this view.`);
|
|
632
|
-
}
|
|
633
|
-
if (failedCommentIds.length > 0) {
|
|
634
|
-
const n = failedCommentIds.length;
|
|
635
|
-
const retry = nextCursor
|
|
636
|
-
? `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.`
|
|
637
|
-
: 'Call again to retry, or pass an id as itemId to fetch that comment and its replies.';
|
|
638
|
-
noticeParts.push(`Could not fetch ${n} ${plural(n, 'comment')} (${plural(n, 'id')} ${failedCommentIds.join(', ')}); ${plural(n, 'its', 'their')} replies were not reached either. ${retry}`);
|
|
639
|
-
}
|
|
640
|
-
if (failedOptionIds.length > 0) {
|
|
641
|
-
const n = failedOptionIds.length;
|
|
642
|
-
noticeParts.push(`Could not fetch ${n} poll ${plural(n, 'option')} (${plural(n, 'id')} ${failedOptionIds.join(', ')}). Call again to retry.`);
|
|
643
|
-
}
|
|
644
|
-
const loaded = totalAvailable != null ? ` with ${loadedSoFar}/${totalAvailable} comments loaded` : '';
|
|
645
|
-
const resumeHint = 'Pass nextCursor as cursor, with the same itemId, to continue.';
|
|
646
|
-
/** Below the depth cap a larger depth reaches every omitted subtree, so a few names do. */
|
|
647
|
-
const named = depth < DEPTH_CAP ? omittedCut.slice(0, OMITTED_IDS_NAMED) : omittedCut;
|
|
648
|
-
const unnamed = omittedCut.length - named.length;
|
|
649
|
-
const omittedIds = `${plural(omittedCut.length, 'id')} ${named.join(', ')}${unnamed > 0 ? `, and ${unnamed} more` : ''}`;
|
|
650
|
-
switch (stop) {
|
|
651
|
-
case 'count':
|
|
652
|
-
noticeParts.push(`Stopped at maxComments ${maxComments}${loaded}.`, ...(input.maxComments > MAX_COMMENTS_CAP
|
|
653
|
-
? [servedAtCap('maxComments', input.maxComments, MAX_COMMENTS_CAP)]
|
|
654
|
-
: []), resumeHint);
|
|
655
|
-
break;
|
|
656
|
-
case 'examined':
|
|
657
|
-
noticeParts.push(`Stopped after examining ${MAX_ITEMS_EXAMINED.toLocaleString('en-US')} items in one call${loaded}. ${resumeHint}`);
|
|
658
|
-
break;
|
|
659
|
-
case 'size':
|
|
660
|
-
noticeParts.push(`Stopped at the ${RESPONSE_BUDGET_BYTES.toLocaleString('en-US')}-byte response budget after ${totalLoaded} ${plural(totalLoaded, 'comment')}${loaded}. ${resumeHint}`);
|
|
661
|
-
break;
|
|
662
|
-
case 'rate_limited':
|
|
663
|
-
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.`);
|
|
664
|
-
break;
|
|
665
|
-
case undefined:
|
|
666
|
-
if (depthCut) {
|
|
667
|
-
const raise = depth < DEPTH_CAP ? `Raise depth (max ${DEPTH_CAP}), or pass` : 'Pass';
|
|
668
|
-
/** With a cursor its depth applies; the cap is named only when it is what was served. */
|
|
669
|
-
const overCap = input.depth > DEPTH_CAP && depth === DEPTH_CAP;
|
|
670
|
-
/**
|
|
671
|
-
* Name only drill-in targets this page can vouch for. A cut carried from
|
|
672
|
-
* an earlier page may have fallen below a comment shown there or one
|
|
673
|
-
* named there as omitted, and this page cannot tell which.
|
|
674
|
-
*/
|
|
675
|
-
const targets = [
|
|
676
|
-
walk.liveDepthCut
|
|
677
|
-
? `a depth-${depth - 1} comment that shows replies`
|
|
678
|
-
: resume?.depthCut
|
|
679
|
-
? `a depth-${depth - 1} comment with replies on an earlier page (shown there, or named as omitted)`
|
|
680
|
-
: undefined,
|
|
681
|
-
omittedCut.length === 1
|
|
682
|
-
? `omitted comment ${omittedCut[0]}`
|
|
683
|
-
: omittedCut.length > 1
|
|
684
|
-
? `an omitted comment with replies (${omittedIds})`
|
|
685
|
-
: undefined,
|
|
686
|
-
].filter((target) => target != null);
|
|
687
|
-
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.`);
|
|
688
|
-
}
|
|
689
|
-
else if (resume && totalLoaded === 0 && noticeParts.length === 0) {
|
|
690
|
-
noticeParts.push('No comments remain past this cursor; the thread has been read to the end.');
|
|
691
|
-
}
|
|
692
|
-
break;
|
|
693
|
-
}
|
|
694
|
-
/** A page nextCursor continues reports the depth cut later, by flag alone, so it names them now. */
|
|
695
|
-
if (stop && omittedCut.length > 0) {
|
|
696
|
-
const n = omittedCut.length;
|
|
697
|
-
const raiseAll = unnamed > 0 ? `, or raise depth (max ${DEPTH_CAP}) to read them all` : '';
|
|
698
|
-
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}.`);
|
|
699
|
-
}
|
|
700
|
-
const notice = noticeParts.join(' ');
|
|
701
|
-
if (stop === 'count' || stop === 'examined' || stop === 'size') {
|
|
702
|
-
ctx.enrich.truncated({ shown: totalLoaded, cap: maxComments, guidance: notice });
|
|
703
|
-
}
|
|
704
|
-
else if (stop === 'rate_limited' || depthCut) {
|
|
705
|
-
ctx.enrich({ truncated: true });
|
|
825
|
+
const { notice, truncationReason, nextCursor, ...fields } = enrichment;
|
|
826
|
+
ctx.enrich(fields);
|
|
827
|
+
if (notice)
|
|
706
828
|
ctx.enrich.notice(notice);
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
}
|
|
711
|
-
if (stop || depthCut) {
|
|
712
|
-
ctx.enrich({
|
|
713
|
-
truncationReason: stop === 'examined' ? 'count' : (stop ?? 'depth'),
|
|
714
|
-
...(nextCursor && { nextCursor }),
|
|
715
|
-
});
|
|
716
|
-
}
|
|
717
|
-
return { item, comments };
|
|
829
|
+
if (truncationReason)
|
|
830
|
+
ctx.enrich({ truncationReason, ...(nextCursor && { nextCursor }) });
|
|
831
|
+
return { item, comments: page.comments };
|
|
718
832
|
},
|
|
719
833
|
format: (result) => [{ type: 'text', text: renderThread(result.item, result.comments) }],
|
|
720
834
|
});
|