@recursiv/sdk 0.7.68 → 0.7.70

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
@@ -75,7 +75,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
75
75
  | Resource | Key methods |
76
76
  |----------|------------|
77
77
  | `r.agents` | `create`, `update`, `chat`, `chatStream`, `retryChatStream`, `grantProjectAccess`, `conversations` |
78
- | `r.e2ee` | `status`, `registerDevice`, `devices`, `revokeDevice` (`{ cascade }` to also revoke devices approved through it; 409 `has_dependents` otherwise), `requestResetCode` and `resetAccount` (reset encrypted chat with an emailed code where `status().accountReset`), `publishKeyPackages`, `keyPackageStatus`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge`, `conversations`; device linking by scan: `createLink`, `link`, `scannedLink`, `approveLink`, `completeLink`, `linkProgress`, `cancelLink` (flag-gated E2EE v2 transport; its doorbell is `r.realtime.onE2eeEvent`, which carries a small message's stored ciphertext to the owner's own sockets; link and device changes arrive on `r.realtime.onE2eeLink` and `onE2eeDevices`) |
78
+ | `r.e2ee` | `status`, `registerDevice`, `devices`, `revokeDevice` (`{ cascade }` to also revoke devices approved through it; 409 `has_dependents` otherwise), `requestResetCode` and `resetAccount` (reset encrypted chat with an emailed code where `status().accountReset`), `publishKeyPackages`, `keyPackageStatus`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge`, `conversations`, `requestStart` and `cancelStartRequest` (ask the other person to encrypt a chat when they have no device yet: a message with `metadata.kind` `e2ee_start_request`; the live ones are `ConversationDetail.encryption_requests`); device linking by scan: `createLink`, `link`, `scannedLink`, `approveLink`, `completeLink`, `linkProgress`, `cancelLink` (flag-gated E2EE v2 transport; its doorbell is `r.realtime.onE2eeEvent`, which carries a small message's stored ciphertext to the owner's own sockets; link and device changes arrive on `r.realtime.onE2eeLink` and `onE2eeDevices`) |
79
79
  | `r.brain` | `sendMessage` |
80
80
  | `r.commands` | `execute`, `gatePrompt` |
81
81
  | `r.projectBrain` | `tasks`, `decisions`, `milestones`, `completeTask`, `usage` |
@@ -99,6 +99,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
99
99
  | `r.communities` | `create`, `join`, `search`, `members` |
100
100
  | `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) |
101
101
  | `r.tags` | `create`, `list` |
102
+ | `r.curator` | `today` (World Today; a world story can carry `story_key` and `discussion`, its count across networks), `storyPosts` / `memberStoryPosts` (one page of a story's posts, keyless or under the member's key), `storyPostPages` (walk every page), `forYou`, `run`, `refreshIfStale`, `seedDeck` |
102
103
  | `r.uploads` | `uploadMedia` — upload post/chat bytes end to end |
103
104
  | `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` |
104
105
 
@@ -682,7 +683,9 @@ interests, no interest over 40%, videos weighted up), and `blocks` lay the page
682
683
  `stories`, `lead_video`, `clips`, `posts`, `people`, `groups`, `topics`, `viral` and
683
684
  `categories`, never the same type twice in a row, each naming the refs its views should send.
684
685
  Every story and item carries `reasons` (`interest:<category>`, `followed:@<handle>`, `breaking`,
685
- `general`, `for_you`, `engaging`, `breadth`). `part: 'stories'` and `part: 'items'` read the
686
+ `general`, `for_you`, `engaging`, `breadth`). An open-network item carries its `card` (text,
687
+ author, media and counts, no URL on its network) to draw it from; a Minds item's is null (read
688
+ the post by id). `part: 'stories'` and `part: 'items'` read the
686
689
  two halves on their own, so an app can draw each as it lands; a part=items answer keeps the
687
690
  stories rail as a `deferred` block. `meta.interests` are category ids for chips (never put them
688
691
  in a URL), `meta.partial` names a source that missed its deadline, and `meta.foryou_cursor`
@@ -740,6 +743,20 @@ a legislature, or a party or politician. Hide politics leaves those out whatever
740
743
  `feeds.top()` carry `political` too (0.7.67), and those reads and `curator.forYouWorld()` leave political stories out
741
744
  under Hide politics.
742
745
 
746
+ A World Today story's discussion across networks (0.7.69; servers with World Story Volume on): `sdk.curator.today()`
747
+ gives each world story the server indexed a `story_key` and, when the reader's pool holds a post, a `discussion`:
748
+ `total`, `people`, `by_network` (`minds`, `bluesky`, `activitypub`, `nostr`), `capped` (the pool reached 1,000: show
749
+ "1,000+") and up to two open-network `previews`; `meta.story_volume` says how the pools stand. Without them (an older
750
+ server, or the feature off) show the story as before. The posts behind the count:
751
+ `sdk.curator.storyPosts({ storyKey, network?, sort?: 'top' | 'latest', cursor?, limit? })` keyless (a visitor's view)
752
+ or `sdk.curator.memberStoryPosts(...)` under the member's key (their own blocks, mutes and hidden posts leave each
753
+ page), and `for await (const page of sdk.curator.storyPostPages({ storyKey }, { reader: 'member' }))` walks every
754
+ page. Each item is `{ kind: 'minds', post: Post }` or `{ kind: 'social', post: SocialPost }` (narrow on `kind`; open
755
+ a social post inside the app, never link out); `text_only` means show its words only. A post removed since the build is
756
+ counted in the page's `withheld`, never replaced, so the pages' items plus `withheld` add up to the page's `matched`:
757
+ `discussion.total` for `network: 'all'`, that network's `discussion.by_network` count for one network (`discussion`
758
+ always counts the whole pool). A cursor whose build is gone answers `status: 'expired'`: start again from the first page.
759
+
743
760
  `sdk.discovery.recommendations({ kind, category?, limit?, session?, exclude? })` is
744
761
  the app's one recommender for suggestion rails: `kind` is `member`, `group`, `tag`,
745
762
  `network_account` or `story`, and `category` is `all` or a browse category id. It
package/dist/index.d.ts CHANGED
@@ -158,7 +158,7 @@ export { UsersResource } from './resources/users.js';
158
158
  export { CommunitiesResource } from './resources/communities.js';
159
159
  export { ChatResource } from './resources/chat.js';
160
160
  export { E2eeResource } from './resources/e2ee.js';
161
- export type { E2eeAccountReset, E2eeDevice, E2eeDevicesRealtimeEvent, E2eeEnvelope, E2eeEvent, E2eeHasDependentsDetails, E2eeKeyPackageStatus, E2eeLink, E2eeLinkCancelledBy, E2eeLinkProgress, E2eeLinkRealtimeEvent, E2eeLinkState, E2eeObjectDownload, E2eeObjectUpload, E2eeRealtimeEvent, E2eeResetCodeSent, E2eeResetErrorCode, E2eeRevokeResponse, E2eeStatus, E2eeStatusReason, } from './resources/e2ee.js';
161
+ export type { E2eeAccountReset, E2eeDevice, E2eeDevicesRealtimeEvent, E2eeEnvelope, E2eeEvent, E2eeHasDependentsDetails, E2eeKeyPackageStatus, E2eeLink, E2eeLinkCancelledBy, E2eeLinkProgress, E2eeLinkRealtimeEvent, E2eeLinkState, E2eeObjectDownload, E2eeObjectUpload, E2eeRealtimeEvent, E2eeResetCodeSent, E2eeResetErrorCode, E2eeRevokeResponse, E2eeStartRequest, E2eeStatus, E2eeStatusReason, } from './resources/e2ee.js';
162
162
  export { ProjectsResource } from './resources/projects.js';
163
163
  export { AgentsResource, SUPPORTED_MODELS } from './resources/agents.js';
164
164
  export { EvidenceResource } from './resources/evidence.js';
@@ -195,7 +195,8 @@ export { VideoResource } from './resources/video.js';
195
195
  export type { VideoUploadCreds, CreateVideoUploadResult, VideoStatusInfo, VideoPlaybackToken, VideoEntitlement, } from './resources/video.js';
196
196
  export { AppSubscriptionsResource } from './resources/app-subscriptions.js';
197
197
  export type { AppSubscriptionBoostCredit, AppSubscriptionCheckoutResult, AppSubscriptionPortalResult, AppSubscriptionStatusResult, AppSubscriptionTierChangePreview, AppSubscriptionTierChangeResult, } from './resources/app-subscriptions.js';
198
- export type { CuratorSource, CuratorPrompt, CuratorRunInput, CuratorRunResult, CuratorTodayEdition, CuratorTodayExcerpt, CuratorTodayLeadImage, CuratorTodayParams, CuratorTodayRadar, CuratorTodayReaction, CuratorTodayResponse, CuratorTodaySource, CuratorTodayStory, ForYouMeta, ForYouParams, ForYouSurface, ForYouWorldBlock, ForYouWorldBlockType, ForYouWorldCategoryShortcut, ForYouWorldItem, ForYouWorldMeta, ForYouWorldParams, ForYouWorldPart, ForYouWorldResponse, ForYouWorldStory, ForYouWorldStoryTarget, ForYouWorldViral, } from './resources/curator.js';
198
+ export type { CuratorSource, CuratorPrompt, CuratorRunInput, CuratorRunResult, CuratorTodayEdition, CuratorTodayExcerpt, CuratorTodayLeadImage, CuratorTodayParams, CuratorTodayRadar, CuratorTodayReaction, CuratorTodayResponse, CuratorTodaySource, CuratorTodayStory, CuratorTodayDiscussion, CuratorStoryDiscussion, CuratorStoryLaneOutcome, CuratorStoryMindsPost, CuratorStoryPostItem, CuratorStoryPostPagesOptions, CuratorStoryPostsPage, CuratorStoryPostsParams, CuratorStoryReader, CuratorStorySocialPost, CuratorStoryVolumeLanes, CuratorStoryVolumeMeta, StoryPostNetwork, StoryPostPool, StoryPostSort, ForYouMeta, ForYouParams, ForYouSurface, ForYouWorldBlock, ForYouWorldBlockType, ForYouWorldCategoryShortcut, ForYouWorldItem, ForYouWorldMeta, ForYouWorldParams, ForYouWorldPart, ForYouWorldResponse, ForYouWorldStory, ForYouWorldStoryTarget, ForYouWorldViral, } from './resources/curator.js';
199
+ export { STORY_POOL_MAX_POSTS, STORY_POST_NETWORKS, STORY_POSTS_PAGE_MAX } from './resources/curator.js';
199
200
  export { ReportsResource } from './resources/reports.js';
200
201
  export type { ReportTargetType, ReportStatus, CreateReportInput, ReportRecord, ResolveReportInput, } from './resources/reports.js';
201
202
  export { ModerationResource } from './resources/moderation.js';
package/dist/index.js CHANGED
@@ -253,6 +253,7 @@ export { JobsResource } from './resources/jobs.js';
253
253
  export { CuratorResource } from './resources/curator.js';
254
254
  export { VideoResource } from './resources/video.js';
255
255
  export { AppSubscriptionsResource } from './resources/app-subscriptions.js';
256
+ export { STORY_POOL_MAX_POSTS, STORY_POST_NETWORKS, STORY_POSTS_PAGE_MAX } from './resources/curator.js';
256
257
  export { ReportsResource } from './resources/reports.js';
257
258
  export { ModerationResource } from './resources/moderation.js';
258
259
  export { LinkPreviewResource } from './resources/link-preview.js';
@@ -14,6 +14,8 @@ export interface ChatMessageEvent {
14
14
  isAi?: boolean;
15
15
  };
16
16
  createdAt: string;
17
+ /** The message's metadata as stored; `kind: 'e2ee_start_request'` is an encryption request (MessageMetadata). */
18
+ metadata?: Record<string, unknown>;
17
19
  }
18
20
  export interface ChatMessageEditEvent {
19
21
  id: string;
@@ -163,11 +165,18 @@ export declare class RealtimeClient {
163
165
  onAgentThinking(callback: (event: AgentThinkingEvent) => void): (() => void) | undefined;
164
166
  /**
165
167
  * 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
+ * change `added`, `accepted`, `removed`, `left` or `invites_cancelled`, sent to everyone in the group
169
+ * and to the people it is about; or `device` (a member's device can be added to the encrypted group
170
+ * now), sent to the group's admin only, on a connection that may read encrypted chat for the group.
171
+ * Read the conversation again; in an encrypted group, check its keys.
168
172
  */
169
173
  onChatMembers(callback: (event: ChatMembersEvent) => void): () => void;
170
- /** A private group was renamed (0.7.59): `{ conversationId, change: 'renamed', name, actorId }`. */
174
+ /**
175
+ * A conversation changed: a private group was renamed (0.7.59,
176
+ * `{ conversationId, change: 'renamed', name, actorId }`), or someone
177
+ * withdrew their request to encrypt a one-on-one chat
178
+ * (`encryption_request_withdrawn`, with `actorId`). Read it again.
179
+ */
171
180
  onChatConversation(callback: (event: ChatConversationEvent) => void): () => void;
172
181
  /**
173
182
  * The E2EE doorbell after each append to an encrypted conversation this
package/dist/realtime.js CHANGED
@@ -429,13 +429,20 @@ export class RealtimeClient {
429
429
  }
430
430
  /**
431
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.
432
+ * change `added`, `accepted`, `removed`, `left` or `invites_cancelled`, sent to everyone in the group
433
+ * and to the people it is about; or `device` (a member's device can be added to the encrypted group
434
+ * now), sent to the group's admin only, on a connection that may read encrypted chat for the group.
435
+ * Read the conversation again; in an encrypted group, check its keys.
434
436
  */
435
437
  onChatMembers(callback) {
436
438
  return this.on('chat:members', callback);
437
439
  }
438
- /** A private group was renamed (0.7.59): `{ conversationId, change: 'renamed', name, actorId }`. */
440
+ /**
441
+ * A conversation changed: a private group was renamed (0.7.59,
442
+ * `{ conversationId, change: 'renamed', name, actorId }`), or someone
443
+ * withdrew their request to encrypt a one-on-one chat
444
+ * (`encryption_request_withdrawn`, with `actorId`). Read it again.
445
+ */
439
446
  onChatConversation(callback) {
440
447
  return this.on('chat:conversation', callback);
441
448
  }
@@ -93,7 +93,10 @@ export declare class ChatResource {
93
93
  * only stops listening for live events.
94
94
  */
95
95
  leave(conversationId: string): Promise<SingleResponse<LeaveConversationResult>>;
96
- /** Accept a message request (`request_status: 'pending'`). Idempotent; also reverses a decline. */
96
+ /**
97
+ * Accept a message request (`request_status: 'pending'`). Idempotent; also reverses a decline. In a
98
+ * private group, everyone in it gets the realtime `chat:members` event (`accepted`); a repeat sends none.
99
+ */
97
100
  acceptRequest(conversationId: string): Promise<SingleResponse<ConversationRequestResult>>;
98
101
  /**
99
102
  * Decline a message request. The conversation leaves the caller's lists and
@@ -108,7 +108,10 @@ export class ChatResource {
108
108
  leave(conversationId) {
109
109
  return this.client.post(`/chat/conversations/${conversationId}/leave`);
110
110
  }
111
- /** Accept a message request (`request_status: 'pending'`). Idempotent; also reverses a decline. */
111
+ /**
112
+ * Accept a message request (`request_status: 'pending'`). Idempotent; also reverses a decline. In a
113
+ * private group, everyone in it gets the realtime `chat:members` event (`accepted`); a repeat sends none.
114
+ */
112
115
  acceptRequest(conversationId) {
113
116
  return this.client.post(`/chat/conversations/${conversationId}/accept`);
114
117
  }
@@ -1,6 +1,7 @@
1
1
  import type { HttpClient } from '../client.js';
2
2
  import type { CompassFilterMeta, DiscoveryLayout, PaginatedResponse, PaginationMeta, Post, SingleResponse } from '../types.js';
3
3
  import type { FeedTopNetworkCard, FeedTopTarget } from './feeds.js';
4
+ import type { SocialPost } from './protocols.js';
4
5
  /** Which surface is asking: Home's For You (default) or Discover's For you. */
5
6
  export type ForYouSurface = 'home' | 'discover';
6
7
  export type ForYouParams = {
@@ -102,6 +103,8 @@ export interface ForYouWorldItem {
102
103
  rate: number;
103
104
  };
104
105
  };
106
+ /** An open-network post's card (no URL on its network): draw the post from it. Null for a Minds post, read by id. Every open-network item carries one. */
107
+ card: FeedTopNetworkCard | null;
105
108
  }
106
109
  /** A viral post in the viral block. `card` is an open-network post's card (no URL on its network); null for a Minds post, read by id. */
107
110
  export interface ForYouWorldViral {
@@ -375,6 +378,18 @@ export interface CuratorTodayStory {
375
378
  lead_image?: CuratorTodayLeadImage | null;
376
379
  /** World stories only: the radar cluster the story seeded, or null. */
377
380
  radar?: CuratorTodayRadar | null;
381
+ /**
382
+ * World Story Volume (servers with it on): the story's stable key, which survives the edition's regenerations. Its
383
+ * posts page is `curator.storyPosts({ storyKey })`. Absent on a Minds story, on a world story the builder has not
384
+ * indexed, and on every story of a server without it.
385
+ */
386
+ story_key?: string;
387
+ /**
388
+ * World Story Volume: how many posts discuss the story across Minds, Bluesky, the Fediverse and Nostr, and up to two
389
+ * of its open-network posts. Present only when the reader's pool holds a post (never `{ total: 0 }`). `posts` stays
390
+ * the Minds previews it was. Absent: show the story as before.
391
+ */
392
+ discussion?: CuratorTodayDiscussion;
378
393
  }
379
394
  export interface CuratorTodayEdition {
380
395
  id: string;
@@ -393,6 +408,8 @@ export interface CuratorTodayResponse {
393
408
  meta: {
394
409
  generating: boolean;
395
410
  compass_filter?: CompassFilterMeta;
411
+ /** World Story Volume: how the story pools stand for this app. Absent on a server without it, or with it off. */
412
+ story_volume?: CuratorStoryVolumeMeta;
396
413
  };
397
414
  }
398
415
  export interface CuratorTodayParams {
@@ -402,6 +419,124 @@ export interface CuratorTodayParams {
402
419
  */
403
420
  compass?: string;
404
421
  }
422
+ /** The networks a story's discussion counts posts on. A mirrored `name@host` Minds account counts as `activitypub`. */
423
+ export declare const STORY_POST_NETWORKS: readonly ["minds", "bluesky", "activitypub", "nostr"];
424
+ export type StoryPostNetwork = (typeof STORY_POST_NETWORKS)[number];
425
+ /** `safe`: posts a cached verdict clears (visitors, and members until their content-filter tiers ship); `open`: words of the rest too. */
426
+ export type StoryPostPool = 'safe' | 'open';
427
+ export type StoryPostSort = 'top' | 'latest';
428
+ /** Posts a story page holds at most (`limit`, the default too). */
429
+ export declare const STORY_POSTS_PAGE_MAX = 25;
430
+ /** Posts a story's pool holds at most; a pool that reaches it is `capped` (shown as "1,000+"). */
431
+ export declare const STORY_POOL_MAX_POSTS = 1000;
432
+ /** A story's pool counts: what a card shows ("312 posts · Bluesky 248 · Nostr 31 · Minds 24 · Fediverse 9"). */
433
+ export interface CuratorStoryDiscussion {
434
+ /** Posts in the reader's pool. */
435
+ total: number;
436
+ /** Distinct authors among them. */
437
+ people: number;
438
+ by_network: Record<StoryPostNetwork, number>;
439
+ pool: StoryPostPool;
440
+ /** The pool reached STORY_POOL_MAX_POSTS: show "1,000+". */
441
+ capped: boolean;
442
+ /** ISO time the pool was built (every ten minutes). */
443
+ built_at: string;
444
+ }
445
+ /** The discussion a world story carries on `curator.today()`. */
446
+ export interface CuratorTodayDiscussion extends CuratorStoryDiscussion {
447
+ /** Up to two of the pool's open-network posts, as the reader may see them (none when they could not be read in time). */
448
+ previews: CuratorStoryPostItem[];
449
+ }
450
+ /** A Minds post discussing the story, in the shape `curator.today()` serves its `posts`. */
451
+ export interface CuratorStoryMindsPost {
452
+ /** JSON `["minds", id]`. */
453
+ key: string;
454
+ kind: 'minds';
455
+ /** Where the author is from: a mirrored `name@host` Minds account counts as `activitypub`. */
456
+ network: StoryPostNetwork;
457
+ post: Post;
458
+ /** No verdict clears the post (the `open` pool only): its words are served, never its media. */
459
+ text_only?: true;
460
+ }
461
+ /** An open-network post discussing the story. Open it inside the app; never link out to its network. */
462
+ export interface CuratorStorySocialPost {
463
+ /** JSON `[protocol, nativeId]`. */
464
+ key: string;
465
+ kind: 'social';
466
+ network: StoryPostNetwork;
467
+ post: SocialPost;
468
+ /** No verdict clears the post (the `open` pool only): its words are served, never its media. */
469
+ text_only?: true;
470
+ }
471
+ /** One post on a story page, or a preview on a card: narrow on `kind`. */
472
+ export type CuratorStoryPostItem = CuratorStoryMindsPost | CuratorStorySocialPost;
473
+ /** `meta.story_volume` on `curator.today()`. */
474
+ export interface CuratorStoryVolumeMeta {
475
+ /** `building`: no build yet (or the last expired); `unavailable`: the counts could not be read. */
476
+ status: 'ok' | 'building' | 'unavailable';
477
+ pool: StoryPostPool;
478
+ built_at: string | null;
479
+ /** False for the minutes after a regeneration, while stories the builder has not read yet carry no key. */
480
+ edition_current: boolean;
481
+ /** World stories indexed, those whose reader's pool holds a post, and those whose build lost an input. */
482
+ stories: number;
483
+ pooled: number;
484
+ degraded: number;
485
+ }
486
+ /** What one lane of a story's build did: counts only. */
487
+ export interface CuratorStoryLaneOutcome {
488
+ state: 'ok' | 'empty' | 'failed' | 'off';
489
+ items: number;
490
+ }
491
+ export interface CuratorStoryVolumeLanes {
492
+ minds: CuratorStoryLaneOutcome;
493
+ source_index: CuratorStoryLaneOutcome;
494
+ attach: CuratorStoryLaneOutcome;
495
+ }
496
+ export interface CuratorStoryPostsParams {
497
+ /** The story's `story_key` from `curator.today()` (a UUID). */
498
+ storyKey: string;
499
+ /** One network's posts, or `all` (default). */
500
+ network?: 'all' | StoryPostNetwork;
501
+ /** `top` (default): the pool's rank; `latest`: newest first. */
502
+ sort?: StoryPostSort;
503
+ /** The previous page's `cursor`, unchanged. It is bound to its network and sort (and build), not to `limit`. */
504
+ cursor?: string;
505
+ /** 1 to STORY_POSTS_PAGE_MAX (default 25). */
506
+ limit?: number;
507
+ }
508
+ /** One page of a story's posts. */
509
+ export interface CuratorStoryPostsPage {
510
+ /** `expired`: the build the cursor walks is gone (kept 30 minutes); the list ends here: start again from the first page. */
511
+ status: 'ok' | 'expired';
512
+ story_key: string;
513
+ pool: StoryPostPool;
514
+ network: 'all' | StoryPostNetwork;
515
+ sort: StoryPostSort;
516
+ items: CuratorStoryPostItem[];
517
+ /** The next page's cursor; null on the last page. */
518
+ cursor: string | null;
519
+ exhausted: boolean;
520
+ /** Places on this page whose post was not listed (removed or edited out since the build, or hidden by the member's own filters): never replaced. */
521
+ withheld: number;
522
+ /** The pool's posts this network filter covers: what walking every page lists, less `withheld`. */
523
+ matched: number;
524
+ /** The reader's pool's counts, as `curator.today()` carries them (without previews); null when the pool is empty. */
525
+ discussion: CuratorStoryDiscussion | null;
526
+ built_at: string;
527
+ /** A lane failed or the cached verdicts could not be read during the build: the pool may be smaller than the story's discussion. */
528
+ degraded: boolean;
529
+ lanes: CuratorStoryVolumeLanes;
530
+ }
531
+ /** Who reads a story page: a visitor (`storyPosts`, the default) or the signed-in member (`memberStoryPosts`). */
532
+ export type CuratorStoryReader = 'visitor' | 'member';
533
+ export interface CuratorStoryPostPagesOptions {
534
+ reader?: CuratorStoryReader;
535
+ /** Stop after this many pages. Default: as many as a capped pool can fill at this `limit`. */
536
+ maxPages?: number;
537
+ /** `false` asks each page once (no retry on a 5xx or 429), as `posts.list` takes it. */
538
+ retry?: boolean;
539
+ }
405
540
  export declare class CuratorResource {
406
541
  private client;
407
542
  constructor(client: HttpClient);
@@ -411,6 +546,39 @@ export declare class CuratorResource {
411
546
  * conversation stories follow. Requires `posts:read`.
412
547
  */
413
548
  today(params?: CuratorTodayParams): Promise<CuratorTodayResponse>;
549
+ /**
550
+ * One page of the posts discussing a World Today story across Minds, Bluesky, the Fediverse and Nostr, as a visitor
551
+ * reads them (the safe pool, floored again on every page). Works keyless: build the client with
552
+ * `new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false })`, since `anonymous` alone still sends
553
+ * `RECURSIV_API_KEY` when the process has one (a shared cache may hold the keyless answer for a minute). With a key it
554
+ * is that app's visitor answer, never cached. For a signed-in member use `memberStoryPosts`. Throws ValidationError
555
+ * (no request) for a key that is not a story key; the server answers 404 while it does not serve story volume and for
556
+ * a story with no pool, and 503 (retry shortly) when it cannot floor the page.
557
+ */
558
+ storyPosts(params: CuratorStoryPostsParams, options?: {
559
+ retry?: boolean;
560
+ }): Promise<SingleResponse<CuratorStoryPostsPage>>;
561
+ /**
562
+ * `storyPosts` for the signed-in member under the app's own key (`users:read` and `posts:read`): their own blocks,
563
+ * mutes and hidden posts leave each page (counted in `withheld`). Never cached.
564
+ */
565
+ memberStoryPosts(params: CuratorStoryPostsParams, options?: {
566
+ retry?: boolean;
567
+ }): Promise<SingleResponse<CuratorStoryPostsPage>>;
568
+ /**
569
+ * Walk a story's pages from `params.cursor` (or the first page), yielding each page: the walk keeps one network and
570
+ * sort, and ends on the last page, on an `expired` page (yielded, so the reader can start again) or at `maxPages`.
571
+ * Over a walk to the last page, `items.length + withheld` sums to the page's `matched`: `discussion.total` for
572
+ * network `all`, and that network's `discussion.by_network` count for one network (`discussion` is the whole pool's).
573
+ * Throws ValidationError (no request) for a bad story key, a `limit` outside 1 to STORY_POSTS_PAGE_MAX, or a
574
+ * `maxPages` that is not a whole number of at least 1 (`Infinity` walks to the last page).
575
+ *
576
+ * ```ts
577
+ * for await (const page of sdk.curator.storyPostPages({ storyKey, network: 'bluesky' })) render(page.items)
578
+ * ```
579
+ */
580
+ storyPostPages(params: CuratorStoryPostsParams, options?: CuratorStoryPostPagesOptions): AsyncGenerator<CuratorStoryPostsPage, void, undefined>;
581
+ private readStoryPosts;
414
582
  /**
415
583
  * Fetch the supplied sources in parallel, annotate each top item with
416
584
  * the supplied prompt template, and insert audience-scoped posts into
@@ -1,3 +1,38 @@
1
+ import { ValidationError } from '../errors.js';
2
+ // ── World Story Volume: a World Today story's discussion across networks ──
3
+ //
4
+ // GET /curator/today gives each world story the builder indexed a `story_key` and, when the reader's pool holds a post,
5
+ // a `discussion`. The posts behind the count are paged by `storyPosts` (keyless: a signed-out visitor, or a key's app
6
+ // as a visitor reads it) and `memberStoryPosts` (the signed-in member's own blocks, mutes and hidden posts applied).
7
+ // Walking every page lists exactly the page's `matched`, less each page's `withheld`: `discussion.total` for network
8
+ // `all`, that network's `discussion.by_network` count for one network (`discussion` counts the whole pool on every page).
9
+ /** The networks a story's discussion counts posts on. A mirrored `name@host` Minds account counts as `activitypub`. */
10
+ export const STORY_POST_NETWORKS = Object.freeze(['minds', 'bluesky', 'activitypub', 'nostr']);
11
+ /** Posts a story page holds at most (`limit`, the default too). */
12
+ export const STORY_POSTS_PAGE_MAX = 25;
13
+ /** Posts a story's pool holds at most; a pool that reaches it is `capped` (shown as "1,000+"). */
14
+ export const STORY_POOL_MAX_POSTS = 1000;
15
+ /** Story keys are UUIDs; anything else never reaches a path. */
16
+ const STORY_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
17
+ function storyPostsPath(reader, storyKey) {
18
+ if (typeof storyKey !== 'string' || !STORY_KEY.test(storyKey)) {
19
+ throw new ValidationError({ type: 'validation_error', code: 'invalid_story_key', message: "Pass a world story's story_key from curator.today()." });
20
+ }
21
+ return reader === 'member' ? `/protocols/social/stories/${storyKey}/posts` : `/curator/today/stories/${storyKey}/posts`;
22
+ }
23
+ /** Only the four fields the story page reads: the server's query is strict, and a caller's extras never reach it. */
24
+ function storyPostsQuery(params) {
25
+ const query = {};
26
+ if (params.network !== undefined)
27
+ query.network = params.network;
28
+ if (params.sort !== undefined)
29
+ query.sort = params.sort;
30
+ if (params.cursor !== undefined)
31
+ query.cursor = params.cursor;
32
+ if (params.limit !== undefined)
33
+ query.limit = params.limit;
34
+ return query;
35
+ }
1
36
  export class CuratorResource {
2
37
  client;
3
38
  constructor(client) {
@@ -11,6 +46,67 @@ export class CuratorResource {
11
46
  today(params = {}) {
12
47
  return params.compass ? this.client.get('/curator/today', { compass: params.compass }) : this.client.get('/curator/today');
13
48
  }
49
+ /**
50
+ * One page of the posts discussing a World Today story across Minds, Bluesky, the Fediverse and Nostr, as a visitor
51
+ * reads them (the safe pool, floored again on every page). Works keyless: build the client with
52
+ * `new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false })`, since `anonymous` alone still sends
53
+ * `RECURSIV_API_KEY` when the process has one (a shared cache may hold the keyless answer for a minute). With a key it
54
+ * is that app's visitor answer, never cached. For a signed-in member use `memberStoryPosts`. Throws ValidationError
55
+ * (no request) for a key that is not a story key; the server answers 404 while it does not serve story volume and for
56
+ * a story with no pool, and 503 (retry shortly) when it cannot floor the page.
57
+ */
58
+ storyPosts(params, options) {
59
+ return this.readStoryPosts('visitor', params, options);
60
+ }
61
+ /**
62
+ * `storyPosts` for the signed-in member under the app's own key (`users:read` and `posts:read`): their own blocks,
63
+ * mutes and hidden posts leave each page (counted in `withheld`). Never cached.
64
+ */
65
+ memberStoryPosts(params, options) {
66
+ return this.readStoryPosts('member', params, options);
67
+ }
68
+ /**
69
+ * Walk a story's pages from `params.cursor` (or the first page), yielding each page: the walk keeps one network and
70
+ * sort, and ends on the last page, on an `expired` page (yielded, so the reader can start again) or at `maxPages`.
71
+ * Over a walk to the last page, `items.length + withheld` sums to the page's `matched`: `discussion.total` for
72
+ * network `all`, and that network's `discussion.by_network` count for one network (`discussion` is the whole pool's).
73
+ * Throws ValidationError (no request) for a bad story key, a `limit` outside 1 to STORY_POSTS_PAGE_MAX, or a
74
+ * `maxPages` that is not a whole number of at least 1 (`Infinity` walks to the last page).
75
+ *
76
+ * ```ts
77
+ * for await (const page of sdk.curator.storyPostPages({ storyKey, network: 'bluesky' })) render(page.items)
78
+ * ```
79
+ */
80
+ async *storyPostPages(params, options = {}) {
81
+ const reader = options.reader ?? 'visitor';
82
+ storyPostsPath(reader, params.storyKey);
83
+ // A limit or maxPages the loop cannot count by (NaN, Infinity, 0) would end the walk before its first request and
84
+ // look like an empty story, so it throws here, before any request, as a bad story key does.
85
+ const limit = params.limit ?? STORY_POSTS_PAGE_MAX;
86
+ if (!Number.isInteger(limit) || limit < 1 || limit > STORY_POSTS_PAGE_MAX) {
87
+ throw new ValidationError({ type: 'validation_error', code: 'invalid_limit', message: `limit must be a whole number from 1 to ${STORY_POSTS_PAGE_MAX}.` });
88
+ }
89
+ const maxPages = options.maxPages ?? Math.ceil(STORY_POOL_MAX_POSTS / limit);
90
+ if (!(maxPages === Number.POSITIVE_INFINITY || (Number.isInteger(maxPages) && maxPages >= 1))) {
91
+ throw new ValidationError({ type: 'validation_error', code: 'invalid_max_pages', message: 'maxPages must be a whole number of pages, at least 1.' });
92
+ }
93
+ const readOptions = options.retry === undefined ? undefined : { retry: options.retry };
94
+ let cursor = params.cursor;
95
+ const seen = new Set(cursor ? [cursor] : []);
96
+ for (let pages = 0; pages < maxPages; pages += 1) {
97
+ const { data: page } = await this.readStoryPosts(reader, { ...params, cursor }, readOptions);
98
+ yield page;
99
+ if (page.status !== 'ok' || page.exhausted || !page.cursor || seen.has(page.cursor))
100
+ return;
101
+ seen.add(page.cursor);
102
+ cursor = page.cursor;
103
+ }
104
+ }
105
+ readStoryPosts(reader, params, options) {
106
+ const path = storyPostsPath(reader, params.storyKey);
107
+ const query = storyPostsQuery(params);
108
+ return options?.retry === undefined ? this.client.get(path, query) : this.client.get(path, query, { retry: options.retry });
109
+ }
14
110
  /**
15
111
  * Fetch the supplied sources in parallel, annotate each top item with
16
112
  * the supplied prompt template, and insert audience-scoped posts into
@@ -232,6 +232,16 @@ export interface E2eeHasDependentsDetails {
232
232
  dependents: string[];
233
233
  root: boolean;
234
234
  }
235
+ /**
236
+ * A live "Encrypt this chat" request (`e2ee.requestStart`): the message that
237
+ * carries it in the chat, and when it was made, by the relay's clock.
238
+ */
239
+ export interface E2eeStartRequest {
240
+ message_id: string;
241
+ requested_at: string;
242
+ /** False when this request was live already, so nothing was sent. Absent from older relays. */
243
+ created?: boolean;
244
+ }
235
245
  /** `revokeDevice`'s answer: the device, and every device the call revoked (the target first). */
236
246
  export interface E2eeRevokeResponse extends SingleResponse<E2eeDevice> {
237
247
  /** Absent on relays before 0.7.58. Empty when the device was already revoked and nothing else was. */
@@ -467,6 +477,37 @@ export declare class E2eeResource {
467
477
  device_id?: string;
468
478
  reason?: 'cancelled' | 'declined';
469
479
  }): Promise<SingleResponse<E2eeLink>>;
480
+ /**
481
+ * "Encrypt this chat" when the other person has no encrypted-chat device
482
+ * yet. The relay writes the request into the chat as a message from this
483
+ * account, with `metadata: { kind: 'e2ee_start_request', v: 1 }` (see
484
+ * `Message.metadata`), so it reaches the other person as a message does:
485
+ * their open chat, chat list and unread count, and one push, "<name> wants
486
+ * to encrypt your chat" (at most one a day per chat). Their device then
487
+ * starts the encryption as usual, with this account's devices. So this
488
+ * account needs an active device first (`no_root`, 409, otherwise).
489
+ *
490
+ * Answers this account's live request, `{ message_id, requested_at,
491
+ * created }`; asking again changes nothing and sends nothing (`created`
492
+ * false). The other person's app finds live requests in
493
+ * `chat.conversation()`'s `encryption_requests`. Refused where encryption
494
+ * cannot start: `conversation_not_eligible` (409), `blocked` (403),
495
+ * `forbidden` (403: not a member this key reaches); `conflict` (409) when
496
+ * the chat is already encrypted; `request_limit` (409, with
497
+ * `details.retryAfterSeconds`) after three requests in a day in this chat.
498
+ * Relays without it answer 404.
499
+ */
500
+ requestStart(conversationId: string): Promise<SingleResponse<E2eeStartRequest>>;
501
+ /**
502
+ * Withdraw this account's request to encrypt the chat: `{ cancelled }`,
503
+ * false when none of its requests was live (answered by a start, or
504
+ * withdrawn already). The request leaves the chat's history; open chats get
505
+ * `chat_message_delete`, and both people's sockets `chat:conversation`
506
+ * `encryption_request_withdrawn`. Relays without it answer 404.
507
+ */
508
+ cancelStartRequest(conversationId: string): Promise<SingleResponse<{
509
+ cancelled: boolean;
510
+ }>>;
470
511
  /**
471
512
  * The ids of the encrypted conversations this account belongs to, in this
472
513
  * key's scope, ordered by id: pass `next` as `after` for the next page
@@ -178,6 +178,39 @@ export class E2eeResource {
178
178
  cancelLink(id, input) {
179
179
  return this.client.post(`/e2ee/links/${encodeURIComponent(id)}/cancel`, input ?? {});
180
180
  }
181
+ /**
182
+ * "Encrypt this chat" when the other person has no encrypted-chat device
183
+ * yet. The relay writes the request into the chat as a message from this
184
+ * account, with `metadata: { kind: 'e2ee_start_request', v: 1 }` (see
185
+ * `Message.metadata`), so it reaches the other person as a message does:
186
+ * their open chat, chat list and unread count, and one push, "<name> wants
187
+ * to encrypt your chat" (at most one a day per chat). Their device then
188
+ * starts the encryption as usual, with this account's devices. So this
189
+ * account needs an active device first (`no_root`, 409, otherwise).
190
+ *
191
+ * Answers this account's live request, `{ message_id, requested_at,
192
+ * created }`; asking again changes nothing and sends nothing (`created`
193
+ * false). The other person's app finds live requests in
194
+ * `chat.conversation()`'s `encryption_requests`. Refused where encryption
195
+ * cannot start: `conversation_not_eligible` (409), `blocked` (403),
196
+ * `forbidden` (403: not a member this key reaches); `conflict` (409) when
197
+ * the chat is already encrypted; `request_limit` (409, with
198
+ * `details.retryAfterSeconds`) after three requests in a day in this chat.
199
+ * Relays without it answer 404.
200
+ */
201
+ requestStart(conversationId) {
202
+ return this.client.put(`/e2ee/conversations/${encodeURIComponent(conversationId)}/start-request`, {});
203
+ }
204
+ /**
205
+ * Withdraw this account's request to encrypt the chat: `{ cancelled }`,
206
+ * false when none of its requests was live (answered by a start, or
207
+ * withdrawn already). The request leaves the chat's history; open chats get
208
+ * `chat_message_delete`, and both people's sockets `chat:conversation`
209
+ * `encryption_request_withdrawn`. Relays without it answer 404.
210
+ */
211
+ cancelStartRequest(conversationId) {
212
+ return this.client.delete(`/e2ee/conversations/${encodeURIComponent(conversationId)}/start-request`);
213
+ }
181
214
  /**
182
215
  * The ids of the encrypted conversations this account belongs to, in this
183
216
  * key's scope, ordered by id: pass `next` as `after` for the next page
@@ -64,7 +64,8 @@ export declare class PostsResource {
64
64
  delete(id: string): Promise<DeleteResponse>;
65
65
  /**
66
66
  * Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
67
- * the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as
67
+ * the promotion safety floor; `sort: 'new'` lists newest first; `media: 'video'` keeps only posts with a
68
+ * video that plays (a Watch feed of the matches, relevance only). Page with `meta.next_cursor` as
68
69
  * `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
69
70
  */
70
71
  search(params: SearchPostsParams): Promise<SearchPostsResponse>;
@@ -66,7 +66,8 @@ export class PostsResource {
66
66
  }
67
67
  /**
68
68
  * Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
69
- * the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as
69
+ * the promotion safety floor; `sort: 'new'` lists newest first; `media: 'video'` keeps only posts with a
70
+ * video that plays (a Watch feed of the matches, relevance only). Page with `meta.next_cursor` as
70
71
  * `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
71
72
  */
72
73
  search(params) {
package/dist/types.d.ts CHANGED
@@ -425,6 +425,13 @@ export interface SearchPostsParams extends PaginationParams {
425
425
  tag_ids?: string;
426
426
  /** `relevance` (default) or `new` (newest first). */
427
427
  sort?: 'relevance' | 'new';
428
+ /**
429
+ * `video` (0.7.69): only posts with a video that plays, ranked as the relevance search ranks them, in the page shape Watch
430
+ * reads (`data`, `meta.next_cursor`, `meta.has_more`), so a player can play a query. A post whose every video cannot
431
+ * play (a legacy file that is gone, an upload still encoding) is left out; the cursor moves past it, so a page can
432
+ * be short and still have `next_cursor`. Relevance only: with `sort: 'new'` it answers 400 `invalid_input`.
433
+ */
434
+ media?: 'video';
428
435
  since?: string;
429
436
  until?: string;
430
437
  /**
@@ -753,6 +760,25 @@ export interface ConversationDetail {
753
760
  * older APIs.
754
761
  */
755
762
  last_activity_at?: string;
763
+ /**
764
+ * The live "Encrypt this chat" requests (`e2ee.requestStart`) that can
765
+ * still be answered, oldest first, at most one per person, however far
766
+ * back in the history their messages are. Served by `conversation()` on a
767
+ * plaintext one-on-one chat only: `[]` when there is none (or the chat is
768
+ * not accepted by both, or a block stands). A request is listed only while
769
+ * both people are inside the encryption pilot and its sender has an active
770
+ * device. Absent on other chats and from older APIs.
771
+ */
772
+ encryption_requests?: ConversationEncryptionRequest[];
773
+ }
774
+ /** One entry of `ConversationDetail.encryption_requests`. */
775
+ export interface ConversationEncryptionRequest {
776
+ /** Who asked. The other person answers by starting encryption as usual, from their own device. */
777
+ sender_id: string;
778
+ /** The request's message (`metadata.kind` `e2ee_start_request`). */
779
+ message_id: string;
780
+ /** When it was asked, by the relay's clock. */
781
+ requested_at: string;
756
782
  }
757
783
  export type ChatMemberRole = 'admin' | 'member';
758
784
  /**
@@ -800,21 +826,39 @@ export interface UpdateGroupResult {
800
826
  * the conversation again (and, in an encrypted group, check its keys).
801
827
  * `actorId` is the admin for `added` and `removed`, and the leaver for `left`
802
828
  * and `invites_cancelled` (the invites an encrypted group's admin sent, which
803
- * went with them).
829
+ * went with them). `accepted`: the person in `userIds` (and `actorId`)
830
+ * accepted their invite; everyone in the group is told. `device`: one of that
831
+ * person's devices can be added to the encrypted group now (it has key
832
+ * packages after it had none); only the group's admin is told, since only the
833
+ * admin's device adds someone it invited, and only on a connection that may
834
+ * read encrypted chat for the group (a session, or a key for the group's app;
835
+ * never an agent's key). Read `change` defensively: an older API never sends
836
+ * the last two, and a newer one may add more.
804
837
  */
805
838
  export interface ChatMembersEvent {
806
839
  conversationId: string;
807
- change: 'added' | 'removed' | 'left' | 'invites_cancelled';
840
+ change: 'added' | 'accepted' | 'removed' | 'left' | 'invites_cancelled' | 'device';
808
841
  userIds: string[];
809
842
  actorId: string;
810
843
  }
811
- /** Realtime `chat:conversation`: a private group was renamed by its admin. */
812
- export interface ChatConversationEvent {
844
+ /**
845
+ * Realtime `chat:conversation`. Ids only, except a group's new name:
846
+ * - `renamed`: a private group was renamed by its admin (`name`).
847
+ * - `encryption_request_withdrawn`: `actorId` withdrew their request to
848
+ * encrypt this one-on-one chat (`e2ee.cancelStartRequest`); it left the
849
+ * history. Read the conversation and its messages again.
850
+ * Treat any other `change` as "read the conversation again".
851
+ */
852
+ export type ChatConversationEvent = {
813
853
  conversationId: string;
814
854
  change: 'renamed';
815
855
  name: string;
816
856
  actorId: string;
817
- }
857
+ } | {
858
+ conversationId: string;
859
+ change: 'encryption_request_withdrawn';
860
+ actorId: string;
861
+ };
818
862
  export interface MessageReaction {
819
863
  type: string;
820
864
  user_id: string;
@@ -827,6 +871,20 @@ export interface MessageMedia {
827
871
  mime_type: string | null;
828
872
  file_name: string | null;
829
873
  }
874
+ /**
875
+ * What a message the relay wrote on someone's behalf is, by `kind`. Only such
876
+ * messages carry it.
877
+ * - `e2ee_start_request` (`v` 1): the sender asked to turn on end-to-end
878
+ * encryption for this one-on-one chat (`e2ee.requestStart`). Show it as
879
+ * that request, not as its `content`, which is a fallback for apps that do
880
+ * not know the kind. It is live while it is in the history and the chat is
881
+ * not encrypted; a withdrawn one leaves the history. The person who did not
882
+ * ask answers it by starting encryption as usual, from their own device.
883
+ */
884
+ export interface MessageMetadata {
885
+ kind: 'e2ee_start_request';
886
+ v: number;
887
+ }
830
888
  export interface Message {
831
889
  id: string;
832
890
  content: string;
@@ -835,6 +893,8 @@ export interface Message {
835
893
  media: MessageMedia[];
836
894
  reactions: MessageReaction[];
837
895
  created_at: string;
896
+ /** Absent on a person's own message, and from older APIs: see MessageMetadata. */
897
+ metadata?: MessageMetadata;
838
898
  }
839
899
  export interface SendMessageInput {
840
900
  conversation_id: string;
@@ -854,6 +914,8 @@ export interface ChatMessageEvent {
854
914
  isAi?: boolean;
855
915
  };
856
916
  createdAt: string;
917
+ /** The message's metadata as stored; `kind: 'e2ee_start_request'` is an encryption request (MessageMetadata). */
918
+ metadata?: Record<string, unknown>;
857
919
  }
858
920
  export interface ChatMessageEditEvent {
859
921
  id: string;
package/llm.md CHANGED
@@ -522,6 +522,9 @@ await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope }); //
522
522
  await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope: reaction, silent: true }); // no push, no move up the chat list; every member's doorbell still rings
523
523
  const { data: page } = await r.e2ee.events(conversationId, { device_id: device.id, after_seq: 0 });
524
524
  await r.e2ee.acknowledge(conversationId, { device_id: device.id, seq: page.headSeq });
525
+ // The other person has no device yet: ask them (0.7.70). The relay writes it into the chat as a message with metadata { kind: 'e2ee_start_request', v: 1 }; their start answers it.
526
+ await r.e2ee.requestStart(conversationId); // → { message_id, requested_at, created }; cancelStartRequest(conversationId) withdraws it
527
+ // The live requests, however far back: (await r.chat.conversation(conversationId)).data.encryption_requests → [{ sender_id, message_id, requested_at }]
525
528
  // 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.
526
529
  // The policy is required: policyForConversationType(conversation.type) from @recursiv/e2ee ('direct' or 'group').
527
530
  r.realtime.onE2eeEvent(({ conversationId, event }) => (event ? controller.applyPushed(conversationId, event, policy) : controller.sync(conversationId, undefined, policy)));
@@ -623,7 +626,7 @@ Methods: `list`, `get`, `create`, `update`, `delete`, `search`, `replies(id, { l
623
626
 
624
627
  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'`.
625
628
 
626
- `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.
629
+ `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; `media: 'video'` (0.7.69) keeps only posts with a video that plays (relevance only), in the page shape Watch reads, so a player can play a query. 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.
627
630
 
628
631
  ### r.bookmarks — Synced saves (per member, per app)
629
632
 
@@ -664,7 +667,7 @@ await r.bookmarks.collections.remove(list.id)
664
667
  const head = r.curator.forYouWorld({ part: 'stories', session }) // member's own app key; read the two halves at once
665
668
  const page = await r.curator.forYouWorld({ part: 'items', session, snapshot }) // items + blocks; snapshot = a previous meta.snapshot
666
669
  // stories: { ref, id, source: today|radar|viral, target, label, kind, breaking, general, theme, counts, reasons, ... }
667
- // items: { ref, kind, target, position, block: 'top' | 'below', media, topic, reasons, explain }
670
+ // items: { ref, kind, target, position, block: 'top' | 'below', media, topic, reasons, explain, card } // card: an open-network post's (no URL), null for Minds
668
671
  // blocks: [{ id, type: stories|lead_video|clips|posts|people|groups|topics|viral|categories, refs, deferred?, entities?, categories?, viral? }]
669
672
  ```
670
673
 
@@ -694,6 +697,18 @@ for (const section of answer.order) render(section, answer.sections[section])
694
697
 
695
698
  `trending` joins when the answer is thin or old, so a search never dead-ends. `top_posts` is floored as `r.posts.search`'s relevance page is; `people` rows carry `followers_count`, `posts_count`, `empty` and `verified` (see `r.profiles.search`). Page further with `r.posts.search`. Needs `posts:read`; `people` needs `users:read`, `groups` `communities:read`; `open_network` and `stories` need `users:read` plus the caller's own app key on its network, `trending` that app key. What the key cannot have is in `omitted` (`scope` / `unavailable`).
696
699
 
700
+ ### r.curator — World Today and its story pages
701
+
702
+ ```typescript
703
+ const { data: edition } = await r.curator.today() // keyless works: new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false })
704
+ const story = edition?.stories.find(s => s.discussion) // story_key + discussion only when the server serves them (0.7.69)
705
+ const { data: page } = await r.curator.storyPosts({ storyKey: story!.story_key!, network: 'bluesky', sort: 'latest' })
706
+ const more = await r.curator.storyPosts({ storyKey: page.story_key, network: 'bluesky', sort: 'latest', cursor: page.cursor! })
707
+ for await (const p of r.curator.storyPostPages({ storyKey: page.story_key }, { reader: 'member' })) render(p.items)
708
+ ```
709
+
710
+ `discussion` = `{ total, people, by_network: { minds, bluesky, activitypub, nostr }, pool, capped, built_at, previews }` (≤2 network previews); absent means show the story as before. `storyPosts` is the visitor view (keyless, cached a minute); `memberStoryPosts` is the member's own view (their blocks, mutes and hidden posts removed; `users:read` + `posts:read`). Items narrow on `kind` (`'minds'` → `Post`, `'social'` → `SocialPost`); `text_only` = words only. A removed post counts in `withheld` and is never replaced, so items + `withheld` over every page = the page's `matched` (`discussion.total` for `network: 'all'`, `discussion.by_network[network]` for one network; `discussion` always counts the whole pool). `status: 'expired'` = the cursor's build is gone: restart from page one. 404 while the server does not serve story volume; a non-UUID key throws `ValidationError` before any request.
711
+
697
712
  ### r.communities
698
713
 
699
714
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.68",
3
+ "version": "0.7.70",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",