@recursiv/sdk 0.7.79 → 0.7.81

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/dist/index.d.ts CHANGED
@@ -66,6 +66,7 @@ import { BookmarksResource } from './resources/bookmarks.js';
66
66
  import { FeedsResource } from './resources/feeds.js';
67
67
  import { AccountsResource } from './resources/accounts.js';
68
68
  import { SignalsResource } from './resources/signals.js';
69
+ import { SealedResource } from './resources/sealed.js';
69
70
  import { RealtimeClient } from './realtime.js';
70
71
  import type { RecursivConfig } from './types.js';
71
72
  export declare class Recursiv {
@@ -141,6 +142,8 @@ export declare class Recursiv {
141
142
  readonly accounts: AccountsResource;
142
143
  /** The member's engagement signals (views, dwell, hides, "More like this") for their own ranking. */
143
144
  readonly signals: SignalsResource;
145
+ /** Sealed mode's config and transport: targets, models, allowance, the attestation relay and the sealed send (no crypto; flag-gated). */
146
+ readonly sealed: SealedResource;
144
147
  readonly realtime: RealtimeClient;
145
148
  constructor(config?: Partial<RecursivConfig>);
146
149
  /**
@@ -219,6 +222,8 @@ export type { BookmarkCollection, BookmarkCollectionInput, BookmarkCollectionKin
219
222
  export { AccountsResource } from './resources/accounts.js';
220
223
  export { SIGNAL_BATCH_MAX, SignalsResource } from './resources/signals.js';
221
224
  export type { SignalBatchResult, SignalEventInput, SignalEventType, SignalNotRecordedReason, SignalRecordResult, SignalSendOptions, } from './resources/signals.js';
225
+ export { SEALED_ALLOWANCE_REMAINING_HEADER, SEALED_REFUSAL_HEADER, SealedResource, SealedTransportError, } from './resources/sealed.js';
226
+ export type { SealedAllowance, SealedAttestationInput, SealedConfig, SealedModel, SealedSendInput, SealedTarget, SealedTargetKind, SealedTransportErrorCode, } from './resources/sealed.js';
222
227
  export type { SiblingAccount, SiblingAccountsResponse, SwitchAccountResult, } from './resources/accounts.js';
223
228
  export type { LinkPreview, LinkPreviewResponse, } from './resources/link-preview.js';
224
229
  export type { AgentCapabilityConsentRequirement, AgentCapabilityDataSensitivity, AgentCapabilityDomain, AgentCapabilityExportBoundary, AgentCapabilityRetentionClass, AgentCapabilitySubdomain, AgentCapabilitySubject, AgentCapabilityTrustTier, AgentToolAudience, AgentToolScope, BrowserUseRunTaskInput, ProjectBrainSettings, ProjectToolBundleClassification, ProjectToolBundleKey, ProjectToolPermission, ProjectToolPermissionOverrides, UpdateProjectSettingsInput, } from './resources/project-brain.js';
package/dist/index.js CHANGED
@@ -67,6 +67,7 @@ import { BookmarksResource } from './resources/bookmarks.js';
67
67
  import { FeedsResource } from './resources/feeds.js';
68
68
  import { AccountsResource } from './resources/accounts.js';
69
69
  import { SignalsResource } from './resources/signals.js';
70
+ import { SealedResource } from './resources/sealed.js';
70
71
  import { RealtimeClient } from './realtime.js';
71
72
  export class Recursiv {
72
73
  /** Shared transport, kept so the client name can be set after construction. */
@@ -142,6 +143,8 @@ export class Recursiv {
142
143
  accounts;
143
144
  /** The member's engagement signals (views, dwell, hides, "More like this") for their own ranking. */
144
145
  signals;
146
+ /** Sealed mode's config and transport: targets, models, allowance, the attestation relay and the sealed send (no crypto; flag-gated). */
147
+ sealed;
145
148
  realtime;
146
149
  constructor(config) {
147
150
  const client = new HttpClient(config);
@@ -210,6 +213,7 @@ export class Recursiv {
210
213
  this.bookmarks = new BookmarksResource(client);
211
214
  this.accounts = new AccountsResource(client);
212
215
  this.signals = new SignalsResource(client);
216
+ this.sealed = new SealedResource(client);
213
217
  this.realtime = new RealtimeClient(client);
214
218
  this.#http = client;
215
219
  }
@@ -271,6 +275,7 @@ export { BOOKMARK_MERGE_MAX_ITEMS, BookmarkCollectionsResource, BookmarksResourc
271
275
  export { FEED_TOP_NETWORKS, FeedsResource } from './resources/feeds.js';
272
276
  export { AccountsResource } from './resources/accounts.js';
273
277
  export { SIGNAL_BATCH_MAX, SignalsResource } from './resources/signals.js';
278
+ export { SEALED_ALLOWANCE_REMAINING_HEADER, SEALED_REFUSAL_HEADER, SealedResource, SealedTransportError, } from './resources/sealed.js';
274
279
  export { OrganizationSettingsResource } from './resources/organization-settings.js';
275
280
  export { ProjectSettingsResource } from './resources/project-settings.js';
276
281
  export { OrganizationSecurityResource } from './resources/organization-security.js';
@@ -14,7 +14,7 @@ export interface ChatMessageEvent {
14
14
  isAi?: boolean;
15
15
  };
16
16
  createdAt: string;
17
- /** The message's metadata as stored; `kind: 'e2ee_start_request'` is an encryption request (MessageMetadata). */
17
+ /** The message's metadata as stored; `kind: 'e2ee_start_request'` is an encryption request, and an agent's reply carries `ai`, its AiTurnStamp (MessageMetadata). */
18
18
  metadata?: Record<string, unknown>;
19
19
  }
20
20
  export interface ChatMessageEditEvent {
@@ -1,4 +1,4 @@
1
- import { DISCOVERY_RAIL_LANE, DISCOVERY_STORY_LANE, DiscoveryEngagementMerge, discoveryLeadable, discoveryModuleLane, discoveryShowsMedia, discoveryTopPlace, isColumnModule, isDiscoveryPostModule, poolDiscoveryGalleries, promotedDiscoveryVideoItems, } from './discovery-merge.js';
1
+ import { DISCOVERY_RAIL_LANE, DISCOVERY_STORY_LANE, DiscoveryEngagementMerge, discoveryLeadable, discoveryModuleEngaged, discoveryModuleLane, discoveryShowsMedia, discoveryTopPlace, isColumnModule, isDiscoveryPostModule, poolDiscoveryGalleries, promotedDiscoveryVideoItems, } from './discovery-merge.js';
2
2
  // ──────────────────────────────────────────────────────────────────────────
3
3
  // Discover's first screen, composed from every source that answered in time.
4
4
  // Ported from the Minds app (lib/firstScreen, 2026-10-04).
@@ -15,7 +15,14 @@ import { DISCOVERY_RAIL_LANE, DISCOVERY_STORY_LANE, DiscoveryEngagementMerge, di
15
15
  // gallery whose first card has one), from the best of the screen by
16
16
  // engagement (FIRST_SCREEN_LEAD_WINDOW). A story, a suggestion rail, a
17
17
  // video strip or a conversation never leads, and neither does a sensitive
18
- // Minds post or an open-network post that links a porn site;
18
+ // Minds post or an open-network post that links a porn site. Nor does a post
19
+ // with no picture or clip that nobody engaged with, while the screen holds a
20
+ // post with either: live (Minds, 2026-10-05, 375x812) Gaming opened on an IGN
21
+ // link with no votes and a text post nobody had engaged with, both under two
22
+ // hours old, while it held a voted clip. Fresh posts with no engagement score
23
+ // as half a typical post, so nine of them ahead of a day-old voted clip put it
24
+ // past the lead's window. Then the best post with media and engagement leads,
25
+ // else the best with media, else the best with engagement;
19
26
  // - then the posts by engagement, every source's together
20
27
  // (DiscoveryEngagementMerge), with the network mix as a soft constraint. A
21
28
  // column of several posts goes in from FIRST_SCREEN_FEATURE_SLOT on;
@@ -108,12 +115,22 @@ export function composeFirstScreen(parts, { keepLead = false, now = Date.now() }
108
115
  const stories = pooled.filter(module => module !== kept && discoveryModuleLane(module) === DISCOVERY_STORY_LANE);
109
116
  const rails = pooled.filter(module => module !== kept && discoveryModuleLane(module) === DISCOVERY_RAIL_LANE);
110
117
  // The lead: the top's first post when it is a video that can play; else the best post near the top of the screen's
111
- // engagement order that opens on a picture or a video; else the best single post; else the best post.
118
+ // engagement order that opens on a picture or a video; else the best single post; else the best post. That one is
119
+ // passed over when it has no picture or clip and nobody engaged with it, for the best post with media and engagement,
120
+ // else with media, else with engagement, wherever it is in the order.
112
121
  const best = merge.rest().filter(mayLead);
113
- const lead = kept
114
- ?? discoveryTopVideoLead(pooled)
122
+ const quiet = (module) => !moduleOpensOnPicture(module) && !discoveryModuleEngaged(module);
123
+ // A kept lead, and the top's playable video, lead as they are: the engagement lanes own the top's order.
124
+ const owned = kept ?? discoveryTopVideoLead(pooled);
125
+ const pick = owned
115
126
  ?? best.slice(0, FIRST_SCREEN_LEAD_WINDOW).find(moduleOpensOnPicture)
116
127
  ?? best.find(module => !isColumnModule(module)) ?? best[0];
128
+ const lead = !owned && pick && quiet(pick)
129
+ ? best.find(module => moduleOpensOnPicture(module) && discoveryModuleEngaged(module))
130
+ ?? best.find(moduleOpensOnPicture)
131
+ ?? best.find(module => !isColumnModule(module) && discoveryModuleEngaged(module))
132
+ ?? pick
133
+ : pick;
117
134
  const out = [];
118
135
  if (lead) {
119
136
  merge.remove(lead);
@@ -98,6 +98,8 @@ export declare function isColumnModule(module: DiscoveryModule): boolean;
98
98
  * as it is stays as it is. `keep`: a module never pooled (a screen's lead).
99
99
  */
100
100
  export declare function poolDiscoveryGalleries(modules: readonly DiscoveryModule[], now: number, keep?: DiscoveryModule): DiscoveryModule[];
101
+ /** Someone engaged with one of the module's posts: its counts are known and above zero (an unknown count is no engagement). */
102
+ export declare function discoveryModuleEngaged(module: DiscoveryModule): boolean;
101
103
  /**
102
104
  * A batch of post modules, taken best first under the network mix's soft constraints (the rules above). Each `take`
103
105
  * reads the rows above the place it fills, so it fits the rows a feed already shows.
@@ -127,6 +129,16 @@ export declare class DiscoveryEngagementMerge {
127
129
  singles?: boolean;
128
130
  newNetwork?: boolean;
129
131
  }): DiscoveryModule | undefined;
132
+ /**
133
+ * The module for the first post row of a feed, or of the feed it turned to below a line. It opens a feed, so the rows
134
+ * above it (another feed's, or none) set none of take's network-mix rules: after five rows of another feed with no
135
+ * Minds post, the home network's floor would put this feed's quiet Minds post first. The floor and the mix hold again
136
+ * from the row under it. The top of the feed's posts lead in their order, as take has them. Otherwise the best post
137
+ * the reader may be shown first leads, unless it has no picture or clip and nobody engaged with it while the batch
138
+ * holds a post that has either: then the best post with media and engagement leads, else the best with media, else
139
+ * the best with engagement. A column never leads while a single post may.
140
+ */
141
+ takeLead(): DiscoveryModule | undefined;
130
142
  }
131
143
  /** Rows that arrive woven in one by one with the rows not seen yet, below the place a reader has reached: for a feed of one source. */
132
144
  export declare function weaveDiscoveryArrival<T extends {
@@ -137,7 +149,9 @@ export declare function weaveDiscoveryArrival<T extends {
137
149
  * moves: they go after the last row seen (`reached`, by id, and one more for a row only peeking in), never above `floor`;
138
150
  * with nothing seen yet, at the end. Below that place the arriving rows and the rows not seen yet are merged: the rows
139
151
  * that are not posts (a story, a rail, a row of the client's own) take the places they would have had woven in one by
140
- * one, and the posts fill the other places by engagement under the network mix (DiscoveryEngagementMerge).
152
+ * one, and the posts fill the other places by engagement under the network mix (DiscoveryEngagementMerge). The first
153
+ * post at the top of the feed, or below the line where it turned (`floor`), leads what comes under it (takeLead): never
154
+ * a post with no picture or clip that nobody engaged with while the batch holds one with either.
141
155
  */
142
156
  export declare function mergeDiscoveryArrival<T extends {
143
157
  id: string;
@@ -359,6 +359,21 @@ export function poolDiscoveryGalleries(modules, now, keep) {
359
359
  return replaced.size ? modules.flatMap(module => replaced.get(module) ?? [module]) : [...modules];
360
360
  }
361
361
  const laneOfRow = (row) => (isDiscoveryPostModule(row) ? discoveryModuleLane(row) : null);
362
+ /** A module of posts only, each one the reader may be shown first, never a strip of clips. */
363
+ function mayLeadModule(module) {
364
+ const presentation = module.presentation;
365
+ const posts = module.items.filter(isPostItem);
366
+ return presentation !== 'video' && posts.length > 0 && posts.length === module.items.length && posts.every(discoveryLeadable);
367
+ }
368
+ /** The module opens on a picture or a clip: its first post shows one. */
369
+ function opensOnMedia(module) {
370
+ const first = module.items[0];
371
+ return !!first && isPostItem(first) && discoveryShowsMedia(first);
372
+ }
373
+ /** Someone engaged with one of the module's posts: its counts are known and above zero (an unknown count is no engagement). */
374
+ export function discoveryModuleEngaged(module) {
375
+ return module.items.some(item => isPostItem(item) && (discoveryEngagementPoints(item) ?? 0) > 0);
376
+ }
362
377
  /**
363
378
  * A batch of post modules, taken best first under the network mix's soft constraints (the rules above). Each `take`
364
379
  * reads the rows above the place it fills, so it fits the rows a feed already shows.
@@ -373,7 +388,7 @@ export class DiscoveryEngagementMerge {
373
388
  this.left = modules.map((module, index) => {
374
389
  const rank = rankDiscoveryModule(module, reference, now) ?? { tier: 1, score: 0 };
375
390
  return { module, lane: discoveryModuleLane(module), title: discoveryRowTitle(module), column: isColumnModule(module), top: discoveryTopPlace(module),
376
- tier: rank.tier, score: rank.score, order: order ? order(module, index) : index };
391
+ tier: rank.tier, score: rank.score, order: order ? order(module, index) : index, leads: mayLeadModule(module), media: opensOnMedia(module), engaged: discoveryModuleEngaged(module) };
377
392
  }).sort((a, b) => (a.top ?? Number.POSITIVE_INFINITY) - (b.top ?? Number.POSITIVE_INFINITY) || a.tier - b.tier || b.score - a.score || a.order - b.order);
378
393
  }
379
394
  get size() { return this.left.length; }
@@ -423,6 +438,30 @@ export class DiscoveryEngagementMerge {
423
438
  ?? pool[0];
424
439
  return pick(chosen);
425
440
  }
441
+ /**
442
+ * The module for the first post row of a feed, or of the feed it turned to below a line. It opens a feed, so the rows
443
+ * above it (another feed's, or none) set none of take's network-mix rules: after five rows of another feed with no
444
+ * Minds post, the home network's floor would put this feed's quiet Minds post first. The floor and the mix hold again
445
+ * from the row under it. The top of the feed's posts lead in their order, as take has them. Otherwise the best post
446
+ * the reader may be shown first leads, unless it has no picture or clip and nobody engaged with it while the batch
447
+ * holds a post that has either: then the best post with media and engagement leads, else the best with media, else
448
+ * the best with engagement. A column never leads while a single post may.
449
+ */
450
+ takeLead() {
451
+ if (this.left.some(candidate => candidate.top !== null))
452
+ return this.take([]);
453
+ const leads = this.left.filter(candidate => candidate.leads && !candidate.column);
454
+ const best = leads[0];
455
+ if (!best)
456
+ return this.take([], { singles: true });
457
+ const chosen = best.media || best.engaged ? best
458
+ : leads.find(candidate => candidate.media && candidate.engaged)
459
+ ?? leads.find(candidate => candidate.media) ?? leads.find(candidate => candidate.engaged);
460
+ if (!chosen)
461
+ return this.take([], { singles: true });
462
+ this.left.splice(this.left.indexOf(chosen), 1);
463
+ return chosen.module;
464
+ }
426
465
  }
427
466
  function weave(first, second) {
428
467
  const out = [];
@@ -451,7 +490,9 @@ export function weaveDiscoveryArrival(rows, arriving, reached, floor = 0) {
451
490
  * moves: they go after the last row seen (`reached`, by id, and one more for a row only peeking in), never above `floor`;
452
491
  * with nothing seen yet, at the end. Below that place the arriving rows and the rows not seen yet are merged: the rows
453
492
  * that are not posts (a story, a rail, a row of the client's own) take the places they would have had woven in one by
454
- * one, and the posts fill the other places by engagement under the network mix (DiscoveryEngagementMerge).
493
+ * one, and the posts fill the other places by engagement under the network mix (DiscoveryEngagementMerge). The first
494
+ * post at the top of the feed, or below the line where it turned (`floor`), leads what comes under it (takeLead): never
495
+ * a post with no picture or clip that nobody engaged with while the batch holds one with either.
455
496
  */
456
497
  export function mergeDiscoveryArrival(rows, arriving, reached, floor = 0, now = Date.now()) {
457
498
  const at = arrivalPlace(rows, reached, floor);
@@ -464,12 +505,14 @@ export function mergeDiscoveryArrival(rows, arriving, reached, floor = 0, now =
464
505
  const place = new Map(posts.map((module, index) => [module, index]));
465
506
  const merge = new DiscoveryEngagementMerge(pooled, { now, order: (module, index) => place.get(module) ?? posts.length + index });
466
507
  const out = [...head];
508
+ let leading = !head.slice(Math.min(floor, head.length)).some(isDiscoveryPostModule);
467
509
  for (const row of below) {
468
510
  if (!isDiscoveryPostModule(row)) {
469
511
  out.push(row);
470
512
  continue;
471
513
  }
472
- const next = merge.take(out);
514
+ const next = leading ? merge.takeLead() : merge.take(out);
515
+ leading = false;
473
516
  if (next)
474
517
  out.push(next);
475
518
  }
@@ -1721,8 +1721,16 @@ export interface SocialConversation {
1721
1721
  focus: SocialPost;
1722
1722
  /** Oldest available ancestor first, ending with the immediate parent. */
1723
1723
  ancestors: SocialPost[];
1724
- /** Available direct replies to focus, not all descendants. */
1724
+ /** Available direct replies to focus, not all descendants (from 0.7.81, without `thread`'s first part). */
1725
1725
  replies: SocialPost[];
1726
+ /**
1727
+ * The focus author's own thread below the post, oldest first (0.7.81): a "1/" post's 2/, 3/, 4/ ..., at each level
1728
+ * the earliest reply by the same author, at most 20 parts. Its first part is a direct reply, served here and not in
1729
+ * `replies`, so render `focus`, then `thread`, then `replies`. Each part passes the same app moderation, member
1730
+ * controls and network labels as a reply. It stops at a part this reader does not show (`limited`). Empty when the
1731
+ * author wrote none and on a read of the post alone (`replies: 'none'`); absent from APIs before 0.7.81.
1732
+ */
1733
+ thread?: SocialPost[];
1726
1734
  coverage: 'public_provider_window';
1727
1735
  limited: boolean;
1728
1736
  /**
@@ -0,0 +1,144 @@
1
+ import type { HttpClient } from '../client.js';
2
+ /**
3
+ * Sealed mode's config and transport: where the app sends a sealed request, which targets and models it may use, and
4
+ * the member's allowance. No crypto lives here, so the SDK stays dependency-free: the app fetches a target's
5
+ * attestation document through the gateway (`attestation()`), verifies it and seals the body with Recursiv's verifier
6
+ * (packages/e2ee attest), then hands this resource the sealed bytes. The gateway relays them to the target without
7
+ * opening them.
8
+ *
9
+ * Off unless the API runs with SEALED_GATEWAY_ENABLED and SEALED_GATEWAY_PROJECTS names the app the key is bound to:
10
+ * until then `config()`, `attestation()` and `send()` throw NotFoundError.
11
+ *
12
+ * React Native: the global fetch there (whatwg-fetch) cannot stream. It has no `response.body`, so a streamed answer
13
+ * cannot be read as it arrives, and it posts a ReadableStream body as the text "[object ReadableStream]". Pass a fetch
14
+ * that streams, such as expo/fetch's, as `send({ fetch })` for streamed answers or a ReadableStream body.
15
+ */
16
+ /**
17
+ * What a target is, which decides how the app attests and seals to it. `router`: Tinfoil's router, which opens the
18
+ * request inside its enclave and passes it to a model server it picks. The only kind today. A later kind (Tinfoil's
19
+ * gateway that cannot open bodies, sealing to one model server) may be added: an app must skip a target whose kind it
20
+ * does not know rather than seal to it.
21
+ */
22
+ export type SealedTargetKind = 'router';
23
+ /** A server the gateway forwards to. */
24
+ export interface SealedTarget {
25
+ /** Exact origin, e.g. `https://router-0.tinfoil.sh`. */
26
+ origin: string;
27
+ kind: SealedTargetKind;
28
+ }
29
+ /** A model the app offers in Sealed. */
30
+ export interface SealedModel {
31
+ /** e.g. `gemma4-31b`. */
32
+ id: string;
33
+ /**
34
+ * The targets that serve this model, a subset of `SealedConfig.targets`. The app attests one of these, seals a
35
+ * request for this model to the key that attestation proves, and names it in `send()`. Lists differ between models
36
+ * when one has moved to another target, so read it per model.
37
+ */
38
+ targets: SealedTarget[];
39
+ }
40
+ /** What `config()` returns. */
41
+ export interface SealedConfig {
42
+ /** The chat route, relative to the API base: `/sealed/v1/chat/completions`. */
43
+ chat_completions_path: string;
44
+ /** The attestation relay, relative to the API base: `/sealed/attestation`. */
45
+ attestation_path: string;
46
+ /** Every target the gateway forwards to, chat and attestation alike. */
47
+ targets: SealedTarget[];
48
+ /** The models the app offers in Sealed, each with the targets that serve it. */
49
+ models: SealedModel[];
50
+ /** The attestation policy version the app's verifier must pin; an older pinned policy means the app needs updating. */
51
+ verifier_policy_version: number;
52
+ allowance: SealedAllowance;
53
+ }
54
+ export interface SealedAllowance {
55
+ requests_per_day: number;
56
+ requests_per_minute: number;
57
+ max_concurrent: number;
58
+ /** The cap on one sealed request body, in bytes. */
59
+ max_request_bytes: number;
60
+ }
61
+ export interface SealedSendInput {
62
+ /** The router the app verified and sealed to: the origin of a `router` target from `config()`, exactly. */
63
+ enclaveOrigin: string;
64
+ /** The EHBP encapsulated key from sealing the body: 64 hex characters. */
65
+ encapsulatedKey: string;
66
+ /** The EHBP-framed sealed body. */
67
+ body: Uint8Array | ArrayBuffer | ReadableStream<Uint8Array>;
68
+ /** Ask for a streamed (server-sent events) answer. */
69
+ stream?: boolean;
70
+ /** The targets from a config already read. When omitted, `send()` reads the config first. */
71
+ targets?: readonly SealedTarget[];
72
+ signal?: AbortSignal;
73
+ /**
74
+ * The fetch to send with; the global fetch when omitted. On React Native pass one that streams (expo/fetch's): the
75
+ * global one there cannot read a streamed answer as it arrives or send a ReadableStream body.
76
+ */
77
+ fetch?: typeof fetch;
78
+ }
79
+ export interface SealedAttestationInput {
80
+ /** The target whose document to fetch: the origin of one of `config().targets`, exactly. */
81
+ target: string;
82
+ /** The verifier's fresh challenge: 32 bytes as 64 hex characters. The document must answer it. */
83
+ nonce: string;
84
+ /** The targets from a config already read. When omitted, `attestation()` reads the config first. */
85
+ targets?: readonly SealedTarget[];
86
+ signal?: AbortSignal;
87
+ }
88
+ /** Why `send()` or `attestation()` refused before or after the request, with nothing shown as the model's words. */
89
+ export type SealedTransportErrorCode =
90
+ /** The router or target is not one the gateway lists: nothing was sent. */
91
+ 'enclave_not_allowed'
92
+ /** The encapsulated key is not 32 bytes of hex: nothing was sent. */
93
+ | 'bad_encapsulated_key'
94
+ /** The attestation nonce is not 32 bytes of hex: nothing was sent. */
95
+ | 'bad_nonce'
96
+ /** A 2xx answer without a valid Ehbp-Response-Nonce: its body was discarded unread. */
97
+ | 'unsealed_answer';
98
+ export declare class SealedTransportError extends Error {
99
+ readonly code: SealedTransportErrorCode;
100
+ constructor(code: SealedTransportErrorCode, message: string);
101
+ }
102
+ /** Set on a refusal the gateway wrote itself; a relayed router answer never carries it. */
103
+ export declare const SEALED_REFUSAL_HEADER = "x-sealed-gateway-refusal";
104
+ /**
105
+ * Requests left in the member's allowance today. The gateway sets it on every answer it relayed from the router and on
106
+ * no other, and the router cannot set it, so an error without it is one the API (or an edge in front of it) wrote.
107
+ */
108
+ export declare const SEALED_ALLOWANCE_REMAINING_HEADER = "x-sealed-allowance-remaining";
109
+ export declare class SealedResource {
110
+ private client;
111
+ constructor(client: HttpClient);
112
+ /** The targets, models, verifier policy version and allowance the app needs before it seals anything. */
113
+ config(): Promise<SealedConfig>;
114
+ /**
115
+ * A target's attestation document, fetched through the gateway so Tinfoil sees the gateway's address rather than the
116
+ * phone's. Returns the document's bytes exactly as the target served them, for the verifier (packages/e2ee attest),
117
+ * which checks that they answer `nonce`; nothing here reads or trusts them. Asked once, never retried: to try again,
118
+ * call it again with a fresh nonce.
119
+ *
120
+ * Throws SealedTransportError before sending anything when the target is not listed or the nonce is malformed, and a
121
+ * RecursivError for every answer that is not a document: RateLimitError past the per-minute check limit,
122
+ * NotFoundError while Sealed is off or not on for this app, and a 502 when the target could not be reached or did not
123
+ * answer with a document.
124
+ */
125
+ attestation(input: SealedAttestationInput): Promise<Uint8Array>;
126
+ /** The URL `send()` posts to. */
127
+ chatCompletionsUrl(): string;
128
+ /**
129
+ * Sends one sealed chat request through the gateway, once. It never retries: a repeated request runs and is billed
130
+ * again, and Sealed never falls back to anything else.
131
+ *
132
+ * Returns the gateway's Response:
133
+ * - a sealed answer (`Ehbp-Response-Nonce` set): open it with the context that sealed the request;
134
+ * - a router error the gateway relayed (it carries X-Sealed-Allowance-Remaining): unauthenticated plaintext when it
135
+ * has no nonce, never to be shown as the model's words.
136
+ *
137
+ * Throws a RecursivError for every error the API wrote rather than relayed, whether or not it is marked
138
+ * X-Sealed-Gateway-Refusal: RateLimitError for an allowance used up (`code` `sealed_allowance_used` or
139
+ * `sealed_too_many_requests`) or the API's own per-IP limit (`rate_limited`), AuthenticationError for a revoked or
140
+ * expired key, NotFoundError while Sealed is off or not on for this app. Throws SealedTransportError when the router
141
+ * is not listed, the key is malformed, or a 2xx answer came back unsealed.
142
+ */
143
+ send(input: SealedSendInput): Promise<Response>;
144
+ }
@@ -0,0 +1,195 @@
1
+ import { AuthenticationError, AuthorizationError, NotFoundError, RateLimitError, RecursivError, ValidationError, errorBodyFromResponse, rateLimitDetailsFromResponse, } from '../errors.js';
2
+ export class SealedTransportError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = 'SealedTransportError';
7
+ this.code = code;
8
+ }
9
+ }
10
+ /** Set on a refusal the gateway wrote itself; a relayed router answer never carries it. */
11
+ export const SEALED_REFUSAL_HEADER = 'x-sealed-gateway-refusal';
12
+ /**
13
+ * Requests left in the member's allowance today. The gateway sets it on every answer it relayed from the router and on
14
+ * no other, and the router cannot set it, so an error without it is one the API (or an edge in front of it) wrote.
15
+ */
16
+ export const SEALED_ALLOWANCE_REMAINING_HEADER = 'x-sealed-allowance-remaining';
17
+ const HEX_32_BYTES = /^[0-9a-fA-F]{64}$/;
18
+ function exactOrigin(value) {
19
+ try {
20
+ const url = new URL(value);
21
+ if (url.username || url.password || url.search || url.hash || url.pathname !== '/')
22
+ return null;
23
+ return url.origin;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ export class SealedResource {
30
+ client;
31
+ constructor(client) {
32
+ this.client = client;
33
+ }
34
+ /** The targets, models, verifier policy version and allowance the app needs before it seals anything. */
35
+ async config() {
36
+ return this.client.get('/sealed/config');
37
+ }
38
+ /**
39
+ * A target's attestation document, fetched through the gateway so Tinfoil sees the gateway's address rather than the
40
+ * phone's. Returns the document's bytes exactly as the target served them, for the verifier (packages/e2ee attest),
41
+ * which checks that they answer `nonce`; nothing here reads or trusts them. Asked once, never retried: to try again,
42
+ * call it again with a fresh nonce.
43
+ *
44
+ * Throws SealedTransportError before sending anything when the target is not listed or the nonce is malformed, and a
45
+ * RecursivError for every answer that is not a document: RateLimitError past the per-minute check limit,
46
+ * NotFoundError while Sealed is off or not on for this app, and a 502 when the target could not be reached or did not
47
+ * answer with a document.
48
+ */
49
+ async attestation(input) {
50
+ // Malformed input is refused here, before the config is read, so it never turns into a network error.
51
+ const origin = exactOrigin(input.target);
52
+ if (!origin) {
53
+ throw new SealedTransportError('enclave_not_allowed', 'That server is not one the gateway forwards to. Nothing was sent.');
54
+ }
55
+ if (!HEX_32_BYTES.test(input.nonce)) {
56
+ throw new SealedTransportError('bad_nonce', 'The nonce must be 32 bytes of hex. Nothing was sent.');
57
+ }
58
+ const targets = input.targets ?? (await this.config()).targets;
59
+ if (!targets.some((target) => target.origin === origin)) {
60
+ throw new SealedTransportError('enclave_not_allowed', 'That server is not one the gateway forwards to. Nothing was sent.');
61
+ }
62
+ const controller = new AbortController();
63
+ const onCallerAbort = () => controller.abort(input.signal?.reason);
64
+ if (input.signal?.aborted)
65
+ controller.abort(input.signal.reason);
66
+ else
67
+ input.signal?.addEventListener('abort', onCallerAbort, { once: true });
68
+ const timer = setTimeout(() => controller.abort(), this.client.requestTimeoutMs);
69
+ try {
70
+ const response = await fetch(this.client.buildUrl('/sealed/attestation', { nonce: input.nonce, target: origin }), {
71
+ method: 'GET',
72
+ headers: { ...this.client.apiKeyAuthHeader(), Accept: 'application/json' },
73
+ signal: controller.signal,
74
+ });
75
+ if (!response.ok)
76
+ throw await gatewayRefusal(response);
77
+ return new Uint8Array(await response.arrayBuffer());
78
+ }
79
+ finally {
80
+ clearTimeout(timer);
81
+ input.signal?.removeEventListener('abort', onCallerAbort);
82
+ }
83
+ }
84
+ /** The URL `send()` posts to. */
85
+ chatCompletionsUrl() {
86
+ return this.client.buildUrl('/sealed/v1/chat/completions');
87
+ }
88
+ /**
89
+ * Sends one sealed chat request through the gateway, once. It never retries: a repeated request runs and is billed
90
+ * again, and Sealed never falls back to anything else.
91
+ *
92
+ * Returns the gateway's Response:
93
+ * - a sealed answer (`Ehbp-Response-Nonce` set): open it with the context that sealed the request;
94
+ * - a router error the gateway relayed (it carries X-Sealed-Allowance-Remaining): unauthenticated plaintext when it
95
+ * has no nonce, never to be shown as the model's words.
96
+ *
97
+ * Throws a RecursivError for every error the API wrote rather than relayed, whether or not it is marked
98
+ * X-Sealed-Gateway-Refusal: RateLimitError for an allowance used up (`code` `sealed_allowance_used` or
99
+ * `sealed_too_many_requests`) or the API's own per-IP limit (`rate_limited`), AuthenticationError for a revoked or
100
+ * expired key, NotFoundError while Sealed is off or not on for this app. Throws SealedTransportError when the router
101
+ * is not listed, the key is malformed, or a 2xx answer came back unsealed.
102
+ */
103
+ async send(input) {
104
+ // Malformed input is refused here, before the config is read, so it never turns into a network error.
105
+ const origin = exactOrigin(input.enclaveOrigin);
106
+ if (!origin) {
107
+ throw new SealedTransportError('enclave_not_allowed', 'That router is not one the gateway forwards to. Nothing was sent.');
108
+ }
109
+ if (!HEX_32_BYTES.test(input.encapsulatedKey)) {
110
+ throw new SealedTransportError('bad_encapsulated_key', 'The encapsulated key must be 32 bytes of hex. Nothing was sent.');
111
+ }
112
+ const targets = input.targets ?? (await this.config()).targets;
113
+ if (!targets.some((target) => target.origin === origin && target.kind === 'router')) {
114
+ throw new SealedTransportError('enclave_not_allowed', 'That router is not one the gateway forwards to. Nothing was sent.');
115
+ }
116
+ // Time out waiting for the answer to start; once it has, a long stream runs until it ends or the caller aborts.
117
+ const controller = new AbortController();
118
+ const onCallerAbort = () => controller.abort(input.signal?.reason);
119
+ if (input.signal?.aborted)
120
+ controller.abort(input.signal.reason);
121
+ else
122
+ input.signal?.addEventListener('abort', onCallerAbort, { once: true });
123
+ const timer = setTimeout(() => controller.abort(), this.client.requestTimeoutMs);
124
+ const fetchImpl = input.fetch ?? fetch;
125
+ const streamedBody = typeof ReadableStream !== 'undefined' && input.body instanceof ReadableStream;
126
+ let response;
127
+ try {
128
+ response = await fetchImpl(this.chatCompletionsUrl(), {
129
+ method: 'POST',
130
+ headers: {
131
+ ...this.client.apiKeyAuthHeader(),
132
+ 'Content-Type': 'application/json',
133
+ Accept: input.stream ? 'text/event-stream' : 'application/json',
134
+ 'Ehbp-Encapsulated-Key': input.encapsulatedKey,
135
+ 'X-Tinfoil-Enclave-Url': origin,
136
+ // For the planned privacy_tier_mismatch 409 (S28 in the Minds AI choices spec): a Standard AI path that
137
+ // receives a request marked Sealed is meant to refuse it. No server path reads this header yet, so nothing
138
+ // enforces that today; the Sealed gateway strips it before the router.
139
+ 'X-Recursiv-Privacy-Tier': 'sealed',
140
+ },
141
+ body: input.body,
142
+ signal: controller.signal,
143
+ ...(streamedBody ? { duplex: 'half' } : {}),
144
+ });
145
+ }
146
+ finally {
147
+ clearTimeout(timer);
148
+ }
149
+ const detach = () => input.signal?.removeEventListener('abort', onCallerAbort);
150
+ if (response.headers.has(SEALED_REFUSAL_HEADER)) {
151
+ detach();
152
+ throw await gatewayRefusal(response);
153
+ }
154
+ const nonce = response.headers.get('ehbp-response-nonce');
155
+ if (response.ok && (!nonce || !HEX_32_BYTES.test(nonce))) {
156
+ detach();
157
+ await response.body?.cancel().catch(() => { });
158
+ throw new SealedTransportError('unsealed_answer', 'The answer was not sealed, so it was discarded unread.');
159
+ }
160
+ // An error the gateway did not relay: the API's 404 while Sealed is off or not on for this app, apiKeyAuth's 401
161
+ // for a revoked or expired key, the API's per-IP 429, or an edge's own error. None of these is the router's.
162
+ if (!response.ok && !response.headers.has(SEALED_ALLOWANCE_REMAINING_HEADER)) {
163
+ detach();
164
+ throw await gatewayRefusal(response);
165
+ }
166
+ if (!response.body)
167
+ detach();
168
+ return response;
169
+ }
170
+ }
171
+ /** A typed error for an answer the API wrote itself (the gateway's refusals and the API's own errors). */
172
+ async function gatewayRefusal(response) {
173
+ let body = null;
174
+ try {
175
+ body = await response.json();
176
+ }
177
+ catch {
178
+ // Fall through to the generic body for the status.
179
+ }
180
+ const err = errorBodyFromResponse(response.status, response.statusText, body);
181
+ switch (response.status) {
182
+ case 400:
183
+ return new ValidationError(err);
184
+ case 401:
185
+ return new AuthenticationError(err);
186
+ case 403:
187
+ return new AuthorizationError(err);
188
+ case 404:
189
+ return new NotFoundError(err);
190
+ case 429:
191
+ return new RateLimitError(err, rateLimitDetailsFromResponse(response.headers, err));
192
+ default:
193
+ return new RecursivError(response.status, err);
194
+ }
195
+ }
package/dist/types.d.ts CHANGED
@@ -723,6 +723,12 @@ export type ConversationEncryptionPolicy = {
723
723
  export interface Conversation {
724
724
  /** Missing on older APIs: treat as unknown, never as plaintext permission. */
725
725
  encryption?: ConversationEncryptionPolicy;
726
+ /**
727
+ * Who can read it now, computed by the server from its current members: see
728
+ * PrivacyTier. Null when no member is an AI agent now; an agent that has left
729
+ * may have read earlier messages. Omitted by older APIs.
730
+ */
731
+ privacy_tier?: PrivacyTier | null;
726
732
  id: string;
727
733
  type: string;
728
734
  name: string | null;
@@ -773,6 +779,8 @@ export interface LeaveConversationResult {
773
779
  export interface ConversationDetail {
774
780
  /** Missing on older APIs: treat as unknown, never as plaintext permission. */
775
781
  encryption?: ConversationEncryptionPolicy;
782
+ /** As `Conversation.privacy_tier`. Omitted by `createGroup()` and by older APIs. */
783
+ privacy_tier?: PrivacyTier | null;
776
784
  id: string;
777
785
  type: string;
778
786
  name: string | null;
@@ -914,10 +922,86 @@ export interface MessageMedia {
914
922
  * not encrypted; a withdrawn one leaves the history. The person who did not
915
923
  * ask answers it by starting encryption as usual, from their own device.
916
924
  */
917
- export interface MessageMetadata {
925
+ export interface EncryptionRequestMessageMetadata {
918
926
  kind: 'e2ee_start_request';
919
927
  v: number;
928
+ ai?: undefined;
929
+ }
930
+ /**
931
+ * Who can read a conversation or message now, as the server computes it from
932
+ * the conversation's current members. Render a label from this value, never
933
+ * from what the member picked. It is not a history: null says no AI agent is a
934
+ * member now, not that none ever read the messages, so never show null as
935
+ * "only people saw this".
936
+ * - `standard`: an AI agent is in the conversation. The app, Recursiv and the
937
+ * AI companies that route and answer each turn can read what is sent there.
938
+ * - `e2ee`: the conversation carries the MLS transport (`encryption.mode`
939
+ * `mls_v2`). Served on the conversation; a `Message` row is never MLS
940
+ * content, so a message is never `e2ee`.
941
+ * - `members_only`, `sealed`, `on_device`: reserved; no API serves them yet.
942
+ */
943
+ export type PrivacyTier = 'standard' | 'members_only' | 'e2ee' | 'sealed' | 'on_device';
944
+ /** Where a tool's data can go (the same values as `AgentCapabilityExportBoundary`). */
945
+ export type ExportBoundary = 'none' | 'org_internal' | 'third_party_processor' | 'public_network';
946
+ /** One tool the model called on a turn. */
947
+ export interface AiTurnStampTool {
948
+ name: string;
949
+ export_boundary: ExportBoundary;
950
+ }
951
+ /**
952
+ * What answered an agent's reply, written by the server when it saves a reply
953
+ * a model wrote through its provider chain (`Message.metadata.ai`). Label
954
+ * replies from this, not from the model the member picked. Some agent
955
+ * messages carry none (server-written notices and limit replies, messages an
956
+ * agent sends outside a model turn): read a missing stamp as unknown.
957
+ */
958
+ export interface AiTurnStamp {
959
+ /** `standard` for every reply the server runs. */
960
+ tier: PrivacyTier;
961
+ /** The model that answered, as `maker/model`, e.g. `google/gemini-3.5-flash`. */
962
+ model: string;
963
+ /** The model's maker as its id names it (the part before `/`), or `unknown`. */
964
+ maker: string;
965
+ /**
966
+ * `openrouter`: through OpenRouter, a routing service. `direct`: straight to
967
+ * the model's maker (Google, Anthropic or OpenAI) at its own API. `other`:
968
+ * through a service the stamp cannot name, such as an address the app's
969
+ * operator configured or a host that did not make the model.
970
+ */
971
+ route: 'openrouter' | 'direct' | 'other';
972
+ /** The agent's usage tier on the turn. */
973
+ budget_tier: 'full' | 'standard' | 'economy' | 'hibernation';
974
+ /**
975
+ * Set when a different model answered than the one picked: `budget`, a
976
+ * smaller one because of usage limits (`model` is that smaller one);
977
+ * `fallback`, because `from` was not available (the picked model, or the
978
+ * smaller one a usage limit chose). Null when the picked model answered.
979
+ */
980
+ substituted: null | {
981
+ from: string;
982
+ reason: 'budget' | 'fallback';
983
+ };
984
+ /** Something Minds AI saved (facts, or parts of other chats) went with the turn. */
985
+ memory_used: boolean;
986
+ /** Parts of the member's other chats, their summaries or decisions went with the turn. */
987
+ recall_used: boolean;
988
+ /** The member's activity (follows, votes, groups, interests) went with the turn. */
989
+ activity_used: boolean;
990
+ /**
991
+ * Every tool the model called on the turn up to this reply, in call order:
992
+ * page tools and any others, by name, with no arguments (so no addresses). A
993
+ * tool the server has not classified reads `public_network`.
994
+ */
995
+ tools: AiTurnStampTool[];
996
+ }
997
+ /** An agent's reply: what answered it. Served only on a message from an agent (`sender.is_ai`). */
998
+ export interface AgentReplyMessageMetadata {
999
+ ai: AiTurnStamp;
1000
+ kind?: undefined;
1001
+ v?: undefined;
920
1002
  }
1003
+ /** A message's `metadata`: an encryption request's, or an agent reply's turn stamp. */
1004
+ export type MessageMetadata = EncryptionRequestMessageMetadata | AgentReplyMessageMetadata;
921
1005
  export interface Message {
922
1006
  id: string;
923
1007
  content: string;
@@ -928,6 +1012,14 @@ export interface Message {
928
1012
  created_at: string;
929
1013
  /** Absent on a person's own message, and from older APIs: see MessageMetadata. */
930
1014
  metadata?: MessageMetadata;
1015
+ /**
1016
+ * Who can read it now: see PrivacyTier. `standard` on an agent's message, or
1017
+ * while an AI agent is a member of the conversation. Null otherwise, which
1018
+ * does not mean only people read it: a message sent while an agent was a
1019
+ * member went to the AI companies with that agent's turns, and reads null
1020
+ * once the agent leaves. Omitted by older APIs.
1021
+ */
1022
+ privacy_tier?: PrivacyTier | null;
931
1023
  }
932
1024
  export interface SendMessageInput {
933
1025
  conversation_id: string;
@@ -947,7 +1039,7 @@ export interface ChatMessageEvent {
947
1039
  isAi?: boolean;
948
1040
  };
949
1041
  createdAt: string;
950
- /** The message's metadata as stored; `kind: 'e2ee_start_request'` is an encryption request (MessageMetadata). */
1042
+ /** The message's metadata as stored; `kind: 'e2ee_start_request'` is an encryption request, and an agent's reply carries `ai`, its AiTurnStamp (MessageMetadata). */
951
1043
  metadata?: Record<string, unknown>;
952
1044
  }
953
1045
  export interface ChatMessageEditEvent {
package/llm.md CHANGED
@@ -512,6 +512,8 @@ Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a
512
512
 
513
513
  Activity time (0.7.67): conversation rows, `communityConversation()` and `conversation(id)` carry `last_activity_at`, the time `conversations()` sorts by: the newest message, or, in an end-to-end encrypted (`mls_v2`) conversation, the relay's receive time of its newest encrypted message when that is later (membership and key changes and quiet events such as reactions do not move it); its creation when it has no messages. In any other conversation, deleting the newest message moves it back to the previous message (or the creation), so do not keep the later of a cached and a served value. A time only, never content: show it on a row the device cannot decrypt. Omitted by older APIs, and by `conversation(id)` to a member who declined the request.
514
514
 
515
+ Who can read it now: conversation rows, `communityConversation()`, `conversation(id)` and each of `messages()` carry `privacy_tier` (`PrivacyTier`), computed by the server from the current members: `standard` where an AI agent is a member, and on an agent's own message (the app, Recursiv and the AI companies that route and answer each turn can read it), `e2ee` for an `mls_v2` conversation (never on a message row), and null when no AI agent is a member now. Null is not a history: messages sent while an agent was a member went to the AI companies with its turns and read null once it leaves, so never show null as "only people saw this". `members_only`, `sealed` and `on_device` are reserved. An agent's reply written by a model through the server's provider chain carries `metadata.ai`, its `AiTurnStamp`: the model that answered and its maker, the route (`openrouter`, `direct` to the maker, or `other`, a service the stamp cannot name), the usage tier, `substituted` (a smaller model for usage limits, or a fallback), whether saved memory, other chats or activity went with the turn, and each tool called by name with its `export_boundary` (no arguments or addresses; an unclassified tool reads `public_network`). Some agent messages carry no stamp (server-written notices, limit replies, messages an agent sends outside a model turn): read a missing stamp as unknown. Label a reply from the stamp, never from the model the member picked. Omitted by older APIs.
516
+
515
517
  ### r.e2ee — End-to-end encryption transport (flag-gated)
516
518
 
517
519
  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), and every later append to it is checked the same way again (both people still accepted, still in the pilot, still only the two of them), so a client need not read the policy before each send; a resend of an event already stored still returns `duplicate: true`, even once a Welcome's target device is gone; blocks apply (`blocked`), to resends too; commits must send `expected_commit_seq`.
@@ -829,11 +831,11 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
829
831
  - 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.
830
832
  - 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.
831
833
  - 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.
832
- - `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. A card (a post shared into a chat, a link preview) asks for the post alone, 0.7.78: `getPublicConversation({ protocol, post, replies: 'none' })` (also `getSharedConversation` and `getConnectedSocialConversation`) answers `focus` with no `ancestors` or `replies` and `repliesStatus: 'not_requested'`; a Nostr note answers from one relay read instead of a whole thread's. An older API answers 400 `invalid_input`: read without it. 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`.
834
+ - `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. The author's own thread (0.7.81): `thread` holds the focus author's continuation below the post, oldest first (a "1/" post's 2/, 3/, 4/ ..., at most 20 parts, each held to the same moderation and member controls as a reply); its first part is not repeated in `replies`, so render `focus`, `thread`, then `replies` (`thread ?? []`: older APIs omit it, and a `replies: 'none'` read answers none). A card (a post shared into a chat, a link preview) asks for the post alone, 0.7.78: `getPublicConversation({ protocol, post, replies: 'none' })` (also `getSharedConversation` and `getConnectedSocialConversation`) answers `focus` with no `ancestors` or `replies` and `repliesStatus: 'not_requested'`; a Nostr note answers from one relay read instead of a whole thread's. An older API answers 400 `invalid_input`: read without it. 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`.
833
835
  - 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.
834
836
  - Hosted Bluesky account on the app's own PDS: `const { data } = await r.protocols.createHostedIdentity({ protocol: 'atproto', handle: 'alice', email })`. Only the label is sent; `data.handle` carries the domain and `data.password` is returned this once, never stored. Never auto-retried. Either the account is finished or it is removed again. 409 `handle_unavailable` = choose another label; 503 `handle_dns_unavailable`/`handle_unresolvable`/`handle_unverified` = operator setup, retry later unchanged.
835
837
  - Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`; `feed` with `ref` is declared in 0.7.60 but not sent yet, so skip kinds you cannot open). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`. Watch's Minds videos: `const { data, meta } = await r.discovery.videos({ limit: 12 })` returns public video posts in hot order from a list shared per app (rebuilt about once a minute), each re-checked per call for the viewer; page on `meta.next_cursor`. With no category about one row in four is an open-network clip (0.7.78): `isWatchNetworkClip(row)` tells `{ kind: 'social', id, post: SocialPost }` from a `Post`; open and react to it as the network's post (its `id` is never a Minds id).
836
- - Progressive Discover feed (0.7.71): `const feed = r.discovery.createProgressiveFeed({ networks, category, recommend, top: { ref: 'builtin:all' }, seen, seenClusters }); const round = await feed.next(part => draw(part))` opens one `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`) and hands each source's page on as a `DiscoveryPart` (`{ source, round, modules, composed?, mixed? }`) the moment it lands, instead of a page that waits for its slowest network (up to 15 s); a round waits `DISCOVERY_STRAGGLER_MS` past its first answer and rejects only when every source failed. The first load is held until Minds and one other network answered (or `FIRST_SCREEN_MS`, 2.5 s, after the first answer; `firstScreenMs: VISITOR_FIRST_SCREEN_MS` for a visitor) and arrives as one `composed: true` part from `composeFirstScreen`: the top's first post when it is a playable video, else a picture lead from the best 8 by engagement (never a story, rail, video strip, column, `isSensitivePost` post or `linksAdultSite` porn link), then every network's posts by engagement (`DiscoveryEngagementMerge`) with row 2 from another network than the lead's when one is left (`FIRST_SCREEN_NETWORK_ROWS`), a story as row 3 and a rail as row 5, nothing dropped. Merge later `mixed` parts with `mergeDiscoveryArrival(rows, arriving, reachedIds)`. `top`/`seen`/`seenClusters`/`bucket` reach every session; the top is read once per open. Also `retryFailed()`, `onStatus(listener)`, `setNetworks(catalog)`; settings `perSource`, `firstScreen`, `onLanded`. For you: `createProgressiveFeed({ createFeed: o => r.discovery.createPersonalFeed(o) }, options, { perSource: false })`. A session's `seen` now also leaves seen network posts out of trend pages, previews and topic pages; in a category a topic page with nothing new ends its topic and after `DISCOVERY_EMPTY_TOPIC_PAGES` (3) in a row the network's other topics are skipped.
838
+ - Progressive Discover feed (0.7.71): `const feed = r.discovery.createProgressiveFeed({ networks, category, recommend, top: { ref: 'builtin:all' }, seen, seenClusters }); const round = await feed.next(part => draw(part))` opens one `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`) and hands each source's page on as a `DiscoveryPart` (`{ source, round, modules, composed?, mixed? }`) the moment it lands, instead of a page that waits for its slowest network (up to 15 s); a round waits `DISCOVERY_STRAGGLER_MS` past its first answer and rejects only when every source failed. The first load is held until Minds and one other network answered (or `FIRST_SCREEN_MS`, 2.5 s, after the first answer; `firstScreenMs: VISITOR_FIRST_SCREEN_MS` for a visitor) and arrives as one `composed: true` part from `composeFirstScreen`: the top's first post when it is a playable video, else a picture lead from the best 8 by engagement (never a story, rail, video strip, column, `isSensitivePost` post or `linksAdultSite` porn link; 0.7.80: never a post with no picture or clip and no engagement while the screen holds one with either, which then leads: media and engagement, else media, else engagement, as `mergeDiscoveryArrival` leads a feed's top and the feed after a line), then every network's posts by engagement (`DiscoveryEngagementMerge`) with row 2 from another network than the lead's when one is left (`FIRST_SCREEN_NETWORK_ROWS`), a story as row 3 and a rail as row 5, nothing dropped. Merge later `mixed` parts with `mergeDiscoveryArrival(rows, arriving, reachedIds)`. `top`/`seen`/`seenClusters`/`bucket` reach every session; the top is read once per open. Also `retryFailed()`, `onStatus(listener)`, `setNetworks(catalog)`; settings `perSource`, `firstScreen`, `onLanded`. For you: `createProgressiveFeed({ createFeed: o => r.discovery.createPersonalFeed(o) }, options, { perSource: false })`. A session's `seen` now also leaves seen network posts out of trend pages, previews and topic pages; in a category a topic page with nothing new ends its topic and after `DISCOVERY_EMPTY_TOPIC_PAGES` (3) in a row the network's other topics are skipped.
837
839
  - Viral read (0.7.59; off until the server enables it for the app): `const { data } = await r.protocols.getPublicViral({ media: 'video', category: 'science', limit: 20 })` (member key, users:read + posts:read, never cached) or keyless `visitor.protocols.getSharedViral(projectId, { media: 'video', compass: 'hide' })` (visitors send compass `all` or `hide` only). Params, all optional: `network` (all, minds, bluesky, activitypub, nostr), `category`, `media: 'video'`, `limit` 5/10/20/40, `cursor` (the last page's; a cursor whose 30-minute build expired ends the list), `compass`. Each item: `key` (stable; drop ones already shown), `protocol`, `post` (checking `protocol` narrows it: `ViralMindsCard` for minds, open at /post/:id; otherwise a `SocialPost`), `label` viral/rising/hot, `score`, `rank`, `category`, `velocity` (show counts with `countsReportedBy` as reported, never verified), `spread`. A visitor's read serves media only where the server cleared it, so even with `media: 'video'` some items arrive with the video withheld (empty `media`): `import { viralItemHasVideo } from '@recursiv/sdk'` and keep `data.items.filter(viralItemHasVideo)` before a vertical video player. `status` ok/building/screening/off: off resolves with no items, and `getSharedViral` also resolves an older API's 401 `missing_api_key` as off; `getPublicViral` throws every 401/403 (never off), so catch it and skip that step.
838
840
  - Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass?, media? })` (`compass: 'hide'` leaves out politics, 0.7.62; `media: 'video'`, 0.7.73, Bluesky: the network's own window of video posts, a `kind: posts` page paged by its cursor, no category or layout, 404 `unsupported_protocol` for a network without one, 400 on an API that predates it; keyless `getSharedTrends(projectId, { ..., media: 'video' })` holds only clips a visitor may see and counts the rest in `mediaFilter.withheld`); render `kind: topics` as source topics and `kind: posts` as source posts. With `trend_feed`, open only a returned current topic using `getPublicTrendFeed({ protocol, topic: trend.nativeId, limit?, cursor? })`. Show provider, observedAt and provider_ranked_window coverage; null rankingWindow is unknown, not a daily/global ranking. Preserve source order, canonical IDs and readRef, and opaque cursors. Topic catalogs do not paginate; Mastodon trends stop at 200 source positions. Both methods require users:read plus posts:read on the current human app key; Nostr trends are unsupported. Propagate errors; trend_unavailable means refresh the topic list. No ingestion or native writes.
839
841
  - One outlet count per story (0.7.71): a radar story (list, story read, archive), a World Today story (`outlets`, with `outlet_count` its total and `lane_mix` its lanes), a category strip story, a `feeds.top`/`feeds.stories` story and a search story carry `outlets: { total, mainstream, independent, other }`, the distinct outlets of the articles the story lists (an allow-listed outlet once, an unlisted host in `other`; the buckets sum to `total`). Show `outlets.total`; `sources.outlet` counts articles. `DiscoveryCluster.outletCount` reads it. An older server omits it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.79",
3
+ "version": "0.7.81",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",