@recursiv/sdk 0.7.46 → 0.7.48

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`, `grantProjectAccess`, `conversations` |
78
- | `r.e2ee` | `registerDevice`, `devices`, `revokeDevice`, `publishKeyPackages`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge` (flag-gated E2EE v2 transport) |
78
+ | `r.e2ee` | `registerDevice`, `devices`, `revokeDevice`, `publishKeyPackages`, `keyPackageStatus`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge` (flag-gated E2EE v2 transport) |
79
79
  | `r.brain` | `sendMessage` |
80
80
  | `r.commands` | `execute`, `gatePrompt` |
81
81
  | `r.projectBrain` | `tasks`, `decisions`, `milestones`, `completeTask`, `usage` |
@@ -514,8 +514,10 @@ const { data: preview } = await r.protocols.prepareNativeReply({
514
514
  parent: { protocol: 'bluesky', nativeId: selectedPost.nativeId },
515
515
  accountIds: [blueskyAccount.id, activityPubAccount.id],
516
516
  linkedPostAccountIds: [activityPubAccount.id],
517
+ // Optional, needs each account's `publish_media` capability:
518
+ media: [{ url: uploadedImageUrl, alt: memberWrittenAltText }],
517
519
  })
518
- // Review each delivery's account, protocol, kind, parent and exact wireText.
520
+ // Review each delivery's account, protocol, kind, parent, media and exact wireText.
519
521
  // After the user approves selected delivery IDs:
520
522
  await r.protocols.submitNativeReply(preview.id, { approve: true, deliveryIds: approvedDeliveryIds })
521
523
  ```
@@ -590,7 +592,7 @@ connections or signing authority. Missing configuration or unavailable safety ch
590
592
  closed. Signed-in members continue using `getPublicConversation` to apply personal
591
593
  blocks/mutes as well. All reply and account endpoints still require authentication.
592
594
 
593
- Standalone public text publication uses `protocols.prepareNativePost({ clientMutationId, text, accountIds })`, then explicitly approved `submitNativePost`. Keep the same mutation ID after uncertain preparation; previously attempted deliveries are reconciled without resending. `nativePostStatus` and paginated `nativePostHistory` expose private app/user-owned outcomes. Review current own posts with `prepareNativePostRemoval`, then approve exact removal IDs through `removeNativePost`. Check connected-account `publish_text` capabilities; only explicitly enabled apps may publish. Text is sent unchanged, public only, with each provider’s actual limits. These methods do not accept media, a reply parent or caller-supplied authority.
595
+ Standalone public publication uses `protocols.prepareNativePost({ clientMutationId, text, accountIds, media? })`, then explicitly approved `submitNativePost`. Keep the same mutation ID after uncertain preparation; previously attempted deliveries are reconciled without resending. `nativePostStatus` and paginated `nativePostHistory` expose private app/user-owned outcomes. Review current own posts with `prepareNativePostRemoval`, then approve exact removal IDs through `removeNativePost`. Check connected-account `publish_text` capabilities; only explicitly enabled apps may publish. Text is sent unchanged, public only, with each provider’s actual limits. Optional `media` is a `NativeMediaInput[]` of at most four already-uploaded Minds images (`{ url, alt?, width?, height?, mimeType? }`, JPEG/PNG/WebP/GIF, Minds-hosted HTTPS URLs only, a Mastodon GIF must be the only attachment). It additionally requires the account's `publish_media` capability; accounts connected before image permissions existed must reconnect. Pass the member's own `alt` — an omitted one is sent as an empty description, never an invented one — and read `receipt.media` (`{ count, alt_missing }`) after sending. These methods still do not accept a reply parent or caller-supplied authority.
594
596
 
595
597
  For Bluesky accounts advertising `quote`, prepare the same publication with `quote: { protocol: 'bluesky', nativeId: sourceAtUri }`. The server resolves the exact source CID using each connected account, checks quote permission and Minds safety, and rechecks before writing. Review each delivery’s `quote` alongside the account and comment before submitting. Changed or unavailable sources cannot publish; uncertain deliveries reconcile the original record key. ActivityPub/Nostr quotes are not provided by this method. Public post reads include at most one filtered `quote` with `state: 'available'` and `post`, or `state: 'unavailable'` without source content. Available quotes may include `embeddedReferences`: exact standalone source-reference lines in the unchanged outer `text`. Omit them only when the quoted original is actually visible; retain them for unavailable or locally hidden quotes. Nostr references are decoded and matched by the server, without following relay hints.
596
598
 
package/dist/client.d.ts CHANGED
@@ -29,7 +29,10 @@ export declare class HttpClient {
29
29
  apiKeyAuthHeader(): Record<string, string>;
30
30
  private getAuthHeaders;
31
31
  private handleResponse;
32
- get<T>(path: string, params?: Record<string, unknown>): Promise<T>;
32
+ /** `retry: false` answers after one attempt: for a caller with its own deadline, a retried 5xx only adds wait. */
33
+ get<T>(path: string, params?: Record<string, unknown>, options?: {
34
+ retry?: boolean;
35
+ }): Promise<T>;
33
36
  post<T>(path: string, body?: unknown, options?: {
34
37
  retry?: boolean;
35
38
  }): Promise<T>;
package/dist/client.js CHANGED
@@ -1,4 +1,4 @@
1
- import { RecursivError, AuthenticationError, AuthorizationError, NotFoundError, ValidationError, InsufficientCreditsError, RateLimitError, ConflictError, } from './errors.js';
1
+ import { RecursivError, AuthenticationError, AuthorizationError, NotFoundError, ValidationError, InsufficientCreditsError, RateLimitError, ConflictError, errorBodyFromResponse, httpStatusMessage, } from './errors.js';
2
2
  const DEFAULT_BASE_URL = 'https://api.recursiv.io/api/v1';
3
3
  export class HttpClient {
4
4
  #apiKey;
@@ -118,8 +118,20 @@ export class HttpClient {
118
118
  code: 'no_response',
119
119
  });
120
120
  }
121
+ // From here on the server answered. Every error below carries its HTTP
122
+ // status, so callers can tell a refusal from an unreachable server; only a
123
+ // fetch that produced no response (network failure, abort) is statusless.
121
124
  if (response.ok) {
122
- return response.json();
125
+ try {
126
+ return (await response.json());
127
+ }
128
+ catch {
129
+ throw new RecursivError(response.status, {
130
+ type: 'api_error',
131
+ message: `${httpStatusMessage(response.status, response.statusText)}: response body is not valid JSON`,
132
+ code: 'invalid_response',
133
+ });
134
+ }
123
135
  }
124
136
  let body;
125
137
  try {
@@ -128,16 +140,13 @@ export class HttpClient {
128
140
  catch {
129
141
  throw new RecursivError(response.status, {
130
142
  type: 'api_error',
131
- message: `HTTP ${response.status}: ${response.statusText}`,
143
+ message: httpStatusMessage(response.status, response.statusText),
132
144
  code: 'unknown_error',
133
145
  });
134
146
  }
135
- // Handle both { error: { message, type, code } } (standard) and
136
- // { error: "string" } (legacy dispatcher routes) response formats
137
- const rawErr = body.error;
138
- const err = typeof rawErr === 'string'
139
- ? { type: 'api_error', message: rawErr, code: 'unknown_error' }
140
- : rawErr;
147
+ // Handles { error: { message, type, code } } (standard), { error: "string" }
148
+ // (legacy dispatcher routes) and any other JSON, which gets a generic body.
149
+ const err = errorBodyFromResponse(response.status, response.statusText, body);
141
150
  switch (response.status) {
142
151
  case 400:
143
152
  throw new ValidationError(err);
@@ -157,12 +166,11 @@ export class HttpClient {
157
166
  throw new RecursivError(response.status, err);
158
167
  }
159
168
  }
160
- async get(path, params) {
169
+ /** `retry: false` answers after one attempt: for a caller with its own deadline, a retried 5xx only adds wait. */
170
+ async get(path, params, options) {
161
171
  const url = this.buildUrl(path, params);
162
- const response = await this.fetchWithRetry(url, {
163
- method: 'GET',
164
- headers: this.getAuthHeaders(),
165
- });
172
+ const request = { method: 'GET', headers: this.getAuthHeaders() };
173
+ const response = await (options?.retry === false ? this.fetchWithTimeout(url, request) : this.fetchWithRetry(url, request));
166
174
  return this.handleResponse(response);
167
175
  }
168
176
  async post(path, body, options) {
package/dist/errors.d.ts CHANGED
@@ -1,4 +1,15 @@
1
1
  import type { ApiErrorBody } from './types.js';
2
+ /** `HTTP 403: Forbidden`, or `HTTP 403` when the response has no reason phrase (HTTP/2). */
3
+ export declare function httpStatusMessage(status: number, statusText?: string): string;
4
+ /**
5
+ * Turn whatever JSON an error response carried into a complete error body.
6
+ * The standard shape passes through unchanged; the legacy `{ error: "text" }`
7
+ * shape keeps its text; anything else (no `error`, a null/number/array error,
8
+ * a non-object body) becomes a generic body. A partial error object keeps the
9
+ * fields it has and gains defaults for `type`, `code` and `message`, so the
10
+ * thrown error always has the response status and never a TypeError.
11
+ */
12
+ export declare function errorBodyFromResponse(status: number, statusText: string | undefined, body: unknown): ApiErrorBody['error'];
2
13
  export declare class RecursivError extends Error {
3
14
  readonly status: number;
4
15
  readonly type: string;
package/dist/errors.js CHANGED
@@ -1,3 +1,30 @@
1
+ /** `HTTP 403: Forbidden`, or `HTTP 403` when the response has no reason phrase (HTTP/2). */
2
+ export function httpStatusMessage(status, statusText) {
3
+ return statusText ? `HTTP ${status}: ${statusText}` : `HTTP ${status}`;
4
+ }
5
+ /**
6
+ * Turn whatever JSON an error response carried into a complete error body.
7
+ * The standard shape passes through unchanged; the legacy `{ error: "text" }`
8
+ * shape keeps its text; anything else (no `error`, a null/number/array error,
9
+ * a non-object body) becomes a generic body. A partial error object keeps the
10
+ * fields it has and gains defaults for `type`, `code` and `message`, so the
11
+ * thrown error always has the response status and never a TypeError.
12
+ */
13
+ export function errorBodyFromResponse(status, statusText, body) {
14
+ const fallback = { type: 'api_error', code: 'unknown_error', message: httpStatusMessage(status, statusText) };
15
+ const raw = body && typeof body === 'object' && !Array.isArray(body) ? body.error : undefined;
16
+ if (typeof raw === 'string')
17
+ return { ...fallback, message: raw };
18
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
19
+ return fallback;
20
+ const error = raw;
21
+ return {
22
+ ...error,
23
+ type: typeof error.type === 'string' ? error.type : fallback.type,
24
+ code: typeof error.code === 'string' ? error.code : fallback.code,
25
+ message: typeof error.message === 'string' ? error.message : fallback.message,
26
+ };
27
+ }
1
28
  function composeMessage(body) {
2
29
  // Surface the hint inline so LLM callers see the resolution path without
3
30
  // having to inspect a structured field. The base message stays first so
package/dist/index.d.ts CHANGED
@@ -144,7 +144,7 @@ export { UsersResource } from './resources/users.js';
144
144
  export { CommunitiesResource } from './resources/communities.js';
145
145
  export { ChatResource } from './resources/chat.js';
146
146
  export { E2eeResource } from './resources/e2ee.js';
147
- export type { E2eeDevice, E2eeEnvelope, E2eeEvent } from './resources/e2ee.js';
147
+ export type { E2eeDevice, E2eeEnvelope, E2eeEvent, E2eeKeyPackageStatus } from './resources/e2ee.js';
148
148
  export { ProjectsResource } from './resources/projects.js';
149
149
  export { AgentsResource, SUPPORTED_MODELS } from './resources/agents.js';
150
150
  export { EvidenceResource } from './resources/evidence.js';
@@ -198,7 +198,7 @@ export type { UploadMediaInput, UploadUrlInput, UploadUrlResult } from './resour
198
198
  export { WalletResource } from './resources/wallet.js';
199
199
  export { ProtocolsResource } from './resources/protocols.js';
200
200
  export { PROTOCOL_ADAPTER_MATURITIES, PROTOCOL_CAPABILITY_OPERATIONS, PROTOCOL_CAPABILITY_STATUSES, } from './resources/protocols.js';
201
- export type { SocialProfile, SocialSubscription, SocialSubscriptionChange, SocialProfilePage, SocialNetwork, SocialPost, SocialConversation, SocialTrend, SocialTrendProvenance, SocialTrends, SocialTrendFeed, SocialPopularProfiles, SocialAuthorFeed, SocialFollowingFeed, SocialFollowingSource, SearchPublicProfilesParams, SearchPublicPostsParams, SocialPostSearch, HostedProtocolConnections, HostedProtocol, HostedProtocolIdentity, HostedNostrChallenge, HostedNostrIdentityProof, NativeNostrPrepareInput, NativeNostrPrepared, NativeNostrAccount, NativeNostrPostState, NativeNostrReceipt, NativeSocialProtocol, FederationIdentity, FederationIdentityView, NativeSocialAccount, NativeSocialCallbackInput, NativeReplyPrepareInput, NativePostPrepareInput, NativePostBatch, NativePostHistory, NativeFollowSourceInput, NativeFollowPrepareInput, NativeFollowTarget, NativeFollowReceipt, NativeFollowState, NativeFollowReview, NativeLikeSourceInput, NativeRepostSourceInput, NativeLikePrepareInput, NativeRepostPrepareInput, NativeLikeTarget, NativeRepostTarget, NativeLikeReceipt, NativeRepostReceipt, NativeLikeState, NativeRepostState, NativeLikeReview, NativeRepostReview, NativeReplyReceipt, NativeReplyBatch, NativeReplyHistory, NativeDelegatedReplyHistoryItem, NativeNostrReplyResult, NativeNostrReplyHistoryItem, NativeReplyRemovalReceipt, NativeReplyRemovalPreview, SocialSourceRef, SocialSourceControl, SocialSourceControlChange, HostedPostPreview, ConnectHostedIdentityInput, PreviewHostedPostInput, PublishHostedPostInput, ListProtocolCandidatesParams, ProtocolAdapterMaturity, ProtocolAdapterMetadata, ProtocolCandidateListResponse, ProtocolCandidatePageMeta, ProtocolCapabilityOperation, ProtocolCapabilityStatus, ProtocolCollectionStatus, ProtocolIngestionReplayResult, ProtocolIngestionStatus, ProtocolQuarantinedCandidate, ProtocolRefreshResult, ProtocolSettings, ProtocolSettingsUpdateResult, ReplayProtocolIngestionJobInput, UpdateSettingsInput as UpdateProtocolSettingsInput, } from './resources/protocols.js';
201
+ export type { SocialProfile, SocialSubscription, SocialSubscriptionChange, SocialProfilePage, SocialNetwork, SocialPost, SocialConversation, SocialTrend, SocialTrendProvenance, SocialTrends, SocialTrendFeed, SocialPopularProfiles, SocialAuthorFeed, SocialFollowingFeed, SocialFollowingSource, SearchPublicProfilesParams, SearchPublicPostsParams, SocialPostSearch, HostedProtocolConnections, HostedProtocol, HostedProtocolIdentity, HostedNostrChallenge, HostedNostrIdentityProof, NativeNostrPrepareInput, NativeNostrPrepared, NativeNostrAccount, NativeNostrPostState, NativeNostrReceipt, NativeSocialProtocol, FederationIdentity, FederationIdentityView, NativeSocialAccount, NativeSocialCallbackInput, NativeReplyPrepareInput, NativePostPrepareInput, NativeMediaInput, NativeMediaItem, NativePostBatch, NativePostHistory, NativeFollowSourceInput, NativeFollowPrepareInput, NativeFollowTarget, NativeFollowReceipt, NativeFollowState, NativeFollowReview, NativeLikeSourceInput, NativeRepostSourceInput, NativeLikePrepareInput, NativeRepostPrepareInput, NativeLikeTarget, NativeRepostTarget, NativeLikeReceipt, NativeRepostReceipt, NativeLikeState, NativeRepostState, NativeLikeReview, NativeRepostReview, NativeReplyReceipt, NativeReplyBatch, NativeReplyHistory, NativeDelegatedReplyHistoryItem, NativeNostrReplyResult, NativeNostrReplyHistoryItem, NativeReplyRemovalReceipt, NativeReplyRemovalPreview, SocialSourceRef, SocialSourceControl, SocialSourceControlChange, HostedPostPreview, ConnectHostedIdentityInput, PreviewHostedPostInput, PublishHostedPostInput, ListProtocolCandidatesParams, ProtocolAdapterMaturity, ProtocolAdapterMetadata, ProtocolCandidateListResponse, ProtocolCandidatePageMeta, ProtocolCapabilityOperation, ProtocolCapabilityStatus, ProtocolCollectionStatus, ProtocolIngestionReplayResult, ProtocolIngestionStatus, ProtocolQuarantinedCandidate, ProtocolRefreshResult, ProtocolSettings, ProtocolSettingsUpdateResult, ReplayProtocolIngestionJobInput, UpdateSettingsInput as UpdateProtocolSettingsInput, } from './resources/protocols.js';
202
202
  export { InboxResource } from './resources/inbox.js';
203
203
  export { NetworkResource } from './resources/network.js';
204
204
  export { SimulatorResource } from './resources/simulator.js';
@@ -41,6 +41,12 @@ export interface DeleteDeploymentParams {
41
41
  export interface PrepareProtocolServersInput {
42
42
  project_id: string;
43
43
  network_id: string;
44
+ /**
45
+ * `production` reserves the stack in the operator's production environment
46
+ * with the API's HOSTED_* settings (handle domain, SMTP, invites, relay name);
47
+ * hostnames must be allow-listed. Default `staging`.
48
+ */
49
+ target?: 'staging' | 'production';
44
50
  hosts: {
45
51
  pds: string;
46
52
  activitypub: string;
@@ -52,7 +58,9 @@ export interface PrepareProtocolServersInput {
52
58
  activitypub: string;
53
59
  nostr: string;
54
60
  };
61
+ /** A staging PLC, or https://plc.directory for a production PDS whose operator set HOSTED_PDS_PRODUCTION_PLC=true. */
55
62
  test_plc_url: string;
63
+ /** Relay publisher whitelist; may be empty for a public production relay. */
56
64
  test_publisher_pubkeys: string[];
57
65
  }
58
66
  export interface PreparedProtocolServers {
@@ -1,7 +1,7 @@
1
1
  import type { DiscoveryLayout, Post } from '../types.js';
2
2
  import type { DiscoveryLearning } from './discovery-learning.js';
3
3
  import type { PostsResource } from './posts.js';
4
- import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar } from './protocols.js';
4
+ import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar, SocialRadarPosts } from './protocols.js';
5
5
  export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
6
6
  export interface DiscoveryGroupCandidate {
7
7
  id: string;
@@ -82,6 +82,21 @@ export type DiscoveryRadarSource = (params: {
82
82
  }) => Promise<{
83
83
  data: SocialRadar;
84
84
  }>;
85
+ /**
86
+ * Readers for a story's own member posts. A member found by the radar's search lane is almost never
87
+ * in the page's hot or trend window, so without these a story counts posts it cannot show.
88
+ * Optional, like the radar: an older server answers 404 and the story shows the posts its page carried.
89
+ */
90
+ export interface DiscoveryRadarMemberSources {
91
+ social?: (params: {
92
+ cluster: string;
93
+ }) => Promise<{
94
+ data: SocialRadarPosts;
95
+ }>;
96
+ minds?: (id: string) => Promise<{
97
+ data: Post;
98
+ }>;
99
+ }
85
100
  export type DiscoveryRadarPresentation = 'breaking' | 'story';
86
101
  export type DiscoveryPostItem = {
87
102
  key: string;
@@ -153,6 +168,7 @@ export declare class DiscoveryFeedSession {
153
168
  private category?;
154
169
  private learning?;
155
170
  private recommend?;
171
+ private members?;
156
172
  private streams;
157
173
  private pending;
158
174
  private seen;
@@ -162,13 +178,22 @@ export declare class DiscoveryFeedSession {
162
178
  private clusters;
163
179
  private radarUsed;
164
180
  private radar?;
165
- constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined, radar?: DiscoveryRadarSource);
181
+ /** Page 0's radar read, started before its stream reads rather than after the slowest of them. */
182
+ private radarLoading;
183
+ /** Member reads per story, started as soon as the story is known and shared by whichever page shows it. */
184
+ private memberReads;
185
+ constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined, radar?: DiscoveryRadarSource, members?: DiscoveryRadarMemberSources | undefined);
166
186
  retryFailed(): void;
167
187
  next(): Promise<DiscoveryFeedPage>;
168
188
  private read;
169
189
  private readPage;
170
190
  /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
171
191
  private radarCluster;
192
+ /** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster. */
193
+ private pickCluster;
194
+ /** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
195
+ private memberPosts;
196
+ private readMembers;
172
197
  private loadRadar;
173
198
  private radarModule;
174
199
  private recommendation;
@@ -4,6 +4,10 @@ export const RECOMMENDATION_MAX_ITEMS = 6;
4
4
  /** Clusters read from the radar per session; the server never holds more. */
5
5
  export const RADAR_MAX_CLUSTERS = 40;
6
6
  const RADAR_MAX_MEMBERS = 12;
7
+ /** Minds members read one by one, so a story reads at most this many. */
8
+ const RADAR_MAX_MINDS_READS = 4;
9
+ /** A discovery page holds its own deadline: a retried 5xx from one provider only makes the whole page wait. */
10
+ const NO_RETRY = { retry: false };
7
11
  const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
8
12
  const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
9
13
  function recommendationItem(kind, candidate) {
@@ -119,6 +123,7 @@ export class DiscoveryFeedSession {
119
123
  category;
120
124
  learning;
121
125
  recommend;
126
+ members;
122
127
  streams;
123
128
  pending = null;
124
129
  seen = new Set();
@@ -128,12 +133,17 @@ export class DiscoveryFeedSession {
128
133
  clusters = null;
129
134
  radarUsed = new Set();
130
135
  radar;
131
- constructor(posts, protocols, networks, category, source, learning, recommend, radar) {
136
+ /** Page 0's radar read, started before its stream reads rather than after the slowest of them. */
137
+ radarLoading = null;
138
+ /** Member reads per story, started as soon as the story is known and shared by whichever page shows it. */
139
+ memberReads = new Map();
140
+ constructor(posts, protocols, networks, category, source, learning, recommend, radar, members) {
132
141
  this.posts = posts;
133
142
  this.protocols = protocols;
134
143
  this.category = category;
135
144
  this.learning = learning;
136
145
  this.recommend = recommend;
146
+ this.members = members;
137
147
  // The radar clusters across every source, so a feed narrowed to one network carries none.
138
148
  if (!source || source === 'all')
139
149
  this.radar = radar;
@@ -174,7 +184,7 @@ export class DiscoveryFeedSession {
174
184
  stream.done = true;
175
185
  return { items: [], layout: ordinary, partial: false };
176
186
  }
177
- const { data } = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor });
187
+ const { data } = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY);
178
188
  validatePosts(data);
179
189
  if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
180
190
  throw new Error('Invalid discovery topic');
@@ -185,7 +195,7 @@ export class DiscoveryFeedSession {
185
195
  stream.done = !stream.topics.length;
186
196
  return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
187
197
  }
188
- const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true });
198
+ const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY);
189
199
  if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
190
200
  throw new Error('Invalid discovery source response');
191
201
  if (data.kind === 'topics') {
@@ -204,6 +214,13 @@ export class DiscoveryFeedSession {
204
214
  return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
205
215
  }
206
216
  async readPage() {
217
+ // The radar is one small snapshot read: start it with the page, not after the slowest provider,
218
+ // and start the lead story's member reads the moment the snapshot names it.
219
+ if (this.page === 0 && this.radar && !this.radarLoading) {
220
+ this.radarLoading = this.loadRadar();
221
+ void this.radarLoading.then(clusters => { const lead = this.pickCluster(clusters, 0); if (lead)
222
+ void this.memberPosts(lead); });
223
+ }
207
224
  const active = this.streams.filter(stream => !stream.done && !stream.failed);
208
225
  const native = active.find(stream => stream.protocol === 'minds');
209
226
  const remote = active.filter(stream => stream.protocol !== 'minds');
@@ -255,8 +272,12 @@ export class DiscoveryFeedSession {
255
272
  let clusterItems = [];
256
273
  if (cluster) {
257
274
  const members = new Set(cluster.memberKeys);
275
+ const onPage = remaining.filter(row => members.has(row.item.key)).map(row => row.item);
276
+ const carried = new Set(onPage.map(item => item.key));
277
+ // Members this page did not carry come from the story's own reads; one an earlier page showed stays shown there.
278
+ const read = (await this.memberPosts(cluster)).filter(item => !carried.has(item.key) && !this.seen.has(item.key));
258
279
  // Under the story, posts keep the server's member order: the lead media member first, then by member score.
259
- clusterItems = remaining.filter(row => members.has(row.item.key)).map(row => row.item).sort((a, b) => cluster.memberKeys.indexOf(a.key) - cluster.memberKeys.indexOf(b.key));
280
+ clusterItems = [...onPage, ...read].sort((a, b) => cluster.memberKeys.indexOf(a.key) - cluster.memberKeys.indexOf(b.key));
260
281
  remaining = remaining.filter(row => !members.has(row.item.key));
261
282
  for (const memberKey of cluster.memberKeys)
262
283
  this.seen.add(memberKey);
@@ -310,11 +331,10 @@ export class DiscoveryFeedSession {
310
331
  if (!this.radar)
311
332
  return null;
312
333
  if (page === 0)
313
- this.clusters = await this.loadRadar();
334
+ this.clusters = await (this.radarLoading ?? this.loadRadar());
314
335
  if (!this.clusters?.length || (page !== 0 && page % 2 !== 0))
315
336
  return null;
316
- const unused = this.clusters.filter(candidate => !this.radarUsed.has(candidate.id));
317
- const cluster = page === 0 ? unused.find(candidate => candidate.kind === 'breaking') ?? unused[0] : unused.find(candidate => candidate.kind !== 'breaking');
337
+ const cluster = this.pickCluster(this.clusters, page);
318
338
  if (!cluster)
319
339
  return null;
320
340
  this.radarUsed.add(cluster.id);
@@ -323,6 +343,45 @@ export class DiscoveryFeedSession {
323
343
  return null;
324
344
  return cluster;
325
345
  }
346
+ /** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster. */
347
+ pickCluster(clusters, page) {
348
+ const unused = clusters.filter(candidate => !this.radarUsed.has(candidate.id));
349
+ return page === 0 ? unused.find(candidate => candidate.kind === 'breaking') ?? unused[0] : unused.find(candidate => candidate.kind !== 'breaking');
350
+ }
351
+ /** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
352
+ memberPosts(cluster) {
353
+ let pending = this.memberReads.get(cluster.id);
354
+ if (!pending) {
355
+ pending = this.readMembers(cluster);
356
+ this.memberReads.set(cluster.id, pending);
357
+ }
358
+ return pending;
359
+ }
360
+ async readMembers(cluster) {
361
+ const wanted = new Set(cluster.memberKeys);
362
+ const mindsIds = cluster.memberKeys.flatMap(memberKey => { try {
363
+ const [kind, id] = JSON.parse(memberKey);
364
+ return kind === 'minds' ? [id] : [];
365
+ }
366
+ catch {
367
+ return [];
368
+ } })
369
+ .slice(0, RADAR_MAX_MINDS_READS);
370
+ const social = this.members?.social && mindsIds.length < cluster.memberKeys.length
371
+ ? withDeadline(this.members.social({ cluster: cluster.id }).then(({ data }) => {
372
+ if (!data || data.cluster !== cluster.id || !Array.isArray(data.items))
373
+ return [];
374
+ return data.items.filter(post => post && typeof post.nativeId === 'string' && post.author?.protocol === post.protocol && wanted.has(key(post.protocol, post.nativeId)))
375
+ .map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post }));
376
+ }), [])
377
+ : Promise.resolve([]);
378
+ const minds = this.members?.minds
379
+ ? Promise.all(mindsIds.map(id => withDeadline(this.members.minds(id).then(({ data }) => data && data.id === id ? [{ key: key('minds', id), kind: 'minds', post: data }] : []), []))).then(rows => rows.flat())
380
+ : Promise.resolve([]);
381
+ const items = [...await social, ...await minds];
382
+ const unique = new Map(items.map(item => [item.key, item]));
383
+ return [...unique.values()];
384
+ }
326
385
  async loadRadar() {
327
386
  let response;
328
387
  let cancel = () => { };
@@ -409,6 +468,12 @@ export class DiscoveryResource {
409
468
  }
410
469
  createFeed(options) {
411
470
  // The radar rides along by default; an older server answers 404 and the session simply shows no story module.
412
- return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend, params => this.protocols.getPublicRadar(params));
471
+ return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend, params => this.protocols.getPublicRadar(params, NO_RETRY), { social: params => this.protocols.getPublicRadarPosts(params, NO_RETRY), minds: id => this.posts.get(id) });
413
472
  }
414
473
  }
474
+ /** A read bounded to three seconds; a late or failed one resolves to `fallback`, never an error. */
475
+ function withDeadline(read, fallback, ms = 3000) {
476
+ let timer;
477
+ return Promise.race([read.catch(() => fallback), new Promise(resolve => { timer = setTimeout(() => resolve(fallback), ms); })])
478
+ .finally(() => clearTimeout(timer));
479
+ }
@@ -30,14 +30,23 @@ export interface E2eeEvent {
30
30
  welcome: string | null;
31
31
  createdAt: string;
32
32
  }
33
+ /** One-time key packages a device still has to hand out. */
34
+ export interface E2eeKeyPackageStatus {
35
+ /** Unclaimed packages that have not expired. Once this reaches 0 nobody can add the device to a new conversation. */
36
+ available: number;
37
+ /** Of `available`, how many expire within 7 days. */
38
+ expiringSoon: number;
39
+ }
33
40
  /**
34
41
  * E2EE v2 ciphertext transport. The server relays envelopes and public
35
42
  * credentials only; encryption happens in the shared core on each device.
36
- * Requires the `e2eeV2` network feature flag.
43
+ * Requires the `e2eeV2` network feature flag and a key acting as the account
44
+ * owner (agent-scoped keys are refused). The realm is the network id.
37
45
  */
38
46
  export declare class E2eeResource {
39
47
  private client;
40
48
  constructor(client: HttpClient);
49
+ /** Register this device's public identity and its signed approval chain. */
41
50
  registerDevice(input: {
42
51
  device_name: string;
43
52
  realm: string;
@@ -50,12 +59,22 @@ export declare class E2eeResource {
50
59
  data: E2eeDevice[];
51
60
  }>;
52
61
  revokeDevice(deviceId: string): Promise<SingleResponse<E2eeDevice>>;
62
+ /** Publish one-time key packages for one of your devices; `available` is how many it now has. */
53
63
  publishKeyPackages(input: {
54
64
  device_id: string;
55
65
  packages: string[];
56
66
  }): Promise<SingleResponse<{
57
67
  published: number;
68
+ available: number;
58
69
  }>>;
70
+ /**
71
+ * How many one-time key packages one of your active devices still has to
72
+ * hand out. Publish more when `available` runs low or most of it is
73
+ * `expiringSoon`: at 0, nobody can add the device to a new conversation.
74
+ */
75
+ keyPackageStatus(params: {
76
+ device_id: string;
77
+ }): Promise<SingleResponse<E2eeKeyPackageStatus>>;
59
78
  claimKeyPackage(input: {
60
79
  device_id: string;
61
80
  target_device_id: string;
@@ -1,13 +1,15 @@
1
1
  /**
2
2
  * E2EE v2 ciphertext transport. The server relays envelopes and public
3
3
  * credentials only; encryption happens in the shared core on each device.
4
- * Requires the `e2eeV2` network feature flag.
4
+ * Requires the `e2eeV2` network feature flag and a key acting as the account
5
+ * owner (agent-scoped keys are refused). The realm is the network id.
5
6
  */
6
7
  export class E2eeResource {
7
8
  client;
8
9
  constructor(client) {
9
10
  this.client = client;
10
11
  }
12
+ /** Register this device's public identity and its signed approval chain. */
11
13
  registerDevice(input) {
12
14
  return this.client.post('/e2ee/devices', input);
13
15
  }
@@ -17,9 +19,18 @@ export class E2eeResource {
17
19
  revokeDevice(deviceId) {
18
20
  return this.client.post(`/e2ee/devices/${encodeURIComponent(deviceId)}/revoke`);
19
21
  }
22
+ /** Publish one-time key packages for one of your devices; `available` is how many it now has. */
20
23
  publishKeyPackages(input) {
21
24
  return this.client.post('/e2ee/key-packages', input);
22
25
  }
26
+ /**
27
+ * How many one-time key packages one of your active devices still has to
28
+ * hand out. Publish more when `available` runs low or most of it is
29
+ * `expiringSoon`: at 0, nobody can add the device to a new conversation.
30
+ */
31
+ keyPackageStatus(params) {
32
+ return this.client.get('/e2ee/key-packages/status', params);
33
+ }
23
34
  claimKeyPackage(input) {
24
35
  return this.client.post('/e2ee/key-packages/claim', input);
25
36
  }
@@ -211,6 +211,8 @@ export interface HostedProtocolConnections {
211
211
  servers: {
212
212
  protocol: 'atproto' | 'activitypub' | 'nostr';
213
213
  url: string;
214
+ /** Handle suffixes the PDS issues (`minds.com` -> `alice.minds.com`); absent means the PDS hostname. */
215
+ handleDomains?: string[];
214
216
  }[];
215
217
  }
216
218
  export type HostedProtocol = 'atproto' | 'activitypub' | 'nostr';
@@ -300,6 +302,18 @@ export interface SocialSourceControl {
300
302
  export type SocialSourceControlChange = Omit<SocialSourceControl, 'id' | 'createdAt'> & {
301
303
  enabled: boolean;
302
304
  };
305
+ /** One image already uploaded to Minds storage. Only Minds-hosted HTTPS URLs are accepted; JPEG, PNG, WebP or GIF; at most four per post. */
306
+ export interface NativeMediaInput {
307
+ url: string;
308
+ /** The member's alt text. When omitted an empty description is sent, never an invented one. */
309
+ alt?: string;
310
+ width?: number;
311
+ height?: number;
312
+ mimeType?: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif';
313
+ }
314
+ export interface NativeMediaItem extends NativeMediaInput {
315
+ alt: string;
316
+ }
303
317
  export interface NativeReplyPrepareInput {
304
318
  clientMutationId: string;
305
319
  text: string;
@@ -309,6 +323,8 @@ export interface NativeReplyPrepareInput {
309
323
  readRef?: string;
310
324
  };
311
325
  accountIds: string[];
326
+ /** Minds-hosted images attached to every selected destination. Omit for a text-only reply. */
327
+ media?: NativeMediaInput[];
312
328
  /** Selected different-network accounts to review as standalone posts with a source link. */
313
329
  linkedPostAccountIds?: string[];
314
330
  /** Fresh review of selected unsent or definitively rejected deliveries. The original selected reviews are atomically fenced; preserve this mutation ID on retry. */
@@ -518,6 +534,11 @@ export interface NativeReplyReceipt {
518
534
  providerId: string | null;
519
535
  acknowledged: boolean;
520
536
  readBack: 'matched' | 'absent' | 'unavailable';
537
+ /** Present when the delivery carried images: how many, and how many had no alt text. */
538
+ media?: {
539
+ count: number;
540
+ alt_missing: number;
541
+ };
521
542
  }
522
543
  export interface NativeReplyRemovalReceipt {
523
544
  /** Absence is observed on the owning server; this is not global erasure. */
@@ -581,6 +602,8 @@ export interface NativeReplyBatch {
581
602
  text: string;
582
603
  wireText: string;
583
604
  receipt: NativeReplyReceipt | null;
605
+ /** The reviewed images exactly as they will be sent. */
606
+ media?: NativeMediaItem[];
584
607
  removal: {
585
608
  id: string;
586
609
  expiresAt: string;
@@ -589,11 +612,13 @@ export interface NativeReplyBatch {
589
612
  } | null;
590
613
  }>;
591
614
  }
592
- /** Public text only; accounts are selected explicitly in the shared editor. */
615
+ /** Public text and Minds-hosted images; accounts are selected explicitly in the shared editor. */
593
616
  export interface NativePostPrepareInput {
594
617
  clientMutationId: string;
595
618
  text: string;
596
619
  accountIds: string[];
620
+ /** Minds-hosted images attached to every selected destination. Omit for a text-only post. */
621
+ media?: NativeMediaInput[];
597
622
  /** Native quote on Bluesky only. The server reads the source and permissions using each selected account. */
598
623
  quote?: {
599
624
  protocol: 'bluesky';
@@ -1026,6 +1051,16 @@ export interface SocialRadarParams {
1026
1051
  category?: string;
1027
1052
  limit?: number;
1028
1053
  }
1054
+ /** One radar story's social member posts, in the cluster's member order. Empty once the story leaves the snapshot. */
1055
+ export interface SocialRadarPosts {
1056
+ cluster: string;
1057
+ builtAt: string | null;
1058
+ items: SocialPost[];
1059
+ }
1060
+ /** Read options for callers that hold their own deadline, such as a discovery page. */
1061
+ export interface SocialReadOptions {
1062
+ retry?: boolean;
1063
+ }
1029
1064
  /** Provider-ranked accounts. `source` says whether this page is the app's hourly index snapshot or a live read. */
1030
1065
  export interface SocialPopularProfiles {
1031
1066
  items: SocialProfile[];
@@ -1147,7 +1182,7 @@ export declare class ProtocolsResource {
1147
1182
  includePreviews?: boolean;
1148
1183
  category?: string;
1149
1184
  discovery?: boolean;
1150
- }): Promise<SingleResponse<SocialTrends>>;
1185
+ }, options?: SocialReadOptions): Promise<SingleResponse<SocialTrends>>;
1151
1186
  /** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. */
1152
1187
  getPublicTrendFeed(params: {
1153
1188
  protocol: string;
@@ -1156,9 +1191,13 @@ export declare class ProtocolsResource {
1156
1191
  cursor?: string;
1157
1192
  category?: string;
1158
1193
  discovery?: boolean;
1159
- }): Promise<SingleResponse<SocialTrendFeed>>;
1194
+ }, options?: SocialReadOptions): Promise<SingleResponse<SocialTrendFeed>>;
1160
1195
  /** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
1161
- getPublicRadar(params?: SocialRadarParams): Promise<SingleResponse<SocialRadar>>;
1196
+ getPublicRadar(params?: SocialRadarParams, options?: SocialReadOptions): Promise<SingleResponse<SocialRadar>>;
1197
+ /** One radar story's social member posts, so its card can show the posts it counts. Minds members are read with `posts.get`. */
1198
+ getPublicRadarPosts(params: {
1199
+ cluster: string;
1200
+ }, options?: SocialReadOptions): Promise<SingleResponse<SocialRadarPosts>>;
1162
1201
  /** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
1163
1202
  getPublicPopularProfiles(params: {
1164
1203
  protocol: string;
@@ -1198,7 +1237,7 @@ export declare class ProtocolsResource {
1198
1237
  }): Promise<SingleResponse<NativeRepostReview>>;
1199
1238
  /** Reconcile the saved action without sending another native write. */
1200
1239
  nativeRepostStatus(id: string): Promise<SingleResponse<NativeRepostReview>>;
1201
- /** Prepare exact public text for selected connected Bluesky/ActivityPub accounts. */
1240
+ /** Prepare exact public text and reviewed images for selected connected Bluesky/ActivityPub accounts. */
1202
1241
  prepareNativePost(input: NativePostPrepareInput): Promise<SingleResponse<NativePostBatch>>;
1203
1242
  /** Explicitly approve selected deliveries; never auto-retry an uncertain write. */
1204
1243
  submitNativePost(id: string, input: {
@@ -35,9 +35,15 @@ function hostedIdentityPath(id) {
35
35
  return `/protocols/identities/${encodeURIComponent(id)}`;
36
36
  }
37
37
  /** Only the two radar fields travel; caller scope or destination extras never reach the query string. */
38
+ /** Forwarded only when given, so a default read makes exactly the call it always made. */
39
+ const readOptions = (options) => options ? [options] : [];
38
40
  function radarQuery(params) {
39
41
  return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) };
40
42
  }
43
+ /** Forward only the media contract fields; nothing else from the app's own media rows. */
44
+ const nativeMediaInput = (media) => media.map(item => ({ url: item.url,
45
+ ...(item.alt !== undefined ? { alt: item.alt } : {}), ...(item.width !== undefined ? { width: item.width } : {}),
46
+ ...(item.height !== undefined ? { height: item.height } : {}), ...(item.mimeType !== undefined ? { mimeType: item.mimeType } : {}) }));
41
47
  export class ProtocolsResource {
42
48
  client;
43
49
  subscriptionListeners = new Set();
@@ -126,21 +132,25 @@ export class ProtocolsResource {
126
132
  return this.client.get('/protocols/social/conversation', { protocol: params.protocol, post: params.post, readRef: params.readRef });
127
133
  }
128
134
  /** Current provider-ranked topics or posts, with explicit source and coverage. Does not ingest content. */
129
- getPublicTrends(params) {
135
+ getPublicTrends(params, options) {
130
136
  return this.client.get('/protocols/social/trends', { protocol: params.protocol, limit: params.limit, cursor: params.cursor,
131
137
  ...(params.includePreviews !== undefined ? { includePreviews: params.includePreviews } : {}),
132
138
  ...(params.category !== undefined ? { category: params.category } : {}),
133
- ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) });
139
+ ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) }, ...readOptions(options));
134
140
  }
135
141
  /** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. */
136
- getPublicTrendFeed(params) {
142
+ getPublicTrendFeed(params, options) {
137
143
  return this.client.get('/protocols/social/trend-feed', { protocol: params.protocol, topic: params.topic, limit: params.limit, cursor: params.cursor,
138
144
  ...(params.category !== undefined ? { category: params.category } : {}),
139
- ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) });
145
+ ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) }, ...readOptions(options));
140
146
  }
141
147
  /** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
142
- getPublicRadar(params = {}) {
143
- return this.client.get('/protocols/social/radar', radarQuery(params));
148
+ getPublicRadar(params = {}, options) {
149
+ return this.client.get('/protocols/social/radar', radarQuery(params), ...readOptions(options));
150
+ }
151
+ /** One radar story's social member posts, so its card can show the posts it counts. Minds members are read with `posts.get`. */
152
+ getPublicRadarPosts(params, options) {
153
+ return this.client.get('/protocols/social/radar/posts', { cluster: params.cluster }, ...readOptions(options));
144
154
  }
145
155
  /** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
146
156
  getPublicPopularProfiles(params) {
@@ -212,11 +222,12 @@ export class ProtocolsResource {
212
222
  hostedIdentityPath(id);
213
223
  return this.client.get(`/protocols/social/reposts/${encodeURIComponent(id)}`);
214
224
  }
215
- /** Prepare exact public text for selected connected Bluesky/ActivityPub accounts. */
225
+ /** Prepare exact public text and reviewed images for selected connected Bluesky/ActivityPub accounts. */
216
226
  prepareNativePost(input) {
217
227
  return this.client.post('/protocols/social/posts/prepare', { clientMutationId: input.clientMutationId,
218
228
  text: input.text, accountIds: input.accountIds,
219
- ...(input.quote ? { quote: { protocol: input.quote.protocol, nativeId: input.quote.nativeId } } : {}) }, { retry: false });
229
+ ...(input.quote ? { quote: { protocol: input.quote.protocol, nativeId: input.quote.nativeId } } : {}),
230
+ ...(input.media !== undefined ? { media: nativeMediaInput(input.media) } : {}) }, { retry: false });
220
231
  }
221
232
  /** Explicitly approve selected deliveries; never auto-retry an uncertain write. */
222
233
  submitNativePost(id, input) {
@@ -248,7 +259,8 @@ export class ProtocolsResource {
248
259
  parent: { protocol: input.parent.protocol, nativeId: input.parent.nativeId,
249
260
  ...(input.parent.readRef !== undefined ? { readRef: input.parent.readRef } : {}) }, accountIds: input.accountIds,
250
261
  ...(input.linkedPostAccountIds !== undefined ? { linkedPostAccountIds: input.linkedPostAccountIds } : {}),
251
- ...(input.recoverFrom !== undefined ? { recoverFrom: { batchId: input.recoverFrom.batchId, deliveryIds: input.recoverFrom.deliveryIds } } : {}) }, { retry: false });
262
+ ...(input.recoverFrom !== undefined ? { recoverFrom: { batchId: input.recoverFrom.batchId, deliveryIds: input.recoverFrom.deliveryIds } } : {}),
263
+ ...(input.media !== undefined ? { media: nativeMediaInput(input.media) } : {}) }, { retry: false });
252
264
  }
253
265
  /** Send only explicitly approved delivery IDs from this preview. Repeated submissions reconcile; they do not replay a write. */
254
266
  submitNativeReply(id, input) {
package/llm.md CHANGED
@@ -478,11 +478,12 @@ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `
478
478
 
479
479
  ### r.e2ee — End-to-end encryption transport (flag-gated)
480
480
 
481
- Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag; otherwise every call returns `feature_disabled`.
481
+ Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag (otherwise `feature_disabled`) and a key acting as the account owner (agent-scoped keys get `agent_key_not_allowed`). The realm is the network id. Only a direct conversation between two people with an accepted request can start encryption (`conversation_not_eligible` otherwise); blocks apply (`blocked`); commits must send `expected_commit_seq`.
482
482
 
483
483
  ```ts
484
- const { data: device } = await r.e2ee.registerDevice({ device_name: 'phone-1', realm: 'minds', signing_key, credential });
485
- await r.e2ee.publishKeyPackages({ device_id: device.id, packages: [keyPackageBase64] });
484
+ const { data: device } = await r.e2ee.registerDevice({ device_name: 'phone-1', realm: networkId, signing_key, credential });
485
+ await r.e2ee.publishKeyPackages({ device_id: device.id, packages: [keyPackageBase64] }); // → { published, available }
486
+ const { data: stock } = await r.e2ee.keyPackageStatus({ device_id: device.id }); // { available, expiringSoon }; refill when low
486
487
  const { data: claimed } = await r.e2ee.claimKeyPackage({ device_id: device.id, target_device_id: peerDeviceId });
487
488
  await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope }); // idempotent per event_id
488
489
  const { data: page } = await r.e2ee.events(conversationId, { device_id: device.id, after_seq: 0 });
@@ -634,7 +635,7 @@ if (subscription.active) {
634
635
  - `r.organizationSettings` / `r.organizationSecurity` — Org configuration
635
636
  - Native follows: `nativeFollowState({ accountId, protocol, nativeId })`, `prepareNativeFollow({ clientMutationId, accountId, protocol, nativeId, action: "follow" | "unfollow" })`, `submitNativeFollow(id, { approve: true, event? })`, `nativeFollowStatus(id)`. Show the acting identity and native following/requested/unfollowed state separately from the Minds feed subscription. Nostr must sign the complete review event without dropping other contacts; stale lists require a fresh review. Keep mutation IDs and reconcile ambiguous outcomes without replay. Requires current follow scopes and API `NATIVE_SOCIAL_FOLLOW_PROJECTS` plus source controls.
636
637
  - Native reposts: `nativeRepostState({ accountId, protocol, nativeId })`, `prepareNativeRepost({ clientMutationId, accountId, protocol, nativeId, action: "repost" | "unrepost" })`, `submitNativeRepost(id, { approve: true, event? })`, `nativeRepostStatus(id)`. Requires `NATIVE_SOCIAL_REPOST_PROJECTS`, account capability and explicit approval; Nostr signs the exact review (kind 6 or own kind 5). Unknown attempts only reconcile. Bluesky connections need the new repost scope; ActivityPub uses write:statuses. Native likes: `nativeLikeState({ accountId, protocol, nativeId })`, `prepareNativeLike({ clientMutationId, accountId, protocol, nativeId, action: "like" | "unlike" })`, `submitNativeLike(id, { approve: true, event? })`, and `nativeLikeStatus(id)`. Display the selected account, source and action before approval. Only Nostr supplies `event`: sign the exact public `signing.event` in the review; never transmit a private key. Preserve mutation IDs on ambiguous responses and refresh unknown results instead of resending. Read capabilities before offering actions; old reply-only connections need reconnecting for likes. Nostr results describe the connected relay, not every public relay. The app-specific `NATIVE_SOCIAL_LIKE_PROJECTS` switch and source controls must be enabled on the API.
637
- - Delegated social accounts: `r.protocols.nativeSocialAccounts()`, `startNativeSocialConnection({ protocol, identifier })`, `finishNativeSocialConnection({ protocol, state, code?, iss?, error? })`, `disconnectNativeSocialAccount(id)`. Example: `const { data } = await r.protocols.startNativeSocialConnection({ protocol: "bluesky", identifier: "alice.bsky.social" })`; open `data.authorizationUrl`, then complete with the same signed-in app user. Never log callback codes, send passwords, or auto-retry OAuth callbacks. Shared replies (off until schema/API deployment and live verification): `prepareNativeReply({ clientMutationId, text, parent: { protocol, nativeId, readRef? }, accountIds, linkedPostAccountIds?, recoverFrom?: { batchId, deliveryIds } })`, `submitNativeReply(id, { approve: true, deliveryIds })`, `nativeReplyStatus(id)`, `nativeReplyHistory({ limit?, cursor? })`, `prepareNativeReplyRemoval(id, { deliveryIds })`, `removeNativeReply(id, { approve: true, removals })`. Review account, destination, `kind`, source and exact `wireText`; connection is not publication approval. `linkedPostAccountIds` explicitly selects different-network standalone posts with a source link, never matching threaded replies or a same-network denial fallback. Preserve mutation IDs, show per-destination results and reconcile uncertain attempts without reposting. To review selected unsent or definitively rejected deliveries again, use recoverFrom with a new mutation ID, the same source/accounts and freshly reviewed text; preserve that ID on uncertain preparation. Only saved recoverable destinations qualify, current gates still apply, and replacedBy disables old sends atomically. Never restart unknown, accepted or published deliveries. The new preview still needs explicit approval. Saved delegated drafts/results: `const { data: history } = await r.protocols.nativeReplyHistory({ limit: 20 })`; use its opaque cursor for the next page and `nativeReplyStatus(item.id)` for details. This is a private read, not a write or provider refresh, and includes Nostr results marked type=nostr. Use nativeNostrReplyResult(item.id) to check that recorded event on its relay without signing or resending. Discard page state on owner/app changes. See README for examples and the callback, revocation and removal contracts.
638
+ - Delegated social accounts: `r.protocols.nativeSocialAccounts()`, `startNativeSocialConnection({ protocol, identifier })`, `finishNativeSocialConnection({ protocol, state, code?, iss?, error? })`, `disconnectNativeSocialAccount(id)`. Example: `const { data } = await r.protocols.startNativeSocialConnection({ protocol: "bluesky", identifier: "alice.bsky.social" })`; open `data.authorizationUrl`, then complete with the same signed-in app user. Never log callback codes, send passwords, or auto-retry OAuth callbacks. Shared replies (off until schema/API deployment and live verification): `prepareNativeReply({ clientMutationId, text, parent: { protocol, nativeId, readRef? }, accountIds, media?, linkedPostAccountIds?, recoverFrom?: { batchId, deliveryIds } })`, `submitNativeReply(id, { approve: true, deliveryIds })`, `nativeReplyStatus(id)`, `nativeReplyHistory({ limit?, cursor? })`, `prepareNativeReplyRemoval(id, { deliveryIds })`, `removeNativeReply(id, { approve: true, removals })`. Review account, destination, `kind`, source, exact `wireText` and every reviewed `media` item with its alt text; connection is not publication approval. `media` takes the same `NativeMediaInput[]` as `prepareNativePost` and needs the account's `publish_media` capability. `linkedPostAccountIds` explicitly selects different-network standalone posts with a source link, never matching threaded replies or a same-network denial fallback. Preserve mutation IDs, show per-destination results and reconcile uncertain attempts without reposting. To review selected unsent or definitively rejected deliveries again, use recoverFrom with a new mutation ID, the same source/accounts and freshly reviewed text; preserve that ID on uncertain preparation. Only saved recoverable destinations qualify, current gates still apply, and replacedBy disables old sends atomically. Never restart unknown, accepted or published deliveries. The new preview still needs explicit approval. Saved delegated drafts/results: `const { data: history } = await r.protocols.nativeReplyHistory({ limit: 20 })`; use its opaque cursor for the next page and `nativeReplyStatus(item.id)` for details. This is a private read, not a write or provider refresh, and includes Nostr results marked type=nostr. Use nativeNostrReplyResult(item.id) to check that recorded event on its relay without signing or resending. Discard page state on owner/app changes. See README for examples and the callback, revocation and removal contracts.
638
639
  - `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 }); 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`.
639
640
  - 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.
640
641
  - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true }); 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.
@@ -822,7 +823,7 @@ publication; history stores no signature, signing key or intent. Keep the acting
822
823
  user/app-bound result IDs and discard UI state after an account switch. The last
823
824
  read-back observation is separate from retained publication/deletion evidence.
824
825
 
825
- Standalone public text posts: `prepareNativePost({ clientMutationId, text, accountIds })` → review → `submitNativePost(id, { approve: true, deliveryIds })`. Use `nativePostStatus(id)` / `nativePostHistory({limit,cursor})` for private durable outcomes and `prepareNativePostRemoval(id,{deliveryIds})` → `removeNativePost(id,{approve:true,removals:[{deliveryId,removalId}]})` for reviewed own-post removal. Check `publish_text` capability; no media, reply parent or audience override. Preserve mutation IDs, never create a new send to recover uncertainty.
826
+ Standalone public text posts: `prepareNativePost({ clientMutationId, text, accountIds })` → review → `submitNativePost(id, { approve: true, deliveryIds })`. Use `nativePostStatus(id)` / `nativePostHistory({limit,cursor})` for private durable outcomes and `prepareNativePostRemoval(id,{deliveryIds})` → `removeNativePost(id,{approve:true,removals:[{deliveryId,removalId}]})` for reviewed own-post removal. Check `publish_text`; no reply parent or audience override. Attach already-uploaded Minds images with `media: [{ url, alt?, width?, height?, mimeType? }]` (at most four JPEG/PNG/WebP/GIF, Minds-hosted HTTPS URLs only, a Mastodon GIF must be the only attachment) on accounts that also advertise `publish_media` — grants made before image scopes existed keep working for text and must be reconnected first. Always pass the member's own `alt`; an omitted one is sent empty, never invented. The receipt reports `media: { count, alt_missing }`. Preserve mutation IDs, never create a new send to recover uncertainty.
826
827
  Bluesky quote: accounts must advertise `quote`; call `prepareNativePost({clientMutationId,text,accountIds,quote:{protocol:'bluesky',nativeId:sourceAtUri}})`, review each returned quote/account/comment, then approve via the same submit method. The server binds the source CID and rechecks permissions before sending. No cross-network quote conversion. Public reads expose one filtered `post.quote` (`available` with `post`, or `unavailable` without source details). Available quotes may include `embeddedReferences`: exact standalone source-reference lines in the unchanged outer `text`. Omit them only when the quoted original is actually visible; retain them for unavailable or locally hidden quotes. Nostr references are decoded and matched by the server, without following relay hints.
827
828
 
828
829
  Public post search (API/SDK 0.7.35): `const { data: page } = await r.protocols.searchPublicPosts({ protocol: 'nostr', query: 'open social', limit: 10 })`. Bluesky/ActivityPub require a selected `accountId` from `nativeSocialAccounts()` with `post_search`; older Bluesky grants must reconnect with consent. Open their results with `getConnectedSocialConversation({ protocol, accountId: page.readerAccountId, post: item.nativeId, readRef: item.readRef })` and pass the same accountId to `reportSource` for post reports. Nostr uses `getPublicConversation`. Pages report provider/observedAt and bounded coverage; no invented exhaustive or global search. Keep cursors paired with the exact owner, query, account, limit and connection revision. No writes, credentials or new ingestion are part of search.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.46",
3
+ "version": "0.7.48",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",