@recursiv/sdk 0.7.53 → 0.7.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -675,6 +675,35 @@ what the viewer follows, joined, blocked or muted, and says why a list is empty
675
675
  showings without a tap rest a suggestion for fourteen days, and engaging clears it.
676
676
  Keep one `session` token per app session so a rail does not reshuffle on every render.
677
677
 
678
+ A category feed never ends at an empty page. `sdk.posts.list({ category, sort: 'hot' })`
679
+ (or `score`, or the default chronological order) pages the window of the category's
680
+ newest judged posts, then continues by keyset through the whole indexed
681
+ history: every page's `meta.next` is `{ offset }`, `{ cursor }` or `null`, and when a
682
+ stage ends `meta.fallover` lists where to go, in order: `category_older` (the keyset
683
+ cursor `next` also carries), `category_network` (the same category on the open
684
+ networks), the member's next categories (`via` names the order they came from), `for_you`
685
+ and `all`. A `DiscoveryFeedSession` follows the cursor itself and reports the Minds
686
+ feed's `fallover` on its pages. `sdk.discovery.category({ category, session? })` answers a
687
+ category's surface in one call: `stories`, `network` and `clips` (each shared per app and
688
+ 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
689
+ viewer's `people` and `groups`; a section that timed out, failed or is off is empty with a
690
+ `reason` for that request only (a failure is never cached, so asking again retries it), and one this
691
+ key may not read is empty with `refused` (`stories` and `network` need the member's own app
692
+ key with `users:read`; `people` and `groups` need `users:read`). In a category, a Bluesky
693
+ topic the provider no longer lists, or whose feed holds nothing in the category, is served
694
+ the network's indexed posts in it (`sourceIndex.reason` of `gone` or `empty`). While the
695
+ provider has more of the topic the page's cursor is the provider's own, so the topic's
696
+ deeper pages are still read; once it has none, or the topic is gone, the cursor is an
697
+ `idx:tf:` one that continues in the index. When the index cannot answer (busy, or its safety
698
+ cache unreadable), the page is what the provider answered: the empty page with its own
699
+ cursor, or the gone topic's 404; only an `idx:tf:` page answers 503 `unavailable`, and that
700
+ ends the index continuation, not the network. The app contract: a `trend_unavailable` 404,
701
+ an empty topic page or a failed `idx:tf:` page never disables a network for the session;
702
+ move to the next topic, keep every provider continuation, start the `idx:tf:` stream once
703
+ per network and session (every such topic answers the same index stream; after a failed
704
+ page it may start again), and pass cursors back unchanged. `DiscoveryFeedSession` does
705
+ this for you.
706
+
678
707
  `sdk.posts.categories()` answers the app's categories in the order this member
679
708
  should see them. `meta.order` is `personal`, `popular` or `static`; `meta.reason`
680
709
  says why an answer is not personal (`low_signal`, `deadline`, `requested`,
package/dist/index.d.ts CHANGED
@@ -147,7 +147,7 @@ export { HttpClient } from './client.js';
147
147
  export { RealtimeClient } from './realtime.js';
148
148
  export type { RealtimeConnectOptions } from './realtime.js';
149
149
  export { AuthResource } from './resources/auth.js';
150
- export type { AuthSession, SignUpInput, SignInInput, UsernameSignInInput, AppContext, ResetPasswordInput, TwoFactorConfirmation, TwoFactorEnrollment, TwoFactorChallenge, CompleteTwoFactorSignInInput, OAuthClientInfo, SendOtpInput, VerifyOtpInput, CreateApiKeyInput, ApiKeyResult, } from './resources/auth.js';
150
+ export type { AuthSession, CurrentSession, SignUpInput, SignInInput, UsernameSignInInput, AppContext, ResetPasswordInput, TwoFactorConfirmation, TwoFactorEnrollment, TwoFactorChallenge, CompleteTwoFactorSignInInput, OAuthClientInfo, SendOtpInput, VerifyOtpInput, CreateApiKeyInput, ApiKeyResult, } from './resources/auth.js';
151
151
  export { PostsResource } from './resources/posts.js';
152
152
  export type { CategoryOrder, CategoryOrderMeta, CategoryOrderReason } from './resources/posts.js';
153
153
  export { UsersResource } from './resources/users.js';
@@ -8,6 +8,20 @@ export interface AuthSession {
8
8
  image: string | null;
9
9
  };
10
10
  }
11
+ /**
12
+ * What `getSession` answers: the account, and which of its sessions the token
13
+ * is. `session.id` is the id `settings.listSessions()` lists that session
14
+ * under. A client that authenticates with an API key gets `is_current: false`
15
+ * on every listed row (a key belongs to no session), so it matches this id to
16
+ * mark its own device.
17
+ */
18
+ export interface CurrentSession extends AuthSession {
19
+ /** Null only when the server's answer names no session row. */
20
+ session: {
21
+ id: string;
22
+ expiresAt: string | null;
23
+ } | null;
24
+ }
11
25
  export interface SignUpInput {
12
26
  email: string;
13
27
  password: string;
@@ -275,10 +289,10 @@ export declare class AuthResource {
275
289
  success: true;
276
290
  }>;
277
291
  /**
278
- * Get current session info using a session token.
279
- * Returns null if the session is invalid or expired.
292
+ * Get current session info using a session token: the account, and the
293
+ * session's id and expiry. Returns null if the session is invalid or expired.
280
294
  */
281
- getSession(sessionToken: string): Promise<AuthSession | null>;
295
+ getSession(sessionToken: string): Promise<CurrentSession | null>;
282
296
  /**
283
297
  * Sign out (invalidate session).
284
298
  */
@@ -596,8 +596,8 @@ export class AuthResource {
596
596
  return { success: true };
597
597
  }
598
598
  /**
599
- * Get current session info using a session token.
600
- * Returns null if the session is invalid or expired.
599
+ * Get current session info using a session token: the account, and the
600
+ * session's id and expiry. Returns null if the session is invalid or expired.
601
601
  */
602
602
  async getSession(sessionToken) {
603
603
  const res = await this.fetchWithTimeout(`${this.baseOrigin}/api/auth/get-session`, {
@@ -612,6 +612,8 @@ export class AuthResource {
612
612
  const data = await res.json();
613
613
  if (!data?.user)
614
614
  return null;
615
+ const sessionId = data.session?.id;
616
+ const expiresAt = data.session?.expiresAt;
615
617
  return {
616
618
  token: sessionToken,
617
619
  user: {
@@ -620,6 +622,9 @@ export class AuthResource {
620
622
  email: data.user.email,
621
623
  image: data.user.image ?? null,
622
624
  },
625
+ session: typeof sessionId === 'string' && sessionId
626
+ ? { id: sessionId, expiresAt: typeof expiresAt === 'string' ? expiresAt : null }
627
+ : null,
623
628
  };
624
629
  }
625
630
  /**
@@ -7,7 +7,7 @@ export type ForYouParams = {
7
7
  offset?: number;
8
8
  /** The previous page's `meta.next_cursor`: pages a stored ranking, so posts read meanwhile never shift the next page. */
9
9
  cursor?: string;
10
- /** Adds `meta.discovery` (layouts) and the public safety floor; limit <= 25. */
10
+ /** Adds `meta.discovery` (layouts); limit <= 25. Every page carries the promotion safety floor, with or without it. */
11
11
  discovery?: boolean;
12
12
  /** `discover` ranks for Discover: fresher, beyond the member's follows, with their category order. */
13
13
  surface?: ForYouSurface;
@@ -1,5 +1,5 @@
1
1
  import type { HttpClient } from '../client.js';
2
- import type { DiscoveryLayout, Post } from '../types.js';
2
+ import type { DiscoveryLayout, FalloverStep, Post } from '../types.js';
3
3
  import type { CuratorResource } from './curator.js';
4
4
  import type { DiscoveryLearning } from './discovery-learning.js';
5
5
  import type { PostsResource } from './posts.js';
@@ -66,6 +66,61 @@ export interface EntityImpressionsInput {
66
66
  /** 'shown' when a suggestion was on screen; 'engaged' on a tap, follow or join. Three showings without a tap rest a suggestion for fourteen days; engaging clears it. */
67
67
  event: 'shown' | 'engaged';
68
68
  }
69
+ /** One section of a category surface: bounded, read under its own deadline; empty with a `reason` when it timed out, failed, is off, or the recommender said why. */
70
+ export interface CategorySurfaceSection<T> {
71
+ items: T[];
72
+ ms: number;
73
+ reason?: string;
74
+ }
75
+ /** A radar cluster themed into the category, with up to six members (headline or excerpt and link only). */
76
+ export type CategoryStory = Pick<SocialRadar['clusters'][number], 'id' | 'label' | 'kind' | 'theme' | 'sources' | 'corroboration' | 'laneMix' | 'velocity' | 'media' | 'freshness' | 'score' | 'members'>;
77
+ export interface CategoryClip {
78
+ id: string;
79
+ title: string | null;
80
+ excerpt: string;
81
+ created_at: string;
82
+ author: {
83
+ id: string;
84
+ username: string | null;
85
+ name: string;
86
+ image: string | null;
87
+ };
88
+ media: Array<{
89
+ id: string;
90
+ url: string;
91
+ type: string;
92
+ thumbnail_url: string | null;
93
+ unplayable?: boolean;
94
+ }>;
95
+ engagement: {
96
+ score: number;
97
+ like_count: number;
98
+ reply_count: number;
99
+ repost_count: number;
100
+ };
101
+ }
102
+ export interface CategorySurface {
103
+ category: string;
104
+ /** When the oldest cached section served (stories, network, clips) was built. */
105
+ as_of: string;
106
+ stories: CategorySurfaceSection<CategoryStory>;
107
+ network: CategorySurfaceSection<SocialPost> & {
108
+ by_protocol: Record<string, number>;
109
+ };
110
+ clips: CategorySurfaceSection<CategoryClip>;
111
+ people: CategorySurfaceSection<EntityRecommendation>;
112
+ groups: CategorySurfaceSection<EntityRecommendation>;
113
+ }
114
+ export interface CategorySurfaceResponse {
115
+ /** Null when the key has no app (meta.reason `no_app`). */
116
+ data: CategorySurface | null;
117
+ meta: {
118
+ category: string;
119
+ as_of?: string;
120
+ reason?: 'no_app';
121
+ personalized: boolean;
122
+ };
123
+ }
69
124
  export interface DiscoveryGroupCandidate {
70
125
  id: string;
71
126
  name: string;
@@ -219,6 +274,12 @@ export interface DiscoveryFeedPage {
219
274
  hasMore: boolean;
220
275
  failedSources: string[];
221
276
  partialCategories: boolean;
277
+ /**
278
+ * The Minds feed's fall-through, once its category feed has ended a stage (the server's
279
+ * `meta.fallover`): where to read next when this session has no more. Absent until then, and
280
+ * on servers that predate it.
281
+ */
282
+ fallover?: FalloverStep[];
222
283
  }
223
284
  /** A gallery strip holds up to twelve image posts gathered from the whole page. */
224
285
  export declare const GALLERY_MAX_ITEMS = 12;
@@ -292,6 +353,22 @@ export declare class DiscoveryResource {
292
353
  reason?: 'disabled' | 'other_app' | 'unavailable' | 'no_app';
293
354
  };
294
355
  }>;
356
+ /**
357
+ * A category's surface in one call: the modules All has, scoped to the category. `stories` (radar
358
+ * clusters themed into it), `network` (open-network posts judged into it, newest first) and `clips`
359
+ * (public video posts among its hot window) are shared per app and category (refreshed after 30 s, kept up to 300 s, so a copy
360
+ * may be that old) and filtered for the viewer on every call: the cached safety verdicts and the promotion term list again, the
361
+ * viewer's blocks and mutes, and for stories and network the member's own hidden sources. `people` and `groups` are the
362
+ * viewer's suggestions in it. A section that timed out, failed or is off is empty
363
+ * with a `reason` and never delays the others. The posts feed itself is `posts.list({ category })`,
364
+ * whose pages carry `meta.next` and `meta.fallover`.
365
+ */
366
+ category(params: {
367
+ category: string;
368
+ session?: string;
369
+ }, options?: {
370
+ retry?: boolean;
371
+ }): Promise<CategorySurfaceResponse>;
295
372
  /**
296
373
  * A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
297
374
  * snapshot cursor) in a session's Minds slot. With a category, the session also reads open-network posts
@@ -22,6 +22,16 @@ const isGone = (error) => typeof error === 'object' && error !== null && error.c
22
22
  const TOPIC_WALK = 5;
23
23
  /** The walk starts no topic read after this long: well inside the page deadline, so it commits its progress rather than timing the network out. */
24
24
  const TOPIC_WALK_MS = 8000;
25
+ /**
26
+ * A topic page the server answered from its open-network source index (`sourceIndex`): in a category, a
27
+ * topic that is gone or holds nothing in it is served the network's indexed posts in the category, which
28
+ * are the same for every such topic. While the provider has more of the topic the page carries the
29
+ * provider's own cursor, which the walk keeps like any other; a topic that is gone or ended carries an
30
+ * `idx:tf:` cursor, the one index stream of the network. So a walk starts that stream at most once per
31
+ * session (a second start would re-read what it already showed), and an index page that shows nothing
32
+ * new counts as an empty topic.
33
+ */
34
+ const INDEX_CURSOR = 'idx:tf:';
25
35
  const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
26
36
  const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
27
37
  function recommendationItem(kind, candidate) {
@@ -188,6 +198,9 @@ export class DiscoveryFeedSession {
188
198
  if (stream.cursor)
189
199
  stream.used = [...stream.used, stream.cursor].slice(-32);
190
200
  stream.cursor = next;
201
+ // A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
202
+ if (Array.isArray(response.meta.fallover))
203
+ stream.fallover = response.meta.fallover;
191
204
  return { items: response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post })),
192
205
  layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial' };
193
206
  }
@@ -237,9 +250,20 @@ export class DiscoveryFeedSession {
237
250
  const topic = topics[0];
238
251
  if (!topic)
239
252
  break;
253
+ // A failed read of the network's index stream (the server's index busy, or its safety cache down) ends that
254
+ // continuation, not the network: the stream may start again from a later topic's page. A provider topic
255
+ // read that fails any other way than gone is still the network's failure.
256
+ const indexContinuation = !!topic.cursor?.startsWith(INDEX_CURSOR);
240
257
  const response = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY)
241
- .catch((error) => { if (isGone(error))
242
- return null; throw error; });
258
+ .catch((error) => {
259
+ if (isGone(error))
260
+ return null;
261
+ if (indexContinuation) {
262
+ stream.indexStarted = false;
263
+ return null;
264
+ }
265
+ throw error;
266
+ });
243
267
  topics.shift();
244
268
  if (!response)
245
269
  continue;
@@ -248,12 +272,20 @@ export class DiscoveryFeedSession {
248
272
  if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
249
273
  throw new Error('Invalid discovery topic');
250
274
  const next = data.cursor;
251
- if (next && next !== topic.cursor && !topic.used.includes(next))
275
+ // A provider cursor is always kept. An index cursor from a provider page starts the network's one index
276
+ // stream: queued the first time only, since every later start would re-read the same stream from its top.
277
+ const indexed = !!data.sourceIndex;
278
+ const startsIndex = !!next?.startsWith(INDEX_CURSOR) && !topic.cursor?.startsWith(INDEX_CURSOR);
279
+ if (next && next !== topic.cursor && !topic.used.includes(next) && !(startsIndex && stream.indexStarted)) {
252
280
  topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32) });
281
+ if (startsIndex)
282
+ stream.indexStarted = true;
283
+ }
253
284
  partial ||= data.categoryFilter?.status === 'partial';
254
- if (data.items.length) {
285
+ const items = indexed ? data.items.filter(post => !this.seen.has(key(post.protocol, post.nativeId))) : data.items;
286
+ if (items.length) {
255
287
  stream.done = !topics.length;
256
- return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial };
288
+ return { items: items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial };
257
289
  }
258
290
  }
259
291
  stream.done = !topics.length;
@@ -369,8 +401,10 @@ export class DiscoveryFeedSession {
369
401
  modules.splice(Math.min(2, modules.length), 0, recommendation);
370
402
  if (cluster)
371
403
  modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
404
+ const fallover = this.streams.find(stream => stream.protocol === 'minds')?.fallover;
372
405
  return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
373
- failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial) };
406
+ failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial),
407
+ ...(fallover ? { fallover } : {}) };
374
408
  }
375
409
  /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
376
410
  async radarCluster(page) {
@@ -534,6 +568,21 @@ export class DiscoveryResource {
534
568
  throw new Error('recordImpressions needs the HTTP client');
535
569
  return this.http.post('/discover/impressions', input);
536
570
  }
571
+ /**
572
+ * A category's surface in one call: the modules All has, scoped to the category. `stories` (radar
573
+ * clusters themed into it), `network` (open-network posts judged into it, newest first) and `clips`
574
+ * (public video posts among its hot window) are shared per app and category (refreshed after 30 s, kept up to 300 s, so a copy
575
+ * may be that old) and filtered for the viewer on every call: the cached safety verdicts and the promotion term list again, the
576
+ * viewer's blocks and mutes, and for stories and network the member's own hidden sources. `people` and `groups` are the
577
+ * viewer's suggestions in it. A section that timed out, failed or is off is empty
578
+ * with a `reason` and never delays the others. The posts feed itself is `posts.list({ category })`,
579
+ * whose pages carry `meta.next` and `meta.fallover`.
580
+ */
581
+ category(params, options) {
582
+ if (!this.http)
583
+ throw new Error('category needs the HTTP client');
584
+ return this.http.get('/discover/category', params, options);
585
+ }
537
586
  /**
538
587
  * A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
539
588
  * snapshot cursor) in a session's Minds slot. With a category, the session also reads open-network posts
@@ -18,7 +18,8 @@ export class HumanInputResource {
18
18
  }
19
19
  /** Raise a blocking question and notify the human wherever they are. */
20
20
  request(input) {
21
- return this.client.post('/human-input', input);
21
+ // This write is not idempotent. A lost response must not replay the ask.
22
+ return this.client.post('/human-input', input, { retry: false });
22
23
  }
23
24
  /** Has the human answered yet? */
24
25
  answers(conversationId) {
@@ -1,5 +1,5 @@
1
1
  import type { HttpClient } from '../client.js';
2
- import type { PaginatedResponse, SingleResponse, SuccessResponse, Profile, ProfileDetail, ProfileMe, LeaderboardEntry, UpdateProfileInput, SearchProfilesParams, ProfileSearchResult, SearchMentionsParams, MentionCandidate, FollowListParams, FollowListProfile, BlockedProfile, MutedProfile, ModerationListParams, ViewerRelationship } from '../types.js';
2
+ import type { PaginatedResponse, SingleResponse, SuccessResponse, Profile, ProfileDetail, ProfileMe, LeaderboardEntry, UpdateProfileInput, SearchProfilesParams, ProfileSearchResult, SearchMentionsParams, MentionCandidate, FollowListParams, FollowListProfile, BlockedProfile, MutedProfile, ModerationListParams, ProfileRelationship } from '../types.js';
3
3
  export declare class ProfilesResource {
4
4
  private client;
5
5
  constructor(client: HttpClient);
@@ -60,8 +60,12 @@ export declare class ProfilesResource {
60
60
  cursor?: string;
61
61
  limit?: number;
62
62
  }): Promise<PaginatedResponse<string>>;
63
- /** The follow relationship with another user in both directions (mutual when both are true). */
64
- isFollowing(userId: string): Promise<SingleResponse<ViewerRelationship>>;
63
+ /**
64
+ * The caller's relationship with another user: follows in both directions
65
+ * (mutual when both are true), and whether the caller blocks (`is_blocking`)
66
+ * or mutes (`is_muting`) them, left out when the server cannot say.
67
+ */
68
+ isFollowing(userId: string): Promise<SingleResponse<ProfileRelationship>>;
65
69
  /**
66
70
  * Block a user. A block is a two-way wall: it severs follows in both
67
71
  * directions and hides each side's posts from the other.
@@ -76,7 +76,11 @@ export class ProfilesResource {
76
76
  followingIds(params) {
77
77
  return this.client.get('/profiles/me/following/ids', params);
78
78
  }
79
- /** The follow relationship with another user in both directions (mutual when both are true). */
79
+ /**
80
+ * The caller's relationship with another user: follows in both directions
81
+ * (mutual when both are true), and whether the caller blocks (`is_blocking`)
82
+ * or mutes (`is_muting`) them, left out when the server cannot say.
83
+ */
80
84
  isFollowing(userId) {
81
85
  return this.client.get(`/profiles/${userId}/is-following`);
82
86
  }
@@ -928,6 +928,8 @@ export interface SocialProfile {
928
928
  followersCount: number | null;
929
929
  followingCount: number | null;
930
930
  postsCount: number | null;
931
+ /** Nostr only: the NIP-05 name its domain has confirmed for this key ("lyn@primal.net"); `handle` stays the key. */
932
+ nip05?: string;
931
933
  subscription: SocialSubscription | null;
932
934
  }
933
935
  export interface SocialSubscription {
@@ -1073,6 +1075,20 @@ export interface SocialTrendFeed extends SocialTrendProvenance {
1073
1075
  trend: SocialTrend;
1074
1076
  items: SocialPost[];
1075
1077
  cursor: string | null;
1078
+ /**
1079
+ * Present when the page came from the app's open-network source index instead of the provider:
1080
+ * with a category, a topic the provider no longer lists (`gone`) or whose feed held nothing in the
1081
+ * category (`empty`) is served the network's indexed posts in the category. While the provider has
1082
+ * more of the topic, `cursor` is the provider's own continuation (the topic's deeper pages may hold
1083
+ * the category); once it has none, or the topic is gone, it is an `idx:tf:` cursor that continues in
1084
+ * the index (`cursor`), the same stream for every topic of the network. The `trend` of a gone topic
1085
+ * carries its id as its title. Pass the cursor back unchanged like any other. When the index cannot
1086
+ * answer, the page is what the provider answered (the empty page with its cursor, or the gone topic's
1087
+ * 404); only an `idx:tf:` page is 503 `unavailable`, which ends that continuation, not the network.
1088
+ */
1089
+ sourceIndex?: SocialTrendSourceIndex & {
1090
+ reason: 'gone' | 'empty' | 'cursor';
1091
+ };
1076
1092
  }
1077
1093
  /** A member of a radar cluster: a headline or excerpt and a link only, never a body. */
1078
1094
  export type SocialRadarSourceKind = 'outlet' | 'hackernews' | 'bluesky' | 'activitypub' | 'nostr' | 'minds';
package/dist/types.d.ts CHANGED
@@ -22,6 +22,38 @@ export interface DiscoveryLayout {
22
22
  theme?: string;
23
23
  windows?: DiscoveryLayoutWindow[];
24
24
  }
25
+ /**
26
+ * How to ask for the next page of the same feed: the next offset while the page order is a ranking,
27
+ * the cursor once the feed pages by keyset (a category feed past its window of newest judged posts), or null when the
28
+ * feed has no more. On a category feed, `fallover` then says where to go.
29
+ */
30
+ export type FeedNext = {
31
+ offset: number;
32
+ } | {
33
+ cursor: string;
34
+ } | null;
35
+ /**
36
+ * One step of a category feed's fall-through, in order: the same category's older posts (the keyset
37
+ * cursor; the same continuation `next` carries), the same category on the open networks, the member's
38
+ * next categories (`via`: the order they came from, the one `posts.categories()` serves), For you
39
+ * (signed in), All.
40
+ */
41
+ export type FalloverStep = {
42
+ kind: 'category_older';
43
+ category: string;
44
+ cursor: string;
45
+ } | {
46
+ kind: 'category_network';
47
+ category: string;
48
+ } | {
49
+ kind: 'category';
50
+ category: string;
51
+ via: 'personal' | 'popular' | 'static';
52
+ } | {
53
+ kind: 'for_you';
54
+ } | {
55
+ kind: 'all';
56
+ };
25
57
  export interface PaginationMeta {
26
58
  discovery?: DiscoveryLayout;
27
59
  scanned?: number;
@@ -36,6 +68,10 @@ export interface PaginationMeta {
36
68
  has_more: boolean;
37
69
  /** Pass this as `cursor` for the next page. Absent on offset-only endpoints. */
38
70
  next_cursor?: string | null;
71
+ /** The next page of this feed (offset or cursor), or null at its end. Absent on older servers. */
72
+ next?: FeedNext;
73
+ /** Present when a stage of a category feed ends: the ordered steps to follow so scrolling never stops. */
74
+ fallover?: FalloverStep[];
39
75
  }
40
76
  export interface PaginatedResponse<T> {
41
77
  data: T[];
@@ -99,6 +135,18 @@ export interface ViewerRelationship {
99
135
  /** This account follows the caller. */
100
136
  follows_you: boolean;
101
137
  }
138
+ /**
139
+ * The caller's relationship with one account (`profiles.isFollowing`): follows
140
+ * in both directions, and whether the caller blocks or mutes it. A server
141
+ * from before the moderation fields, or one whose read of them failed, leaves
142
+ * them out: absent means unknown, never "not blocked".
143
+ */
144
+ export interface ProfileRelationship extends ViewerRelationship {
145
+ /** The caller blocks this account. */
146
+ is_blocking?: boolean;
147
+ /** The caller mutes this account. */
148
+ is_muting?: boolean;
149
+ }
102
150
  export interface FollowUser extends ViewerRelationship {
103
151
  id: string;
104
152
  name: string;
package/llm.md CHANGED
@@ -367,7 +367,9 @@ await r.auth.signOut(session.token)
367
367
  ```
368
368
 
369
369
  `getSession(token)` and `signOut(token)` send the explicit session token as
370
- Bearer auth, with a Cookie fallback for native/server runtimes.
370
+ Bearer auth, with a Cookie fallback for native/server runtimes. `getSession`
371
+ also answers `session: { id, expiresAt }`; an API-key client lists sessions with
372
+ `is_current: false`, so it matches `session.id` to mark its own device.
371
373
 
372
374
  Methods: `signUp`, `signIn`, `getSession`, `signOut`, `createApiKey`, `signInAndCreateKey`, `signUpAndCreateKey`, `verifyOtpAndCreateKey`
373
375
 
@@ -479,7 +481,7 @@ const { data: rows } = await r.profiles.followers('user_xxx') // each row: is_fo
479
481
  await r.profiles.mute('user_xxx') // server hides their posts and reposts of them from your feeds
480
482
  ```
481
483
 
482
- Methods: `list`, `me`, `get`, `getByUsername`, `search` (ranked by match x ln(1 + followers + min(posts, 100)) over one pool of 200, the same for every page; each row (`ProfileSearchResult`) carries `followers_count`, `posts_count`, `empty` (fold empties under "more handles") and `verified` (the accounts the operator names)), `mentions` (people + agents for an @-picker, follows first), `update`, `follow`, `unfollow`, `followers`, `following`, `followingIds` (your follow set as ids, cursor-paged), `isFollowing`, `related`, `leaderboard`, `block`, `unblock`, `blocked`, `mute`, `unmute`, `muted`
484
+ Methods: `list`, `me`, `get`, `getByUsername`, `search` (ranked by match x ln(1 + followers + min(posts, 100)) over one pool of 200, the same for every page; each row (`ProfileSearchResult`) carries `followers_count`, `posts_count`, `empty` (fold empties under "more handles") and `verified` (the accounts the operator names)), `mentions` (people + agents for an @-picker, follows first), `update`, `follow`, `unfollow`, `followers`, `following`, `followingIds` (your follow set as ids, cursor-paged), `isFollowing` (follows both ways, plus `is_blocking`/`is_muting` when the server knows), `related`, `leaderboard`, `block`, `unblock`, `blocked`, `mute`, `unmute`, `muted`
483
485
 
484
486
  ---
485
487
 
@@ -708,7 +710,7 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
708
710
  - `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`.
709
711
  - 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.
710
712
  - 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.
711
- - 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.
713
+ - 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? }`.
712
714
  - 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.
713
715
  - `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.
714
716
  - 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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.53",
3
+ "version": "0.7.54",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",