@recursiv/sdk 0.7.59 → 0.7.63

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 CHANGED
@@ -96,10 +96,10 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
96
96
  | `r.posts` | `create`, `update`, `list`, `liked`, `bookmarked`, `bookmark`, `unbookmark`, `search`, `replies`, `react` |
97
97
  | `r.bookmarks` | `save`, `unsave`, `posts`, `list`, `keys`, `merge` — the member's saved posts, open-network posts and articles, synced across devices |
98
98
  | `r.communities` | `create`, `join`, `search`, `members` |
99
- | `r.chat` | `send`, `dm`, `createGroup`, `messages` (cursor paging), `acceptRequest`, `declineRequest`, `leave`, `deleteConversation` |
99
+ | `r.chat` | `send`, `dm`, `createGroup` (`encrypted: true` for an end-to-end encrypted group, 0.7.59), `messages` (cursor paging), `acceptRequest`, `declineRequest`, `leave`, `deleteConversation`; a private group's admin: `addMembers`, `removeMember`, `updateGroup` (0.7.59) |
100
100
  | `r.tags` | `create`, `list` |
101
101
  | `r.uploads` | `uploadMedia` — upload post/chat bytes end to end |
102
- | `r.video` | `entitlement` (may the caller publish video, and how much is left), `createUpload`, `getStatus`, `getPlaybackToken` |
102
+ | `r.video` | `entitlement` (may the caller publish video, and how much is left), `createUpload`, `renewUpload` (the creator's fresh TUS credentials for the same video, with `createUpload`'s `resumeGrant`, so a resume past the hour continues it; 0.7.63), `getStatus`, `getPlaybackToken` |
103
103
 
104
104
  ### Platform
105
105
  | Resource | Purpose |
@@ -653,8 +653,13 @@ sources; create a new session to refresh exhausted source windows. A topics netw
653
653
  (Bluesky) is read one topic page at a time; in a category the catalog's previews are not
654
654
  judged to be in it, so a read walks into the topics at once, and walks past a topic the
655
655
  provider no longer lists (`trend_unavailable`) or one with nothing in the category, up
656
- to five topic pages a read, so a page reaches a topic that carries posts. Any other
657
- failure marks the source failed until `retryFailed()`.
656
+ to five topic pages a read, so a page reaches a topic that carries posts. A network read
657
+ that fails for a passing reason (past the 15 s page deadline, a dropped connection, a 429
658
+ or a 5xx) leaves the network out of that page and the next, named in `retryingSources`
659
+ until a later page asks it again (from the page after, when its turn comes), up to two
660
+ times in a row. Any other failure, or one past that, marks the source failed
661
+ (`failedSources`) until `retryFailed()`. The Minds read is asked once and is never
662
+ retried this way: any failure of it is in `failedSources` on that page.
658
663
 
659
664
  `sdk.discovery.createPersonalFeed({ networks, category?, learning?, recommend? })`
660
665
  builds the same kind of session for a member's For you tab: its Minds slot reads
@@ -671,6 +676,10 @@ Both take a Politics choice, `compass` (`all`, `mix`, `hide`, or cells in the or
671
676
  page carries its Minds read's answer as `page.compass` (`unavailable`: the page is unfiltered). Only Minds
672
677
  posts are filtered, so a view that leaves posts out reads best with `source: 'minds'`.
673
678
  The catalog lists `compass_filter` on every network when the filter is served to the viewer.
679
+ Hide politics (`compass: 'hide'`) also applies on its own, with or without compass judgments on the server, to
680
+ `sdk.discovery.videos`, `protocols.getPublicTrends` and the radar (`getPublicRadar`, keyless `getSharedRadar`; 0.7.62):
681
+ it leaves out what is categorised politics (a story not themed yet, or `mixed`, too) and says what it did in
682
+ `meta.compass_filter`. These reads answer a side or Mix unfiltered, marked `unavailable`.
674
683
 
675
684
  `sdk.discovery.recommendations({ kind, category?, limit?, session?, exclude? })` is
676
685
  the app's one recommender for suggestion rails: `kind` is `member`, `group`, `tag`,
@@ -690,14 +699,16 @@ history: every page's `meta.next` is `{ offset }`, `{ cursor }` or `null`, and w
690
699
  stage ends `meta.fallover` lists where to go, in order: `category_older` (the keyset
691
700
  cursor `next` also carries), `category_network` (the same category on the open
692
701
  networks), the member's next categories (`via` names the order they came from), `for_you`
693
- and `all`. A `DiscoveryFeedSession` follows the cursor itself and reports the Minds
694
- feed's `fallover` on its pages. `sdk.discovery.category({ category, session? })` answers a
702
+ and `all`. A `feed` step (`ref`, the next feed's ref; 0.7.60) is declared but not sent yet:
703
+ skip a kind your app cannot open. A `DiscoveryFeedSession` follows the cursor itself and
704
+ reports the Minds feed's `fallover` on its pages, leaving out any step this SDK does not
705
+ know or a malformed one. `sdk.discovery.category({ category, session? })` answers a
695
706
  category's surface in one call: `stories`, `network` and `clips` (each shared per app and
696
707
  category, refreshed after 30 s, kept up to 300 s and warmed as the warmer reaches the category, then filtered for the viewer on every call: the cached safety verdicts of story members, network posts and clips again, the term list, the viewer's blocks and mutes over clips, and the member's own hidden sources over network posts and story members) and the
697
708
  viewer's `people` and `groups`; a section that timed out, failed or is off is empty with a
698
709
  `reason` for that request only (a failure is never cached, so asking again retries it), and one this
699
710
  key may not read is empty with `refused` (`stories` and `network` need the member's own app
700
- key with `users:read`; `people` and `groups` need `users:read`). `sdk.discovery.videos({ category?, limit?, cursor?, offset? })`
711
+ key with `users:read`; `people` and `groups` need `users:read`). `sdk.discovery.videos({ category?, limit?, cursor?, offset?, compass? })`
701
712
  is Watch's Minds read: the app's public video posts in hot order, from one list the server shares among every viewer of
702
713
  the app and rebuilds about once a minute, each post checked again on every call under the public floors and the viewer's
703
714
  blocks and mutes, in the `Post` shape with the viewer's own `user_reaction` and `reposted_by_viewer_id` (page on
@@ -0,0 +1,129 @@
1
+ /**
2
+ * FeedDefinition v1, as apps read and write it: the one recipe behind every feed (a list of sources read as one union,
3
+ * a ranking mode, optional filters and exclusions). Types and constants only; the server
4
+ * (packages/server/src/features/feeds/FeedDefinition.ts) validates, canonicalises and digests a definition, and its
5
+ * FeedDefinitionSdkContract test holds these constants equal to its own.
6
+ *
7
+ * Nothing serves a definition yet: no route reads one before the cutover. Discover's built-in categories, All and
8
+ * For you are definitions held in code on the server (refs `builtin:category:<id>`, `builtin:all`, `builtin:for_you`).
9
+ *
10
+ * A definition never holds the viewer's safe/open setting, blocks or mutes, or a compass Mix or Choose-sides choice:
11
+ * `filters.politics: 'hide'` is the only Politics value a feed stores.
12
+ */
13
+ export declare const FEED_DEFINITION_VERSION: 1;
14
+ /** Sources a definition may name, read as one union. Only `builtin:all` and `builtin:for_you` have none. */
15
+ export declare const FEED_SOURCES_MAX = 50;
16
+ /** The largest canonical definition the server keeps, in bytes. */
17
+ export declare const FEED_DEFINITION_MAX_BYTES: number;
18
+ export declare const FEED_SOURCE_KINDS: readonly ["category", "tag", "account", "community", "collection", "list", "story", "bsky_feed", "bsky_list", "bsky_starterpack", "nostr_set"];
19
+ export type FeedSourceKind = typeof FEED_SOURCE_KINDS[number];
20
+ export declare const FEED_RANKING_MODES: readonly ["hot", "new", "top", "for_you", "viral"];
21
+ export type FeedRankingMode = typeof FEED_RANKING_MODES[number];
22
+ export declare const FEED_RANKING_RANGES: readonly ["24h", "7d", "30d", "1y"];
23
+ export type FeedRankingRange = typeof FEED_RANKING_RANGES[number];
24
+ export declare const FEED_MEDIA: readonly ["any", "images", "video", "longread"];
25
+ export type FeedMedia = typeof FEED_MEDIA[number];
26
+ export declare const FEED_NETWORKS: readonly ["minds", "bluesky", "activitypub", "nostr"];
27
+ export type FeedNetwork = typeof FEED_NETWORKS[number];
28
+ /** Source kinds a topical feed may hold (with a hot, new or top ranking); any other feed is 'wide'. */
29
+ export declare const FEED_TOPICAL_SOURCE_KINDS: readonly ["category", "tag", "account", "community", "list", "collection", "story"];
30
+ export declare const FEED_TOPICAL_MODES: readonly ["hot", "new", "top"];
31
+ /** The one field that names what each source kind reads. */
32
+ export declare const FEED_SOURCE_KEY: {
33
+ readonly category: "id";
34
+ readonly tag: "slug";
35
+ readonly account: "ref";
36
+ readonly community: "id";
37
+ readonly collection: "id";
38
+ readonly list: "id";
39
+ readonly story: "clusterId";
40
+ readonly bsky_feed: "uri";
41
+ readonly bsky_list: "uri";
42
+ readonly bsky_starterpack: "uri";
43
+ readonly nostr_set: "naddr";
44
+ };
45
+ /** An account by its native id: a Minds user, an atproto DID, a Nostr hex pubkey or a WebFinger account. */
46
+ export type FeedAccountRef = `user:${string}` | `did:${string}` | `nostr:${string}` | `acct:${string}`;
47
+ export type FeedSource =
48
+ /** A Discover category id, as `posts.categories()` lists them. */
49
+ {
50
+ kind: 'category';
51
+ id: string;
52
+ }
53
+ /** A hashtag, without the `#`. */
54
+ | {
55
+ kind: 'tag';
56
+ slug: string;
57
+ } | {
58
+ kind: 'account';
59
+ ref: FeedAccountRef;
60
+ } | {
61
+ kind: 'community';
62
+ id: string;
63
+ }
64
+ /** A member's collection of saved posts, or a list (after the cutover). */
65
+ | {
66
+ kind: 'collection';
67
+ id: string;
68
+ } | {
69
+ kind: 'list';
70
+ id: string;
71
+ }
72
+ /** A World Today story, by its radar cluster id. */
73
+ | {
74
+ kind: 'story';
75
+ clusterId: string;
76
+ }
77
+ /** Bluesky feeds, lists and starter packs by their at:// record URI (after the cutover). */
78
+ | {
79
+ kind: 'bsky_feed';
80
+ uri: string;
81
+ } | {
82
+ kind: 'bsky_list';
83
+ uri: string;
84
+ } | {
85
+ kind: 'bsky_starterpack';
86
+ uri: string;
87
+ }
88
+ /** A Nostr people, interest, bookmark or curation set by its NIP-19 naddr (after the cutover). */
89
+ | {
90
+ kind: 'nostr_set';
91
+ naddr: string;
92
+ };
93
+ export interface FeedRanking {
94
+ mode: FeedRankingMode;
95
+ range?: FeedRankingRange;
96
+ }
97
+ export interface FeedFilters {
98
+ /** Absent means any media. */
99
+ media?: FeedMedia;
100
+ /** Absent means every network. */
101
+ networks?: FeedNetwork[];
102
+ /** The only Politics value a feed stores. Mix and Choose sides stay the viewer's own. */
103
+ politics?: 'hide';
104
+ /** BCP 47 language tags such as `en` or `pt-br`; at most 10. */
105
+ languages?: string[];
106
+ }
107
+ export interface FeedExclude {
108
+ /** At most 50 tags. */
109
+ tags?: string[];
110
+ /** At most 50 accounts. */
111
+ accounts?: FeedAccountRef[];
112
+ }
113
+ export interface FeedDefinition {
114
+ v: typeof FEED_DEFINITION_VERSION;
115
+ /** 1 to 50, read as one union; empty only for `builtin:all` and `builtin:for_you`. */
116
+ sources: FeedSource[];
117
+ ranking: FeedRanking;
118
+ filters?: FeedFilters;
119
+ exclude?: FeedExclude;
120
+ }
121
+ export type BuiltinFeedRef = `builtin:category:${string}` | 'builtin:all' | 'builtin:for_you';
122
+ /** A feed's address: a built-in, a member feed's current version (`feed:<uuid>`), or one version of it. */
123
+ export type FeedRef = BuiltinFeedRef | `feed:${string}` | `feed:${string}@${number}`;
124
+ /** Computed by the server, never stored: 'anon' feeds are ranked once and may be shared; 'viewer' feeds are ranked per viewer. */
125
+ export type FeedAudience = 'anon' | 'viewer';
126
+ /** Computed by the server: 'wide' feeds leave out Bluesky authors who asked not to be recommended. */
127
+ export type FeedConsentClass = 'topical' | 'wide';
128
+ /** The built-in feed for a Discover category. */
129
+ export declare function builtinCategoryFeedRef(category: string): BuiltinFeedRef;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * FeedDefinition v1, as apps read and write it: the one recipe behind every feed (a list of sources read as one union,
3
+ * a ranking mode, optional filters and exclusions). Types and constants only; the server
4
+ * (packages/server/src/features/feeds/FeedDefinition.ts) validates, canonicalises and digests a definition, and its
5
+ * FeedDefinitionSdkContract test holds these constants equal to its own.
6
+ *
7
+ * Nothing serves a definition yet: no route reads one before the cutover. Discover's built-in categories, All and
8
+ * For you are definitions held in code on the server (refs `builtin:category:<id>`, `builtin:all`, `builtin:for_you`).
9
+ *
10
+ * A definition never holds the viewer's safe/open setting, blocks or mutes, or a compass Mix or Choose-sides choice:
11
+ * `filters.politics: 'hide'` is the only Politics value a feed stores.
12
+ */
13
+ export const FEED_DEFINITION_VERSION = 1;
14
+ /** Sources a definition may name, read as one union. Only `builtin:all` and `builtin:for_you` have none. */
15
+ export const FEED_SOURCES_MAX = 50;
16
+ /** The largest canonical definition the server keeps, in bytes. */
17
+ export const FEED_DEFINITION_MAX_BYTES = 16 * 1024;
18
+ export const FEED_SOURCE_KINDS = [
19
+ 'category', 'tag', 'account', 'community', 'collection', 'list', 'story',
20
+ 'bsky_feed', 'bsky_list', 'bsky_starterpack', 'nostr_set',
21
+ ];
22
+ export const FEED_RANKING_MODES = ['hot', 'new', 'top', 'for_you', 'viral'];
23
+ export const FEED_RANKING_RANGES = ['24h', '7d', '30d', '1y'];
24
+ export const FEED_MEDIA = ['any', 'images', 'video', 'longread'];
25
+ export const FEED_NETWORKS = ['minds', 'bluesky', 'activitypub', 'nostr'];
26
+ /** Source kinds a topical feed may hold (with a hot, new or top ranking); any other feed is 'wide'. */
27
+ export const FEED_TOPICAL_SOURCE_KINDS = ['category', 'tag', 'account', 'community', 'list', 'collection', 'story'];
28
+ export const FEED_TOPICAL_MODES = ['hot', 'new', 'top'];
29
+ /** The one field that names what each source kind reads. */
30
+ export const FEED_SOURCE_KEY = {
31
+ category: 'id',
32
+ tag: 'slug',
33
+ account: 'ref',
34
+ community: 'id',
35
+ collection: 'id',
36
+ list: 'id',
37
+ story: 'clusterId',
38
+ bsky_feed: 'uri',
39
+ bsky_list: 'uri',
40
+ bsky_starterpack: 'uri',
41
+ nostr_set: 'naddr',
42
+ };
43
+ /** The built-in feed for a Discover category. */
44
+ export function builtinCategoryFeedRef(category) {
45
+ return `builtin:category:${category}`;
46
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './resources/discovery-learning.js';
2
2
  import { DiscoveryResource } from './resources/discovery.js';
3
3
  export * from './resources/discovery.js';
4
+ export * from './feed-definition.js';
4
5
  import { AuthResource } from './resources/auth.js';
5
6
  import { PostsResource } from './resources/posts.js';
6
7
  import { UsersResource } from './resources/users.js';
@@ -234,7 +235,7 @@ export type { Brief, BriefEmail, MyBrief, MyBriefEntry, BriefParams, BriefShippe
234
235
  export type { HumanAsk, HumanAskStatus, CreateHumanAskInput, ListHumanAsksParams } from './resources/human-asks.js';
235
236
  export { HumanInputResource } from './resources/human-input.js';
236
237
  export type { Goal, GoalTask, CreateGoalInput, UpdateGoalInput, ListGoalsParams, ReorderGoalTasksInput } from './resources/goals.js';
237
- export type { DispatcherTask, TaskClaim, ClaimRecord, DispatcherStats, CreateTaskInput, UpdateTaskInput, ListTasksParams, SignalRecord, OutcomeRecord, PauseState, PauseInput, SignalsParams, OutcomesParams, DiscoveryParams, StaleStuckParams } from './resources/dispatcher.js';
238
+ export type { DispatcherTask, TaskClaim, ClaimRecord, DispatcherStats, CreateTaskInput, UpdateTaskInput, ListTasksParams, SignalRecord, OutcomeRecord, PauseState, PauseInput, SignalsParams, OutcomesParams, DiscoveryParams, StaleStuckParams, TaskPlan, TaskPlanStep, SetTaskPlanInput, UpdateTaskPlanStepInput } from './resources/dispatcher.js';
238
239
  export { SwarmsResource } from './resources/swarms.js';
239
240
  export type { Swarm, SwarmMember, CreateSwarmInput, ListSwarmsParams, ScheduledTask, ScheduleTaskInput } from './resources/swarms.js';
240
241
  export { TemplatesResource } from './resources/templates.js';
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export * from './resources/discovery-learning.js';
2
2
  import { DiscoveryResource } from './resources/discovery.js';
3
3
  export * from './resources/discovery.js';
4
+ export * from './feed-definition.js';
4
5
  import { HttpClient } from './client.js';
5
6
  import { AuthResource } from './resources/auth.js';
6
7
  import { PostsResource } from './resources/posts.js';
@@ -1,6 +1,7 @@
1
1
  import type { Socket } from 'socket.io-client';
2
2
  import { HttpClient } from './client.js';
3
3
  import type { E2eeDevicesRealtimeEvent, E2eeLinkRealtimeEvent, E2eeRealtimeEvent } from './resources/e2ee.js';
4
+ import type { ChatConversationEvent, ChatMembersEvent } from './types.js';
4
5
  export interface ChatMessageEvent {
5
6
  id: string;
6
7
  conversationId: string;
@@ -147,7 +148,8 @@ export declare class RealtimeClient {
147
148
  *
148
149
  * Server events include `notification`, `feed_update`,
149
150
  * `conversation_created`, `chat_message`, `chat_typing`, `agent_thinking`,
150
- * `e2ee:event`, `e2ee:link` and `e2ee:devices`.
151
+ * `chat:members`, `chat:conversation`, `e2ee:event`, `e2ee:link` and
152
+ * `e2ee:devices`.
151
153
  */
152
154
  on<T = unknown>(event: string, listener: (payload: T) => void): () => void;
153
155
  /** Join a conversation room now, and again after every reconnect, until you leave it. */
@@ -159,6 +161,14 @@ export declare class RealtimeClient {
159
161
  onMessageDelete(callback: (msg: ChatMessageDeleteEvent) => void): (() => void) | undefined;
160
162
  onTyping(callback: (event: TypingEvent) => void): (() => void) | undefined;
161
163
  onAgentThinking(callback: (event: AgentThinkingEvent) => void): (() => void) | undefined;
164
+ /**
165
+ * A private group's people changed (0.7.59): `{ conversationId, change, userIds, actorId }`,
166
+ * change `added`, `removed`, `left` or `invites_cancelled`. Sent to everyone in the group
167
+ * and to the people it is about. Read the conversation again; in an encrypted group, check its keys.
168
+ */
169
+ onChatMembers(callback: (event: ChatMembersEvent) => void): () => void;
170
+ /** A private group was renamed (0.7.59): `{ conversationId, change: 'renamed', name, actorId }`. */
171
+ onChatConversation(callback: (event: ChatConversationEvent) => void): () => void;
162
172
  /**
163
173
  * The E2EE doorbell after each append to an encrypted conversation this
164
174
  * account belongs to: `{ conversationId, seq }`, plus the stored `event`
package/dist/realtime.js CHANGED
@@ -369,7 +369,8 @@ export class RealtimeClient {
369
369
  *
370
370
  * Server events include `notification`, `feed_update`,
371
371
  * `conversation_created`, `chat_message`, `chat_typing`, `agent_thinking`,
372
- * `e2ee:event`, `e2ee:link` and `e2ee:devices`.
372
+ * `chat:members`, `chat:conversation`, `e2ee:event`, `e2ee:link` and
373
+ * `e2ee:devices`.
373
374
  */
374
375
  on(event, listener) {
375
376
  // Every call is its own subscription, even for a function that is already
@@ -426,6 +427,18 @@ export class RealtimeClient {
426
427
  onAgentThinking(callback) {
427
428
  return this.on('agent_thinking', callback);
428
429
  }
430
+ /**
431
+ * A private group's people changed (0.7.59): `{ conversationId, change, userIds, actorId }`,
432
+ * change `added`, `removed`, `left` or `invites_cancelled`. Sent to everyone in the group
433
+ * and to the people it is about. Read the conversation again; in an encrypted group, check its keys.
434
+ */
435
+ onChatMembers(callback) {
436
+ return this.on('chat:members', callback);
437
+ }
438
+ /** A private group was renamed (0.7.59): `{ conversationId, change: 'renamed', name, actorId }`. */
439
+ onChatConversation(callback) {
440
+ return this.on('chat:conversation', callback);
441
+ }
429
442
  /**
430
443
  * The E2EE doorbell after each append to an encrypted conversation this
431
444
  * account belongs to: `{ conversationId, seq }`, plus the stored `event`
@@ -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
- /** Create a group conversation */
41
- createGroup(input: {
42
- name: string;
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<ConversationDetail>>;
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.
@@ -44,10 +44,46 @@ export class ChatResource {
44
44
  dm(input) {
45
45
  return this.client.post('/chat/dm', input);
46
46
  }
47
- /** Create a group conversation */
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.
@@ -15,6 +15,13 @@ export interface WatchVideosParams {
15
15
  cursor?: string;
16
16
  /** A position in the list to start a first read at (0 to 400); a later page sends `cursor`. */
17
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;
18
25
  }
19
26
  /** A media item as `discovery.videos` serves it: the poster, and the player's size, length and HLS address when known. */
20
27
  export interface WatchVideoMedia extends MediaItem {
@@ -62,6 +69,8 @@ export interface WatchVideosResponse {
62
69
  built_at: string | null;
63
70
  /** `no_app`: the API key is bound to no app, so there is no list. */
64
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;
65
74
  };
66
75
  }
67
76
  /** What GET /discover/recommendations suggests: members, groups, tags, open-network accounts or World Today stories. */
@@ -306,6 +315,23 @@ export declare const isDiscoveryPost: (item: DiscoveryItem) => item is Discovery
306
315
  export declare const RECOMMENDATION_MAX_ITEMS = 6;
307
316
  /** Clusters read from the radar per session; the server never holds more. */
308
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;
309
335
  /** json-render Spec: values are references to authenticated SDK results, never
310
336
  * arbitrary executable components, links, generated text or provider markup. */
311
337
  export interface DiscoveryModule {
@@ -331,7 +357,17 @@ export interface DiscoveryModule {
331
357
  export interface DiscoveryFeedPage {
332
358
  modules: DiscoveryModule[];
333
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
+ */
334
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[];
335
371
  partialCategories: boolean;
336
372
  /**
337
373
  * The Minds feed's fall-through, once its category feed has ended a stage (the server's
@@ -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;
@@ -204,8 +280,9 @@ export class DiscoveryFeedSession {
204
280
  stream.used = [...stream.used, stream.cursor].slice(-32);
205
281
  stream.cursor = next;
206
282
  // A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
207
- if (Array.isArray(response.meta.fallover))
208
- stream.fallover = response.meta.fallover;
283
+ const fallover = readFallover(response.meta.fallover);
284
+ if (fallover)
285
+ stream.fallover = fallover;
209
286
  return { items: response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post })),
210
287
  layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial',
211
288
  ...(response.meta.compass_filter ? { compass: response.meta.compass_filter } : {}) };
@@ -217,7 +294,7 @@ export class DiscoveryFeedSession {
217
294
  const started = Date.now();
218
295
  if (stream.topics)
219
296
  return this.walkTopics(stream, params, validatePosts, false, started);
220
- 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));
221
298
  if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
222
299
  throw new Error('Invalid discovery source response');
223
300
  if (data.kind === 'topics') {
@@ -260,7 +337,7 @@ export class DiscoveryFeedSession {
260
337
  // continuation, not the network: the stream may start again from a later topic's page. A provider topic
261
338
  // read that fails any other way than gone is still the network's failure.
262
339
  const indexContinuation = !!topic.cursor?.startsWith(INDEX_CURSOR);
263
- 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))
264
341
  .catch((error) => {
265
342
  if (isGone(error))
266
343
  return null;
@@ -305,11 +382,18 @@ export class DiscoveryFeedSession {
305
382
  void this.radarLoading.then(clusters => { const lead = this.pickCluster(clusters, 0); if (lead)
306
383
  void this.memberPosts(lead); });
307
384
  }
308
- const active = this.streams.filter(stream => !stream.done && !stream.failed);
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)));
309
389
  const native = active.find(stream => stream.protocol === 'minds');
310
390
  const remote = active.filter(stream => stream.protocol !== 'minds');
311
391
  const selected = [...(native ? [native] : []), ...Array.from({ length: Math.min(native ? 3 : 4, remote.length) }, (_, index) => remote[(this.nextSource + index) % remote.length])];
312
392
  this.nextSource += 3;
393
+ for (const stream of selected) {
394
+ stream.failed = false;
395
+ stream.retryAt = undefined;
396
+ }
313
397
  const reads = await Promise.all(selected.map(async (stream) => {
314
398
  // Commit continuation state only if this page finishes within its UI
315
399
  // deadline. A late provider cannot move the cursor past unseen content.
@@ -317,14 +401,21 @@ export class DiscoveryFeedSession {
317
401
  let cancel = () => { };
318
402
  try {
319
403
  const read = await Promise.race([this.read(candidate), new Promise((_resolve, reject) => {
320
- const timer = setTimeout(() => reject(new Error('Discovery source timed out')), 15000);
404
+ const timer = setTimeout(() => reject(new SourceTimedOut()), DISCOVERY_SOURCE_TIMEOUT_MS);
321
405
  cancel = () => clearTimeout(timer);
322
406
  })]);
323
407
  Object.assign(stream, candidate);
408
+ stream.failures = 0;
324
409
  return read;
325
410
  }
326
- 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.
327
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;
328
419
  return { items: [], layout: ordinary, partial: false };
329
420
  }
330
421
  finally {
@@ -410,8 +501,10 @@ export class DiscoveryFeedSession {
410
501
  const fallover = this.streams.find(stream => stream.protocol === 'minds')?.fallover;
411
502
  // Only the Minds read carries what a Politics choice did (the open networks' reads are not filtered by it).
412
503
  const compass = reads.find(read => read.compass)?.compass;
413
- return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
414
- failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial),
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),
415
508
  ...(fallover ? { fallover } : {}), ...(compass ? { compass } : {}) };
416
509
  }
417
510
  /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
@@ -1,5 +1,54 @@
1
1
  import type { HttpClient } from '../client.js';
2
2
  import type { SingleResponse, DeleteResponse, PaginationParams } from '../types.js';
3
+ /** One step of a task's plan, as the checklist card shows it. */
4
+ export interface TaskPlanStep {
5
+ position: number;
6
+ title: string;
7
+ status: 'pending' | 'in_progress' | 'done' | 'skipped' | 'blocked';
8
+ /** Why a step is blocked or was skipped. */
9
+ reason: string | null;
10
+ /** The specialist agent doing the step. */
11
+ agent: {
12
+ id: string;
13
+ name: string;
14
+ } | null;
15
+ /** The approval or question a blocked step waits on, in the plan's conversation. */
16
+ waitingOn: {
17
+ executionId: string | null;
18
+ messageId: string | null;
19
+ } | null;
20
+ startedAt: string | null;
21
+ finishedAt: string | null;
22
+ }
23
+ export interface TaskPlan {
24
+ taskId: string;
25
+ title: string;
26
+ /** The chat the plan's card lives in. */
27
+ conversationId: string | null;
28
+ steps: TaskPlanStep[];
29
+ }
30
+ export interface SetTaskPlanInput {
31
+ agent?: string;
32
+ /** The chat to post the card in. Only the first plan that names one posts it. */
33
+ conversation_id?: string | null;
34
+ steps: Array<{
35
+ title: string;
36
+ agent_id?: string | null;
37
+ }>;
38
+ organization_id?: string;
39
+ }
40
+ export interface UpdateTaskPlanStepInput {
41
+ agent?: string;
42
+ status: TaskPlanStep['status'];
43
+ title?: string;
44
+ /** Kept only for 'blocked' and 'skipped'. */
45
+ reason?: string | null;
46
+ /** Only for 'blocked': the approval it waits on. */
47
+ waiting_on_execution_id?: string | null;
48
+ /** Only for 'blocked': the question message it waits on. */
49
+ waiting_on_message_id?: string | null;
50
+ organization_id?: string;
51
+ }
3
52
  export interface DispatcherTask {
4
53
  id: string;
5
54
  title: string;
@@ -275,6 +324,13 @@ export declare class DispatcherResource {
275
324
  }): Promise<SingleResponse<{
276
325
  ok: true;
277
326
  }>>;
327
+ /**
328
+ * Write a claimed task's step plan (the checklist card). Replaces every step
329
+ * that has not finished; done and skipped steps stay. Requires the claim.
330
+ */
331
+ setPlan(taskId: string, input: SetTaskPlanInput): Promise<SingleResponse<TaskPlan>>;
332
+ /** Move one step of a claimed task's plan. Started and finished times are stamped server-side. */
333
+ updatePlanStep(taskId: string, position: number, input: UpdateTaskPlanStepInput): Promise<SingleResponse<TaskPlan>>;
278
334
  /**
279
335
  * Mark a task as done. A task with a thresholded check that has no passing
280
336
  * run refuses with 409 and the failing check names — run
@@ -28,6 +28,19 @@ export class DispatcherResource {
28
28
  const { organization_id, ...body } = input;
29
29
  return this.client.post(`/dispatcher/heartbeat/${encodeURIComponent(taskId)}${orgQuery(organization_id)}`, body);
30
30
  }
31
+ /**
32
+ * Write a claimed task's step plan (the checklist card). Replaces every step
33
+ * that has not finished; done and skipped steps stay. Requires the claim.
34
+ */
35
+ setPlan(taskId, input) {
36
+ const { organization_id, ...body } = input;
37
+ return this.client.put(`/dispatcher/tasks/${encodeURIComponent(taskId)}/plan${orgQuery(organization_id)}`, body);
38
+ }
39
+ /** Move one step of a claimed task's plan. Started and finished times are stamped server-side. */
40
+ updatePlanStep(taskId, position, input) {
41
+ const { organization_id, ...body } = input;
42
+ return this.client.patch(`/dispatcher/tasks/${encodeURIComponent(taskId)}/plan/steps/${position}${orgQuery(organization_id)}`, body);
43
+ }
31
44
  /**
32
45
  * Mark a task as done. A task with a thresholded check that has no passing
33
46
  * run refuses with 409 and the failing check names — run
@@ -95,9 +95,13 @@ export interface E2eeEvent {
95
95
  * quiet one; never a commit, never a Welcome, never one whose envelope is over
96
96
  * 16 KB), and only on a socket whose credential could read it with `events()`:
97
97
  * one authenticated by the account owner's session or by an owner key whose
98
- * app or organization binding reaches the conversation. A socket opened with
99
- * an agent-scoped key gets the content-free form only. When present it is the
100
- * stored event exactly as `events()` returns it, JSON byte for byte.
98
+ * app or organization binding reaches the conversation, once that sign-in has
99
+ * called `registerDevice`, `publishKeyPackages`, `keyPackageStatus`,
100
+ * `events`, `acknowledge` or `appendEvent` for one of the account's active
101
+ * devices since the socket connected, as a sync after connecting does. A
102
+ * socket opened with an agent-scoped key, or whose sign-in spoke only for a
103
+ * device since revoked, gets the content-free form only. When present it is
104
+ * the stored event exactly as `events()` returns it, JSON byte for byte.
101
105
  *
102
106
  * With the shared engine, hand `event` to `applyPushed(conversationId, event)`
103
107
  * on the device session: it applies the event without a relay read when it is
@@ -11,6 +11,10 @@ export interface RequestHumanInputInput {
11
11
  steps: string;
12
12
  /** One-tap answers (2-4 short words) — rendered as buttons on the phone. */
13
13
  options?: string[];
14
+ /** The option you recommend; must be one of `options`. Marked "Recommended" on the card. */
15
+ recommended?: string;
16
+ /** You keep working on `recommended` unless the human answers otherwise. Needs `recommended`. */
17
+ continuing_with_default?: boolean;
14
18
  }
15
19
  export interface HumanInputRequest {
16
20
  conversation_id: string;
@@ -982,6 +982,13 @@ export interface SocialPost {
982
982
  author: Omit<SocialProfile, 'subscription'>;
983
983
  text: string;
984
984
  createdAt: string;
985
+ /**
986
+ * The language the post declares, as the lower-case primary subtag of its BCP-47 tag ("de", "en", "pt"): Bluesky's
987
+ * record `langs[0]`, a Mastodon status's `language`, a Nostr note's NIP-32 `ISO-639-1` self-label. Absent when the post
988
+ * declares none (most Nostr notes) or a malformed one, and on posts served from the open-network source index; the
989
+ * server never guesses it from the text and never drops a post for it. Use it to order the viewer's language first.
990
+ */
991
+ language?: string;
985
992
  replyCount: number | null;
986
993
  likeCount: number | null;
987
994
  repostCount: number | null;
@@ -1120,7 +1127,12 @@ export type SocialTrends = SocialTrendProvenance & ({
1120
1127
  safetyFilter?: {
1121
1128
  withheld: number;
1122
1129
  };
1123
- });
1130
+ }) & {
1131
+ /** Present when `getPublicTrends` named a filtering `compass`: what Hide politics did to this page (counts only). */
1132
+ meta?: {
1133
+ compass_filter?: CompassFilterMeta;
1134
+ };
1135
+ };
1124
1136
  export interface SocialTrendFeed extends SocialTrendProvenance {
1125
1137
  trend: SocialTrend;
1126
1138
  items: SocialPost[];
@@ -1246,10 +1258,20 @@ export interface SocialRadar {
1246
1258
  selected: string;
1247
1259
  status: 'complete' | 'partial';
1248
1260
  };
1261
+ /** Present when the read named a filtering `compass`: what Hide politics did to the stories listed (counts only). */
1262
+ meta?: {
1263
+ compass_filter?: CompassFilterMeta;
1264
+ };
1249
1265
  }
1250
1266
  export interface SocialRadarParams {
1251
1267
  category?: string;
1252
1268
  limit?: number;
1269
+ /**
1270
+ * A Politics choice. Hide politics (`hide`) leaves out the stories whose theme is politics, and the ones not themed yet
1271
+ * or themed `mixed` (a story has no compass placement, so it fails closed); the stories after them fill the list.
1272
+ * A side or Mix is answered unfiltered, marked `unavailable`; the keyless `getSharedRadar` takes only `all` or `hide`.
1273
+ */
1274
+ compass?: string;
1253
1275
  }
1254
1276
  /** One radar story's social member posts, in the cluster's member order. Empty once the story leaves the snapshot. */
1255
1277
  export interface SocialRadarPosts {
@@ -1643,7 +1665,10 @@ export declare class ProtocolsResource {
1643
1665
  }): Promise<SingleResponse<SocialConversation>>;
1644
1666
  /**
1645
1667
  * Current provider-ranked topics or posts, with explicit source and coverage. Does not ingest content.
1646
- * Every page, previews included, gets the safety floor; `discovery` only asks for a layout.
1668
+ * Every page, previews included, gets the safety floor; `discovery` only asks for a layout. `compass` (a Politics
1669
+ * choice, spelled as `posts.list` takes it): Hide politics (`hide`) leaves out the topics and posts whose cached
1670
+ * browse category is politics, where the filter is served to the member; a side or Mix is answered unfiltered. The
1671
+ * page then says what it did in `meta.compass_filter`.
1647
1672
  */
1648
1673
  getPublicTrends(params: {
1649
1674
  protocol: string;
@@ -1652,6 +1677,7 @@ export declare class ProtocolsResource {
1652
1677
  includePreviews?: boolean;
1653
1678
  category?: string;
1654
1679
  discovery?: boolean;
1680
+ compass?: string;
1655
1681
  }, options?: SocialReadOptions): Promise<SingleResponse<SocialTrends>>;
1656
1682
  /** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. Screened like `getPublicTrends`. */
1657
1683
  getPublicTrendFeed(params: {
@@ -42,11 +42,12 @@ function hostedIdentityPath(id) {
42
42
  export function viralItemHasVideo(item) {
43
43
  return item.post.media.some(media => media.type === 'video');
44
44
  }
45
- /** Only the two radar fields travel; caller scope or destination extras never reach the query string. */
45
+ /** Only the radar's own fields travel; caller scope or destination extras never reach the query string. */
46
46
  /** Forwarded only when given, so a default read makes exactly the call it always made. */
47
47
  const readOptions = (options) => options ? [options] : [];
48
48
  function radarQuery(params) {
49
- return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) };
49
+ return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}),
50
+ ...(params.compass !== undefined ? { compass: params.compass } : {}) };
50
51
  }
51
52
  /** Only the six Viral read fields travel, each only when given: the read answers any other parameter with a 400. */
52
53
  function viralQuery(params) {
@@ -204,13 +205,17 @@ export class ProtocolsResource {
204
205
  }
205
206
  /**
206
207
  * Current provider-ranked topics or posts, with explicit source and coverage. Does not ingest content.
207
- * Every page, previews included, gets the safety floor; `discovery` only asks for a layout.
208
+ * Every page, previews included, gets the safety floor; `discovery` only asks for a layout. `compass` (a Politics
209
+ * choice, spelled as `posts.list` takes it): Hide politics (`hide`) leaves out the topics and posts whose cached
210
+ * browse category is politics, where the filter is served to the member; a side or Mix is answered unfiltered. The
211
+ * page then says what it did in `meta.compass_filter`.
208
212
  */
209
213
  getPublicTrends(params, options) {
210
214
  return this.client.get('/protocols/social/trends', { protocol: params.protocol, limit: params.limit, cursor: params.cursor,
211
215
  ...(params.includePreviews !== undefined ? { includePreviews: params.includePreviews } : {}),
212
216
  ...(params.category !== undefined ? { category: params.category } : {}),
213
- ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) }, ...readOptions(options));
217
+ ...(params.discovery !== undefined ? { discovery: params.discovery } : {}),
218
+ ...(params.compass !== undefined ? { compass: params.compass } : {}) }, ...readOptions(options));
214
219
  }
215
220
  /** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. Screened like `getPublicTrends`. */
216
221
  getPublicTrendFeed(params, options) {
@@ -13,6 +13,12 @@ export interface CreateVideoUploadResult {
13
13
  upload: VideoUploadCreds;
14
14
  /** The HLS URL the video will play from once encoding completes. */
15
15
  hlsUrl: string;
16
+ /**
17
+ * The creator's proof for `renewUpload` (valid 24 h): pass it back to get
18
+ * fresh credentials for this same video when a resume outlives the hour
19
+ * `upload` is signed for. Absent from servers older than 0.7.63's API.
20
+ */
21
+ resumeGrant?: string;
16
22
  }
17
23
  export interface VideoStatusInfo {
18
24
  status: string | null;
@@ -59,6 +65,20 @@ export declare class VideoResource {
59
65
  }): Promise<{
60
66
  data: CreateVideoUploadResult;
61
67
  }>;
68
+ /**
69
+ * Fresh upload credentials for a video this member started, so a TUS resume
70
+ * that outlives the hour `upload` is signed for continues the same video.
71
+ * Pass the `resumeGrant` from `createUpload`. 404 for anyone else's video, an
72
+ * expired grant or an older API without the route; 409 `upload_finished` once
73
+ * a post carries the video or Bunny holds the whole file; 402 as `createUpload`;
74
+ * 503 when the video provider could not answer: keep the partial upload and
75
+ * retry (it never means the video is gone).
76
+ */
77
+ renewUpload(playbackId: string, input: {
78
+ grant: string;
79
+ }): Promise<{
80
+ data: CreateVideoUploadResult;
81
+ }>;
62
82
  /** Encode status + metadata for a video. */
63
83
  getStatus(playbackId: string): Promise<{
64
84
  data: VideoStatusInfo;
@@ -19,6 +19,18 @@ export class VideoResource {
19
19
  createUpload(input) {
20
20
  return this.client.post('/video/create-upload', input ?? {});
21
21
  }
22
+ /**
23
+ * Fresh upload credentials for a video this member started, so a TUS resume
24
+ * that outlives the hour `upload` is signed for continues the same video.
25
+ * Pass the `resumeGrant` from `createUpload`. 404 for anyone else's video, an
26
+ * expired grant or an older API without the route; 409 `upload_finished` once
27
+ * a post carries the video or Bunny holds the whole file; 402 as `createUpload`;
28
+ * 503 when the video provider could not answer: keep the partial upload and
29
+ * retry (it never means the video is gone).
30
+ */
31
+ renewUpload(playbackId, input) {
32
+ return this.client.post(`/video/${encodeURIComponent(playbackId)}/renew-upload`, input);
33
+ }
22
34
  /** Encode status + metadata for a video. */
23
35
  getStatus(playbackId) {
24
36
  return this.client.get(`/video/${playbackId}/status`);
package/dist/types.d.ts CHANGED
@@ -37,6 +37,10 @@ export type FeedNext = {
37
37
  * cursor; the same continuation `next` carries), the same category on the open networks, the member's
38
38
  * next categories (`via`: the order they came from, the one `posts.categories()` serves), For you
39
39
  * (signed in), All.
40
+ *
41
+ * `feed` (0.7.60) names the next feed by its ref (e.g. `builtin:category:science`, or a member's `feed:<id>`). No
42
+ * server sends it yet: it comes with feeds. Skip a step whose kind your app cannot open yet; a feed session
43
+ * (`discovery.createFeed`) already leaves out any step this SDK does not know, or a malformed one.
40
44
  */
41
45
  export type FalloverStep = {
42
46
  kind: 'category_older';
@@ -53,6 +57,9 @@ export type FalloverStep = {
53
57
  kind: 'for_you';
54
58
  } | {
55
59
  kind: 'all';
60
+ } | {
61
+ kind: 'feed';
62
+ ref: string;
56
63
  };
57
64
  /**
58
65
  * What a Politics choice (`compass`) did to a page, on the feeds that take one (`posts.list` on discovery, category
@@ -634,6 +641,25 @@ export interface ConversationMember {
634
641
  username: string;
635
642
  image: string | null;
636
643
  is_ai: boolean;
644
+ /**
645
+ * `admin` for a private group's admin: the one person who adds, removes and
646
+ * renames, and whose device sets up an encrypted group's keys. The creator,
647
+ * then, once they leave, the longest-standing person left. `member` for
648
+ * everyone else. Omitted by APIs before SDK 0.7.59's group routes.
649
+ */
650
+ role?: ChatMemberRole;
651
+ /**
652
+ * Each member's message request, in a private group or a one-on-one chat.
653
+ * Someone who follows the person who wrote (or who made the group), and an
654
+ * agent, start `accepted`, as the person who wrote does; a chat started
655
+ * without a request reads `accepted` for both. Anyone else starts `pending`
656
+ * and reads `accepted` once they accept or reply, or, in a one-on-one chat,
657
+ * start a chat back. So `accepted` alone does not mean they accepted
658
+ * anything. Declining stays private: to anyone else a member who declined
659
+ * still reads `pending`. Omitted in community rooms and by older APIs
660
+ * (one-on-one chats: before this field was served for them).
661
+ */
662
+ request_status?: ChatRequestStatus;
637
663
  }
638
664
  export interface ConversationMemberDetail extends ConversationMember {
639
665
  joined_at: string;
@@ -683,6 +709,12 @@ export interface LeaveConversationResult {
683
709
  left: true;
684
710
  /** True when the caller was the last person in it, so the room was removed. */
685
711
  conversation_deleted: boolean;
712
+ /**
713
+ * When the admin of an encrypted group leaves: the invitees who had not
714
+ * accepted, whose invites were cancelled with them (the next admin
715
+ * re-invites). Omitted when there were none.
716
+ */
717
+ invites_cancelled?: string[];
686
718
  }
687
719
  export interface ConversationDetail {
688
720
  /** Missing on older APIs: treat as unknown, never as plaintext permission. */
@@ -702,6 +734,67 @@ export interface ConversationDetail {
702
734
  can_leave?: boolean;
703
735
  created_at: string;
704
736
  }
737
+ export type ChatMemberRole = 'admin' | 'member';
738
+ /**
739
+ * Why an encrypted group refused someone: `details.reason` of a 409
740
+ * `conversation_not_eligible` (`RecursivError`) from
741
+ * `chat.createGroup({ encrypted: true })` or `chat.addMembers`, beside
742
+ * `details.max_people`. The group must be a private group (not a DM, not a
743
+ * community room) of people only (no agents), all inside the encryption
744
+ * pilot, and at most `max_people` (8 at launch) counting pending invitees.
745
+ */
746
+ export type EncryptedGroupRefusalReason = 'not_a_private_group' | 'community' | 'too_many_people' | 'agent' | 'outside_pilot';
747
+ export interface CreateGroupInput {
748
+ name: string;
749
+ /** Up to 100 people, invited in this order. */
750
+ member_ids: string[];
751
+ /**
752
+ * End-to-end encrypted. The group and its encryption record are created
753
+ * together or not at all: a refusal never leaves a plaintext group behind.
754
+ */
755
+ encrypted?: boolean;
756
+ /** With `encrypted`: the creator's active encrypted-chat device, when they have one. */
757
+ device_id?: string;
758
+ }
759
+ export interface AddGroupMembersResult {
760
+ id: string;
761
+ /** Who was added, in the order asked; `pending` for someone who does not follow you (a message request). */
762
+ added: Array<{
763
+ id: string;
764
+ request_status: ChatRequestStatus;
765
+ }>;
766
+ /** Asked for but already in the group: nothing changed for them. */
767
+ already_members: string[];
768
+ }
769
+ export interface RemoveGroupMemberResult {
770
+ id: string;
771
+ user_id: string;
772
+ removed: true;
773
+ }
774
+ export interface UpdateGroupResult {
775
+ id: string;
776
+ name: string;
777
+ }
778
+ /**
779
+ * Realtime `chat:members`: a private group's people changed. Ids only: read
780
+ * the conversation again (and, in an encrypted group, check its keys).
781
+ * `actorId` is the admin for `added` and `removed`, and the leaver for `left`
782
+ * and `invites_cancelled` (the invites an encrypted group's admin sent, which
783
+ * went with them).
784
+ */
785
+ export interface ChatMembersEvent {
786
+ conversationId: string;
787
+ change: 'added' | 'removed' | 'left' | 'invites_cancelled';
788
+ userIds: string[];
789
+ actorId: string;
790
+ }
791
+ /** Realtime `chat:conversation`: a private group was renamed by its admin. */
792
+ export interface ChatConversationEvent {
793
+ conversationId: string;
794
+ change: 'renamed';
795
+ name: string;
796
+ actorId: string;
797
+ }
705
798
  export interface MessageReaction {
706
799
  type: string;
707
800
  user_id: string;
package/llm.md CHANGED
@@ -498,10 +498,12 @@ const { data: group } = await r.chat.createGroup({ name: 'Team', member_ids: ['u
498
498
  const { data: msgs } = await r.chat.messages(dm.id, { limit: 50 })
499
499
  ```
500
500
 
501
- Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
501
+ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `addMembers`, `removeMember`, `updateGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
502
502
 
503
503
  Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growing `offset`. Conversations carry `can_delete` / `can_leave`; check them before offering Delete or Leave.
504
504
 
505
+ Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group or a one-on-one chat, `request_status`: `accepted` from the start for the person who wrote, anyone who follows them and agents, else `pending` until that person accepts or replies (a decline reads `pending` to anyone else), so `accepted` alone does not mean they accepted anything. Only the admin (the creator, then the longest-standing person left) may `addMembers(id, { member_ids })`, `removeMember(id, userId)` and `updateGroup(id, { name })` (else 403 `not_admin`). `createGroup({ name, member_ids, encrypted: true, device_id? })` makes an end-to-end encrypted group of people in the pilot, at most 8 counting pending invitees, or throws 409 `conversation_not_eligible` with `details.reason` and creates nothing. `r.realtime.onChatMembers` / `onChatConversation` tell you when people or the name change.
506
+
505
507
  ### r.e2ee — End-to-end encryption transport (flag-gated)
506
508
 
507
509
  Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag (otherwise `feature_disabled`) and a key acting as the account owner (agent-scoped keys get `agent_key_not_allowed`). The realm is the network id. Only a direct conversation between two people with an accepted request can start encryption (`conversation_not_eligible` otherwise), and every later append to it is checked the same way again (both people still accepted, still in the pilot, still only the two of them), so a client need not read the policy before each send; a resend of an event already stored still returns `duplicate: true`, even once a Welcome's target device is gone; blocks apply (`blocked`), to resends too; commits must send `expected_commit_seq`.
@@ -519,7 +521,8 @@ await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope: react
519
521
  const { data: page } = await r.e2ee.events(conversationId, { device_id: device.id, after_seq: 0 });
520
522
  await r.e2ee.acknowledge(conversationId, { device_id: device.id, seq: page.headSeq });
521
523
  // Doorbell after every append: { conversationId, seq, event? }. `event` (a small application message, as events() returns it) comes only to a socket of the owner's session or an owner key, never an agent-scoped key.
522
- r.realtime.onE2eeEvent(({ conversationId, event }) => (event ? controller.applyPushed(conversationId, event) : controller.sync(conversationId)));
524
+ // The policy is required: policyForConversationType(conversation.type) from @recursiv/e2ee ('direct' or 'group').
525
+ r.realtime.onE2eeEvent(({ conversationId, event }) => (event ? controller.applyPushed(conversationId, event, policy) : controller.sync(conversationId, undefined, policy)));
523
526
  ```
524
527
 
525
528
  Link a new device by scanning (0.7.57; `status()` reports `links: true` when the relay can). The new device N shows the link id in a QR code; an approved device A of the same account scans it. The relay is a short-lived mailbox (180 s, 120 s more once scanned; one open link per sign-in, at most 3 per account; 10 creates per sign-in and 20 per account an hour, refused with 409 `link_limit`, never 429). `approval` and `tag` go only to the sign-in that created the link. Reserved labels get 400 `invalid_label`; a 503 `storage_unavailable` is temporary, so keep polling until `expiresAt`. Own devices carry `createdAt` and `lastSeenAt`.
@@ -616,6 +619,8 @@ const { data: results } = await r.posts.search({ query: 'shipped', community_id:
616
619
 
617
620
  Methods: `list`, `get`, `create`, `update`, `delete`, `search`, `replies(id, { limit, cursor })` (after `replies_next_cursor` on `get`), `react`, `unreact`, `liked`, `bookmarked`, `bookmark`, `unbookmark`
618
621
 
622
+ Feed definitions (types only; no route serves one yet): `FeedDefinition` is `{ v: 1, sources, ranking: { mode, range? }, filters?, exclude? }`, with 1-`FEED_SOURCES_MAX` (50) sources read as one union (`FEED_SOURCE_KINDS`; `FEED_SOURCE_KEY[kind]` names each kind's one field, e.g. `{ kind: 'tag', slug: 'rust' }`, `{ kind: 'account', ref: 'did:plc:…' }`). `filters.politics` stores only `'hide'`; viewer settings, blocks, mutes and compass Mix/Sides never go in a definition. Built-in feeds: `builtinCategoryFeedRef('science')` → `'builtin:category:science'`, plus `'builtin:all'` and `'builtin:for_you'`.
623
+
619
624
  `search` ranks relevance x recency x engagement (recency falls as a power of age: this month outranks years ago) through the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as `cursor`: later relevance pages are slices of the ranking the first page kept for ten minutes (nothing skipped or repeated while it lives; after that, the page continues past the last post's score boundary), newest-first pages continue by (created_at, id). A cursor is bound to its search and sort: another search's, or the other sort's, is 400 `invalid_cursor`. The first relevance page carries `latest` (up to three of the newest matches not on the page, one per author, for a Latest strip); the last page carries `fall_over.lanes` (`new`, `open_network`, `tag`): draw a way on, not a wall.
620
625
 
621
626
  ### r.bookmarks — Synced saves (per member, per app)
@@ -680,7 +685,13 @@ the presign/PUT sequence in application code.
680
685
 
681
686
  Video: check `const { data } = await r.video.entitlement()` before offering the
682
687
  picker (`data.allowed`, `data.reason` 'tier' | 'quota', `data.remainingSeconds`),
683
- then `r.video.createUpload()`, which runs the same check.
688
+ then `r.video.createUpload()`, which runs the same check. Upload in chunks
689
+ (tus `chunkSize`) and keep the result for the attached file: a retry resumes the
690
+ same video. The TUS signature lasts an hour; past it, call
691
+ `r.video.renewUpload(playbackId, { grant: result.resumeGrant })` (0.7.63) for fresh
692
+ credentials for the same video. 404 (not yours, expired grant, older API) or 409
693
+ `upload_finished` means start a new upload instead. 503 means the video provider
694
+ could not answer: keep the partial upload and ask again later (`Retry-After`).
684
695
 
685
696
  ### r.chat — Messaging
686
697
 
@@ -691,10 +702,12 @@ const { data: group } = await r.chat.createGroup({ name: 'Team', member_ids: ['u
691
702
  const { data: msgs } = await r.chat.messages(dm.id, { limit: 50 })
692
703
  ```
693
704
 
694
- Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
705
+ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `addMembers`, `removeMember`, `updateGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
695
706
 
696
707
  Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growing `offset`. Conversations carry `can_delete` / `can_leave`; check them before offering Delete or Leave.
697
708
 
709
+ Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group or a one-on-one chat, `request_status`: `accepted` from the start for the person who wrote, anyone who follows them and agents, else `pending` until that person accepts or replies (a decline reads `pending` to anyone else), so `accepted` alone does not mean they accepted anything. Only the admin (the creator, then the longest-standing person left) may `addMembers(id, { member_ids })`, `removeMember(id, userId)` and `updateGroup(id, { name })` (else 403 `not_admin`). `createGroup({ name, member_ids, encrypted: true, device_id? })` makes an end-to-end encrypted group of people in the pilot, at most 8 counting pending invitees, or throws 409 `conversation_not_eligible` with `details.reason` and creates nothing. `r.realtime.onChatMembers` / `onChatConversation` tell you when people or the name change.
710
+
698
711
  ### r.tags
699
712
 
700
713
  ```typescript
@@ -742,9 +755,10 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
742
755
  - `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
743
756
  - Native Nostr: `const { data: preview } = await r.protocols.prepareNativeNostr(identityId, { action: "publish", content: draft })`; review and sign the exact public event, then `submitNativeNostr(identityId, { intent: preview.intent, event, approve: true })`. Preparation/submission never auto-retry. After uncertainty, use `getNativeNostrPost(identityId, preview.postId ?? preview.eventId)` before a user-selected retry of the identical intent/event: deletion checks the original note's `postId`, publication checks `eventId`. Check `nativeNostrAccount()` enablement first; keys stay with the signer. For replies, after `nativeNostrAccount()` advertises `reply_text_note`, use `prepareNativeNostr(identityId, { action: "reply", content: draft, parentId: post.nativeId })`. Review returned parent/root/participants/relay and exact event, sign with the connected client signer, then `submitNativeNostr(identityId, { intent, event, approve: true })`. Requires users:read, posts:read and posts:write. Kind1 public notes only; reply enablement and shared UI/live proof remain pending. Do not inherit arbitrary notification tags or relay hints. Signing mutations never auto-retry; check `getNativeNostrPost(identityId, eventId)` after uncertainty before a user-selected identical-event retry.
744
757
  - Hosted Bluesky account on the app's own PDS: `const { data } = await r.protocols.createHostedIdentity({ protocol: 'atproto', handle: 'alice', email })`. Only the label is sent; `data.handle` carries the domain and `data.password` is returned this once, never stored. Never auto-retried. Either the account is finished or it is removed again. 409 `handle_unavailable` = choose another label; 503 `handle_dns_unavailable`/`handle_unresolvable`/`handle_unverified` = operator setup, retry later unchanged.
745
- - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`. Watch's Minds videos: `const { data, meta } = await r.discovery.videos({ limit: 12 })` returns public video posts in hot order from a list shared per app (rebuilt about once a minute), each re-checked per call for the viewer; page on `meta.next_cursor`.
758
+ - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`; `feed` with `ref` is declared in 0.7.60 but not sent yet, so skip kinds you cannot open). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`. Watch's Minds videos: `const { data, meta } = await r.discovery.videos({ limit: 12 })` returns public video posts in hot order from a list shared per app (rebuilt about once a minute), each re-checked per call for the viewer; page on `meta.next_cursor`.
746
759
  - Viral read (0.7.59; off until the server enables it for the app): `const { data } = await r.protocols.getPublicViral({ media: 'video', category: 'science', limit: 20 })` (member key, users:read + posts:read, never cached) or keyless `visitor.protocols.getSharedViral(projectId, { media: 'video', compass: 'hide' })` (visitors send compass `all` or `hide` only). Params, all optional: `network` (all, minds, bluesky, activitypub, nostr), `category`, `media: 'video'`, `limit` 5/10/20/40, `cursor` (the last page's; a cursor whose 30-minute build expired ends the list), `compass`. Each item: `key` (stable; drop ones already shown), `protocol`, `post` (checking `protocol` narrows it: `ViralMindsCard` for minds, open at /post/:id; otherwise a `SocialPost`), `label` viral/rising/hot, `score`, `rank`, `category`, `velocity` (show counts with `countsReportedBy` as reported, never verified), `spread`. A visitor's read serves media only where the server cleared it, so even with `media: 'video'` some items arrive with the video withheld (empty `media`): `import { viralItemHasVideo } from '@recursiv/sdk'` and keep `data.items.filter(viralItemHasVideo)` before a vertical video player. `status` ok/building/screening/off: off resolves with no items, and `getSharedViral` also resolves an older API's 401 `missing_api_key` as off; `getPublicViral` throws every 401/403 (never off), so catch it and skip that step.
747
- - Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor? })`; render `kind: topics` as source topics and `kind: posts` as source posts. With `trend_feed`, open only a returned current topic using `getPublicTrendFeed({ protocol, topic: trend.nativeId, limit?, cursor? })`. Show provider, observedAt and provider_ranked_window coverage; null rankingWindow is unknown, not a daily/global ranking. Preserve source order, canonical IDs and readRef, and opaque cursors. Topic catalogs do not paginate; Mastodon trends stop at 200 source positions. Both methods require users:read plus posts:read on the current human app key; Nostr trends are unsupported. Propagate errors; trend_unavailable means refresh the topic list. No ingestion or native writes.
760
+ - Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass? })` (`compass: 'hide'` leaves out politics, 0.7.62); render `kind: topics` as source topics and `kind: posts` as source posts. With `trend_feed`, open only a returned current topic using `getPublicTrendFeed({ protocol, topic: trend.nativeId, limit?, cursor? })`. Show provider, observedAt and provider_ranked_window coverage; null rankingWindow is unknown, not a daily/global ranking. Preserve source order, canonical IDs and readRef, and opaque cursors. Topic catalogs do not paginate; Mastodon trends stop at 200 source positions. Both methods require users:read plus posts:read on the current human app key; Nostr trends are unsupported. Propagate errors; trend_unavailable means refresh the topic list. No ingestion or native writes.
761
+ - Post language: a `SocialPost` may carry `language`, the lower-case primary subtag the post declares (`'de'`, `'en'`; Bluesky `langs[0]`, Mastodon `language`, a Nostr NIP-32 `ISO-639-1` label). It is absent when the post declares none (most Nostr notes) and on posts from the source index; it is never guessed and no post is dropped for it. Order by it (the viewer's language first, undeclared next), never filter on it.
748
762
  - `listCandidates({ protocol?, limit?, cursor? })` and `getCandidate(id)` are read-only quarantine review, not published-post search. Both require `admin` scope, a project-bound key, a live admin role, and existing project-admin access. The key supplies app/network scope; neither method accepts another project or network. They never invoke refresh, change settings, or publish.
749
763
  - Lists return `{ data: ProtocolQuarantinedCandidate[], meta: { limit, has_more, next_cursor } }`; use the opaque `next_cursor`, not offset pagination. The API defaults `limit` to 20 and permits at most 50. Details return `{ data: ProtocolQuarantinedCandidate }`. Every candidate has `state: 'quarantined'` and `content_format: 'plain'`; render its bounded `content` as text, respect `content_truncated`, and distinguish upstream `published_at` from local `observed_at` / `created_at`.
750
764
  - Safe source IDs are preserved byte-exact. `external_id: null` means unsafe or malformed provenance was withheld, not an invented ID; `author.native_id` may likewise be absent or withheld. Preserve nulls in both list and detail views, and use the candidate's own `id` for `getCandidate`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.59",
3
+ "version": "0.7.63",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",