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