@recursiv/sdk 0.7.45 → 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,6 +1,6 @@
1
1
  import type { DiscoveryLayout } from '../types.js';
2
- /** Layout presentations plus the recommendation module kinds, so viewers who keep hiding a kind stop seeing it. */
3
- export type DiscoveryPresentation = DiscoveryLayout['presentation'] | 'groups' | 'people' | 'tags';
2
+ /** Layout presentations plus the recommendation and radar module kinds, so viewers who keep hiding a kind stop seeing it. */
3
+ export type DiscoveryPresentation = DiscoveryLayout['presentation'] | 'groups' | 'people' | 'tags' | 'breaking' | 'story';
4
4
  type Counts = {
5
5
  views: number;
6
6
  engagements: number;
@@ -1,8 +1,9 @@
1
- const names = ['posts', 'visual', 'conversation', 'video', 'thread', 'longread', 'groups', 'people', 'tags'];
1
+ const names = ['posts', 'visual', 'conversation', 'video', 'thread', 'longread', 'groups', 'people', 'tags', 'breaking', 'story'];
2
2
  const fresh = (now) => ({ version: 1, startedAt: now, layouts: {
3
3
  video: { views: 0, engagements: 0, hides: 0 }, posts: { views: 0, engagements: 0, hides: 0 }, visual: { views: 0, engagements: 0, hides: 0 }, conversation: { views: 0, engagements: 0, hides: 0 },
4
4
  thread: { views: 0, engagements: 0, hides: 0 }, longread: { views: 0, engagements: 0, hides: 0 },
5
5
  groups: { views: 0, engagements: 0, hides: 0 }, people: { views: 0, engagements: 0, hides: 0 }, tags: { views: 0, engagements: 0, hides: 0 },
6
+ breaking: { views: 0, engagements: 0, hides: 0 }, story: { views: 0, engagements: 0, hides: 0 },
6
7
  } });
7
8
  /** Local, per-viewer experimental presentation preferences. Stores counts only,
8
9
  * never source text, author IDs or inferred traits. It cannot promote content,
@@ -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 } 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;
@@ -38,6 +38,66 @@ export interface DiscoveryRecommendationSources {
38
38
  people?: () => Promise<DiscoveryPersonCandidate[]>;
39
39
  tags?: () => Promise<DiscoveryTagCandidate[]>;
40
40
  }
41
+ /** An outlet or Hacker News member of a radar cluster: rendered as a link chip, never as a post card. */
42
+ export interface DiscoveryClusterSource {
43
+ kind: 'outlet' | 'hackernews';
44
+ title: string;
45
+ url: string;
46
+ outlet?: string;
47
+ lane?: 'mainstream' | 'independent';
48
+ publishedAt: string;
49
+ }
50
+ /** One story from the app's topic radar, validated for rendering. Every value is a reference to server data; the label is a verbatim headline or topic name. */
51
+ export interface DiscoveryCluster {
52
+ id: string;
53
+ label: string;
54
+ kind: 'breaking' | 'viral' | 'story';
55
+ theme?: string;
56
+ outletCount: number;
57
+ laneMix: {
58
+ mainstream: number;
59
+ independent: number;
60
+ };
61
+ sources: Record<string, number>;
62
+ velocity: {
63
+ normalized: number;
64
+ };
65
+ newestAt: string;
66
+ lead?: {
67
+ type: 'image' | 'video';
68
+ url: string;
69
+ poster?: string;
70
+ width?: number;
71
+ height?: number;
72
+ memberKey: string;
73
+ };
74
+ links: DiscoveryClusterSource[];
75
+ /** Keys of the minds/social DiscoveryPostItems that belong to the story, in this session's key format. */
76
+ memberKeys: string[];
77
+ }
78
+ /** Reader for the app's radar snapshot; bounded to three seconds and optional, so an older server simply yields no radar module. */
79
+ export type DiscoveryRadarSource = (params: {
80
+ limit?: number;
81
+ category?: string;
82
+ }) => Promise<{
83
+ data: SocialRadar;
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
+ }
100
+ export type DiscoveryRadarPresentation = 'breaking' | 'story';
41
101
  export type DiscoveryPostItem = {
42
102
  key: string;
43
103
  kind: 'minds';
@@ -59,15 +119,21 @@ export type DiscoveryItem = DiscoveryPostItem | {
59
119
  key: string;
60
120
  kind: 'tag';
61
121
  tag: DiscoveryTagCandidate;
122
+ } | {
123
+ key: string;
124
+ kind: 'cluster';
125
+ cluster: DiscoveryCluster;
62
126
  };
63
127
  export declare const isDiscoveryPost: (item: DiscoveryItem) => item is DiscoveryPostItem;
64
128
  /** At most this many candidates per recommendation module. */
65
129
  export declare const RECOMMENDATION_MAX_ITEMS = 6;
130
+ /** Clusters read from the radar per session; the server never holds more. */
131
+ export declare const RADAR_MAX_CLUSTERS = 40;
66
132
  /** json-render Spec: values are references to authenticated SDK results, never
67
133
  * arbitrary executable components, links, generated text or provider markup. */
68
134
  export interface DiscoveryModule {
69
135
  id: string;
70
- presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind;
136
+ presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind | DiscoveryRadarPresentation;
71
137
  source: DiscoveryLayout['source'];
72
138
  /** Subject of the featured window (a browse-category id or `mixed`), for specific module copy. */
73
139
  theme?: string;
@@ -76,7 +142,7 @@ export interface DiscoveryModule {
76
142
  root: 'module';
77
143
  elements: {
78
144
  module: {
79
- type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo' | 'DiscoveryThread' | 'DiscoveryLongRead' | 'DiscoveryGroups' | 'DiscoveryPeople' | 'DiscoveryTags';
145
+ type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo' | 'DiscoveryThread' | 'DiscoveryLongRead' | 'DiscoveryGroups' | 'DiscoveryPeople' | 'DiscoveryTags' | 'DiscoveryBreaking' | 'DiscoveryStory';
80
146
  props: {
81
147
  itemKeys: string[];
82
148
  };
@@ -102,16 +168,34 @@ export declare class DiscoveryFeedSession {
102
168
  private category?;
103
169
  private learning?;
104
170
  private recommend?;
171
+ private members?;
105
172
  private streams;
106
173
  private pending;
107
174
  private seen;
108
175
  private page;
109
176
  private nextSource;
110
- constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined);
177
+ /** The radar snapshot, read once on the first page; null until then. */
178
+ private clusters;
179
+ private radarUsed;
180
+ private radar?;
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);
111
186
  retryFailed(): void;
112
187
  next(): Promise<DiscoveryFeedPage>;
113
188
  private read;
114
189
  private readPage;
190
+ /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
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;
197
+ private loadRadar;
198
+ private radarModule;
115
199
  private recommendation;
116
200
  }
117
201
  export declare class DiscoveryResource {
@@ -1,6 +1,13 @@
1
1
  export const isDiscoveryPost = (item) => item.kind === 'minds' || item.kind === 'social';
2
2
  /** At most this many candidates per recommendation module. */
3
3
  export const RECOMMENDATION_MAX_ITEMS = 6;
4
+ /** Clusters read from the radar per session; the server never holds more. */
5
+ export const RADAR_MAX_CLUSTERS = 40;
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 };
4
11
  const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
5
12
  const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
6
13
  function recommendationItem(kind, candidate) {
@@ -21,6 +28,75 @@ function recommendationItem(kind, candidate) {
21
28
  return null;
22
29
  return { key: `rec:tag:${(nonEmpty(value.slug) ? value.slug : value.name).toLowerCase()}`, kind: 'tag', tag: value };
23
30
  }
31
+ const RADAR_KINDS = new Set(['breaking', 'viral', 'story']);
32
+ const RADAR_SOCIAL_KINDS = new Set(['bluesky', 'activitypub', 'nostr']);
33
+ const httpUrl = (value) => typeof value === 'string' && value.length <= 4096 && /^https?:\/\//i.test(value);
34
+ const countOf = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
35
+ const dimension = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
36
+ /** Validate one radar cluster: bad or unexpected shapes are dropped, never rendered. Member keys are translated to this session's key format. */
37
+ function radarCluster(value) {
38
+ if (!value || typeof value !== 'object')
39
+ return null;
40
+ const c = value;
41
+ if (typeof c.id !== 'string' || !/^[0-9a-f]{64}$/.test(c.id) || !nonEmpty(c.label) || typeof c.kind !== 'string' || !RADAR_KINDS.has(c.kind))
42
+ return null;
43
+ const newestAt = (c.freshness ?? {}).newestAt;
44
+ if (typeof newestAt !== 'string' || !Number.isFinite(Date.parse(newestAt)))
45
+ return null;
46
+ const laneMix = (c.laneMix ?? {});
47
+ const mainstream = countOf(laneMix.mainstream);
48
+ const independent = countOf(laneMix.independent);
49
+ if (mainstream === null || independent === null)
50
+ return null;
51
+ const normalized = c.velocity?.normalized;
52
+ const sources = {};
53
+ if (c.sources && typeof c.sources === 'object')
54
+ for (const [name, count] of Object.entries(c.sources)) {
55
+ const n = countOf(count);
56
+ if (n !== null && /^[a-z]{1,32}$/.test(name))
57
+ sources[name] = n;
58
+ }
59
+ const links = [];
60
+ const memberKeys = [];
61
+ const translated = new Map();
62
+ for (const raw of Array.isArray(c.members) ? c.members.slice(0, RADAR_MAX_MEMBERS) : []) {
63
+ if (!raw || typeof raw !== 'object')
64
+ continue;
65
+ const member = raw;
66
+ if (typeof member.key !== 'string' || !member.key || member.key.length > 4096)
67
+ continue;
68
+ if (member.kind === 'outlet' || member.kind === 'hackernews') {
69
+ if (!nonEmpty(member.title) || !httpUrl(member.url) || typeof member.publishedAt !== 'string')
70
+ continue;
71
+ const outlet = (member.outlet ?? {});
72
+ links.push({ kind: member.kind, title: member.title, url: member.url, ...(nonEmpty(outlet.name) ? { outlet: outlet.name } : {}),
73
+ ...(outlet.lane === 'mainstream' || outlet.lane === 'independent' ? { lane: outlet.lane } : {}), publishedAt: member.publishedAt });
74
+ continue;
75
+ }
76
+ let itemKey = null;
77
+ if (member.kind === 'minds')
78
+ itemKey = key('minds', member.key);
79
+ else if (typeof member.kind === 'string' && RADAR_SOCIAL_KINDS.has(member.kind)) {
80
+ try {
81
+ const parsed = JSON.parse(member.key);
82
+ if (Array.isArray(parsed) && parsed.length === 2 && parsed[0] === member.kind && nonEmpty(parsed[1]))
83
+ itemKey = key(parsed[0], parsed[1]);
84
+ }
85
+ catch { /* not a post reference */ }
86
+ }
87
+ if (itemKey && !memberKeys.includes(itemKey)) {
88
+ memberKeys.push(itemKey);
89
+ translated.set(member.key, itemKey);
90
+ }
91
+ }
92
+ const media = (c.media?.lead ?? null);
93
+ const lead = media && (media.type === 'image' || media.type === 'video') && httpUrl(media.url) && typeof media.memberKey === 'string' && media.memberKey
94
+ ? { type: media.type, url: media.url, ...(httpUrl(media.poster) ? { poster: media.poster } : {}), ...(dimension(media.width) && dimension(media.height) ? { width: dimension(media.width), height: dimension(media.height) } : {}),
95
+ memberKey: translated.get(media.memberKey) ?? media.memberKey } : undefined;
96
+ const theme = typeof c.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(c.theme) ? c.theme : undefined;
97
+ return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount: mainstream + independent, laneMix: { mainstream, independent }, sources,
98
+ velocity: { normalized: typeof normalized === 'number' && Number.isFinite(normalized) && normalized >= 0 ? normalized : 0 }, newestAt, ...(lead ? { lead } : {}), links, memberKeys };
99
+ }
24
100
  const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
25
101
  const key = (protocol, id) => JSON.stringify([protocol, id]);
26
102
  const presentations = new Set(['posts', 'visual', 'conversation', 'video', 'thread', 'longread']);
@@ -47,17 +123,30 @@ export class DiscoveryFeedSession {
47
123
  category;
48
124
  learning;
49
125
  recommend;
126
+ members;
50
127
  streams;
51
128
  pending = null;
52
129
  seen = new Set();
53
130
  page = 0;
54
131
  nextSource = 0;
55
- constructor(posts, protocols, networks, category, source, learning, recommend) {
132
+ /** The radar snapshot, read once on the first page; null until then. */
133
+ clusters = null;
134
+ radarUsed = new Set();
135
+ 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) {
56
141
  this.posts = posts;
57
142
  this.protocols = protocols;
58
143
  this.category = category;
59
144
  this.learning = learning;
60
145
  this.recommend = recommend;
146
+ this.members = members;
147
+ // The radar clusters across every source, so a feed narrowed to one network carries none.
148
+ if (!source || source === 'all')
149
+ this.radar = radar;
61
150
  const sources = [...new Set(networks.filter(n => n.capabilities.includes('public_trends')).map(n => n.protocol))].slice(0, 16);
62
151
  this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
63
152
  .map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
@@ -95,7 +184,7 @@ export class DiscoveryFeedSession {
95
184
  stream.done = true;
96
185
  return { items: [], layout: ordinary, partial: false };
97
186
  }
98
- 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);
99
188
  validatePosts(data);
100
189
  if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
101
190
  throw new Error('Invalid discovery topic');
@@ -106,7 +195,7 @@ export class DiscoveryFeedSession {
106
195
  stream.done = !stream.topics.length;
107
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' };
108
197
  }
109
- 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);
110
199
  if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
111
200
  throw new Error('Invalid discovery source response');
112
201
  if (data.kind === 'topics') {
@@ -125,6 +214,13 @@ export class DiscoveryFeedSession {
125
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' };
126
215
  }
127
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
+ }
128
224
  const active = this.streams.filter(stream => !stream.done && !stream.failed);
129
225
  const native = active.find(stream => stream.protocol === 'minds');
130
226
  const remote = active.filter(stream => stream.protocol !== 'minds');
@@ -169,6 +265,23 @@ export class DiscoveryFeedSession {
169
265
  const modules = [];
170
266
  const page = this.page++;
171
267
  let remaining = mixed;
268
+ // The radar leads the first page with the top breaking story and returns
269
+ // every second page with the next one. Its member posts move under the
270
+ // story, on this page or on any later one, so the hot stream never repeats them.
271
+ const cluster = mixed.length ? await this.radarCluster(page) : null;
272
+ let clusterItems = [];
273
+ if (cluster) {
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));
279
+ // Under the story, posts keep the server's member order: the lead media member first, then by member score.
280
+ clusterItems = [...onPage, ...read].sort((a, b) => cluster.memberKeys.indexOf(a.key) - cluster.memberKeys.indexOf(b.key));
281
+ remaining = remaining.filter(row => !members.has(row.item.key));
282
+ for (const memberKey of cluster.memberKeys)
283
+ this.seen.add(memberKey);
284
+ }
172
285
  // One gallery strip per page: it may gather images from the whole page, so a
173
286
  // second one would only sweep up leftovers into a stub.
174
287
  let galleryMinted = false;
@@ -208,9 +321,102 @@ export class DiscoveryFeedSession {
208
321
  const recommendation = modules.length ? await this.recommendation(page) : null;
209
322
  if (recommendation)
210
323
  modules.splice(Math.min(2, modules.length), 0, recommendation);
324
+ if (cluster)
325
+ modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
211
326
  return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
212
327
  failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial) };
213
328
  }
329
+ /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
330
+ async radarCluster(page) {
331
+ if (!this.radar)
332
+ return null;
333
+ if (page === 0)
334
+ this.clusters = await (this.radarLoading ?? this.loadRadar());
335
+ if (!this.clusters?.length || (page !== 0 && page % 2 !== 0))
336
+ return null;
337
+ const cluster = this.pickCluster(this.clusters, page);
338
+ if (!cluster)
339
+ return null;
340
+ this.radarUsed.add(cluster.id);
341
+ // A viewer who keeps hiding radar stories stops seeing them, through the same bandit as layouts.
342
+ if (this.learning && this.learning.choose(cluster.kind === 'breaking' ? 'breaking' : 'story', page) === 'posts')
343
+ return null;
344
+ return cluster;
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
+ }
385
+ async loadRadar() {
386
+ let response;
387
+ let cancel = () => { };
388
+ try {
389
+ response = await Promise.race([this.radar({ limit: RADAR_MAX_CLUSTERS, ...(this.category ? { category: this.category } : {}) }), new Promise((_resolve, reject) => {
390
+ const timer = setTimeout(() => reject(new Error('Radar source timed out')), 3000);
391
+ cancel = () => clearTimeout(timer);
392
+ })]);
393
+ }
394
+ catch {
395
+ return [];
396
+ }
397
+ finally {
398
+ cancel();
399
+ }
400
+ const clusters = response?.data?.clusters;
401
+ if (!Array.isArray(clusters))
402
+ return [];
403
+ const out = [];
404
+ const ids = new Set();
405
+ for (const value of clusters.slice(0, RADAR_MAX_CLUSTERS)) {
406
+ const cluster = radarCluster(value);
407
+ if (cluster && !ids.has(cluster.id)) {
408
+ ids.add(cluster.id);
409
+ out.push(cluster);
410
+ }
411
+ }
412
+ return out;
413
+ }
414
+ radarModule(page, cluster, posts) {
415
+ const presentation = cluster.kind === 'breaking' ? 'breaking' : 'story';
416
+ const items = [{ key: `radar:${cluster.id}`, kind: 'cluster', cluster }, ...posts];
417
+ return { id: `discovery-${page}-radar-${cluster.id}`, presentation, source: 'fallback', items, ...(cluster.theme && cluster.theme !== 'mixed' ? { theme: cluster.theme } : {}),
418
+ spec: { root: 'module', elements: { module: { type: presentation === 'breaking' ? 'DiscoveryBreaking' : 'DiscoveryStory', props: { itemKeys: items.map(item => item.key) }, children: [] } } } };
419
+ }
214
420
  async recommendation(page) {
215
421
  const kinds = RECOMMENDATION_KINDS.filter(kind => typeof this.recommend?.[kind] === 'function');
216
422
  const kind = kinds[page % Math.max(1, kinds.length)];
@@ -261,6 +467,13 @@ export class DiscoveryResource {
261
467
  this.protocols = protocols;
262
468
  }
263
469
  createFeed(options) {
264
- return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend);
470
+ // The radar rides along by default; an older server answers 404 and the session simply shows no story module.
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) });
265
472
  }
266
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';
@@ -929,6 +954,113 @@ export interface SocialTrendFeed extends SocialTrendProvenance {
929
954
  items: SocialPost[];
930
955
  cursor: string | null;
931
956
  }
957
+ /** A member of a radar cluster: a headline or excerpt and a link only, never a body. */
958
+ export type SocialRadarSourceKind = 'outlet' | 'hackernews' | 'bluesky' | 'activitypub' | 'nostr' | 'minds';
959
+ export interface SocialRadarMember {
960
+ kind: SocialRadarSourceKind;
961
+ /** outlet: the article url; hackernews: the `hn:<id>` candidate id; social: JSON `[protocol, nativeId]`; minds: the post id. */
962
+ key: string;
963
+ title: string;
964
+ url?: string;
965
+ publishedAt: string;
966
+ outlet?: {
967
+ name: string;
968
+ lane: 'mainstream' | 'independent';
969
+ };
970
+ engagement?: {
971
+ likes?: number;
972
+ reposts?: number;
973
+ replies?: number;
974
+ score?: number;
975
+ perHour?: number;
976
+ samples: number;
977
+ };
978
+ media?: {
979
+ type: 'image' | 'video';
980
+ url: string;
981
+ poster?: string;
982
+ width?: number;
983
+ height?: number;
984
+ };
985
+ providerRank?: number;
986
+ }
987
+ /** One story on the radar. `breaking` is outlet corroboration inside a short window; `viral` is social velocity above its network's norm. */
988
+ export interface SocialRadarCluster {
989
+ id: string;
990
+ /** Verbatim: a World Today headline, a provider topic name or the medoid outlet title. Never generated. */
991
+ label: string;
992
+ kind: 'breaking' | 'viral' | 'story';
993
+ seed: {
994
+ kind: 'world_story' | 'trend_topic' | 'emergent';
995
+ ref: string;
996
+ };
997
+ /** A browse-category id or `mixed`, from cached judgments only. */
998
+ theme?: string;
999
+ sources: Partial<Record<SocialRadarSourceKind, number>>;
1000
+ corroboration: number;
1001
+ laneMix: {
1002
+ mainstream: number;
1003
+ independent: number;
1004
+ };
1005
+ velocity: {
1006
+ perHour: number;
1007
+ normalized: number;
1008
+ samples: number;
1009
+ };
1010
+ media: {
1011
+ images: number;
1012
+ videos: number;
1013
+ lead?: {
1014
+ type: 'image' | 'video';
1015
+ url: string;
1016
+ poster?: string;
1017
+ width?: number;
1018
+ height?: number;
1019
+ memberKey: string;
1020
+ };
1021
+ };
1022
+ freshness: {
1023
+ newestAt: string;
1024
+ oldestAt: string;
1025
+ outletsInBreakingWindow: number;
1026
+ };
1027
+ score: number;
1028
+ members: SocialRadarMember[];
1029
+ }
1030
+ /** The app's radar snapshot: rebuilt by a server job on its own interval, never on a request. `builtAt` is null before the first tick. */
1031
+ export interface SocialRadar {
1032
+ version: 'topic-radar-v1';
1033
+ builtAt: string | null;
1034
+ windowHours: number;
1035
+ breakingWindowHours: number;
1036
+ clusters: SocialRadarCluster[];
1037
+ provenance: {
1038
+ providers: Array<{
1039
+ name: string;
1040
+ observedAt: string;
1041
+ coverage: 'provider_ranked_window' | 'allowlisted_outlets' | 'minds_hot';
1042
+ }>;
1043
+ };
1044
+ /** Present when a category was selected; `partial` while some clusters have no cached theme yet. */
1045
+ radar_filter?: {
1046
+ selected: string;
1047
+ status: 'complete' | 'partial';
1048
+ };
1049
+ }
1050
+ export interface SocialRadarParams {
1051
+ category?: string;
1052
+ limit?: number;
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
+ }
932
1064
  /** Provider-ranked accounts. `source` says whether this page is the app's hourly index snapshot or a live read. */
933
1065
  export interface SocialPopularProfiles {
934
1066
  items: SocialProfile[];
@@ -1009,6 +1141,8 @@ export declare class ProtocolsResource {
1009
1141
  category?: string;
1010
1142
  discovery?: boolean;
1011
1143
  }): Promise<SingleResponse<SocialTrendFeed>>;
1144
+ /** Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary. */
1145
+ getSharedRadar(projectId: string, params?: SocialRadarParams): Promise<SingleResponse<SocialRadar>>;
1012
1146
  publicNetworks(): Promise<SingleResponse<SocialNetwork[]>>;
1013
1147
  /** Query the live public network. It does not import users or posts. */
1014
1148
  searchPublicPosts(params: SearchPublicPostsParams): Promise<SingleResponse<SocialPostSearch>>;
@@ -1048,7 +1182,7 @@ export declare class ProtocolsResource {
1048
1182
  includePreviews?: boolean;
1049
1183
  category?: string;
1050
1184
  discovery?: boolean;
1051
- }): Promise<SingleResponse<SocialTrends>>;
1185
+ }, options?: SocialReadOptions): Promise<SingleResponse<SocialTrends>>;
1052
1186
  /** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. */
1053
1187
  getPublicTrendFeed(params: {
1054
1188
  protocol: string;
@@ -1057,7 +1191,13 @@ export declare class ProtocolsResource {
1057
1191
  cursor?: string;
1058
1192
  category?: string;
1059
1193
  discovery?: boolean;
1060
- }): Promise<SingleResponse<SocialTrendFeed>>;
1194
+ }, options?: SocialReadOptions): Promise<SingleResponse<SocialTrendFeed>>;
1195
+ /** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
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>>;
1061
1201
  /** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
1062
1202
  getPublicPopularProfiles(params: {
1063
1203
  protocol: string;
@@ -1097,7 +1237,7 @@ export declare class ProtocolsResource {
1097
1237
  }): Promise<SingleResponse<NativeRepostReview>>;
1098
1238
  /** Reconcile the saved action without sending another native write. */
1099
1239
  nativeRepostStatus(id: string): Promise<SingleResponse<NativeRepostReview>>;
1100
- /** Prepare exact public text for selected connected Bluesky/ActivityPub accounts. */
1240
+ /** Prepare exact public text and reviewed images for selected connected Bluesky/ActivityPub accounts. */
1101
1241
  prepareNativePost(input: NativePostPrepareInput): Promise<SingleResponse<NativePostBatch>>;
1102
1242
  /** Explicitly approve selected deliveries; never auto-retry an uncertain write. */
1103
1243
  submitNativePost(id: string, input: {
@@ -34,6 +34,16 @@ function hostedIdentityPath(id) {
34
34
  }
35
35
  return `/protocols/identities/${encodeURIComponent(id)}`;
36
36
  }
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] : [];
40
+ function radarQuery(params) {
41
+ return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) };
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 } : {}) }));
37
47
  export class ProtocolsResource {
38
48
  client;
39
49
  subscriptionListeners = new Set();
@@ -80,6 +90,10 @@ export class ProtocolsResource {
80
90
  return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/trend-feed`, { protocol: params.protocol, topic: params.topic,
81
91
  limit: params.limit, cursor: params.cursor, category: params.category, discovery: params.discovery });
82
92
  }
93
+ /** Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary. */
94
+ getSharedRadar(projectId, params = {}) {
95
+ return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/radar`, radarQuery(params));
96
+ }
83
97
  publicNetworks() {
84
98
  return this.client.get('/protocols/social/networks');
85
99
  }
@@ -118,17 +132,25 @@ export class ProtocolsResource {
118
132
  return this.client.get('/protocols/social/conversation', { protocol: params.protocol, post: params.post, readRef: params.readRef });
119
133
  }
120
134
  /** Current provider-ranked topics or posts, with explicit source and coverage. Does not ingest content. */
121
- getPublicTrends(params) {
135
+ getPublicTrends(params, options) {
122
136
  return this.client.get('/protocols/social/trends', { protocol: params.protocol, limit: params.limit, cursor: params.cursor,
123
137
  ...(params.includePreviews !== undefined ? { includePreviews: params.includePreviews } : {}),
124
138
  ...(params.category !== undefined ? { category: params.category } : {}),
125
- ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) });
139
+ ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) }, ...readOptions(options));
126
140
  }
127
141
  /** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. */
128
- getPublicTrendFeed(params) {
142
+ getPublicTrendFeed(params, options) {
129
143
  return this.client.get('/protocols/social/trend-feed', { protocol: params.protocol, topic: params.topic, limit: params.limit, cursor: params.cursor,
130
144
  ...(params.category !== undefined ? { category: params.category } : {}),
131
- ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) });
145
+ ...(params.discovery !== undefined ? { discovery: params.discovery } : {}) }, ...readOptions(options));
146
+ }
147
+ /** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
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));
132
154
  }
133
155
  /** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
134
156
  getPublicPopularProfiles(params) {
@@ -200,11 +222,12 @@ export class ProtocolsResource {
200
222
  hostedIdentityPath(id);
201
223
  return this.client.get(`/protocols/social/reposts/${encodeURIComponent(id)}`);
202
224
  }
203
- /** Prepare exact public text for selected connected Bluesky/ActivityPub accounts. */
225
+ /** Prepare exact public text and reviewed images for selected connected Bluesky/ActivityPub accounts. */
204
226
  prepareNativePost(input) {
205
227
  return this.client.post('/protocols/social/posts/prepare', { clientMutationId: input.clientMutationId,
206
228
  text: input.text, accountIds: input.accountIds,
207
- ...(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 });
208
231
  }
209
232
  /** Explicitly approve selected deliveries; never auto-retry an uncertain write. */
210
233
  submitNativePost(id, input) {
@@ -236,7 +259,8 @@ export class ProtocolsResource {
236
259
  parent: { protocol: input.parent.protocol, nativeId: input.parent.nativeId,
237
260
  ...(input.parent.readRef !== undefined ? { readRef: input.parent.readRef } : {}) }, accountIds: input.accountIds,
238
261
  ...(input.linkedPostAccountIds !== undefined ? { linkedPostAccountIds: input.linkedPostAccountIds } : {}),
239
- ...(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 });
240
264
  }
241
265
  /** Send only explicitly approved delivery IDs from this preview. Repeated submissions reconcile; they do not replay a write. */
242
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.45",
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",