@recursiv/sdk 0.7.62 → 0.7.65

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.
@@ -0,0 +1,228 @@
1
+ import type { HttpClient } from '../client.js';
2
+ import type { CompassFilterMeta } from '../types.js';
3
+ /**
4
+ * The top of a feed: what the member (or a visitor) is most likely to engage with, rotated by what they have seen.
5
+ * `feeds.top` and `feeds.stories` read it for a member with their own key of the app; `feeds.publicTop` reads a
6
+ * visitor's top keylessly. A feed session (`discovery.createFeed({ top })`) opens its first page with it.
7
+ *
8
+ * Every tap target is in the app: a Minds post by id, an open-network post by `/social/post/<protocol>/<id>`, a story
9
+ * by id. Nothing here is a URL on another network.
10
+ */
11
+ /** The open networks whose lanes a top carries. */
12
+ export type FeedTopNetwork = 'bluesky' | 'activitypub' | 'nostr';
13
+ export declare const FEED_TOP_NETWORKS: readonly FeedTopNetwork[];
14
+ /** Where a tap on an item goes, always in the app. `path` is the app's own page of a network post, never its URL there. */
15
+ export type FeedTopTarget = {
16
+ kind: 'post';
17
+ id: string;
18
+ } | {
19
+ kind: 'network_post';
20
+ protocol: string;
21
+ nativeId: string;
22
+ path: string;
23
+ readRef?: string;
24
+ };
25
+ /** Why an item is where it is. */
26
+ export interface FeedTopExplain {
27
+ score: number;
28
+ engagement: {
29
+ source: string;
30
+ rate: number;
31
+ };
32
+ media: string;
33
+ origin: string;
34
+ /** Where the item came from: the Minds ranking, Watch's list, a radar story, the member's follows, or a network's trends. */
35
+ source: 'minds' | 'watch' | 'radar' | 'following' | 'trends';
36
+ /** 'safe' for everyone; 'open' only for a member who turned safe defaults off. */
37
+ label: 'safe' | 'open';
38
+ cluster: string | null;
39
+ topic: string | null;
40
+ /** 'demoted': the member viewed it in the last 6 hours (it sits below rank 20). A visitor's items are all 'fresh'. */
41
+ impression: 'fresh' | 'demoted';
42
+ }
43
+ /** One item of the top: a Minds post (`ref` is its id) or an open-network post (`ref` is '<protocol>/<nativeId>'). */
44
+ export interface FeedTopItem {
45
+ ref: string;
46
+ kind: 'post' | 'network_post';
47
+ authorId: string;
48
+ target: FeedTopTarget;
49
+ position: number;
50
+ /** 'top' for the first 10: send `block: 'top'` and the position with a visible view of one (an impression). */
51
+ block: 'top' | 'below';
52
+ explain: FeedTopExplain;
53
+ }
54
+ /** What an app draws for an open-network lane entry. No URL on the post's network: the app opens it by `target.path`. */
55
+ export interface FeedTopNetworkCard {
56
+ text: string;
57
+ createdAt: string;
58
+ author: {
59
+ nativeId: string;
60
+ handle: string;
61
+ displayName: string;
62
+ avatarUrl: string | null;
63
+ };
64
+ media: Array<{
65
+ type: 'image' | 'video';
66
+ url: string;
67
+ alt: string;
68
+ poster?: string;
69
+ width?: number;
70
+ height?: number;
71
+ }>;
72
+ counts: {
73
+ likes: number | null;
74
+ replies: number | null;
75
+ reposts: number | null;
76
+ };
77
+ sensitive: boolean;
78
+ /** The post quotes another; read the quote when the post is opened. */
79
+ hasQuote: boolean;
80
+ }
81
+ /** One entry of an open-network lane: up to six per network, ranked by engagement and rotated by impressions. */
82
+ export interface FeedTopNetworkEntry extends FeedTopItem {
83
+ kind: 'network_post';
84
+ protocol: FeedTopNetwork;
85
+ card: FeedTopNetworkCard;
86
+ }
87
+ /** A story as the top names it: its id and in-app target, never an outlet's or a network's URL. */
88
+ export interface FeedStory {
89
+ id: string;
90
+ /** 'cluster:<id>': what an impression of the story names. */
91
+ ref: string;
92
+ target: {
93
+ kind: 'story';
94
+ id: string;
95
+ };
96
+ label: string;
97
+ kind: 'breaking' | 'viral' | 'story';
98
+ breaking: boolean;
99
+ theme: string | null;
100
+ sources: Partial<Record<'outlet' | 'hackernews' | 'bluesky' | 'activitypub' | 'nostr' | 'minds', number>>;
101
+ media: {
102
+ images: number;
103
+ videos: number;
104
+ };
105
+ freshness: {
106
+ newestAt: string;
107
+ oldestAt: string;
108
+ };
109
+ explain: {
110
+ score: number;
111
+ parts: {
112
+ ageHours: number;
113
+ engagement: number;
114
+ media: number;
115
+ kind: number;
116
+ freshness: number;
117
+ };
118
+ impression: 'fresh' | 'demoted';
119
+ };
120
+ }
121
+ export interface FeedTopMeta {
122
+ ref: string;
123
+ poolBuiltAt: string;
124
+ poolSize: number;
125
+ /** Fewer items than asked for (a thin pool, the caps, or only items the member saw recently). */
126
+ short: boolean;
127
+ /** `impressions`: 'on' when the order uses what the member saw; otherwise the order is the pool's, jittered per session. */
128
+ flag: {
129
+ top: 'on';
130
+ impressions: 'on' | 'off' | 'unavailable';
131
+ };
132
+ stoppedBy: 'pool' | 'caps' | 'seen' | null;
133
+ /** 'safe' for a member on safe defaults, 'open' for one who turned them off. */
134
+ safety: 'safe' | 'open';
135
+ mindsSource: 'member' | 'anonymous' | 'none' | 'following';
136
+ networks: Record<FeedTopNetwork, 'ok' | 'unavailable'> | null;
137
+ compass_filter?: CompassFilterMeta;
138
+ }
139
+ /** GET /feeds/top. `networks` is null on a feed with no lanes (Following, a story); `lead` is null when the feed has no story. */
140
+ export interface FeedTopResponse {
141
+ items: FeedTopItem[];
142
+ networks: Record<FeedTopNetwork, FeedTopNetworkEntry[]> | null;
143
+ lead: FeedStory | null;
144
+ meta: FeedTopMeta;
145
+ }
146
+ export interface FeedStoriesResponse {
147
+ data: FeedStory[];
148
+ meta: {
149
+ after: string | null;
150
+ limit: number;
151
+ short: boolean;
152
+ flag: {
153
+ top: 'on';
154
+ impressions: 'on' | 'off' | 'unavailable';
155
+ };
156
+ compass_filter?: CompassFilterMeta;
157
+ };
158
+ }
159
+ export interface PublicFeedTopMeta {
160
+ ref: string;
161
+ /** The 15-minute bucket the page was drawn for: the same for every visitor in it. */
162
+ bucket: number;
163
+ poolBuiltAt: string | null;
164
+ poolSize: number;
165
+ short: boolean;
166
+ flag: {
167
+ top: 'on';
168
+ };
169
+ compass_filter?: CompassFilterMeta;
170
+ }
171
+ /** A visitor's top (`feeds.publicTop`): safe items only, the same for every visitor in one 15-minute bucket. */
172
+ export interface PublicFeedTopResponse {
173
+ items: FeedTopItem[];
174
+ networks: Record<FeedTopNetwork, FeedTopNetworkEntry[]> | null;
175
+ lead: FeedStory | null;
176
+ meta: PublicFeedTopMeta;
177
+ }
178
+ export interface FeedTopParams {
179
+ /** `builtin:all`, `category:<a browse category id>`, `following`, `watch` or `story:<a 64-character cluster id>`. */
180
+ ref: string;
181
+ /** 1..25, default 25. */
182
+ limit?: number;
183
+ /** Opaque per app session ([A-Za-z0-9_-], up to 64): the same token keeps one session's order stable across reads. */
184
+ session?: string;
185
+ /** A Politics choice, as `posts.list` takes it (refused on `following`). */
186
+ compass?: string;
187
+ }
188
+ export interface FeedStoriesParams {
189
+ /** The story the member is on (a 64-character cluster id): left out of the next ones. */
190
+ after?: string;
191
+ /** 1..10, default 10. */
192
+ limit?: number;
193
+ compass?: string;
194
+ }
195
+ export interface PublicFeedTopParams {
196
+ /** The app's project id. */
197
+ projectId: string;
198
+ /** `all` (default), `watch` or `category:<a browse category id>`. */
199
+ ref?: string;
200
+ /** 1..25 (the server's default is 25). */
201
+ limit?: number;
202
+ /** `all` or `hide`: a visitor's Politics choice. */
203
+ compass?: string;
204
+ }
205
+ export declare class FeedsResource {
206
+ private readonly http;
207
+ constructor(http: HttpClient);
208
+ /**
209
+ * The top of a feed for the signed-in member, with their own key of the app: the items they are most likely to engage
210
+ * with, rotated by what they have seen, the open-network lanes and the lead story. Off (404) until the app's operator
211
+ * turns it on; a 503 `feed_top_unavailable` carries Retry-After. Always private: never cached for anyone else.
212
+ */
213
+ top(params: FeedTopParams, options?: {
214
+ retry?: boolean;
215
+ }): Promise<FeedTopResponse>;
216
+ /** The stories the member should meet next (the story tail), by the same ranker as the top's lead. */
217
+ stories(params?: FeedStoriesParams, options?: {
218
+ retry?: boolean;
219
+ }): Promise<FeedStoriesResponse>;
220
+ /**
221
+ * A visitor's top of All, a category or Watch (tRPC `publicDiscovery.feedTop`): safe items only, the same for every
222
+ * visitor in one 15-minute bucket. Read with no credential at all (no key, no cookie: `credentials: 'omit'`), so the
223
+ * edge can hold it; a credentialed read would be private and uncached. Off (404) until the app's operator turns it on.
224
+ */
225
+ publicTop(params: PublicFeedTopParams, options?: {
226
+ timeoutMs?: number;
227
+ }): Promise<PublicFeedTopResponse>;
228
+ }
@@ -0,0 +1,61 @@
1
+ import { NotFoundError, RecursivError } from '../errors.js';
2
+ export const FEED_TOP_NETWORKS = ['bluesky', 'activitypub', 'nostr'];
3
+ export class FeedsResource {
4
+ http;
5
+ constructor(http) {
6
+ this.http = http;
7
+ }
8
+ /**
9
+ * The top of a feed for the signed-in member, with their own key of the app: the items they are most likely to engage
10
+ * with, rotated by what they have seen, the open-network lanes and the lead story. Off (404) until the app's operator
11
+ * turns it on; a 503 `feed_top_unavailable` carries Retry-After. Always private: never cached for anyone else.
12
+ */
13
+ top(params, options) {
14
+ return this.http.get('/feeds/top', { ref: params.ref, limit: params.limit, session: params.session, compass: params.compass }, options);
15
+ }
16
+ /** The stories the member should meet next (the story tail), by the same ranker as the top's lead. */
17
+ stories(params = {}, options) {
18
+ return this.http.get('/feeds/stories', { after: params.after, limit: params.limit, compass: params.compass }, options);
19
+ }
20
+ /**
21
+ * A visitor's top of All, a category or Watch (tRPC `publicDiscovery.feedTop`): safe items only, the same for every
22
+ * visitor in one 15-minute bucket. Read with no credential at all (no key, no cookie: `credentials: 'omit'`), so the
23
+ * edge can hold it; a credentialed read would be private and uncached. Off (404) until the app's operator turns it on.
24
+ */
25
+ async publicTop(params, options) {
26
+ // One fixed key order, so equal reads are one edge-cached URL.
27
+ const input = { projectId: params.projectId, ref: params.ref ?? 'all', ...(params.limit !== undefined ? { limit: params.limit } : {}),
28
+ ...(params.compass ? { compass: params.compass } : {}) };
29
+ const url = `${this.http.baseOrigin}/api/trpc/publicDiscovery.feedTop?input=${encodeURIComponent(JSON.stringify(input))}`;
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), options?.timeoutMs ?? this.http.requestTimeoutMs);
32
+ try {
33
+ let response;
34
+ try {
35
+ response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'omit', signal: controller.signal });
36
+ }
37
+ catch (error) {
38
+ throw new RecursivError(0, { type: 'api_error', code: controller.signal.aborted ? 'timeout' : 'network_error',
39
+ message: controller.signal.aborted ? 'The visitor top did not answer in time.' : `The visitor top could not be reached (${error instanceof Error ? error.message : String(error)}).` });
40
+ }
41
+ let body = null;
42
+ try {
43
+ body = await response.json();
44
+ }
45
+ catch {
46
+ body = null;
47
+ }
48
+ const data = body?.result?.data;
49
+ if (response.ok && data && typeof data === 'object')
50
+ return data;
51
+ const error = body?.error && typeof body.error === 'object' ? body.error : undefined;
52
+ const said = [error?.json?.message, error?.message].find((value) => typeof value === 'string' && value.length > 0);
53
+ const code = typeof error?.data?.code === 'string' ? error.data.code.toLowerCase() : 'unknown_error';
54
+ const failure = { type: response.status === 404 ? 'not_found_error' : 'api_error', code, message: said ?? `HTTP ${response.status}` };
55
+ throw response.status === 404 ? new NotFoundError(failure) : new RecursivError(response.ok ? 502 : response.status, failure);
56
+ }
57
+ finally {
58
+ clearTimeout(timer);
59
+ }
60
+ }
61
+ }
@@ -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;
@@ -35,8 +35,9 @@ export interface SearchPerson {
35
35
  pro: boolean;
36
36
  founder: boolean;
37
37
  /**
38
- * The account wears the display name of a verified or founder account it is not: it is listed after every account
39
- * that is not such a copy. Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
38
+ * The account wears the display name of a verified or founder account it is not. Such an account is listed only when
39
+ * the query asks for its handle ("@handle"), after every account that is not such a copy (an older API listed it
40
+ * last for any query). Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
40
41
  */
41
42
  name_matches_verified?: boolean;
42
43
  name_matches_verified_handle?: string | null;
@@ -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
@@ -649,9 +649,15 @@ export interface ConversationMember {
649
649
  */
650
650
  role?: ChatMemberRole;
651
651
  /**
652
- * In a private group, every member's request: `pending` until an invitee
653
- * accepts. Declining stays private, so to anyone else a member who declined
654
- * still reads `pending`. Omitted outside private groups and by older APIs.
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).
655
661
  */
656
662
  request_status?: ChatRequestStatus;
657
663
  }
@@ -1255,8 +1261,9 @@ export interface ProfileSearchResult extends Profile {
1255
1261
  pro: boolean;
1256
1262
  founder: boolean;
1257
1263
  /**
1258
- * The account wears the display name of a verified or founder account it is not: it is listed after every account
1259
- * that is not such a copy. Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
1264
+ * The account wears the display name of a verified or founder account it is not. Such an account is listed only when
1265
+ * the query asks for its handle ("@handle"), after every account that is not such a copy (an older API listed it
1266
+ * last for any query). Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
1260
1267
  */
1261
1268
  name_matches_verified?: boolean;
1262
1269
  name_matches_verified_handle?: string | null;
package/llm.md CHANGED
@@ -502,7 +502,7 @@ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `
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, `request_status`. 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.
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
506
 
507
507
  ### r.e2ee — End-to-end encryption transport (flag-gated)
508
508
 
@@ -639,6 +639,19 @@ await r.bookmarks.unsave(key) // or uns
639
639
 
640
640
  Newest saved first. Pages can be short while they skip posts the member can no longer read: page on `meta.next_cursor`, not length. Needs the member's own app key (else 403 `project_binding_required`). 503 `bookmarks_unavailable` means the server has no synced bookmarks yet: keep saves on the device (pass `{ retry: false }` to the probe so the 503 is not retried).
641
641
 
642
+ ### r.feeds — The top of a feed (member and visitor)
643
+
644
+ ```typescript
645
+ const top = await r.feeds.top({ ref: 'builtin:all', limit: 10, session }) // member's own app key; category:<id>, following, watch, story:<id>
646
+ // top.items: { ref, kind, target, position, block: 'top' | 'below', explain }; top.networks: { bluesky, activitypub, nostr } lanes with a card each
647
+ // top.lead: the story to open with; every target is in the app (post id, /social/post/<protocol>/<id>, story id), never a URL out
648
+ const { data: next } = await r.feeds.stories({ after: storyId }) // the story tail, same ranker as the lead
649
+ const page = await r.feeds.publicTop({ projectId, ref: 'all' }) // a visitor's top: safe items only, keyless, edge-cacheable
650
+ const feed = r.discovery.createFeed({ networks, top: { ref: 'builtin:all', session }, seen: seenRefs })
651
+ ```
652
+
653
+ Each answers 404 while the app's operator has it off; `feeds.top` is private to the member and 503 `feed_top_unavailable` carries Retry-After. With a visible view of a top item, send `block: 'top'` and its position. In a feed session the top's read starts with page 0's reads and the top holds the page at most 1.5 s from its start, the by-id reads of its posts included; off, failed or late, the session is unchanged, and the radar's story reads its posts as soon as the radar answers either way. Page 0 then opens with the top's lead story and items (each item carries `top: { ref, position, block }`), its lanes replace the networks' first reads, and no hot page repeats a top item. `seen` (Minds ids and `<protocol>/<nativeId>` refs) leaves seen posts out of the hot pages, walking on past a page left under five posts (up to three more). `seenClusters` (`Map<id, { n, clicked }>`) and a visitor's `bucket` rotate the story when the top names none (`pickRadarCluster`).
654
+
642
655
  ### r.search
643
656
 
644
657
  ```typescript
@@ -685,7 +698,13 @@ the presign/PUT sequence in application code.
685
698
 
686
699
  Video: check `const { data } = await r.video.entitlement()` before offering the
687
700
  picker (`data.allowed`, `data.reason` 'tier' | 'quota', `data.remainingSeconds`),
688
- then `r.video.createUpload()`, which runs the same check.
701
+ then `r.video.createUpload()`, which runs the same check. Upload in chunks
702
+ (tus `chunkSize`) and keep the result for the attached file: a retry resumes the
703
+ same video. The TUS signature lasts an hour; past it, call
704
+ `r.video.renewUpload(playbackId, { grant: result.resumeGrant })` (0.7.63) for fresh
705
+ credentials for the same video. 404 (not yours, expired grant, older API) or 409
706
+ `upload_finished` means start a new upload instead. 503 means the video provider
707
+ could not answer: keep the partial upload and ask again later (`Retry-After`).
689
708
 
690
709
  ### r.chat — Messaging
691
710
 
@@ -700,7 +719,7 @@ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `
700
719
 
701
720
  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.
702
721
 
703
- Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group, `request_status`. 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.
722
+ 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.
704
723
 
705
724
  ### r.tags
706
725
 
@@ -752,6 +771,7 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
752
771
  - 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`.
753
772
  - 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.
754
773
  - 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.
774
+ - 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.
755
775
  - `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.
756
776
  - 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`.
757
777
  - 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.62",
3
+ "version": "0.7.65",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",