@recursiv/sdk 0.7.78 → 0.7.80
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 +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +5 -0
- package/dist/resources/discovery-first-screen.js +22 -5
- package/dist/resources/discovery-merge.d.ts +15 -1
- package/dist/resources/discovery-merge.js +46 -3
- package/dist/resources/sealed.d.ts +97 -0
- package/dist/resources/sealed.js +145 -0
- package/dist/resources/search.d.ts +61 -5
- package/llm.md +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,6 +94,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
|
|
|
94
94
|
| Resource | Key methods |
|
|
95
95
|
|----------|------------|
|
|
96
96
|
| `r.posts` | `create`, `update`, `list`, `get`, `getMany(ids)` (up to 25 posts by id per request, no thread; a `Map` of each id's post or `NotFoundError`; 0.7.77), `liked`, `bookmarked`, `bookmark`, `unbookmark`, `search`, `replies`, `react` |
|
|
97
|
+
| `r.search` | `query` (blended search in sections; a thin answer steps down the search ladder, each item labelled `via`, the answer's `ladder` says how) |
|
|
97
98
|
| `r.bookmarks` | `save`, `unsave`, `posts`, `list`, `keys`, `merge` — the member's saved posts, open-network posts and articles, synced across devices; `collections`: `list`, `create`, `update`, `remove`, `add`, `removeItem`, `items`, `posts` — private named collections of saves (0.7.68, where `network.getConfig()` advertises `capabilities.bookmark_collections`) |
|
|
98
99
|
| `r.feeds` | `top` (what the member is most likely to engage with, rotated by what they saw, with open-network lanes and the lead story), `stories` (the story tail), `publicTop` (a visitor's top, keyless and edge-cacheable) |
|
|
99
100
|
| `r.communities` | `create`, `join`, `search`, `members` |
|
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: routers, models, allowance, 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
|
/**
|
|
@@ -211,7 +214,7 @@ export { ModerationResource } from './resources/moderation.js';
|
|
|
211
214
|
export type { ModerationTargetType, ModerationActionType, ModerationAppealStatus, ModerationPrinciple, PublicModerationAction, PublicModerationLog, OwnModerationAppeal, OwnModerationAction, OwnModerationLog, ApplyModerationActionInput, ModerationAction, ListModerationActionsParams, RecordedModerationTargetType, AgentModerationMode, AgentModerationGrant, } from './resources/moderation.js';
|
|
212
215
|
export { LinkPreviewResource } from './resources/link-preview.js';
|
|
213
216
|
export { SEARCH_SECTIONS, SearchResource } from './resources/search.js';
|
|
214
|
-
export type { SearchAnswer, SearchGroup, SearchOmitReason, SearchParams, SearchPerson, SearchRelated, SearchResponse, SearchSection, SearchStory, SearchTopic } from './resources/search.js';
|
|
217
|
+
export type { PublicSearchPage, PublicSearchTab, SearchAnswer, SearchGroup, SearchLadder, SearchOmitReason, SearchParams, SearchPerson, SearchPost, SearchRelated, SearchResponse, SearchSection, SearchStage, SearchStory, SearchTopic, SearchVia, } from './resources/search.js';
|
|
215
218
|
export { BOOKMARK_MERGE_MAX_ITEMS, BookmarkCollectionsResource, BookmarksResource } from './resources/bookmarks.js';
|
|
216
219
|
export { FEED_TOP_NETWORKS, FeedsResource } from './resources/feeds.js';
|
|
217
220
|
export type { FeedStoriesParams, FeedStoriesResponse, FeedStory, FeedTopExplain, FeedTopItem, FeedTopMeta, FeedTopNetwork, FeedTopNetworkCard, FeedTopNetworkEntry, FeedTopParams, FeedTopResponse, FeedTopTarget, PublicFeedTopMeta, PublicFeedTopParams, PublicFeedTopResponse, } from './resources/feeds.js';
|
|
@@ -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, SealedConfig, SealedSendInput, 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: routers, models, allowance, 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';
|
|
@@ -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
|
|
114
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { HttpClient } from '../client.js';
|
|
2
|
+
/**
|
|
3
|
+
* Sealed mode's config and transport: where the app sends a sealed request, which routers and models it may use, and
|
|
4
|
+
* the member's allowance. No crypto lives here, so the SDK stays dependency-free: the app verifies the router's
|
|
5
|
+
* attestation and seals the body with Recursiv's verifier (packages/e2ee attest), then hands this resource the sealed
|
|
6
|
+
* bytes. The gateway relays them to the router without opening them.
|
|
7
|
+
*
|
|
8
|
+
* Off unless the API runs with SEALED_GATEWAY_ENABLED and SEALED_GATEWAY_PROJECTS names the app the key is bound to:
|
|
9
|
+
* until then `config()` and `send()` throw NotFoundError.
|
|
10
|
+
*
|
|
11
|
+
* React Native: the global fetch there (whatwg-fetch) cannot stream. It has no `response.body`, so a streamed answer
|
|
12
|
+
* cannot be read as it arrives, and it posts a ReadableStream body as the text "[object ReadableStream]". Pass a fetch
|
|
13
|
+
* that streams, such as expo/fetch's, as `send({ fetch })` for streamed answers or a ReadableStream body.
|
|
14
|
+
*/
|
|
15
|
+
/** What `config()` returns. */
|
|
16
|
+
export interface SealedConfig {
|
|
17
|
+
/** The chat route, relative to the API base: `/sealed/v1/chat/completions`. */
|
|
18
|
+
chat_completions_path: string;
|
|
19
|
+
/**
|
|
20
|
+
* The routers the gateway forwards to, as exact origins (`https://inference.tinfoil.sh`). The app attests one of
|
|
21
|
+
* these, seals to the key that attestation proves, and names it in `send()`.
|
|
22
|
+
*/
|
|
23
|
+
routers: string[];
|
|
24
|
+
/** The models the app offers in Sealed, e.g. `gemma4-31b`. */
|
|
25
|
+
models: string[];
|
|
26
|
+
/** The attestation policy version the app's verifier must pin; an older pinned policy means the app needs updating. */
|
|
27
|
+
verifier_policy_version: number;
|
|
28
|
+
allowance: SealedAllowance;
|
|
29
|
+
}
|
|
30
|
+
export interface SealedAllowance {
|
|
31
|
+
requests_per_day: number;
|
|
32
|
+
requests_per_minute: number;
|
|
33
|
+
max_concurrent: number;
|
|
34
|
+
/** The cap on one sealed request body, in bytes. */
|
|
35
|
+
max_request_bytes: number;
|
|
36
|
+
}
|
|
37
|
+
export interface SealedSendInput {
|
|
38
|
+
/** The router the app verified and sealed to: one of `config().routers`, exactly. */
|
|
39
|
+
enclaveOrigin: string;
|
|
40
|
+
/** The EHBP encapsulated key from sealing the body: 64 hex characters. */
|
|
41
|
+
encapsulatedKey: string;
|
|
42
|
+
/** The EHBP-framed sealed body. */
|
|
43
|
+
body: Uint8Array | ArrayBuffer | ReadableStream<Uint8Array>;
|
|
44
|
+
/** Ask for a streamed (server-sent events) answer. */
|
|
45
|
+
stream?: boolean;
|
|
46
|
+
/** The routers from a config already read. When omitted, `send()` reads the config first. */
|
|
47
|
+
routers?: readonly string[];
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
/**
|
|
50
|
+
* The fetch to send with; the global fetch when omitted. On React Native pass one that streams (expo/fetch's): the
|
|
51
|
+
* global one there cannot read a streamed answer as it arrives or send a ReadableStream body.
|
|
52
|
+
*/
|
|
53
|
+
fetch?: typeof fetch;
|
|
54
|
+
}
|
|
55
|
+
/** Why `send()` refused before or after the request, with nothing shown to the member as the model's words. */
|
|
56
|
+
export type SealedTransportErrorCode =
|
|
57
|
+
/** The router is not one the gateway lists: nothing was sent. */
|
|
58
|
+
'enclave_not_allowed'
|
|
59
|
+
/** The encapsulated key is not 32 bytes of hex: nothing was sent. */
|
|
60
|
+
| 'bad_encapsulated_key'
|
|
61
|
+
/** A 2xx answer without a valid Ehbp-Response-Nonce: its body was discarded unread. */
|
|
62
|
+
| 'unsealed_answer';
|
|
63
|
+
export declare class SealedTransportError extends Error {
|
|
64
|
+
readonly code: SealedTransportErrorCode;
|
|
65
|
+
constructor(code: SealedTransportErrorCode, message: string);
|
|
66
|
+
}
|
|
67
|
+
/** Set on a refusal the gateway wrote itself; a relayed router answer never carries it. */
|
|
68
|
+
export declare const SEALED_REFUSAL_HEADER = "x-sealed-gateway-refusal";
|
|
69
|
+
/**
|
|
70
|
+
* Requests left in the member's allowance today. The gateway sets it on every answer it relayed from the router and on
|
|
71
|
+
* 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.
|
|
72
|
+
*/
|
|
73
|
+
export declare const SEALED_ALLOWANCE_REMAINING_HEADER = "x-sealed-allowance-remaining";
|
|
74
|
+
export declare class SealedResource {
|
|
75
|
+
private client;
|
|
76
|
+
constructor(client: HttpClient);
|
|
77
|
+
/** The routers, models, verifier policy version and allowance the app needs before it seals anything. */
|
|
78
|
+
config(): Promise<SealedConfig>;
|
|
79
|
+
/** The URL `send()` posts to. */
|
|
80
|
+
chatCompletionsUrl(): string;
|
|
81
|
+
/**
|
|
82
|
+
* Sends one sealed chat request through the gateway, once. It never retries: a repeated request runs and is billed
|
|
83
|
+
* again, and Sealed never falls back to anything else.
|
|
84
|
+
*
|
|
85
|
+
* Returns the gateway's Response:
|
|
86
|
+
* - a sealed answer (`Ehbp-Response-Nonce` set): open it with the context that sealed the request;
|
|
87
|
+
* - a router error the gateway relayed (it carries X-Sealed-Allowance-Remaining): unauthenticated plaintext when it
|
|
88
|
+
* has no nonce, never to be shown as the model's words.
|
|
89
|
+
*
|
|
90
|
+
* Throws a RecursivError for every error the API wrote rather than relayed, whether or not it is marked
|
|
91
|
+
* X-Sealed-Gateway-Refusal: RateLimitError for an allowance used up (`code` `sealed_allowance_used` or
|
|
92
|
+
* `sealed_too_many_requests`) or the API's own per-IP limit (`rate_limited`), AuthenticationError for a revoked or
|
|
93
|
+
* expired key, NotFoundError while Sealed is off or not on for this app. Throws SealedTransportError when the router
|
|
94
|
+
* is not listed, the key is malformed, or a 2xx answer came back unsealed.
|
|
95
|
+
*/
|
|
96
|
+
send(input: SealedSendInput): Promise<Response>;
|
|
97
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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 routers, 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
|
+
/** The URL `send()` posts to. */
|
|
39
|
+
chatCompletionsUrl() {
|
|
40
|
+
return this.client.buildUrl('/sealed/v1/chat/completions');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Sends one sealed chat request through the gateway, once. It never retries: a repeated request runs and is billed
|
|
44
|
+
* again, and Sealed never falls back to anything else.
|
|
45
|
+
*
|
|
46
|
+
* Returns the gateway's Response:
|
|
47
|
+
* - a sealed answer (`Ehbp-Response-Nonce` set): open it with the context that sealed the request;
|
|
48
|
+
* - a router error the gateway relayed (it carries X-Sealed-Allowance-Remaining): unauthenticated plaintext when it
|
|
49
|
+
* has no nonce, never to be shown as the model's words.
|
|
50
|
+
*
|
|
51
|
+
* Throws a RecursivError for every error the API wrote rather than relayed, whether or not it is marked
|
|
52
|
+
* X-Sealed-Gateway-Refusal: RateLimitError for an allowance used up (`code` `sealed_allowance_used` or
|
|
53
|
+
* `sealed_too_many_requests`) or the API's own per-IP limit (`rate_limited`), AuthenticationError for a revoked or
|
|
54
|
+
* expired key, NotFoundError while Sealed is off or not on for this app. Throws SealedTransportError when the router
|
|
55
|
+
* is not listed, the key is malformed, or a 2xx answer came back unsealed.
|
|
56
|
+
*/
|
|
57
|
+
async send(input) {
|
|
58
|
+
const origin = exactOrigin(input.enclaveOrigin);
|
|
59
|
+
const routers = input.routers ?? (await this.config()).routers;
|
|
60
|
+
if (!origin || !routers.includes(origin)) {
|
|
61
|
+
throw new SealedTransportError('enclave_not_allowed', 'That router is not one the gateway forwards to. Nothing was sent.');
|
|
62
|
+
}
|
|
63
|
+
if (!HEX_32_BYTES.test(input.encapsulatedKey)) {
|
|
64
|
+
throw new SealedTransportError('bad_encapsulated_key', 'The encapsulated key must be 32 bytes of hex. Nothing was sent.');
|
|
65
|
+
}
|
|
66
|
+
// Time out waiting for the answer to start; once it has, a long stream runs until it ends or the caller aborts.
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const onCallerAbort = () => controller.abort(input.signal?.reason);
|
|
69
|
+
if (input.signal?.aborted)
|
|
70
|
+
controller.abort(input.signal.reason);
|
|
71
|
+
else
|
|
72
|
+
input.signal?.addEventListener('abort', onCallerAbort, { once: true });
|
|
73
|
+
const timer = setTimeout(() => controller.abort(), this.client.requestTimeoutMs);
|
|
74
|
+
const fetchImpl = input.fetch ?? fetch;
|
|
75
|
+
const streamedBody = typeof ReadableStream !== 'undefined' && input.body instanceof ReadableStream;
|
|
76
|
+
let response;
|
|
77
|
+
try {
|
|
78
|
+
response = await fetchImpl(this.chatCompletionsUrl(), {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
...this.client.apiKeyAuthHeader(),
|
|
82
|
+
'Content-Type': 'application/json',
|
|
83
|
+
Accept: input.stream ? 'text/event-stream' : 'application/json',
|
|
84
|
+
'Ehbp-Encapsulated-Key': input.encapsulatedKey,
|
|
85
|
+
'X-Tinfoil-Enclave-Url': origin,
|
|
86
|
+
// For the planned privacy_tier_mismatch 409 (S28 in the Minds AI choices spec): a Standard AI path that
|
|
87
|
+
// receives a request marked Sealed is meant to refuse it. No server path reads this header yet, so nothing
|
|
88
|
+
// enforces that today; the Sealed gateway strips it before the router.
|
|
89
|
+
'X-Recursiv-Privacy-Tier': 'sealed',
|
|
90
|
+
},
|
|
91
|
+
body: input.body,
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
...(streamedBody ? { duplex: 'half' } : {}),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
}
|
|
99
|
+
const detach = () => input.signal?.removeEventListener('abort', onCallerAbort);
|
|
100
|
+
if (response.headers.has(SEALED_REFUSAL_HEADER)) {
|
|
101
|
+
detach();
|
|
102
|
+
throw await gatewayRefusal(response);
|
|
103
|
+
}
|
|
104
|
+
const nonce = response.headers.get('ehbp-response-nonce');
|
|
105
|
+
if (response.ok && (!nonce || !HEX_32_BYTES.test(nonce))) {
|
|
106
|
+
detach();
|
|
107
|
+
await response.body?.cancel().catch(() => { });
|
|
108
|
+
throw new SealedTransportError('unsealed_answer', 'The answer was not sealed, so it was discarded unread.');
|
|
109
|
+
}
|
|
110
|
+
// An error the gateway did not relay: the API's 404 while Sealed is off or not on for this app, apiKeyAuth's 401
|
|
111
|
+
// 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.
|
|
112
|
+
if (!response.ok && !response.headers.has(SEALED_ALLOWANCE_REMAINING_HEADER)) {
|
|
113
|
+
detach();
|
|
114
|
+
throw await gatewayRefusal(response);
|
|
115
|
+
}
|
|
116
|
+
if (!response.body)
|
|
117
|
+
detach();
|
|
118
|
+
return response;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** A typed error for an answer the API wrote itself (the gateway's refusals and the API's own errors). */
|
|
122
|
+
async function gatewayRefusal(response) {
|
|
123
|
+
let body = null;
|
|
124
|
+
try {
|
|
125
|
+
body = await response.json();
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Fall through to the generic body for the status.
|
|
129
|
+
}
|
|
130
|
+
const err = errorBodyFromResponse(response.status, response.statusText, body);
|
|
131
|
+
switch (response.status) {
|
|
132
|
+
case 400:
|
|
133
|
+
return new ValidationError(err);
|
|
134
|
+
case 401:
|
|
135
|
+
return new AuthenticationError(err);
|
|
136
|
+
case 403:
|
|
137
|
+
return new AuthorizationError(err);
|
|
138
|
+
case 404:
|
|
139
|
+
return new NotFoundError(err);
|
|
140
|
+
case 429:
|
|
141
|
+
return new RateLimitError(err, rateLimitDetailsFromResponse(response.headers, err));
|
|
142
|
+
default:
|
|
143
|
+
return new RecursivError(response.status, err);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -6,6 +6,58 @@ export declare const SEARCH_SECTIONS: readonly ["top_posts", "latest_posts", "op
|
|
|
6
6
|
export type SearchSection = (typeof SEARCH_SECTIONS)[number];
|
|
7
7
|
/** Why a section is absent: out of time, the server was busy, not available for this app, not allowed for this key, or it failed. */
|
|
8
8
|
export type SearchOmitReason = 'timeout' | 'saturated' | 'unavailable' | 'scope' | 'error';
|
|
9
|
+
/**
|
|
10
|
+
* Where an item of a search came from (the search ladder, server 0.7.79): the words as written, the word a typo meant,
|
|
11
|
+
* the known words with the rest dropped, and what is popular in the category the words are about or on the app. The
|
|
12
|
+
* deep rungs (`semantic`, `related_topic`, `open_network`) are named ahead of their servers.
|
|
13
|
+
*/
|
|
14
|
+
export type SearchVia = 'exact' | 'did_you_mean' | 'relaxed' | 'semantic' | 'related_topic' | 'open_network' | 'trending_category' | 'trending';
|
|
15
|
+
/**
|
|
16
|
+
* The search ladder's account of an answer, so an app labels it honestly: "No exact matches for X" (`exactCount` 0),
|
|
17
|
+
* "Did you mean Y?" (`didYouMean`), "Showing posts about memes" (`keptTerms`, `droppedTerms`), "Related: Comedy"
|
|
18
|
+
* (`inferredCategories`), "Popular in Comedy right now". `steps` names the rungs that answered, in order (`['guarded']`
|
|
19
|
+
* alone: the query gets what is popular and no suggestion). `withheld` and `pending` count posts a visitor's floors held
|
|
20
|
+
* back. `unavailable` names sections this key cannot read: say so, never fill them.
|
|
21
|
+
*/
|
|
22
|
+
export interface SearchLadder {
|
|
23
|
+
exactCount: number;
|
|
24
|
+
didYouMean: string | null;
|
|
25
|
+
keptTerms: string[];
|
|
26
|
+
droppedTerms: string[];
|
|
27
|
+
inferredCategories: Array<{
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
via: 'name' | 'seed' | 'alias' | 'emoji';
|
|
31
|
+
}>;
|
|
32
|
+
expandedTags: string[];
|
|
33
|
+
steps: string[];
|
|
34
|
+
withheld: number;
|
|
35
|
+
pending: number;
|
|
36
|
+
unavailable: string[];
|
|
37
|
+
}
|
|
38
|
+
/** A post as a search lists it: `via` says how the ladder found it (absent from servers before 0.7.79). */
|
|
39
|
+
export type SearchPost = Post & {
|
|
40
|
+
via?: SearchVia;
|
|
41
|
+
};
|
|
42
|
+
/** A visitor's search tabs (tRPC `publicDiscovery.search`, read keyless through the app's public client). */
|
|
43
|
+
export type PublicSearchTab = 'top' | 'latest' | 'people' | 'groups' | 'videos' | 'news' | 'tag';
|
|
44
|
+
/** `fast` answers within about a second; `deep` waits for the reads `fast` left running. */
|
|
45
|
+
export type SearchStage = 'fast' | 'deep';
|
|
46
|
+
/** One page of a visitor's search: every item labelled `via`, never empty while the app has anything popular. */
|
|
47
|
+
export interface PublicSearchPage<T = Record<string, unknown>> {
|
|
48
|
+
tab: PublicSearchTab;
|
|
49
|
+
q: string;
|
|
50
|
+
items: Array<T & {
|
|
51
|
+
via: SearchVia;
|
|
52
|
+
}>;
|
|
53
|
+
ladder: SearchLadder;
|
|
54
|
+
meta: {
|
|
55
|
+
limit: number;
|
|
56
|
+
stage: SearchStage;
|
|
57
|
+
next_cursor: string | null;
|
|
58
|
+
has_more: boolean;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
9
61
|
export interface SearchParams {
|
|
10
62
|
q: string;
|
|
11
63
|
/** Items per section, 1-20 (default 6). */
|
|
@@ -88,9 +140,10 @@ export interface SearchStory {
|
|
|
88
140
|
} | null;
|
|
89
141
|
/**
|
|
90
142
|
* Where the words were found: the story's headline, or only its members'. `article` (a server newer than 0.7.74): no
|
|
91
|
-
* story is about the query, and this is one outlet's article whose headline carries its words.
|
|
143
|
+
* story is about the query, and this is one outlet's article whose headline carries its words. `related` (a server
|
|
144
|
+
* newer than 0.7.78): neither, and a visitor's search ladder served it as a story about what the words are about.
|
|
92
145
|
*/
|
|
93
|
-
matched: 'label' | 'member' | 'article';
|
|
146
|
+
matched: 'label' | 'member' | 'article' | 'related';
|
|
94
147
|
/** An `article`'s link and outlet. */
|
|
95
148
|
article?: {
|
|
96
149
|
url: string;
|
|
@@ -117,12 +170,12 @@ export interface SearchAnswer {
|
|
|
117
170
|
sections: {
|
|
118
171
|
/** Relevance x recency x engagement, through the promotion safety floor. `has_more`: page further with `posts.search`. */
|
|
119
172
|
top_posts?: {
|
|
120
|
-
items:
|
|
173
|
+
items: SearchPost[];
|
|
121
174
|
has_more: boolean;
|
|
122
175
|
};
|
|
123
176
|
/** Newest first, never repeating a top post. `partial`: these are the newest of what was read in time. */
|
|
124
177
|
latest_posts?: {
|
|
125
|
-
items:
|
|
178
|
+
items: SearchPost[];
|
|
126
179
|
partial: boolean;
|
|
127
180
|
};
|
|
128
181
|
open_network?: {
|
|
@@ -144,12 +197,13 @@ export interface SearchAnswer {
|
|
|
144
197
|
/** Categories and topics around the matches. */
|
|
145
198
|
related?: SearchRelated;
|
|
146
199
|
/** Present when the answer is thin or old: what is moving in the nearest category. */
|
|
200
|
+
/** `category` null with the ladder: what is popular on the app right now. */
|
|
147
201
|
trending?: {
|
|
148
202
|
category: {
|
|
149
203
|
id: string;
|
|
150
204
|
name: string;
|
|
151
205
|
} | null;
|
|
152
|
-
items:
|
|
206
|
+
items: SearchPost[];
|
|
153
207
|
};
|
|
154
208
|
};
|
|
155
209
|
/** Answered, non-empty sections in the order to render them. */
|
|
@@ -161,6 +215,8 @@ export interface SearchAnswer {
|
|
|
161
215
|
}>;
|
|
162
216
|
/** Fewer than five matches across Minds and the open networks. */
|
|
163
217
|
few_results: boolean;
|
|
218
|
+
/** The search ladder's account of the answer (server 0.7.79): present when the server ran the ladder. */
|
|
219
|
+
ladder?: SearchLadder;
|
|
164
220
|
}
|
|
165
221
|
export interface SearchResponse {
|
|
166
222
|
data: SearchAnswer;
|
package/llm.md
CHANGED
|
@@ -703,7 +703,7 @@ for (const section of answer.order) render(section, answer.sections[section])
|
|
|
703
703
|
// top_posts, latest_posts, open_network, people, groups, topics, stories, related, trending
|
|
704
704
|
```
|
|
705
705
|
|
|
706
|
-
`trending` joins when the answer is thin or old, so a search never dead-ends. `top_posts` is floored as `r.posts.search`'s relevance page is; `people` rows carry `followers_count`, `posts_count`, `empty` and `verified` (see `r.profiles.search`); `groups` rows carry `description` (one line) and `last_active_at`, to tell groups of one name apart. Page further with `r.posts.search`. Needs `posts:read`; `people` needs `users:read`, `groups` `communities:read`; `open_network` and `stories` need `users:read` plus the caller's own app key on its network, `trending` that app key. What the key cannot have is in `omitted` (`scope` / `unavailable`).
|
|
706
|
+
`trending` joins when the answer is thin or old, so a search never dead-ends. A thin answer steps down the search ladder (0.7.79): the word a typo meant, the known words with the rest dropped ("wiggly memes" searches "memes"), what is popular in the category the words are about, then on the app. Every item says how in `via` (`SearchVia`) and `answer.ladder` (`SearchLadder`) says what happened: label it honestly ("No exact matches for X", "Did you mean Y?", "Popular in Comedy right now"); `ladder.unavailable` names sections the key cannot read. Visitors read the same ladder keyless through tRPC `publicDiscovery.search` (`PublicSearchPage`). `top_posts` is floored as `r.posts.search`'s relevance page is; `people` rows carry `followers_count`, `posts_count`, `empty` and `verified` (see `r.profiles.search`); `groups` rows carry `description` (one line) and `last_active_at`, to tell groups of one name apart. Page further with `r.posts.search`. Needs `posts:read`; `people` needs `users:read`, `groups` `communities:read`; `open_network` and `stories` need `users:read` plus the caller's own app key on its network, `trending` that app key. What the key cannot have is in `omitted` (`scope` / `unavailable`).
|
|
707
707
|
|
|
708
708
|
### r.curator — World Today and its story pages
|
|
709
709
|
|
|
@@ -833,7 +833,7 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
|
|
|
833
833
|
- 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
834
|
- 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
835
|
- 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.
|
|
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; 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
837
|
- 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
838
|
- 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
839
|
- 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.
|