@recursiv/sdk 0.7.52 → 0.7.54

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.
@@ -1,9 +1,126 @@
1
- import type { DiscoveryLayout, Post } from '../types.js';
1
+ import type { HttpClient } from '../client.js';
2
+ import type { DiscoveryLayout, FalloverStep, Post } from '../types.js';
2
3
  import type { CuratorResource } from './curator.js';
3
4
  import type { DiscoveryLearning } from './discovery-learning.js';
4
5
  import type { PostsResource } from './posts.js';
5
6
  import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar, SocialRadarPosts } from './protocols.js';
6
7
  export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
8
+ /** What GET /discover/recommendations suggests: members, groups, tags, open-network accounts or World Today stories. */
9
+ export type EntityKind = 'member' | 'group' | 'tag' | 'network_account' | 'story';
10
+ /**
11
+ * One suggestion. `key` is the entity's id (a uuid), or `<protocol>:<native id>` for an open-network account.
12
+ * The public display fields depend on the kind: members carry id, username, name, image, bio and
13
+ * follower_count; groups id, name, slug, image, description and member_count; tags id, name and slug;
14
+ * open-network accounts protocol, actor, handle, display_name and avatar_url (never a link out); stories id,
15
+ * headline, kicker, story_kind and edition_date.
16
+ */
17
+ export interface EntityRecommendation {
18
+ kind: EntityKind;
19
+ key: string;
20
+ /** active: posted in the last 7 days or 4+ times in 30 (for groups, 3+ distinct posters in 30 days); warm: activity in 90 days. */
21
+ tier: 'active' | 'warm';
22
+ score: number;
23
+ /** The entity's share of the requested category, or null for category=all. */
24
+ share: number | null;
25
+ last_active_at: string | null;
26
+ posts_30d: number;
27
+ /** Distinct posters in 30 days (groups and tags), otherwise null. */
28
+ authors_30d: number | null;
29
+ /** Up to three browse categories, largest share first. */
30
+ categories: Array<{
31
+ category: string;
32
+ share: number;
33
+ }>;
34
+ [display: string]: unknown;
35
+ }
36
+ export interface EntityRecommendationsParams {
37
+ kind: EntityKind;
38
+ /** 'all' (default) or a browse category id. */
39
+ category?: string;
40
+ /** 1..50, default 10. */
41
+ limit?: number;
42
+ /** A per-session token ([A-Za-z0-9_-], up to 64): the same token keeps the order stable; a new one rotates near-ties. */
43
+ session?: string;
44
+ /** Up to 50 keys of the kind to leave out, e.g. open-network accounts the member follows through a connected account. */
45
+ exclude?: string[];
46
+ }
47
+ export interface EntityRecommendationsResponse {
48
+ data: EntityRecommendation[];
49
+ meta: {
50
+ kind: EntityKind;
51
+ category: string;
52
+ as_of: string | null;
53
+ /** Why the list is empty: serving is off, the key's app is not the one signals are built for, no snapshot yet, the viewer's follows could not be checked, or the key has no app. */
54
+ reason?: 'disabled' | 'other_app' | 'no_snapshot' | 'viewer_facts_unavailable' | 'no_app';
55
+ /** Suggestions left out because they are resting for this viewer, and because the viewer follows, joined, blocked or muted them. */
56
+ resting: number;
57
+ excluded: number;
58
+ /** True when the member's own category order lifted suggestions in their first categories. */
59
+ personalized: boolean;
60
+ };
61
+ }
62
+ export interface EntityImpressionsInput {
63
+ kind: EntityKind;
64
+ /** 1..50 keys of the kind. */
65
+ keys: string[];
66
+ /** 'shown' when a suggestion was on screen; 'engaged' on a tap, follow or join. Three showings without a tap rest a suggestion for fourteen days; engaging clears it. */
67
+ event: 'shown' | 'engaged';
68
+ }
69
+ /** One section of a category surface: bounded, read under its own deadline; empty with a `reason` when it timed out, failed, is off, or the recommender said why. */
70
+ export interface CategorySurfaceSection<T> {
71
+ items: T[];
72
+ ms: number;
73
+ reason?: string;
74
+ }
75
+ /** A radar cluster themed into the category, with up to six members (headline or excerpt and link only). */
76
+ export type CategoryStory = Pick<SocialRadar['clusters'][number], 'id' | 'label' | 'kind' | 'theme' | 'sources' | 'corroboration' | 'laneMix' | 'velocity' | 'media' | 'freshness' | 'score' | 'members'>;
77
+ export interface CategoryClip {
78
+ id: string;
79
+ title: string | null;
80
+ excerpt: string;
81
+ created_at: string;
82
+ author: {
83
+ id: string;
84
+ username: string | null;
85
+ name: string;
86
+ image: string | null;
87
+ };
88
+ media: Array<{
89
+ id: string;
90
+ url: string;
91
+ type: string;
92
+ thumbnail_url: string | null;
93
+ unplayable?: boolean;
94
+ }>;
95
+ engagement: {
96
+ score: number;
97
+ like_count: number;
98
+ reply_count: number;
99
+ repost_count: number;
100
+ };
101
+ }
102
+ export interface CategorySurface {
103
+ category: string;
104
+ /** When the oldest cached section served (stories, network, clips) was built. */
105
+ as_of: string;
106
+ stories: CategorySurfaceSection<CategoryStory>;
107
+ network: CategorySurfaceSection<SocialPost> & {
108
+ by_protocol: Record<string, number>;
109
+ };
110
+ clips: CategorySurfaceSection<CategoryClip>;
111
+ people: CategorySurfaceSection<EntityRecommendation>;
112
+ groups: CategorySurfaceSection<EntityRecommendation>;
113
+ }
114
+ export interface CategorySurfaceResponse {
115
+ /** Null when the key has no app (meta.reason `no_app`). */
116
+ data: CategorySurface | null;
117
+ meta: {
118
+ category: string;
119
+ as_of?: string;
120
+ reason?: 'no_app';
121
+ personalized: boolean;
122
+ };
123
+ }
7
124
  export interface DiscoveryGroupCandidate {
8
125
  id: string;
9
126
  name: string;
@@ -157,12 +274,18 @@ export interface DiscoveryFeedPage {
157
274
  hasMore: boolean;
158
275
  failedSources: string[];
159
276
  partialCategories: boolean;
277
+ /**
278
+ * The Minds feed's fall-through, once its category feed has ended a stage (the server's
279
+ * `meta.fallover`): where to read next when this session has no more. Absent until then, and
280
+ * on servers that predate it.
281
+ */
282
+ fallover?: FalloverStep[];
160
283
  }
161
284
  /** A gallery strip holds up to twelve image posts gathered from the whole page. */
162
285
  export declare const GALLERY_MAX_ITEMS = 12;
163
286
  /** Per-viewer feed session. Create a new session when user, app, category or
164
287
  * source changes. All reads retain existing server authorization/moderation.
165
- * At most four requests per page; providers fail independently. */
288
+ * At most four sources per page (a topics network's read walks up to TOPIC_WALK topic pages); providers fail independently. */
166
289
  export declare class DiscoveryFeedSession {
167
290
  private posts;
168
291
  private protocols;
@@ -187,6 +310,15 @@ export declare class DiscoveryFeedSession {
187
310
  retryFailed(): void;
188
311
  next(): Promise<DiscoveryFeedPage>;
189
312
  private read;
313
+ /**
314
+ * One read of a topics network: the next topic page that carries posts. A topic the provider no
315
+ * longer lists (Bluesky rotates its trends between the catalog read and the walk through it) is
316
+ * that topic's end, not the network's failure; a topic page with nothing in the category is
317
+ * walked past too, and its continuation kept, so the page reaches a topic that carries posts
318
+ * instead of spending a round on each empty one. Bounded by TOPIC_WALK reads and TOPIC_WALK_MS;
319
+ * a walk that stops short has moved the stream on, and the next read continues from there.
320
+ */
321
+ private walkTopics;
190
322
  private readPage;
191
323
  /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
192
324
  private radarCluster;
@@ -203,7 +335,40 @@ export declare class DiscoveryResource {
203
335
  private posts;
204
336
  private protocols;
205
337
  private curator?;
206
- constructor(posts: PostsResource, protocols: ProtocolsResource, curator?: Pick<CuratorResource, "forYou"> | undefined);
338
+ private http?;
339
+ constructor(posts: PostsResource, protocols: ProtocolsResource, curator?: Pick<CuratorResource, "forYou"> | undefined, http?: Pick<HttpClient, "get" | "post"> | undefined);
340
+ /**
341
+ * Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
342
+ * with several posters, tags used by several people, open-network accounts or stories, overall or in one
343
+ * browse category. What the viewer follows, joined, blocked or muted is left out, and so is anything
344
+ * resting for them. Call `recordImpressions` when suggestions are actually on screen and when one is tapped.
345
+ */
346
+ recommendations(params: EntityRecommendationsParams, options?: {
347
+ retry?: boolean;
348
+ }): Promise<EntityRecommendationsResponse>;
349
+ /** Records that suggestions were shown (at most once per half hour counts) or engaged with. */
350
+ recordImpressions(input: EntityImpressionsInput): Promise<{
351
+ data: {
352
+ recorded: number;
353
+ reason?: 'disabled' | 'other_app' | 'unavailable' | 'no_app';
354
+ };
355
+ }>;
356
+ /**
357
+ * A category's surface in one call: the modules All has, scoped to the category. `stories` (radar
358
+ * clusters themed into it), `network` (open-network posts judged into it, newest first) and `clips`
359
+ * (public video posts among its hot window) are shared per app and category (refreshed after 30 s, kept up to 300 s, so a copy
360
+ * may be that old) and filtered for the viewer on every call: the cached safety verdicts and the promotion term list again, the
361
+ * viewer's blocks and mutes, and for stories and network the member's own hidden sources. `people` and `groups` are the
362
+ * viewer's suggestions in it. A section that timed out, failed or is off is empty
363
+ * with a `reason` and never delays the others. The posts feed itself is `posts.list({ category })`,
364
+ * whose pages carry `meta.next` and `meta.fallover`.
365
+ */
366
+ category(params: {
367
+ category: string;
368
+ session?: string;
369
+ }, options?: {
370
+ retry?: boolean;
371
+ }): Promise<CategorySurfaceResponse>;
207
372
  /**
208
373
  * A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
209
374
  * snapshot cursor) in a session's Minds slot. With a category, the session also reads open-network posts
@@ -8,6 +8,30 @@ const RADAR_MAX_MEMBERS = 12;
8
8
  const RADAR_MAX_MINDS_READS = 4;
9
9
  /** A discovery page holds its own deadline: a retried 5xx from one provider only makes the whole page wait. */
10
10
  const NO_RETRY = { retry: false };
11
+ /**
12
+ * The provider no longer lists the topic (`trend_unavailable`): that topic's end, never a network
13
+ * failure. Matched on the server's code, which every SDK error carries: any other 404 on the route
14
+ * (`unsupported_protocol`, a missing route) is the network failing, and still fails the source.
15
+ */
16
+ const isGone = (error) => typeof error === 'object' && error !== null && error.code === 'trend_unavailable';
17
+ /**
18
+ * Topic pages one read of a topics network walks past when each is gone or carries nothing in the
19
+ * category, so a page reaches a topic that carries posts. Live (2026-09-26, Bluesky, news) 21 of 25
20
+ * topics answered no posts in the category; one topic a read left the network out of most pages.
21
+ */
22
+ const TOPIC_WALK = 5;
23
+ /** The walk starts no topic read after this long: well inside the page deadline, so it commits its progress rather than timing the network out. */
24
+ const TOPIC_WALK_MS = 8000;
25
+ /**
26
+ * A topic page the server answered from its open-network source index (`sourceIndex`): in a category, a
27
+ * topic that is gone or holds nothing in it is served the network's indexed posts in the category, which
28
+ * are the same for every such topic. While the provider has more of the topic the page carries the
29
+ * provider's own cursor, which the walk keeps like any other; a topic that is gone or ended carries an
30
+ * `idx:tf:` cursor, the one index stream of the network. So a walk starts that stream at most once per
31
+ * session (a second start would re-read what it already showed), and an index page that shows nothing
32
+ * new counts as an empty topic.
33
+ */
34
+ const INDEX_CURSOR = 'idx:tf:';
11
35
  const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
12
36
  const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
13
37
  function recommendationItem(kind, candidate) {
@@ -116,7 +140,7 @@ function windowLayout(layout, index) {
116
140
  }
117
141
  /** Per-viewer feed session. Create a new session when user, app, category or
118
142
  * source changes. All reads retain existing server authorization/moderation.
119
- * At most four requests per page; providers fail independently. */
143
+ * At most four sources per page (a topics network's read walks up to TOPIC_WALK topic pages); providers fail independently. */
120
144
  export class DiscoveryFeedSession {
121
145
  posts;
122
146
  protocols;
@@ -174,6 +198,9 @@ export class DiscoveryFeedSession {
174
198
  if (stream.cursor)
175
199
  stream.used = [...stream.used, stream.cursor].slice(-32);
176
200
  stream.cursor = next;
201
+ // A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
202
+ if (Array.isArray(response.meta.fallover))
203
+ stream.fallover = response.meta.fallover;
177
204
  return { items: response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post })),
178
205
  layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial' };
179
206
  }
@@ -181,23 +208,9 @@ export class DiscoveryFeedSession {
181
208
  if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(post => post.protocol !== stream.protocol || post.author.protocol !== stream.protocol))
182
209
  throw new Error('Invalid discovery source response');
183
210
  };
184
- if (stream.topics) {
185
- const topic = stream.topics[0];
186
- if (!topic) {
187
- stream.done = true;
188
- return { items: [], layout: ordinary, partial: false };
189
- }
190
- const { data } = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY);
191
- validatePosts(data);
192
- if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
193
- throw new Error('Invalid discovery topic');
194
- const next = data.cursor;
195
- stream.topics.shift();
196
- if (next && next !== topic.cursor && !topic.used.includes(next))
197
- stream.topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32) });
198
- stream.done = !stream.topics.length;
199
- return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
200
- }
211
+ const started = Date.now();
212
+ if (stream.topics)
213
+ return this.walkTopics(stream, params, validatePosts, false, started);
201
214
  const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY);
202
215
  if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
203
216
  throw new Error('Invalid discovery source response');
@@ -206,7 +219,14 @@ export class DiscoveryFeedSession {
206
219
  throw new Error('Invalid discovery preview');
207
220
  stream.topics = [...new Set(data.items.map(topic => topic.nativeId))].map(topic => ({ topic, used: [] }));
208
221
  stream.done = !stream.topics.length;
209
- return { items: data.items.flatMap(topic => !this.category && topic.preview ? [{ key: key(topic.preview.protocol, topic.preview.nativeId), kind: 'social', post: topic.preview }] : []), layout: ordinary, partial: data.categoryFilter?.status === 'partial' };
222
+ const partial = data.categoryFilter?.status === 'partial';
223
+ // Outside a category the catalog's previews are this page. In a category a preview is not
224
+ // judged to be in it, so the catalog carries no card: the read walks into its topics now,
225
+ // not a round later, so the network's first page in a category is not empty by design.
226
+ const previews = data.items.flatMap(topic => !this.category && topic.preview ? [{ key: key(topic.preview.protocol, topic.preview.nativeId), kind: 'social', post: topic.preview }] : []);
227
+ if (previews.length || stream.done)
228
+ return { items: previews, layout: ordinary, partial };
229
+ return this.walkTopics(stream, params, validatePosts, partial, started);
210
230
  }
211
231
  validatePosts(data);
212
232
  const next = data.cursor ?? undefined;
@@ -216,6 +236,61 @@ export class DiscoveryFeedSession {
216
236
  stream.cursor = next;
217
237
  return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
218
238
  }
239
+ /**
240
+ * One read of a topics network: the next topic page that carries posts. A topic the provider no
241
+ * longer lists (Bluesky rotates its trends between the catalog read and the walk through it) is
242
+ * that topic's end, not the network's failure; a topic page with nothing in the category is
243
+ * walked past too, and its continuation kept, so the page reaches a topic that carries posts
244
+ * instead of spending a round on each empty one. Bounded by TOPIC_WALK reads and TOPIC_WALK_MS;
245
+ * a walk that stops short has moved the stream on, and the next read continues from there.
246
+ */
247
+ async walkTopics(stream, params, validatePosts, partial, started) {
248
+ const topics = stream.topics;
249
+ for (let reads = 0; reads < TOPIC_WALK && Date.now() - started < TOPIC_WALK_MS; reads++) {
250
+ const topic = topics[0];
251
+ if (!topic)
252
+ break;
253
+ // A failed read of the network's index stream (the server's index busy, or its safety cache down) ends that
254
+ // continuation, not the network: the stream may start again from a later topic's page. A provider topic
255
+ // read that fails any other way than gone is still the network's failure.
256
+ const indexContinuation = !!topic.cursor?.startsWith(INDEX_CURSOR);
257
+ const response = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY)
258
+ .catch((error) => {
259
+ if (isGone(error))
260
+ return null;
261
+ if (indexContinuation) {
262
+ stream.indexStarted = false;
263
+ return null;
264
+ }
265
+ throw error;
266
+ });
267
+ topics.shift();
268
+ if (!response)
269
+ continue;
270
+ const { data } = response;
271
+ validatePosts(data);
272
+ if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
273
+ throw new Error('Invalid discovery topic');
274
+ const next = data.cursor;
275
+ // A provider cursor is always kept. An index cursor from a provider page starts the network's one index
276
+ // stream: queued the first time only, since every later start would re-read the same stream from its top.
277
+ const indexed = !!data.sourceIndex;
278
+ const startsIndex = !!next?.startsWith(INDEX_CURSOR) && !topic.cursor?.startsWith(INDEX_CURSOR);
279
+ if (next && next !== topic.cursor && !topic.used.includes(next) && !(startsIndex && stream.indexStarted)) {
280
+ topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32) });
281
+ if (startsIndex)
282
+ stream.indexStarted = true;
283
+ }
284
+ partial ||= data.categoryFilter?.status === 'partial';
285
+ const items = indexed ? data.items.filter(post => !this.seen.has(key(post.protocol, post.nativeId))) : data.items;
286
+ if (items.length) {
287
+ stream.done = !topics.length;
288
+ return { items: items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial };
289
+ }
290
+ }
291
+ stream.done = !topics.length;
292
+ return { items: [], layout: ordinary, partial };
293
+ }
219
294
  async readPage() {
220
295
  // The radar is one small snapshot read: start it with the page, not after the slowest provider,
221
296
  // and start the lead story's member reads the moment the snapshot names it.
@@ -326,8 +401,10 @@ export class DiscoveryFeedSession {
326
401
  modules.splice(Math.min(2, modules.length), 0, recommendation);
327
402
  if (cluster)
328
403
  modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
404
+ const fallover = this.streams.find(stream => stream.protocol === 'minds')?.fallover;
329
405
  return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
330
- failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial) };
406
+ failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial),
407
+ ...(fallover ? { fallover } : {}) };
331
408
  }
332
409
  /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
333
410
  async radarCluster(page) {
@@ -466,10 +543,45 @@ export class DiscoveryResource {
466
543
  posts;
467
544
  protocols;
468
545
  curator;
469
- constructor(posts, protocols, curator) {
546
+ http;
547
+ constructor(posts, protocols, curator, http) {
470
548
  this.posts = posts;
471
549
  this.protocols = protocols;
472
550
  this.curator = curator;
551
+ this.http = http;
552
+ }
553
+ /**
554
+ * Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
555
+ * with several posters, tags used by several people, open-network accounts or stories, overall or in one
556
+ * browse category. What the viewer follows, joined, blocked or muted is left out, and so is anything
557
+ * resting for them. Call `recordImpressions` when suggestions are actually on screen and when one is tapped.
558
+ */
559
+ recommendations(params, options) {
560
+ if (!this.http)
561
+ throw new Error('recommendations needs the HTTP client');
562
+ const { exclude, ...rest } = params;
563
+ return this.http.get('/discover/recommendations', { ...rest, exclude: exclude?.length ? exclude.join(',') : undefined }, options);
564
+ }
565
+ /** Records that suggestions were shown (at most once per half hour counts) or engaged with. */
566
+ recordImpressions(input) {
567
+ if (!this.http)
568
+ throw new Error('recordImpressions needs the HTTP client');
569
+ return this.http.post('/discover/impressions', input);
570
+ }
571
+ /**
572
+ * A category's surface in one call: the modules All has, scoped to the category. `stories` (radar
573
+ * clusters themed into it), `network` (open-network posts judged into it, newest first) and `clips`
574
+ * (public video posts among its hot window) are shared per app and category (refreshed after 30 s, kept up to 300 s, so a copy
575
+ * may be that old) and filtered for the viewer on every call: the cached safety verdicts and the promotion term list again, the
576
+ * viewer's blocks and mutes, and for stories and network the member's own hidden sources. `people` and `groups` are the
577
+ * viewer's suggestions in it. A section that timed out, failed or is off is empty
578
+ * with a `reason` and never delays the others. The posts feed itself is `posts.list({ category })`,
579
+ * whose pages carry `meta.next` and `meta.fallover`.
580
+ */
581
+ category(params, options) {
582
+ if (!this.http)
583
+ throw new Error('category needs the HTTP client');
584
+ return this.http.get('/discover/category', params, options);
473
585
  }
474
586
  /**
475
587
  * A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
@@ -18,7 +18,8 @@ export class HumanInputResource {
18
18
  }
19
19
  /** Raise a blocking question and notify the human wherever they are. */
20
20
  request(input) {
21
- return this.client.post('/human-input', input);
21
+ // This write is not idempotent. A lost response must not replay the ask.
22
+ return this.client.post('/human-input', input, { retry: false });
22
23
  }
23
24
  /** Has the human answered yet? */
24
25
  answers(conversationId) {
@@ -9,7 +9,13 @@ export interface LinkPreview {
9
9
  siteName: string | null;
10
10
  }
11
11
  export interface LinkPreviewResponse {
12
- data: LinkPreview;
12
+ /**
13
+ * The preview, or null when the link cannot be previewed: the page refused a
14
+ * server (a private repository, a paywall or bot wall), is not HTML, or could
15
+ * not be reached. Show the plain link. Servers before this change answered
16
+ * 422 for these links instead, so treat that rejection the same way.
17
+ */
18
+ data: LinkPreview | null;
13
19
  cached?: boolean;
14
20
  }
15
21
  export declare class LinkPreviewResource {
@@ -17,9 +23,10 @@ export declare class LinkPreviewResource {
17
23
  constructor(client: HttpClient);
18
24
  /**
19
25
  * Fetch Open Graph / Twitter card metadata for a public URL. Cached
20
- * server-side so repeated lookups are cheap. No auth required — the
21
- * endpoint is intentionally public so unauthenticated UIs can render
22
- * link previews.
26
+ * server-side so repeated lookups are cheap, including links that have no
27
+ * preview (`data: null`). No auth required — the endpoint is intentionally
28
+ * public so unauthenticated UIs can render link previews. A URL that is not
29
+ * http(s), or points at a private or internal address, still rejects (422).
23
30
  */
24
31
  get(url: string): Promise<LinkPreviewResponse>;
25
32
  }
@@ -5,9 +5,10 @@ export class LinkPreviewResource {
5
5
  }
6
6
  /**
7
7
  * Fetch Open Graph / Twitter card metadata for a public URL. Cached
8
- * server-side so repeated lookups are cheap. No auth required — the
9
- * endpoint is intentionally public so unauthenticated UIs can render
10
- * link previews.
8
+ * server-side so repeated lookups are cheap, including links that have no
9
+ * preview (`data: null`). No auth required — the endpoint is intentionally
10
+ * public so unauthenticated UIs can render link previews. A URL that is not
11
+ * http(s), or points at a private or internal address, still rejects (422).
11
12
  */
12
13
  get(url) {
13
14
  return this.client.get('/link-preview', { url });
@@ -7,6 +7,12 @@ export interface NotificationActor {
7
7
  username: string | null;
8
8
  image: string | null;
9
9
  is_ai: boolean | null;
10
+ /**
11
+ * Follow rows only: whether the viewer already follows this person, so
12
+ * Follow back can render without reading the follow set. Absent on other
13
+ * rows and from servers older than this field.
14
+ */
15
+ viewer_follows?: boolean;
10
16
  }
11
17
  /**
12
18
  * The post a notification references, as it reads NOW, with the viewer's own
@@ -15,7 +21,18 @@ export interface NotificationActor {
15
21
  */
16
22
  export interface NotificationPostPreview {
17
23
  id: string;
24
+ /**
25
+ * The post's words as plain text (no Markdown or HTML marks; a link reads as
26
+ * its label and a bare URL as its site), up to 140 characters. A longer post
27
+ * is cut at the last whole word and ends in "…". Show it as it is.
28
+ */
18
29
  excerpt: string | null;
30
+ /**
31
+ * True when `excerpt` is a cut of a longer post. Use this rather than the
32
+ * excerpt's length to decide whether there is more to read. Absent from
33
+ * servers older than this field.
34
+ */
35
+ excerpt_truncated?: boolean;
19
36
  media_url: string | null;
20
37
  media_type: string | null;
21
38
  reactions_count?: number;
@@ -24,7 +41,10 @@ export interface NotificationPostPreview {
24
41
  downvote_count?: number;
25
42
  /** The viewer's own reaction on this post ('upvote', 'downvote', ...), if any. */
26
43
  user_reaction?: string | null;
44
+ /** Stored replies a reader can open. */
27
45
  reply_count?: number;
46
+ /** A legacy import's comment count; none of those comments are stored as replies. */
47
+ reply_count_external?: number;
28
48
  unique_commenters?: number;
29
49
  reposts_count?: number;
30
50
  /** Id of the viewer's own repost of this post; delete it to undo the repost. */
@@ -43,6 +63,12 @@ export interface Notification {
43
63
  read_at: string | null;
44
64
  /** For `post_reaction` rows: which reaction ('upvote', 'downvote', ...). Null otherwise or on older rows. */
45
65
  reaction_type?: string | null;
66
+ /**
67
+ * For `agent_needs_input` rows: whether you have written in the agent's
68
+ * thread since it asked, which is what answers an ask. The title then reads
69
+ * "You answered <agent>". Null on other rows; absent from older servers.
70
+ */
71
+ answered?: boolean | null;
46
72
  actor?: NotificationActor | null;
47
73
  post_preview?: NotificationPostPreview | null;
48
74
  }
@@ -1,5 +1,6 @@
1
1
  import type { HttpClient } from '../client.js';
2
- import type { PaginatedResponse, SingleResponse, DeleteResponse, Post, PostDetail, CreatePostInput, CreatedPost, UpdatePostInput, ListPostsParams, ListPostRepliesParams, PostRepliesPage, SearchPostsParams, ReactionType } from '../types.js';
2
+ import type { PaginatedResponse, SingleResponse, DeleteResponse, Post, PostDetail, CreatePostInput, CreatedPost, UpdatePostInput, ListPostsParams, ListPostRepliesParams, PostRepliesPage, SearchPostsParams, SearchPostsResponse, ReactionType } from '../types.js';
3
+ import type { BookmarkedPost, BookmarksPage, SavedBookmark, UnsavedBookmark } from './bookmarks.js';
3
4
  /** How GET /posts/categories ordered its answer: the member's own order, the app's popularity order, or the fixed order. */
4
5
  export type CategoryOrder = 'personal' | 'popular' | 'static';
5
6
  /** Why an answer is not `personal`. */
@@ -33,6 +34,19 @@ export declare class PostsResource {
33
34
  }>;
34
35
  /** List posts the authenticated caller has upvoted. */
35
36
  liked(params?: Pick<ListPostsParams, 'limit' | 'offset' | 'cursor'>): Promise<PaginatedResponse<Post>>;
37
+ /**
38
+ * The caller's saved posts in this app, newest saved first, each with `bookmarked_at`. Page on
39
+ * `meta.next_cursor` (a page can be short while it skips posts the member can no longer read).
40
+ * Same as `bookmarks.posts`; 503 `bookmarks_unavailable` where synced bookmarks are not available.
41
+ */
42
+ bookmarked(params?: {
43
+ limit?: number;
44
+ cursor?: string;
45
+ }): Promise<BookmarksPage<BookmarkedPost>>;
46
+ /** Save a post to the caller's synced bookmarks (idempotent). A bare repost is saved as its original: see `post_id`. */
47
+ bookmark(postId: string): Promise<SingleResponse<SavedBookmark>>;
48
+ /** Remove a post from the caller's synced bookmarks (idempotent). Pass the `post_id` its save returned. */
49
+ unbookmark(postId: string): Promise<SingleResponse<UnsavedBookmark>>;
36
50
  /** Posts similar to this one (embedding neighbours), in the app's scope. */
37
51
  similar(id: string, params?: {
38
52
  limit?: number;
@@ -48,8 +62,12 @@ export declare class PostsResource {
48
62
  update(id: string, input: UpdatePostInput): Promise<SingleResponse<CreatedPost>>;
49
63
  /** Delete a post */
50
64
  delete(id: string): Promise<DeleteResponse>;
51
- /** Search posts by text query */
52
- search(params: SearchPostsParams): Promise<PaginatedResponse<Post>>;
65
+ /**
66
+ * Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
67
+ * the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as
68
+ * `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
69
+ */
70
+ search(params: SearchPostsParams): Promise<SearchPostsResponse>;
53
71
  /** Add or update a reaction on a post */
54
72
  react(postId: string, type: ReactionType): Promise<SingleResponse<{
55
73
  success: true;
@@ -20,6 +20,22 @@ export class PostsResource {
20
20
  liked(params) {
21
21
  return this.list({ ...params, liked: true });
22
22
  }
23
+ /**
24
+ * The caller's saved posts in this app, newest saved first, each with `bookmarked_at`. Page on
25
+ * `meta.next_cursor` (a page can be short while it skips posts the member can no longer read).
26
+ * Same as `bookmarks.posts`; 503 `bookmarks_unavailable` where synced bookmarks are not available.
27
+ */
28
+ bookmarked(params) {
29
+ return this.client.get('/posts', { bookmarked: true, ...params });
30
+ }
31
+ /** Save a post to the caller's synced bookmarks (idempotent). A bare repost is saved as its original: see `post_id`. */
32
+ bookmark(postId) {
33
+ return this.client.put('/bookmarks', { kind: 'post', post_id: postId });
34
+ }
35
+ /** Remove a post from the caller's synced bookmarks (idempotent). Pass the `post_id` its save returned. */
36
+ unbookmark(postId) {
37
+ return this.client.delete(`/bookmarks/${encodeURIComponent(`post:${postId}`)}`);
38
+ }
23
39
  /** Posts similar to this one (embedding neighbours), in the app's scope. */
24
40
  similar(id, params) {
25
41
  return this.client.get(`/posts/${encodeURIComponent(id)}/similar`, params);
@@ -44,7 +60,11 @@ export class PostsResource {
44
60
  delete(id) {
45
61
  return this.client.delete(`/posts/${id}`);
46
62
  }
47
- /** Search posts by text query */
63
+ /**
64
+ * Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
65
+ * the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as
66
+ * `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
67
+ */
48
68
  search(params) {
49
69
  return this.client.get('/posts/search', params);
50
70
  }