@recursiv/sdk 0.7.58 → 0.7.62
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/README.md +32 -9
- package/dist/errors.d.ts +13 -0
- package/dist/errors.js +12 -0
- package/dist/feed-definition.d.ts +129 -0
- package/dist/feed-definition.js +46 -0
- package/dist/index.d.ts +8 -6
- package/dist/index.js +3 -2
- package/dist/realtime.d.ts +11 -1
- package/dist/realtime.js +14 -1
- package/dist/resources/agents.d.ts +70 -0
- package/dist/resources/agents.js +10 -0
- package/dist/resources/chat.d.ts +40 -6
- package/dist/resources/chat.js +41 -2
- package/dist/resources/curator.d.ts +5 -1
- package/dist/resources/curator.js +6 -2
- package/dist/resources/discovery.d.ts +119 -1
- package/dist/resources/discovery.js +140 -17
- package/dist/resources/e2ee.d.ts +87 -2
- package/dist/resources/e2ee.js +31 -2
- package/dist/resources/email.d.ts +27 -3
- package/dist/resources/email.js +18 -3
- package/dist/resources/env-vars.d.ts +25 -0
- package/dist/resources/env-vars.js +8 -0
- package/dist/resources/moderation.d.ts +22 -0
- package/dist/resources/moderation.js +16 -0
- package/dist/resources/posts.d.ts +1 -1
- package/dist/resources/posts.js +6 -2
- package/dist/resources/protocols.d.ts +230 -4
- package/dist/resources/protocols.js +66 -5
- package/dist/types.d.ts +137 -0
- package/llm.md +27 -9
- package/package.json +1 -1
package/dist/resources/chat.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
|
-
import type { PaginatedResponse, SingleResponse, SuccessResponse, Conversation, ConversationDetail, Message, SendMessageInput, SentMessage, DmInput, DmResult, PaginationParams, ConversationRequestResult, LeaveConversationResult } from '../types.js';
|
|
2
|
+
import type { PaginatedResponse, SingleResponse, SuccessResponse, Conversation, ConversationDetail, Message, SendMessageInput, SentMessage, DmInput, DmResult, PaginationParams, ConversationRequestResult, LeaveConversationResult, CreateGroupInput, AddGroupMembersResult, RemoveGroupMemberResult, UpdateGroupResult } from '../types.js';
|
|
3
3
|
export declare class ChatResource {
|
|
4
4
|
private client;
|
|
5
5
|
constructor(client: HttpClient);
|
|
@@ -37,11 +37,42 @@ export declare class ChatResource {
|
|
|
37
37
|
}): Promise<SingleResponse<SentMessage>>;
|
|
38
38
|
/** Get or create a DM conversation with a user */
|
|
39
39
|
dm(input: DmInput): Promise<SingleResponse<DmResult>>;
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Create a group conversation. Invitees who do not follow you get it as a
|
|
42
|
+
* message request (`request_status: 'pending'`); you are its admin
|
|
43
|
+
* (`role: 'admin'`).
|
|
44
|
+
*
|
|
45
|
+
* `encrypted: true` (0.7.59) creates an end-to-end encrypted private group:
|
|
46
|
+
* people only, all inside the encryption pilot, at most 8 counting you.
|
|
47
|
+
* Otherwise it throws 409 `conversation_not_eligible` with `details.reason`
|
|
48
|
+
* (EncryptedGroupRefusalReason) and creates nothing: never a plaintext group
|
|
49
|
+
* instead. Pass `device_id` when you have an encrypted-chat device; your
|
|
50
|
+
* device then sets up the group's keys (only the admin's device may).
|
|
51
|
+
*/
|
|
52
|
+
createGroup(input: CreateGroupInput): Promise<SingleResponse<ConversationDetail>>;
|
|
53
|
+
/**
|
|
54
|
+
* Add people to a private group (0.7.59). Only its admin may (403
|
|
55
|
+
* `not_admin`). Someone who does not follow you is added as a message
|
|
56
|
+
* request (`pending`); someone already in it is listed in
|
|
57
|
+
* `already_members`. In an encrypted group the group's rule must still
|
|
58
|
+
* hold afterwards, or nobody is added (409 `conversation_not_eligible`,
|
|
59
|
+
* `details.reason`); a plaintext group holds at most 101 people (409 `group_full`).
|
|
60
|
+
* Everyone in the group gets the realtime `chat:members` event.
|
|
61
|
+
*/
|
|
62
|
+
addMembers(conversationId: string, input: {
|
|
43
63
|
member_ids: string[];
|
|
44
|
-
}): Promise<SingleResponse<
|
|
64
|
+
}): Promise<SingleResponse<AddGroupMembersResult>>;
|
|
65
|
+
/**
|
|
66
|
+
* Remove someone from a private group, or cancel their invite (0.7.59).
|
|
67
|
+
* Only its admin may (403 `not_admin`); to remove yourself, `leave()` (409
|
|
68
|
+
* `use_leave`). They lose the conversation at once; in an encrypted group
|
|
69
|
+
* the members' devices then take them out of its keys before sending again.
|
|
70
|
+
*/
|
|
71
|
+
removeMember(conversationId: string, userId: string): Promise<SingleResponse<RemoveGroupMemberResult>>;
|
|
72
|
+
/** Rename a private group (0.7.59). Only its admin may (403 `not_admin`). */
|
|
73
|
+
updateGroup(conversationId: string, input: {
|
|
74
|
+
name: string;
|
|
75
|
+
}): Promise<SingleResponse<UpdateGroupResult>>;
|
|
45
76
|
/**
|
|
46
77
|
* Delete a conversation for every member. Allowed when the conversation's
|
|
47
78
|
* `can_delete` is true: the caller started it, or every other member is an
|
|
@@ -53,7 +84,10 @@ export declare class ChatResource {
|
|
|
53
84
|
* Remove the caller from a conversation (a group, or a room whose other
|
|
54
85
|
* members are all agents). The last person to leave removes the room. A
|
|
55
86
|
* one-on-one with another person cannot be left (409 `dm_leave_unsupported`);
|
|
56
|
-
* check `can_leave` on the conversation first.
|
|
87
|
+
* check `can_leave` on the conversation first. When a group's admin leaves,
|
|
88
|
+
* the longest-standing person left becomes its admin; in an encrypted group
|
|
89
|
+
* the invites the admin sent that nobody accepted are cancelled
|
|
90
|
+
* (`invites_cancelled`).
|
|
57
91
|
*
|
|
58
92
|
* This changes membership. It is not `realtime.leaveConversation()`, which
|
|
59
93
|
* only stops listening for live events.
|
package/dist/resources/chat.js
CHANGED
|
@@ -44,10 +44,46 @@ export class ChatResource {
|
|
|
44
44
|
dm(input) {
|
|
45
45
|
return this.client.post('/chat/dm', input);
|
|
46
46
|
}
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Create a group conversation. Invitees who do not follow you get it as a
|
|
49
|
+
* message request (`request_status: 'pending'`); you are its admin
|
|
50
|
+
* (`role: 'admin'`).
|
|
51
|
+
*
|
|
52
|
+
* `encrypted: true` (0.7.59) creates an end-to-end encrypted private group:
|
|
53
|
+
* people only, all inside the encryption pilot, at most 8 counting you.
|
|
54
|
+
* Otherwise it throws 409 `conversation_not_eligible` with `details.reason`
|
|
55
|
+
* (EncryptedGroupRefusalReason) and creates nothing: never a plaintext group
|
|
56
|
+
* instead. Pass `device_id` when you have an encrypted-chat device; your
|
|
57
|
+
* device then sets up the group's keys (only the admin's device may).
|
|
58
|
+
*/
|
|
48
59
|
createGroup(input) {
|
|
49
60
|
return this.client.post('/chat/conversations/group', input);
|
|
50
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Add people to a private group (0.7.59). Only its admin may (403
|
|
64
|
+
* `not_admin`). Someone who does not follow you is added as a message
|
|
65
|
+
* request (`pending`); someone already in it is listed in
|
|
66
|
+
* `already_members`. In an encrypted group the group's rule must still
|
|
67
|
+
* hold afterwards, or nobody is added (409 `conversation_not_eligible`,
|
|
68
|
+
* `details.reason`); a plaintext group holds at most 101 people (409 `group_full`).
|
|
69
|
+
* Everyone in the group gets the realtime `chat:members` event.
|
|
70
|
+
*/
|
|
71
|
+
addMembers(conversationId, input) {
|
|
72
|
+
return this.client.post(`/chat/conversations/${conversationId}/members`, input);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Remove someone from a private group, or cancel their invite (0.7.59).
|
|
76
|
+
* Only its admin may (403 `not_admin`); to remove yourself, `leave()` (409
|
|
77
|
+
* `use_leave`). They lose the conversation at once; in an encrypted group
|
|
78
|
+
* the members' devices then take them out of its keys before sending again.
|
|
79
|
+
*/
|
|
80
|
+
removeMember(conversationId, userId) {
|
|
81
|
+
return this.client.delete(`/chat/conversations/${conversationId}/members/${userId}`);
|
|
82
|
+
}
|
|
83
|
+
/** Rename a private group (0.7.59). Only its admin may (403 `not_admin`). */
|
|
84
|
+
updateGroup(conversationId, input) {
|
|
85
|
+
return this.client.patch(`/chat/conversations/${conversationId}`, input);
|
|
86
|
+
}
|
|
51
87
|
/**
|
|
52
88
|
* Delete a conversation for every member. Allowed when the conversation's
|
|
53
89
|
* `can_delete` is true: the caller started it, or every other member is an
|
|
@@ -61,7 +97,10 @@ export class ChatResource {
|
|
|
61
97
|
* Remove the caller from a conversation (a group, or a room whose other
|
|
62
98
|
* members are all agents). The last person to leave removes the room. A
|
|
63
99
|
* one-on-one with another person cannot be left (409 `dm_leave_unsupported`);
|
|
64
|
-
* check `can_leave` on the conversation first.
|
|
100
|
+
* check `can_leave` on the conversation first. When a group's admin leaves,
|
|
101
|
+
* the longest-standing person left becomes its admin; in an encrypted group
|
|
102
|
+
* the invites the admin sent that nobody accepted are cancelled
|
|
103
|
+
* (`invites_cancelled`).
|
|
65
104
|
*
|
|
66
105
|
* This changes membership. It is not `realtime.leaveConversation()`, which
|
|
67
106
|
* only stops listening for live events.
|
|
@@ -11,6 +11,10 @@ export type ForYouParams = {
|
|
|
11
11
|
discovery?: boolean;
|
|
12
12
|
/** `discover` ranks for Discover: fresher, beyond the member's follows, with their category order. */
|
|
13
13
|
surface?: ForYouSurface;
|
|
14
|
+
/** A Politics choice, as `posts.list` takes it (`ListPostsParams.compass`); the page says what it did in `meta.compass_filter`. */
|
|
15
|
+
compass?: string;
|
|
16
|
+
/** `false` asks once and is never sent, as `posts.list` takes it (`ListPostsParams.retry`). */
|
|
17
|
+
retry?: boolean;
|
|
14
18
|
};
|
|
15
19
|
export interface ForYouMeta extends PaginationMeta {
|
|
16
20
|
discovery?: DiscoveryLayout;
|
|
@@ -269,7 +273,7 @@ export declare class CuratorResource {
|
|
|
269
273
|
* affinity, no model calls). Returns posts in the same shape as
|
|
270
274
|
* `sdk.posts.list()`, so any feed UI renders it unchanged. Requires a
|
|
271
275
|
* project-scoped key (the feed is per-app). Servers that predate `cursor`,
|
|
272
|
-
* `discovery` and `surface` ignore them.
|
|
276
|
+
* `discovery` and `surface` ignore them. `retry: false` asks once and is never sent (`ListPostsParams.retry`).
|
|
273
277
|
*/
|
|
274
278
|
forYou(params?: ForYouParams): Promise<PaginatedResponse<Post> & {
|
|
275
279
|
meta: ForYouMeta;
|
|
@@ -48,9 +48,13 @@ export class CuratorResource {
|
|
|
48
48
|
* affinity, no model calls). Returns posts in the same shape as
|
|
49
49
|
* `sdk.posts.list()`, so any feed UI renders it unchanged. Requires a
|
|
50
50
|
* project-scoped key (the feed is per-app). Servers that predate `cursor`,
|
|
51
|
-
* `discovery` and `surface` ignore them.
|
|
51
|
+
* `discovery` and `surface` ignore them. `retry: false` asks once and is never sent (`ListPostsParams.retry`).
|
|
52
52
|
*/
|
|
53
53
|
forYou(params = {}) {
|
|
54
|
-
|
|
54
|
+
// Without `retry`, the same request older SDKs make.
|
|
55
|
+
if (!('retry' in params))
|
|
56
|
+
return this.client.get('/curator/foryou', params);
|
|
57
|
+
const { retry, ...query } = params;
|
|
58
|
+
return retry === false ? this.client.get('/curator/foryou', query, { retry: false }) : this.client.get('/curator/foryou', query);
|
|
55
59
|
}
|
|
56
60
|
}
|
|
@@ -1,10 +1,78 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
|
-
import type { DiscoveryLayout, FalloverStep, Post } from '../types.js';
|
|
2
|
+
import type { CompassFilterMeta, DiscoveryLayout, FalloverStep, MediaItem, Post } from '../types.js';
|
|
3
3
|
import type { CuratorResource } from './curator.js';
|
|
4
4
|
import type { DiscoveryLearning } from './discovery-learning.js';
|
|
5
5
|
import type { PostsResource } from './posts.js';
|
|
6
6
|
import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar, SocialRadarPosts } from './protocols.js';
|
|
7
7
|
export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
|
|
8
|
+
/** A page of Watch's Minds videos (`discovery.videos`). */
|
|
9
|
+
export interface WatchVideosParams {
|
|
10
|
+
/** A browse category id: the videos among the category's judged posts. Omitted: the whole app. */
|
|
11
|
+
category?: string;
|
|
12
|
+
/** Posts per page, 1 to 25 (default 12). */
|
|
13
|
+
limit?: number;
|
|
14
|
+
/** The `meta.next_cursor` of the page before. */
|
|
15
|
+
cursor?: string;
|
|
16
|
+
/** A position in the list to start a first read at (0 to 400); a later page sends `cursor`. */
|
|
17
|
+
offset?: number;
|
|
18
|
+
/**
|
|
19
|
+
* A Politics choice, spelled as `posts.list` takes it. Only Hide politics (`hide`) is applied here: the videos whose
|
|
20
|
+
* browse category is politics (or whose compass judgment places them in politics) leave the page and the next ones
|
|
21
|
+
* fill it, with or without compass judgments on the server. A side or Mix is answered unfiltered, marked
|
|
22
|
+
* `unavailable`. The page says what it did in `meta.compass_filter`.
|
|
23
|
+
*/
|
|
24
|
+
compass?: string;
|
|
25
|
+
}
|
|
26
|
+
/** A media item as `discovery.videos` serves it: the poster, and the player's size, length and HLS address when known. */
|
|
27
|
+
export interface WatchVideoMedia extends MediaItem {
|
|
28
|
+
thumbnail_url: string | null;
|
|
29
|
+
/** Set while the file is not ready on the CDN; absent once it is. */
|
|
30
|
+
status?: string;
|
|
31
|
+
width?: number;
|
|
32
|
+
height?: number;
|
|
33
|
+
duration_sec?: number;
|
|
34
|
+
hls_url?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A post as `discovery.videos` serves it: the `Post` shape (so a page is a valid `posts.list` page), with the engagement
|
|
38
|
+
* GET /posts sends (live votes plus imported likes, the replies the reader can open) and the viewer's own vote and repost.
|
|
39
|
+
*/
|
|
40
|
+
export interface WatchVideoPost extends Post {
|
|
41
|
+
title: string | null;
|
|
42
|
+
is_nsfw: boolean;
|
|
43
|
+
score: number;
|
|
44
|
+
upvote_count: number;
|
|
45
|
+
downvote_count: number;
|
|
46
|
+
/** The viewer's own reaction ('upvote', 'downvote', ...); null for a visitor or when they have none. */
|
|
47
|
+
user_reaction: string | null;
|
|
48
|
+
reply_count: number;
|
|
49
|
+
reply_count_external: number;
|
|
50
|
+
unique_commenters: number;
|
|
51
|
+
reposts_count: number;
|
|
52
|
+
/** Id of the viewer's own repost of the post; delete it to undo the repost. */
|
|
53
|
+
reposted_by_viewer_id: string | null;
|
|
54
|
+
media: WatchVideoMedia[];
|
|
55
|
+
}
|
|
56
|
+
export interface WatchVideosResponse {
|
|
57
|
+
/** Public Minds video posts in hot order (with the viewer's own `user_reaction` and `reposted_by_viewer_id`). */
|
|
58
|
+
data: WatchVideoPost[];
|
|
59
|
+
meta: {
|
|
60
|
+
limit: number;
|
|
61
|
+
/** The position in the list this page started at. */
|
|
62
|
+
offset: number;
|
|
63
|
+
has_more: boolean;
|
|
64
|
+
/** Pass as `cursor` for the next page; null at the end of the list. */
|
|
65
|
+
next_cursor: string | null;
|
|
66
|
+
/** Positions in the list this page examined (a client paging by offset adds it to the offset). */
|
|
67
|
+
scanned: number;
|
|
68
|
+
/** When the shared list the page was cut from was built; null when the app has none. */
|
|
69
|
+
built_at: string | null;
|
|
70
|
+
/** `no_app`: the API key is bound to no app, so there is no list. */
|
|
71
|
+
reason?: 'no_app';
|
|
72
|
+
/** Present when the request named a filtering `compass`: what the Politics choice did to this page. */
|
|
73
|
+
compass_filter?: CompassFilterMeta;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
8
76
|
/** What GET /discover/recommendations suggests: members, groups, tags, open-network accounts or World Today stories. */
|
|
9
77
|
export type EntityKind = 'member' | 'group' | 'tag' | 'network_account' | 'story';
|
|
10
78
|
/**
|
|
@@ -247,6 +315,23 @@ export declare const isDiscoveryPost: (item: DiscoveryItem) => item is Discovery
|
|
|
247
315
|
export declare const RECOMMENDATION_MAX_ITEMS = 6;
|
|
248
316
|
/** Clusters read from the radar per session; the server never holds more. */
|
|
249
317
|
export declare const RADAR_MAX_CLUSTERS = 40;
|
|
318
|
+
/** How long one source's read may take before the page goes on without it (DiscoveryFeedSession). */
|
|
319
|
+
export declare const DISCOVERY_SOURCE_TIMEOUT_MS = 15000;
|
|
320
|
+
/**
|
|
321
|
+
* Times in a row an open network whose read failed for a passing reason (a timeout, a dropped connection, a 429 or a
|
|
322
|
+
* 5xx) sits out one page and is asked again, before it is left out until `retryFailed()`. Before, one such failure left
|
|
323
|
+
* the network out of the rest of the session: one Bluesky blip and Watch or Discover never read Bluesky again. The Minds
|
|
324
|
+
* read is not retried this way: its failure is named in `failedSources` at once (#4047), which the Minds app shows as
|
|
325
|
+
* "Minds posts didn't load" with Try again and which its Politics fallback (minds#1212) waits for.
|
|
326
|
+
*/
|
|
327
|
+
export declare const DISCOVERY_SOURCE_RETRIES = 2;
|
|
328
|
+
/**
|
|
329
|
+
* Whether a source's read failed for a reason that passes: it ran past its deadline, its call never got an answer (a
|
|
330
|
+
* failed connection or an abort, as the session's read reports it), or the server answered 408, 425, 429 or 5xx (or no
|
|
331
|
+
* status at all, 0). A response the session refuses (the wrong shape or protocol, including a TypeError thrown while
|
|
332
|
+
* checking it) and any other 4xx are the source's own failure and are not asked again.
|
|
333
|
+
*/
|
|
334
|
+
export declare function transientSourceFailure(error: unknown): boolean;
|
|
250
335
|
/** json-render Spec: values are references to authenticated SDK results, never
|
|
251
336
|
* arbitrary executable components, links, generated text or provider markup. */
|
|
252
337
|
export interface DiscoveryModule {
|
|
@@ -272,7 +357,17 @@ export interface DiscoveryModule {
|
|
|
272
357
|
export interface DiscoveryFeedPage {
|
|
273
358
|
modules: DiscoveryModule[];
|
|
274
359
|
hasMore: boolean;
|
|
360
|
+
/**
|
|
361
|
+
* Sources left out of the rest of the session (until `retryFailed()`): any failed Minds read (#4047), a network read
|
|
362
|
+
* the session refused, or a network's passing failures past DISCOVERY_SOURCE_RETRIES.
|
|
363
|
+
*/
|
|
275
364
|
failedSources: string[];
|
|
365
|
+
/**
|
|
366
|
+
* Open networks whose read failed for a passing reason (a timeout, a dropped connection, a 429 or a 5xx): missing from
|
|
367
|
+
* this page and the next, and named here until a later page asks them again (from the page after the next, when the
|
|
368
|
+
* round-robin reaches them). Never 'minds'. Absent when there are none, and on SDKs that predate it.
|
|
369
|
+
*/
|
|
370
|
+
retryingSources?: string[];
|
|
276
371
|
partialCategories: boolean;
|
|
277
372
|
/**
|
|
278
373
|
* The Minds feed's fall-through, once its category feed has ended a stage (the server's
|
|
@@ -280,6 +375,12 @@ export interface DiscoveryFeedPage {
|
|
|
280
375
|
* on servers that predate it.
|
|
281
376
|
*/
|
|
282
377
|
fallover?: FalloverStep[];
|
|
378
|
+
/**
|
|
379
|
+
* What the session's Politics choice (`compass`, createFeed and createPersonalFeed) did to this page's Minds read:
|
|
380
|
+
* that read's `meta.compass_filter`, counts only. `unavailable` means the page was served unfiltered. Absent without
|
|
381
|
+
* a choice, on a page whose Minds read failed or was not made, and on servers that predate the filter.
|
|
382
|
+
*/
|
|
383
|
+
compass?: CompassFilterMeta;
|
|
283
384
|
}
|
|
284
385
|
/** A gallery strip holds up to twelve image posts gathered from the whole page. */
|
|
285
386
|
export declare const GALLERY_MAX_ITEMS = 12;
|
|
@@ -369,6 +470,16 @@ export declare class DiscoveryResource {
|
|
|
369
470
|
}, options?: {
|
|
370
471
|
retry?: boolean;
|
|
371
472
|
}): Promise<CategorySurfaceResponse>;
|
|
473
|
+
/**
|
|
474
|
+
* Watch's Minds videos for the viewer: the app's public video posts in hot order (engagement decayed by age), from
|
|
475
|
+
* one list the server shares among every viewer of the app and rebuilds about once a minute, each post checked again
|
|
476
|
+
* on every read under the public floors (not NSFW, removed or hidden; no spam-labelled or adult author or group; the
|
|
477
|
+
* cached safety verdict) and the viewer's blocks and mutes. A video whose file is gone or not on the CDN yet is left
|
|
478
|
+
* out or marked `unplayable`. Page on `meta.next_cursor`. Needs posts:read; the app is the API key's project.
|
|
479
|
+
*/
|
|
480
|
+
videos(params?: WatchVideosParams, options?: {
|
|
481
|
+
retry?: boolean;
|
|
482
|
+
}): Promise<WatchVideosResponse>;
|
|
372
483
|
/**
|
|
373
484
|
* A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
|
|
374
485
|
* snapshot cursor) in a session's Minds slot. With a category, the session also reads open-network posts
|
|
@@ -380,12 +491,19 @@ export declare class DiscoveryResource {
|
|
|
380
491
|
learning?: DiscoveryLearning;
|
|
381
492
|
recommend?: DiscoveryRecommendationSources;
|
|
382
493
|
category?: string;
|
|
494
|
+
compass?: string;
|
|
383
495
|
}): DiscoveryFeedSession;
|
|
496
|
+
/**
|
|
497
|
+
* Discover's feed session. `compass` (a Politics choice, as `posts.list` takes it) goes on every Minds read, whose
|
|
498
|
+
* pages then carry `meta.compass_filter`; the open networks' reads and the radar are not filtered by it, so a view
|
|
499
|
+
* that leaves posts out (Hide politics, Choose sides) is best read with `source: 'minds'`.
|
|
500
|
+
*/
|
|
384
501
|
createFeed(options: {
|
|
385
502
|
networks: SocialNetwork[];
|
|
386
503
|
category?: string;
|
|
387
504
|
source?: string;
|
|
388
505
|
learning?: DiscoveryLearning;
|
|
389
506
|
recommend?: DiscoveryRecommendationSources;
|
|
507
|
+
compass?: string;
|
|
390
508
|
}): DiscoveryFeedSession;
|
|
391
509
|
}
|
|
@@ -32,6 +32,53 @@ const TOPIC_WALK_MS = 8000;
|
|
|
32
32
|
* new counts as an empty topic.
|
|
33
33
|
*/
|
|
34
34
|
const INDEX_CURSOR = 'idx:tf:';
|
|
35
|
+
/** How long one source's read may take before the page goes on without it (DiscoveryFeedSession). */
|
|
36
|
+
export const DISCOVERY_SOURCE_TIMEOUT_MS = 15_000;
|
|
37
|
+
/**
|
|
38
|
+
* Times in a row an open network whose read failed for a passing reason (a timeout, a dropped connection, a 429 or a
|
|
39
|
+
* 5xx) sits out one page and is asked again, before it is left out until `retryFailed()`. Before, one such failure left
|
|
40
|
+
* the network out of the rest of the session: one Bluesky blip and Watch or Discover never read Bluesky again. The Minds
|
|
41
|
+
* read is not retried this way: its failure is named in `failedSources` at once (#4047), which the Minds app shows as
|
|
42
|
+
* "Minds posts didn't load" with Try again and which its Politics fallback (minds#1212) waits for.
|
|
43
|
+
*/
|
|
44
|
+
export const DISCOVERY_SOURCE_RETRIES = 2;
|
|
45
|
+
/** The page deadline a source's read ran past. */
|
|
46
|
+
class SourceTimedOut extends Error {
|
|
47
|
+
constructor() { super('Discovery source timed out'); this.name = 'SourceTimedOut'; }
|
|
48
|
+
}
|
|
49
|
+
/** A source's own call that never got an answer: the connection failed (fetch's TypeError) or was aborted. */
|
|
50
|
+
class SourceUnreachable extends Error {
|
|
51
|
+
reason;
|
|
52
|
+
constructor(reason) {
|
|
53
|
+
super('Discovery source unreachable');
|
|
54
|
+
this.reason = reason;
|
|
55
|
+
this.name = 'SourceUnreachable';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A source's own call, with a failed connection told apart from a TypeError thrown while checking its answer (an
|
|
60
|
+
* answer with no `items` throws one at `data.items.length`): only the call's own TypeError or abort is unreachable.
|
|
61
|
+
*/
|
|
62
|
+
function reach(call) {
|
|
63
|
+
return call.catch((error) => {
|
|
64
|
+
const name = typeof error === 'object' && error !== null ? error.name : undefined;
|
|
65
|
+
throw name === 'TypeError' || name === 'AbortError' || name === 'TimeoutError' ? new SourceUnreachable(error) : error;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether a source's read failed for a reason that passes: it ran past its deadline, its call never got an answer (a
|
|
70
|
+
* failed connection or an abort, as the session's read reports it), or the server answered 408, 425, 429 or 5xx (or no
|
|
71
|
+
* status at all, 0). A response the session refuses (the wrong shape or protocol, including a TypeError thrown while
|
|
72
|
+
* checking it) and any other 4xx are the source's own failure and are not asked again.
|
|
73
|
+
*/
|
|
74
|
+
export function transientSourceFailure(error) {
|
|
75
|
+
if (error instanceof SourceTimedOut || error instanceof SourceUnreachable)
|
|
76
|
+
return true;
|
|
77
|
+
if (typeof error !== 'object' || error === null)
|
|
78
|
+
return false;
|
|
79
|
+
const { status } = error;
|
|
80
|
+
return typeof status === 'number' && (status === 0 || status === 408 || status === 425 || status === 429 || status >= 500);
|
|
81
|
+
}
|
|
35
82
|
const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
|
|
36
83
|
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
|
|
37
84
|
function recommendationItem(kind, candidate) {
|
|
@@ -121,6 +168,32 @@ function radarCluster(value) {
|
|
|
121
168
|
return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount: mainstream + independent, laneMix: { mainstream, independent }, sources,
|
|
122
169
|
velocity: { normalized: typeof normalized === 'number' && Number.isFinite(normalized) && normalized >= 0 ? normalized : 0 }, newestAt, ...(lead ? { lead } : {}), links, memberKeys };
|
|
123
170
|
}
|
|
171
|
+
const FALLOVER_VIA = new Set(['personal', 'popular', 'static']);
|
|
172
|
+
const filled = (value) => typeof value === 'string' && value.length > 0;
|
|
173
|
+
/**
|
|
174
|
+
* The Minds read's `meta.fallover`, step by step. A step of a kind this SDK knows (`feed` included, 0.7.60) that carries
|
|
175
|
+
* the fields its kind declares is kept as the server sent it; a step of a kind a later server added, or a malformed
|
|
176
|
+
* one, is left out and never guessed at, so every step a page reports is a FalloverStep. Undefined when the page has
|
|
177
|
+
* no list (a server before the contract, or a feed that is not a category's).
|
|
178
|
+
*/
|
|
179
|
+
function readFallover(value) {
|
|
180
|
+
if (!Array.isArray(value))
|
|
181
|
+
return undefined;
|
|
182
|
+
return value.filter((raw) => {
|
|
183
|
+
if (!raw || typeof raw !== 'object')
|
|
184
|
+
return false;
|
|
185
|
+
const step = raw;
|
|
186
|
+
switch (step.kind) {
|
|
187
|
+
case 'category_older': return filled(step.category) && filled(step.cursor);
|
|
188
|
+
case 'category_network': return filled(step.category);
|
|
189
|
+
case 'category': return filled(step.category) && typeof step.via === 'string' && FALLOVER_VIA.has(step.via);
|
|
190
|
+
case 'for_you':
|
|
191
|
+
case 'all': return true;
|
|
192
|
+
case 'feed': return filled(step.ref);
|
|
193
|
+
default: return false;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
124
197
|
const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
|
|
125
198
|
const key = (protocol, id) => JSON.stringify([protocol, id]);
|
|
126
199
|
const presentations = new Set(['posts', 'visual', 'conversation', 'video', 'thread', 'longread']);
|
|
@@ -175,8 +248,11 @@ export class DiscoveryFeedSession {
|
|
|
175
248
|
this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
|
|
176
249
|
.map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
|
|
177
250
|
}
|
|
178
|
-
retryFailed() { for (const stream of this.streams)
|
|
179
|
-
stream.failed = false;
|
|
251
|
+
retryFailed() { for (const stream of this.streams) {
|
|
252
|
+
stream.failed = false;
|
|
253
|
+
stream.failures = 0;
|
|
254
|
+
stream.retryAt = undefined;
|
|
255
|
+
} }
|
|
180
256
|
next() {
|
|
181
257
|
if (this.pending)
|
|
182
258
|
return this.pending;
|
|
@@ -191,7 +267,12 @@ export class DiscoveryFeedSession {
|
|
|
191
267
|
if (stream.protocol === 'minds') {
|
|
192
268
|
// Discover leads with what is moving now: the REST hot order (engagement
|
|
193
269
|
// decayed by age). Hot pages by offset; the server sends no cursor for it.
|
|
194
|
-
|
|
270
|
+
// Asked once, as the open networks are: a 503 retried twice with its Retry-After ran past this read's 15 s
|
|
271
|
+
// deadline, and the Minds lane failed (Bill 2026-10-01: "couldnt load discover minds posts didnt load"). The
|
|
272
|
+
// opt-out rides in the params (ListPostsParams.retry), which a reader handed to a session forwards to read the
|
|
273
|
+
// right page at all: a second argument was dropped by the Minds app's own wrappers (lib/discoverFeedResource,
|
|
274
|
+
// lib/compassStatus withCompass, lib/watchSource), which pass the params alone (review of #4047).
|
|
275
|
+
const response = await this.posts.list({ ...params, sort: 'hot', ...(stream.cursor ? { cursor: stream.cursor } : { offset: stream.offset }), retry: false });
|
|
195
276
|
stream.offset += response.meta.scanned ?? response.meta.limit;
|
|
196
277
|
const next = response.meta.next_cursor ?? undefined;
|
|
197
278
|
stream.done = !response.meta.has_more || !!next && (next === stream.cursor || stream.used.includes(next));
|
|
@@ -199,10 +280,12 @@ export class DiscoveryFeedSession {
|
|
|
199
280
|
stream.used = [...stream.used, stream.cursor].slice(-32);
|
|
200
281
|
stream.cursor = next;
|
|
201
282
|
// A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
|
|
202
|
-
|
|
203
|
-
|
|
283
|
+
const fallover = readFallover(response.meta.fallover);
|
|
284
|
+
if (fallover)
|
|
285
|
+
stream.fallover = fallover;
|
|
204
286
|
return { items: response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post })),
|
|
205
|
-
layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial'
|
|
287
|
+
layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial',
|
|
288
|
+
...(response.meta.compass_filter ? { compass: response.meta.compass_filter } : {}) };
|
|
206
289
|
}
|
|
207
290
|
const validatePosts = (data) => {
|
|
208
291
|
if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(post => post.protocol !== stream.protocol || post.author.protocol !== stream.protocol))
|
|
@@ -211,7 +294,7 @@ export class DiscoveryFeedSession {
|
|
|
211
294
|
const started = Date.now();
|
|
212
295
|
if (stream.topics)
|
|
213
296
|
return this.walkTopics(stream, params, validatePosts, false, started);
|
|
214
|
-
const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY);
|
|
297
|
+
const { data } = await reach(this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY));
|
|
215
298
|
if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
|
|
216
299
|
throw new Error('Invalid discovery source response');
|
|
217
300
|
if (data.kind === 'topics') {
|
|
@@ -254,7 +337,7 @@ export class DiscoveryFeedSession {
|
|
|
254
337
|
// continuation, not the network: the stream may start again from a later topic's page. A provider topic
|
|
255
338
|
// read that fails any other way than gone is still the network's failure.
|
|
256
339
|
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)
|
|
340
|
+
const response = await reach(this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY))
|
|
258
341
|
.catch((error) => {
|
|
259
342
|
if (isGone(error))
|
|
260
343
|
return null;
|
|
@@ -299,11 +382,18 @@ export class DiscoveryFeedSession {
|
|
|
299
382
|
void this.radarLoading.then(clusters => { const lead = this.pickCluster(clusters, 0); if (lead)
|
|
300
383
|
void this.memberPosts(lead); });
|
|
301
384
|
}
|
|
302
|
-
|
|
385
|
+
// A network that failed for a passing reason sat out the page after its failure; from this one it takes its turn
|
|
386
|
+
// again. It stays failed, and named in retryingSources, until a page asks it: with more than three networks the
|
|
387
|
+
// round-robin may pass it over on the page it is due (Codex review of #4037).
|
|
388
|
+
const active = this.streams.filter(stream => !stream.done && (!stream.failed || (stream.retryAt !== undefined && this.page >= stream.retryAt)));
|
|
303
389
|
const native = active.find(stream => stream.protocol === 'minds');
|
|
304
390
|
const remote = active.filter(stream => stream.protocol !== 'minds');
|
|
305
391
|
const selected = [...(native ? [native] : []), ...Array.from({ length: Math.min(native ? 3 : 4, remote.length) }, (_, index) => remote[(this.nextSource + index) % remote.length])];
|
|
306
392
|
this.nextSource += 3;
|
|
393
|
+
for (const stream of selected) {
|
|
394
|
+
stream.failed = false;
|
|
395
|
+
stream.retryAt = undefined;
|
|
396
|
+
}
|
|
307
397
|
const reads = await Promise.all(selected.map(async (stream) => {
|
|
308
398
|
// Commit continuation state only if this page finishes within its UI
|
|
309
399
|
// deadline. A late provider cannot move the cursor past unseen content.
|
|
@@ -311,14 +401,21 @@ export class DiscoveryFeedSession {
|
|
|
311
401
|
let cancel = () => { };
|
|
312
402
|
try {
|
|
313
403
|
const read = await Promise.race([this.read(candidate), new Promise((_resolve, reject) => {
|
|
314
|
-
const timer = setTimeout(() => reject(new
|
|
404
|
+
const timer = setTimeout(() => reject(new SourceTimedOut()), DISCOVERY_SOURCE_TIMEOUT_MS);
|
|
315
405
|
cancel = () => clearTimeout(timer);
|
|
316
406
|
})]);
|
|
317
407
|
Object.assign(stream, candidate);
|
|
408
|
+
stream.failures = 0;
|
|
318
409
|
return read;
|
|
319
410
|
}
|
|
320
|
-
catch {
|
|
411
|
+
catch (error) {
|
|
412
|
+
// A network's passing failure sits out the next page and is asked again from the one after, up to
|
|
413
|
+
// DISCOVERY_SOURCE_RETRIES times in a row; any other failure, or one past that, leaves the source out until
|
|
414
|
+
// retryFailed(). The Minds read fails at once, whatever the reason (#4047): the app's "Minds posts didn't load"
|
|
415
|
+
// notice, its Try again and its Politics fallback (minds#1212) all wait for 'minds' in failedSources.
|
|
321
416
|
stream.failed = true;
|
|
417
|
+
stream.failures = (stream.failures ?? 0) + 1;
|
|
418
|
+
stream.retryAt = stream.protocol !== 'minds' && transientSourceFailure(error) && stream.failures <= DISCOVERY_SOURCE_RETRIES ? this.page + 2 : undefined;
|
|
322
419
|
return { items: [], layout: ordinary, partial: false };
|
|
323
420
|
}
|
|
324
421
|
finally {
|
|
@@ -402,9 +499,13 @@ export class DiscoveryFeedSession {
|
|
|
402
499
|
if (cluster)
|
|
403
500
|
modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
|
|
404
501
|
const fallover = this.streams.find(stream => stream.protocol === 'minds')?.fallover;
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
502
|
+
// Only the Minds read carries what a Politics choice did (the open networks' reads are not filtered by it).
|
|
503
|
+
const compass = reads.find(read => read.compass)?.compass;
|
|
504
|
+
const retrying = this.streams.filter(stream => stream.failed && stream.retryAt !== undefined).map(stream => stream.protocol);
|
|
505
|
+
return { modules, hasMore: this.streams.some(stream => !stream.done && (!stream.failed || stream.retryAt !== undefined)),
|
|
506
|
+
failedSources: this.streams.filter(stream => stream.failed && stream.retryAt === undefined).map(stream => stream.protocol),
|
|
507
|
+
...(retrying.length ? { retryingSources: retrying } : {}), partialCategories: reads.some(read => read.partial),
|
|
508
|
+
...(fallover ? { fallover } : {}), ...(compass ? { compass } : {}) };
|
|
408
509
|
}
|
|
409
510
|
/** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
|
|
410
511
|
async radarCluster(page) {
|
|
@@ -583,6 +684,18 @@ export class DiscoveryResource {
|
|
|
583
684
|
throw new Error('category needs the HTTP client');
|
|
584
685
|
return this.http.get('/discover/category', params, options);
|
|
585
686
|
}
|
|
687
|
+
/**
|
|
688
|
+
* Watch's Minds videos for the viewer: the app's public video posts in hot order (engagement decayed by age), from
|
|
689
|
+
* one list the server shares among every viewer of the app and rebuilds about once a minute, each post checked again
|
|
690
|
+
* on every read under the public floors (not NSFW, removed or hidden; no spam-labelled or adult author or group; the
|
|
691
|
+
* cached safety verdict) and the viewer's blocks and mutes. A video whose file is gone or not on the CDN yet is left
|
|
692
|
+
* out or marked `unplayable`. Page on `meta.next_cursor`. Needs posts:read; the app is the API key's project.
|
|
693
|
+
*/
|
|
694
|
+
videos(params = {}, options) {
|
|
695
|
+
if (!this.http)
|
|
696
|
+
throw new Error('videos needs the HTTP client');
|
|
697
|
+
return this.http.get('/discover/videos', { ...params }, options);
|
|
698
|
+
}
|
|
586
699
|
/**
|
|
587
700
|
* A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
|
|
588
701
|
* snapshot cursor) in a session's Minds slot. With a category, the session also reads open-network posts
|
|
@@ -593,10 +706,13 @@ export class DiscoveryResource {
|
|
|
593
706
|
const curator = this.curator;
|
|
594
707
|
if (!curator)
|
|
595
708
|
throw new Error('createPersonalFeed needs the curator resource');
|
|
709
|
+
const compass = options.compass;
|
|
596
710
|
const list = {
|
|
597
|
-
// The session's `sort` and `category` are Discover's hot-order knobs; the ranker ignores them.
|
|
711
|
+
// The session's `sort` and `category` are Discover's hot-order knobs; the ranker ignores them. A Politics choice
|
|
712
|
+
// goes on every ranker read.
|
|
598
713
|
list: async (params) => {
|
|
599
|
-
const response = await curator.forYou({ limit: params?.limit, offset: params?.offset, cursor: params?.cursor, discovery: true, surface: 'discover'
|
|
714
|
+
const response = await curator.forYou({ limit: params?.limit, offset: params?.offset, cursor: params?.cursor, discovery: true, surface: 'discover',
|
|
715
|
+
...(compass ? { compass } : {}), ...(params?.retry === undefined ? {} : { retry: params.retry }) });
|
|
600
716
|
// A server that predates the cursor says has_more: false on a partial ranked page although its
|
|
601
717
|
// continuation serves the next offset. A cursor-aware server always says `personalized`, and its
|
|
602
718
|
// has_more is the end of the feed.
|
|
@@ -606,9 +722,16 @@ export class DiscoveryResource {
|
|
|
606
722
|
};
|
|
607
723
|
return new DiscoveryFeedSession(list, this.protocols, options.category ? options.networks : [], options.category, options.category ? 'all' : 'minds', options.learning, options.recommend);
|
|
608
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* Discover's feed session. `compass` (a Politics choice, as `posts.list` takes it) goes on every Minds read, whose
|
|
727
|
+
* pages then carry `meta.compass_filter`; the open networks' reads and the radar are not filtered by it, so a view
|
|
728
|
+
* that leaves posts out (Hide politics, Choose sides) is best read with `source: 'minds'`.
|
|
729
|
+
*/
|
|
609
730
|
createFeed(options) {
|
|
731
|
+
const compass = options.compass;
|
|
732
|
+
const posts = compass ? { list: params => this.posts.list({ ...params, compass }) } : this.posts;
|
|
610
733
|
// The radar rides along by default; an older server answers 404 and the session simply shows no story module.
|
|
611
|
-
return new DiscoveryFeedSession(
|
|
734
|
+
return new DiscoveryFeedSession(posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend, params => this.protocols.getPublicRadar(params, NO_RETRY), { social: params => this.protocols.getPublicRadarPosts(params, NO_RETRY), minds: id => this.posts.get(id) });
|
|
612
735
|
}
|
|
613
736
|
}
|
|
614
737
|
/** A read bounded to three seconds; a late or failed one resolves to `fallback`, never an error. */
|