@recursiv/sdk 0.7.63 → 0.7.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
95
95
  |----------|------------|
96
96
  | `r.posts` | `create`, `update`, `list`, `liked`, `bookmarked`, `bookmark`, `unbookmark`, `search`, `replies`, `react` |
97
97
  | `r.bookmarks` | `save`, `unsave`, `posts`, `list`, `keys`, `merge` — the member's saved posts, open-network posts and articles, synced across devices |
98
+ | `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) |
98
99
  | `r.communities` | `create`, `join`, `search`, `members` |
99
100
  | `r.chat` | `send`, `dm`, `createGroup` (`encrypted: true` for an end-to-end encrypted group, 0.7.59), `messages` (cursor paging), `acceptRequest`, `declineRequest`, `leave`, `deleteConversation`; a private group's admin: `addMembers`, `removeMember`, `updateGroup` (0.7.59) |
100
101
  | `r.tags` | `create`, `list` |
@@ -669,6 +670,39 @@ and says whether the member is personalized yet (`meta.personalized`). With a
669
670
  `category` (the member's first), the session also reads open-network posts from it;
670
671
  without, it is Minds only. It never carries the radar.
671
672
 
673
+ ### The top of a feed
674
+
675
+ `sdk.feeds.top({ ref, limit?, session?, compass? })` answers the top of a feed for the
676
+ signed-in member, read with their own key of the app (`ref`: `builtin:all`,
677
+ `category:<id>`, `following`, `watch` or `story:<cluster id>`): `items` (the posts most
678
+ likely to be engaged with, often video, rotated by what the member has seen), `networks`
679
+ (Bluesky, Fediverse and Nostr lanes of up to six posts, each with a `card` to draw and no
680
+ URL on its network), `lead` (the story the feed should open with) and `meta` (`short`,
681
+ `safety`, whether the order used impressions). Every item's `target` is in the app: a
682
+ post id, `/social/post/<protocol>/<id>`, or a story id. `sdk.feeds.stories({ after?, limit? })`
683
+ is the story tail from the same ranker. `sdk.feeds.publicTop({ projectId, ref?, limit?, compass? })`
684
+ is a visitor's top (safe items only, the same for every visitor in one 15-minute bucket),
685
+ read with no credential so the edge can hold it. Each answers 404 while the app's operator
686
+ has it off. Send `block: 'top'` and the item's position with a visible view of a top item.
687
+
688
+ A feed session opens with it when asked: `createFeed({ networks, top: { ref, session? }, seen,
689
+ seenClusters?, bucket? })` (or `new DiscoveryFeedSession(..., { top: { ref, read, post? }, seen })`
690
+ with your own readers). The top's read starts with the first page's reads, and the top holds
691
+ the page at most 1.5 s from its start, the reads of its items' posts included; a top that is
692
+ off, fails or is late leaves the session exactly as it is without one. The radar's story reads
693
+ its posts as soon as the radar answers, whatever the top does. When it answers, page 0 opens with the story it leads with, then its items (each item
694
+ carries `top: { ref, position, block }`, a lane entry also `lane`), and its lanes stand in
695
+ for the networks' first reads. Minds items the first hot page does not carry are read by id
696
+ once, within the same 1.5 s (`top.post`, or `GET /posts/:id` without retries). `seen` (Minds post ids and
697
+ `<protocol>/<nativeId>` refs the member already saw) leaves those posts out of the hot pages,
698
+ and no hot page repeats a top item; a hot page left under five posts walks on, up to three
699
+ more pages, and the feed's `fallover` still reports when the ranking ends. Without impressions
700
+ in the top's order, the member's seen top items follow the unseen ones. With no server lead,
701
+ `seenClusters` (`Map<id, { n, clicked }>`) skips a story shown three times without an open,
702
+ and a visitor's `bucket` rotates page 0's story among the breaking ones
703
+ (`pickRadarCluster` is the same rule on its own). With none of these options a session is
704
+ unchanged.
705
+
672
706
  Both take a Politics choice, `compass` (`all`, `mix`, `hide`, or cells in the order
673
707
  `lo,co,ro,lm,cm,rm,ll,cl,rl`, optionally `np`), and send it with every Minds read
674
708
  (`posts.list` and `curator.forYou` take it too). Each such page says what it did in
@@ -17,7 +17,11 @@ export declare const FEED_SOURCES_MAX = 50;
17
17
  export declare const FEED_DEFINITION_MAX_BYTES: number;
18
18
  export declare const FEED_SOURCE_KINDS: readonly ["category", "tag", "account", "community", "collection", "list", "story", "bsky_feed", "bsky_list", "bsky_starterpack", "nostr_set"];
19
19
  export type FeedSourceKind = typeof FEED_SOURCE_KINDS[number];
20
- export declare const FEED_RANKING_MODES: readonly ["hot", "new", "top", "for_you", "viral"];
20
+ /**
21
+ * 'engagement' ranks by predicted engagement for the reader: the top of a feed (`feeds.top`). Like For you
22
+ * and viral it reaches past the sources on the reader's behalf.
23
+ */
24
+ export declare const FEED_RANKING_MODES: readonly ["hot", "new", "top", "for_you", "viral", "engagement"];
21
25
  export type FeedRankingMode = typeof FEED_RANKING_MODES[number];
22
26
  export declare const FEED_RANKING_RANGES: readonly ["24h", "7d", "30d", "1y"];
23
27
  export type FeedRankingRange = typeof FEED_RANKING_RANGES[number];
@@ -19,7 +19,11 @@ export const FEED_SOURCE_KINDS = [
19
19
  'category', 'tag', 'account', 'community', 'collection', 'list', 'story',
20
20
  'bsky_feed', 'bsky_list', 'bsky_starterpack', 'nostr_set',
21
21
  ];
22
- export const FEED_RANKING_MODES = ['hot', 'new', 'top', 'for_you', 'viral'];
22
+ /**
23
+ * 'engagement' ranks by predicted engagement for the reader: the top of a feed (`feeds.top`). Like For you
24
+ * and viral it reaches past the sources on the reader's behalf.
25
+ */
26
+ export const FEED_RANKING_MODES = ['hot', 'new', 'top', 'for_you', 'viral', 'engagement'];
23
27
  export const FEED_RANKING_RANGES = ['24h', '7d', '30d', '1y'];
24
28
  export const FEED_MEDIA = ['any', 'images', 'video', 'longread'];
25
29
  export const FEED_NETWORKS = ['minds', 'bluesky', 'activitypub', 'nostr'];
package/dist/index.d.ts CHANGED
@@ -62,6 +62,7 @@ import { ModerationResource } from './resources/moderation.js';
62
62
  import { LinkPreviewResource } from './resources/link-preview.js';
63
63
  import { SearchResource } from './resources/search.js';
64
64
  import { BookmarksResource } from './resources/bookmarks.js';
65
+ import { FeedsResource } from './resources/feeds.js';
65
66
  import { AccountsResource } from './resources/accounts.js';
66
67
  import { RealtimeClient } from './realtime.js';
67
68
  import type { RecursivConfig } from './types.js';
@@ -121,6 +122,8 @@ export declare class Recursiv {
121
122
  readonly jobs: JobsResource;
122
123
  readonly curator: CuratorResource;
123
124
  readonly discovery: DiscoveryResource;
125
+ /** The top of a feed: what the member (or a visitor) is most likely to engage with, rotated by what they have seen. */
126
+ readonly feeds: FeedsResource;
124
127
  readonly video: VideoResource;
125
128
  readonly appSubscriptions: AppSubscriptionsResource;
126
129
  readonly boosts: BoostsResource;
@@ -201,6 +204,8 @@ export { LinkPreviewResource } from './resources/link-preview.js';
201
204
  export { SEARCH_SECTIONS, SearchResource } from './resources/search.js';
202
205
  export type { SearchAnswer, SearchGroup, SearchOmitReason, SearchParams, SearchPerson, SearchRelated, SearchResponse, SearchSection, SearchStory, SearchTopic } from './resources/search.js';
203
206
  export { BOOKMARK_MERGE_MAX_ITEMS, BookmarksResource } from './resources/bookmarks.js';
207
+ export { FEED_TOP_NETWORKS, FeedsResource } from './resources/feeds.js';
208
+ export type { FeedStoriesParams, FeedStoriesResponse, FeedStory, FeedTopExplain, FeedTopItem, FeedTopMeta, FeedTopNetwork, FeedTopNetworkCard, FeedTopNetworkEntry, FeedTopParams, FeedTopResponse, FeedTopTarget, PublicFeedTopMeta, PublicFeedTopParams, PublicFeedTopResponse, } from './resources/feeds.js';
204
209
  export type { BookmarkedPost, BookmarkInput, BookmarkItem, BookmarkKey, BookmarkKeyRow, BookmarkKind, BookmarkMergeItem, BookmarkMergeResult, BookmarkProtocol, BookmarkRequestOptions, BookmarksPage, SavedBookmark, UnsavedBookmark, } from './resources/bookmarks.js';
205
210
  export { AccountsResource } from './resources/accounts.js';
206
211
  export type { SiblingAccount, SiblingAccountsResponse, SwitchAccountResult, } from './resources/accounts.js';
package/dist/index.js CHANGED
@@ -63,6 +63,7 @@ import { ModerationResource } from './resources/moderation.js';
63
63
  import { LinkPreviewResource } from './resources/link-preview.js';
64
64
  import { SearchResource } from './resources/search.js';
65
65
  import { BookmarksResource } from './resources/bookmarks.js';
66
+ import { FeedsResource } from './resources/feeds.js';
66
67
  import { AccountsResource } from './resources/accounts.js';
67
68
  import { RealtimeClient } from './realtime.js';
68
69
  export class Recursiv {
@@ -122,6 +123,8 @@ export class Recursiv {
122
123
  jobs;
123
124
  curator;
124
125
  discovery;
126
+ /** The top of a feed: what the member (or a visitor) is most likely to engage with, rotated by what they have seen. */
127
+ feeds;
125
128
  video;
126
129
  appSubscriptions;
127
130
  boosts;
@@ -188,7 +191,8 @@ export class Recursiv {
188
191
  this.webhooks = new WebhooksResource(client);
189
192
  this.jobs = new JobsResource(client);
190
193
  this.curator = new CuratorResource(client);
191
- this.discovery = new DiscoveryResource(this.posts, this.protocols, this.curator, client);
194
+ this.feeds = new FeedsResource(client);
195
+ this.discovery = new DiscoveryResource(this.posts, this.protocols, this.curator, client, this.feeds);
192
196
  this.video = new VideoResource(client);
193
197
  this.appSubscriptions = new AppSubscriptionsResource(client);
194
198
  this.boosts = new BoostsResource(client);
@@ -254,6 +258,7 @@ export { ModerationResource } from './resources/moderation.js';
254
258
  export { LinkPreviewResource } from './resources/link-preview.js';
255
259
  export { SEARCH_SECTIONS, SearchResource } from './resources/search.js';
256
260
  export { BOOKMARK_MERGE_MAX_ITEMS, BookmarksResource } from './resources/bookmarks.js';
261
+ export { FEED_TOP_NETWORKS, FeedsResource } from './resources/feeds.js';
257
262
  export { AccountsResource } from './resources/accounts.js';
258
263
  export { OrganizationSettingsResource } from './resources/organization-settings.js';
259
264
  export { ProjectSettingsResource } from './resources/project-settings.js';
@@ -2,6 +2,7 @@ import type { HttpClient } from '../client.js';
2
2
  import type { CompassFilterMeta, DiscoveryLayout, FalloverStep, MediaItem, Post } from '../types.js';
3
3
  import type { CuratorResource } from './curator.js';
4
4
  import type { DiscoveryLearning } from './discovery-learning.js';
5
+ import type { FeedStory, FeedTopItem, FeedTopNetwork, FeedTopNetworkEntry, FeedsResource } from './feeds.js';
5
6
  import type { PostsResource } from './posts.js';
6
7
  import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar, SocialRadarPosts } from './protocols.js';
7
8
  export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
@@ -284,15 +285,97 @@ export interface DiscoveryRadarMemberSources {
284
285
  }>;
285
286
  }
286
287
  export type DiscoveryRadarPresentation = 'breaking' | 'story';
288
+ /** Where an item sits in the top of the feed: set only on the items a session's top placed on its first page. */
289
+ export interface DiscoveryTopPlacement {
290
+ /** The item's ref in the top (a Minds post id, or '<protocol>/<nativeId>'): what a visible view of it names. */
291
+ ref: string;
292
+ /** Its position in the top, or in its network's lane. */
293
+ position: number;
294
+ /** 'top' for the first 10: send `block: 'top'` and the position with a visible view of one (an impression). */
295
+ block: 'top' | 'below';
296
+ /** Set on an open-network lane's entry: its network. */
297
+ lane?: FeedTopNetwork;
298
+ }
287
299
  export type DiscoveryPostItem = {
288
300
  key: string;
289
301
  kind: 'minds';
290
302
  post: Post;
303
+ top?: DiscoveryTopPlacement;
291
304
  } | {
292
305
  key: string;
293
306
  kind: 'social';
294
307
  post: SocialPost;
308
+ top?: DiscoveryTopPlacement;
295
309
  };
310
+ /** What a session reads of a top answer: the member route's (`feeds.top`) and the visitor read's (`feeds.publicTop`) common shape. */
311
+ export interface DiscoveryTopAnswer {
312
+ items: FeedTopItem[];
313
+ networks: Partial<Record<FeedTopNetwork, FeedTopNetworkEntry[]>> | null;
314
+ lead: FeedStory | null;
315
+ meta?: {
316
+ flag?: {
317
+ impressions?: string;
318
+ };
319
+ };
320
+ }
321
+ /** Reads the top of the session's feed. */
322
+ export type DiscoveryTopSource = (params: {
323
+ ref: string;
324
+ limit: number;
325
+ session?: string;
326
+ }) => Promise<DiscoveryTopAnswer>;
327
+ /** The top of the feed a session opens with (`discovery.createFeed({ top })`, or DiscoveryFeedSession's options). */
328
+ export interface DiscoveryTopOptions {
329
+ /**
330
+ * The feed's ref as the reader takes it: `builtin:all`, `category:<id>`, `following`, `watch` or `story:<id>` for
331
+ * `feeds.top`; `all`, `watch` or `category:<id>` for `feeds.publicTop`.
332
+ */
333
+ ref: string;
334
+ /** Items asked for, 1..25 (default DISCOVERY_TOP_LIMIT). */
335
+ limit?: number;
336
+ /** The app's session token (`feeds.top` keeps one session's order stable with it). */
337
+ session?: string;
338
+ /** The reader. createFeed reads `feeds.top` (with its `compass`); a session built directly has no top without one. */
339
+ read?: DiscoveryTopSource;
340
+ /**
341
+ * Reads one Minds post by id, for a top item the first hot page does not carry, within the top's one deadline
342
+ * (DISCOVERY_TOP_TIMEOUT_MS from the page's start). Defaults to the session's radar Minds reader; createFeed passes
343
+ * `GET /posts/:id` without retries. Without either, a Minds top item shows only when the first hot page carries it.
344
+ */
345
+ post?: (id: string) => Promise<{
346
+ data: Post;
347
+ }>;
348
+ }
349
+ /** What a member saw of a radar story: how often its lead was on screen, and whether they ever opened it. */
350
+ export interface DiscoverySeenCluster {
351
+ n: number;
352
+ clicked: boolean;
353
+ }
354
+ /** The options a session takes besides its readers. With none set, a session answers exactly what 0.7.63's did. */
355
+ export interface DiscoveryFeedSessionOptions {
356
+ /**
357
+ * The top of the feed. Its read starts with the first page's reads, and it holds the page at most
358
+ * DISCOVERY_TOP_TIMEOUT_MS from the page's start, the reads of its items' posts included: a top that fails (404 while
359
+ * it is off, a network error, a timeout) leaves the session exactly as it is without one. The radar's own story reads
360
+ * its member posts as soon as the radar answers, whatever the top does; a different story the top leads with reads its
361
+ * own from the moment the top answers.
362
+ * When it answers, the first page opens with its items (each carrying `top`), its open-network lanes stand in for the
363
+ * networks' first reads (a topics network's previews), its lead is the page's story, and no later hot page repeats an
364
+ * item it placed.
365
+ */
366
+ top?: DiscoveryTopOptions;
367
+ /**
368
+ * What the member already saw (Minds post ids and '<protocol>/<nativeId>' refs, as the app records visible views): left
369
+ * out of the Minds hot pages. A page the filter leaves under DISCOVERY_SEEN_MIN_ITEMS walks on, up to
370
+ * DISCOVERY_SEEN_WALK more hot pages, and the stream's fall-through (`fallover`) still reports when it ends. When the
371
+ * top's order did not use impressions (`meta.flag.impressions` not 'on'), the top's seen items follow its unseen ones.
372
+ */
373
+ seen?: Iterable<string>;
374
+ /** The client's own story rotation, for a session with no server lead: a story shown RADAR_REST_AFTER times with no open is skipped. */
375
+ seenClusters?: ReadonlyMap<string, DiscoverySeenCluster>;
376
+ /** A visitor's rotation bucket (e.g. floor(now / 15 min)): page 0's story rotates among the breaking stories by it. */
377
+ bucket?: number;
378
+ }
296
379
  export type DiscoveryItem = DiscoveryPostItem | {
297
380
  key: string;
298
381
  kind: 'group';
@@ -325,6 +408,22 @@ export declare const DISCOVERY_SOURCE_TIMEOUT_MS = 15000;
325
408
  * "Minds posts didn't load" with Try again and which its Politics fallback (minds#1212) waits for.
326
409
  */
327
410
  export declare const DISCOVERY_SOURCE_RETRIES = 2;
411
+ /**
412
+ * The longest the top of the feed holds the first page, counted from the page's start. The top's read and the reads of
413
+ * the posts its items need (Minds posts by id, a story's network posts) share this one deadline: a top that has not
414
+ * answered by then is left out, and an item whose post was not read by then shows only if the first hot page carries it.
415
+ */
416
+ export declare const DISCOVERY_TOP_TIMEOUT_MS = 1500;
417
+ /** Top items a session asks for unless its options say otherwise: the top block. */
418
+ export declare const DISCOVERY_TOP_LIMIT = 10;
419
+ /** A hot page the seen set (or the top) leaves under this many posts walks on to the next hot page... */
420
+ export declare const DISCOVERY_SEEN_MIN_ITEMS = 5;
421
+ /** ...up to this many more pages in one read; then the page is what it is and the next read goes on from there. */
422
+ export declare const DISCOVERY_SEEN_WALK = 3;
423
+ /** A story whose lead was on screen this many times with no open is skipped (DiscoveryFeedSessionOptions.seenClusters). */
424
+ export declare const RADAR_REST_AFTER = 3;
425
+ /** A visitor's page-0 story rotates by bucket among this many best breaking stories (or best stories when none is breaking). */
426
+ export declare const RADAR_BUCKET_ROTATION = 5;
328
427
  /**
329
428
  * Whether a source's read failed for a reason that passes: it ran past its deadline, its call never got an answer (a
330
429
  * failed connection or an abort, as the session's read reports it), or the server answered 408, 425, 429 or 5xx (or no
@@ -332,6 +431,17 @@ export declare const DISCOVERY_SOURCE_RETRIES = 2;
332
431
  * checking it) and any other 4xx are the source's own failure and are not asked again.
333
432
  */
334
433
  export declare function transientSourceFailure(error: unknown): boolean;
434
+ /** Page 0's story, else a later page's: the client's own pick, for a session with no lead from the top of the feed. */
435
+ export interface RadarPickOptions {
436
+ /** Stories this session already showed. */
437
+ used?: ReadonlySet<string>;
438
+ /** What the member saw of each story: one shown RADAR_REST_AFTER times with no open is skipped. */
439
+ seenClusters?: ReadonlyMap<string, DiscoverySeenCluster>;
440
+ /** A visitor's bucket: page 0's story rotates by it among the RADAR_BUCKET_ROTATION best breaking stories (best stories when none is breaking). */
441
+ bucket?: number;
442
+ }
443
+ /** Page 0: the top breaking story, else the top story. Later pages: the next unused story that is not breaking. */
444
+ export declare function pickRadarCluster(clusters: readonly DiscoveryCluster[], page: number, options?: RadarPickOptions): DiscoveryCluster | undefined;
335
445
  /** json-render Spec: values are references to authenticated SDK results, never
336
446
  * arbitrary executable components, links, generated text or provider markup. */
337
447
  export interface DiscoveryModule {
@@ -407,7 +517,21 @@ export declare class DiscoveryFeedSession {
407
517
  private radarLoading;
408
518
  /** Member reads per story, started as soon as the story is known and shared by whichever page shows it. */
409
519
  private memberReads;
410
- constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined, radar?: DiscoveryRadarSource, members?: DiscoveryRadarMemberSources | undefined);
520
+ /** The top of the feed (options.top with a reader), its read started with page 0's reads, and the keys it placed. */
521
+ private top?;
522
+ private topLoading;
523
+ /** The story the top leads with, once its answer is in (null: it names none); undefined until then. */
524
+ private topLead;
525
+ private topKeys;
526
+ /** What the member already saw (options.seen): left out of the Minds hot pages. */
527
+ private appSeen;
528
+ private seenClusters?;
529
+ private bucket?;
530
+ constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined, radar?: DiscoveryRadarSource, members?: DiscoveryRadarMemberSources | undefined, options?: DiscoveryFeedSessionOptions);
531
+ /** The Minds hot pages leave out what the member saw or the top placed, and walk on past a page that leaves too little. */
532
+ private get filtering();
533
+ /** A Minds hot item the page leaves out: shown on an earlier page, placed by the top, or in the member's seen set. */
534
+ private hiddenFromHot;
411
535
  retryFailed(): void;
412
536
  next(): Promise<DiscoveryFeedPage>;
413
537
  private read;
@@ -421,10 +545,35 @@ export declare class DiscoveryFeedSession {
421
545
  */
422
546
  private walkTopics;
423
547
  private readPage;
424
- /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
548
+ /**
549
+ * Page 0: the top's lead when it names one, else the top breaking cluster, else the top cluster. Every second later
550
+ * page: the next unused story or viral cluster.
551
+ */
425
552
  private radarCluster;
426
- /** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster. */
553
+ /** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster (pickRadarCluster). */
427
554
  private pickCluster;
555
+ /** Page 0's story: the top's lead (from the radar snapshot when it holds it, so its links and members come along), else the client's pick. */
556
+ private leadOf;
557
+ /** One top item as its own module, in the top's order: a video plays as a video module (unless the member's layouts say posts). */
558
+ private topModule;
559
+ /**
560
+ * The top's read, then the posts its items need, all under one deadline of DISCOVERY_TOP_TIMEOUT_MS from the page's
561
+ * start: the reads of its posts get what is left of it, never a deadline of their own. Null when it failed, timed out
562
+ * or answered nothing usable: the page is then as without a top.
563
+ */
564
+ private loadTop;
565
+ /**
566
+ * Checks a top answer and reads what its items need before `deadline`: Minds posts by id, a story's network posts
567
+ * (lanes come with their cards). A lead it names starts reading its member posts at once.
568
+ */
569
+ private readyTop;
570
+ /**
571
+ * Places the top on page 0: each lane stands in for its network's first read (a topics network's catalog previews; a
572
+ * posts network's read follows its lane), and the top's items, read by id or carried by this page, become the page's
573
+ * first items, marked seen so no hot page repeats them. Without impressions in the top's order, the member's seen
574
+ * items follow the unseen ones.
575
+ */
576
+ private placeTop;
428
577
  /** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
429
578
  private memberPosts;
430
579
  private readMembers;
@@ -437,7 +586,8 @@ export declare class DiscoveryResource {
437
586
  private protocols;
438
587
  private curator?;
439
588
  private http?;
440
- constructor(posts: PostsResource, protocols: ProtocolsResource, curator?: Pick<CuratorResource, "forYou"> | undefined, http?: Pick<HttpClient, "get" | "post"> | undefined);
589
+ private feeds?;
590
+ constructor(posts: PostsResource, protocols: ProtocolsResource, curator?: Pick<CuratorResource, "forYou"> | undefined, http?: Pick<HttpClient, "get" | "post"> | undefined, feeds?: Pick<FeedsResource, "top"> | undefined);
441
591
  /**
442
592
  * Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
443
593
  * with several posters, tags used by several people, open-network accounts or stories, overall or in one
@@ -497,6 +647,10 @@ export declare class DiscoveryResource {
497
647
  * Discover's feed session. `compass` (a Politics choice, as `posts.list` takes it) goes on every Minds read, whose
498
648
  * pages then carry `meta.compass_filter`; the open networks' reads and the radar are not filtered by it, so a view
499
649
  * that leaves posts out (Hide politics, Choose sides) is best read with `source: 'minds'`.
650
+ *
651
+ * `top` opens the first page with the top of the feed (`feeds.top`, with the same `compass`, unless `top.read` is
652
+ * given); `seen` leaves what the member saw out of the hot pages; `seenClusters` and `bucket` rotate the story when
653
+ * the top names none (DiscoveryFeedSessionOptions). With none of them the session is what it was before they existed.
500
654
  */
501
655
  createFeed(options: {
502
656
  networks: SocialNetwork[];
@@ -505,5 +659,5 @@ export declare class DiscoveryResource {
505
659
  learning?: DiscoveryLearning;
506
660
  recommend?: DiscoveryRecommendationSources;
507
661
  compass?: string;
508
- }): DiscoveryFeedSession;
662
+ } & DiscoveryFeedSessionOptions): DiscoveryFeedSession;
509
663
  }
@@ -42,6 +42,26 @@ export const DISCOVERY_SOURCE_TIMEOUT_MS = 15_000;
42
42
  * "Minds posts didn't load" with Try again and which its Politics fallback (minds#1212) waits for.
43
43
  */
44
44
  export const DISCOVERY_SOURCE_RETRIES = 2;
45
+ /**
46
+ * The longest the top of the feed holds the first page, counted from the page's start. The top's read and the reads of
47
+ * the posts its items need (Minds posts by id, a story's network posts) share this one deadline: a top that has not
48
+ * answered by then is left out, and an item whose post was not read by then shows only if the first hot page carries it.
49
+ */
50
+ export const DISCOVERY_TOP_TIMEOUT_MS = 1500;
51
+ /** What the top's deadline answers when it passes first. */
52
+ const TOP_LATE = Symbol('top of the feed late');
53
+ /** Top items a session asks for unless its options say otherwise: the top block. */
54
+ export const DISCOVERY_TOP_LIMIT = 10;
55
+ /** Stories whose network posts a top's items are read from, at most. */
56
+ const TOP_MAX_STORY_READS = 3;
57
+ /** A hot page the seen set (or the top) leaves under this many posts walks on to the next hot page... */
58
+ export const DISCOVERY_SEEN_MIN_ITEMS = 5;
59
+ /** ...up to this many more pages in one read; then the page is what it is and the next read goes on from there. */
60
+ export const DISCOVERY_SEEN_WALK = 3;
61
+ /** A story whose lead was on screen this many times with no open is skipped (DiscoveryFeedSessionOptions.seenClusters). */
62
+ export const RADAR_REST_AFTER = 3;
63
+ /** A visitor's page-0 story rotates by bucket among this many best breaking stories (or best stories when none is breaking). */
64
+ export const RADAR_BUCKET_ROTATION = 5;
45
65
  /** The page deadline a source's read ran past. */
46
66
  class SourceTimedOut extends Error {
47
67
  constructor() { super('Discovery source timed out'); this.name = 'SourceTimedOut'; }
@@ -168,6 +188,103 @@ function radarCluster(value) {
168
188
  return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount: mainstream + independent, laneMix: { mainstream, independent }, sources,
169
189
  velocity: { normalized: typeof normalized === 'number' && Number.isFinite(normalized) && normalized >= 0 ? normalized : 0 }, newestAt, ...(lead ? { lead } : {}), links, memberKeys };
170
190
  }
191
+ /**
192
+ * The top's lead story as a cluster, for a page whose radar snapshot does not hold it (the radar read failed or timed
193
+ * out): its label, kind, theme, sources and freshness, with no links or member posts (the app opens the story by id).
194
+ */
195
+ function storyCluster(value) {
196
+ if (!value || typeof value !== 'object')
197
+ return null;
198
+ const s = value;
199
+ if (typeof s.id !== 'string' || !/^[0-9a-f]{64}$/.test(s.id) || !nonEmpty(s.label) || typeof s.kind !== 'string' || !RADAR_KINDS.has(s.kind))
200
+ return null;
201
+ const newestAt = (s.freshness ?? {}).newestAt;
202
+ if (typeof newestAt !== 'string' || !Number.isFinite(Date.parse(newestAt)))
203
+ return null;
204
+ const sources = {};
205
+ if (s.sources && typeof s.sources === 'object')
206
+ for (const [name, count] of Object.entries(s.sources)) {
207
+ const n = countOf(count);
208
+ if (n !== null && /^[a-z]{1,32}$/.test(name))
209
+ sources[name] = n;
210
+ }
211
+ const theme = typeof s.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(s.theme) ? s.theme : undefined;
212
+ return { id: s.id, label: s.label.slice(0, 300), kind: s.kind, ...(theme ? { theme } : {}), outletCount: sources.outlet ?? 0,
213
+ laneMix: { mainstream: 0, independent: 0 }, sources, velocity: { normalized: 0 }, newestAt, links: [], memberKeys: [] };
214
+ }
215
+ /** Page 0: the top breaking story, else the top story. Later pages: the next unused story that is not breaking. */
216
+ export function pickRadarCluster(clusters, page, options = {}) {
217
+ const rested = (cluster) => { const seen = options.seenClusters?.get(cluster.id); return !!seen && !seen.clicked && seen.n >= RADAR_REST_AFTER; };
218
+ const unused = clusters.filter(candidate => !options.used?.has(candidate.id) && !rested(candidate));
219
+ if (page !== 0)
220
+ return unused.find(candidate => candidate.kind !== 'breaking');
221
+ const breaking = unused.filter(candidate => candidate.kind === 'breaking');
222
+ if (options.bucket !== undefined && Number.isSafeInteger(options.bucket)) {
223
+ const turn = (breaking.length ? breaking : unused).slice(0, RADAR_BUCKET_ROTATION);
224
+ if (turn.length)
225
+ return turn[((options.bucket % turn.length) + turn.length) % turn.length];
226
+ }
227
+ return breaking[0] ?? unused[0];
228
+ }
229
+ const TOP_NETWORKS = new Set(['bluesky', 'activitypub', 'nostr']);
230
+ const idString = (value) => typeof value === 'string' && value.length > 0 && value.length <= 4096;
231
+ const counted = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
232
+ /** The refs an app's seen set may hold for a post: its id, or '<protocol>/<nativeId>' (an ActivityPub id also without https://). */
233
+ function seenRefsOf(protocol, id) {
234
+ if (protocol === 'minds')
235
+ return [id];
236
+ const ref = `${protocol}/${id}`;
237
+ return protocol === 'activitypub' && /^https:\/\//i.test(id) ? [ref, `${protocol}/${id.replace(/^https:\/\//i, '')}`] : [ref];
238
+ }
239
+ function topRow(value) {
240
+ if (!value || typeof value !== 'object')
241
+ return null;
242
+ const item = value;
243
+ const target = (item.target ?? {});
244
+ if (!idString(item.ref))
245
+ return null;
246
+ const explain = (item.explain ?? {});
247
+ const cluster = typeof explain.cluster === 'string' && /^[0-9a-f]{64}$/.test(explain.cluster) ? explain.cluster : null;
248
+ if (item.kind === 'post' && target.kind === 'post' && nonEmpty(target.id))
249
+ return { ref: item.ref, key: key('minds', target.id), kind: 'minds', id: target.id, cluster, seenRefs: seenRefsOf('minds', target.id) };
250
+ if (item.kind === 'network_post' && target.kind === 'network_post' && typeof target.protocol === 'string' && TOP_NETWORKS.has(target.protocol) && idString(target.nativeId)) {
251
+ return { ref: item.ref, key: key(target.protocol, target.nativeId), kind: 'social', id: target.nativeId, cluster, seenRefs: seenRefsOf(target.protocol, target.nativeId) };
252
+ }
253
+ return null;
254
+ }
255
+ /**
256
+ * One open-network lane entry as a post the session can show: the card's text, author, media and counts. The card
257
+ * carries no URL on the post's network, so neither does the post (`sourceUrl` and the author's `profileUrl` are empty):
258
+ * the app opens it by its ref, in the app.
259
+ */
260
+ function laneItem(value, protocol) {
261
+ if (!value || typeof value !== 'object')
262
+ return null;
263
+ const entry = value;
264
+ const target = (entry.target ?? {});
265
+ const card = (entry.card ?? null);
266
+ const author = (card?.author ?? null);
267
+ if (entry.protocol !== protocol || !idString(entry.ref) || target.kind !== 'network_post' || target.protocol !== protocol || !idString(target.nativeId))
268
+ return null;
269
+ if (!card || typeof card.text !== 'string' || typeof card.createdAt !== 'string' || !author || !idString(author.nativeId) || !nonEmpty(author.handle))
270
+ return null;
271
+ const counts = (card.counts ?? {});
272
+ const sensitive = card.sensitive === true;
273
+ const media = [];
274
+ for (const raw of Array.isArray(card.media) ? card.media.slice(0, 8) : []) {
275
+ const m = (raw ?? {});
276
+ if ((m.type !== 'image' && m.type !== 'video') || !httpUrl(m.url))
277
+ continue;
278
+ media.push({ type: m.type, url: m.url, alt: typeof m.alt === 'string' ? m.alt : '', ...(httpUrl(m.poster) ? { poster: m.poster } : {}),
279
+ ...(dimension(m.width) && dimension(m.height) ? { width: dimension(m.width), height: dimension(m.height) } : {}), ...(sensitive ? { sensitive: true } : {}) });
280
+ }
281
+ const post = { protocol, nativeId: target.nativeId, sourceUrl: '', ...(nonEmpty(target.readRef) ? { readRef: target.readRef } : {}),
282
+ author: { protocol, nativeId: author.nativeId, handle: author.handle, displayName: nonEmpty(author.displayName) ? author.displayName : author.handle, description: '',
283
+ profileUrl: '', avatarUrl: httpUrl(author.avatarUrl) ? author.avatarUrl : null, followersCount: null, followingCount: null, postsCount: null },
284
+ text: card.text, createdAt: card.createdAt, replyCount: counted(counts.replies), likeCount: counted(counts.likes), repostCount: counted(counts.reposts),
285
+ media, hasAdditionalContent: card.hasQuote === true, ...(sensitive ? { sensitive: true } : {}) };
286
+ return { key: key(protocol, target.nativeId), kind: 'social', post, top: { ref: entry.ref, position: 0, block: 'top', lane: protocol } };
287
+ }
171
288
  const FALLOVER_VIA = new Set(['personal', 'popular', 'static']);
172
289
  const filled = (value) => typeof value === 'string' && value.length > 0;
173
290
  /**
@@ -234,7 +351,17 @@ export class DiscoveryFeedSession {
234
351
  radarLoading = null;
235
352
  /** Member reads per story, started as soon as the story is known and shared by whichever page shows it. */
236
353
  memberReads = new Map();
237
- constructor(posts, protocols, networks, category, source, learning, recommend, radar, members) {
354
+ /** The top of the feed (options.top with a reader), its read started with page 0's reads, and the keys it placed. */
355
+ top;
356
+ topLoading = null;
357
+ /** The story the top leads with, once its answer is in (null: it names none); undefined until then. */
358
+ topLead;
359
+ topKeys = new Set();
360
+ /** What the member already saw (options.seen): left out of the Minds hot pages. */
361
+ appSeen = null;
362
+ seenClusters;
363
+ bucket;
364
+ constructor(posts, protocols, networks, category, source, learning, recommend, radar, members, options = {}) {
238
365
  this.posts = posts;
239
366
  this.protocols = protocols;
240
367
  this.category = category;
@@ -247,6 +374,19 @@ export class DiscoveryFeedSession {
247
374
  const sources = [...new Set(networks.filter(n => n.capabilities.includes('public_trends')).map(n => n.protocol))].slice(0, 16);
248
375
  this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
249
376
  .map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
377
+ const { top, seen, seenClusters, bucket } = options;
378
+ if (top && typeof top.read === 'function' && nonEmpty(top.ref))
379
+ this.top = { ...top, read: top.read };
380
+ if (seen)
381
+ this.appSeen = new Set([...seen].filter((ref) => typeof ref === 'string').slice(0, 10_000));
382
+ this.seenClusters = seenClusters;
383
+ this.bucket = bucket;
384
+ }
385
+ /** The Minds hot pages leave out what the member saw or the top placed, and walk on past a page that leaves too little. */
386
+ get filtering() { return !!this.appSeen || this.topKeys.size > 0; }
387
+ /** A Minds hot item the page leaves out: shown on an earlier page, placed by the top, or in the member's seen set. */
388
+ hiddenFromHot(item) {
389
+ return this.seen.has(item.key) || this.topKeys.has(item.key) || (item.kind === 'minds' && !!this.appSeen?.has(item.post.id));
250
390
  }
251
391
  retryFailed() { for (const stream of this.streams) {
252
392
  stream.failed = false;
@@ -272,20 +412,44 @@ export class DiscoveryFeedSession {
272
412
  // opt-out rides in the params (ListPostsParams.retry), which a reader handed to a session forwards to read the
273
413
  // right page at all: a second argument was dropped by the Minds app's own wrappers (lib/discoverFeedResource,
274
414
  // lib/compassStatus withCompass, lib/watchSource), which pass the params alone (review of #4047).
275
- const response = await this.posts.list({ ...params, sort: 'hot', ...(stream.cursor ? { cursor: stream.cursor } : { offset: stream.offset }), retry: false });
276
- stream.offset += response.meta.scanned ?? response.meta.limit;
277
- const next = response.meta.next_cursor ?? undefined;
278
- stream.done = !response.meta.has_more || !!next && (next === stream.cursor || stream.used.includes(next));
279
- if (stream.cursor)
280
- stream.used = [...stream.used, stream.cursor].slice(-32);
281
- stream.cursor = next;
282
- // A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
283
- const fallover = readFallover(response.meta.fallover);
284
- if (fallover)
285
- stream.fallover = fallover;
286
- return { items: response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post })),
287
- layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial',
288
- ...(response.meta.compass_filter ? { compass: response.meta.compass_filter } : {}) };
415
+ // With a seen set or a top, a hot page leaves out what the member saw or the top placed; one left under
416
+ // DISCOVERY_SEEN_MIN_ITEMS walks on to the next hot page, up to DISCOVERY_SEEN_WALK more, so the feed never looks
417
+ // ended while the ranking goes on. Each kept post keeps its own window's layout. Without either, one page as before.
418
+ const kept = [];
419
+ const layouts = [];
420
+ let head;
421
+ let partial = false;
422
+ let filtered = false;
423
+ for (let walked = 0;; walked++) {
424
+ const response = await this.posts.list({ ...params, sort: 'hot', ...(stream.cursor ? { cursor: stream.cursor } : { offset: stream.offset }), retry: false });
425
+ stream.offset += response.meta.scanned ?? response.meta.limit;
426
+ const next = response.meta.next_cursor ?? undefined;
427
+ stream.done = !response.meta.has_more || !!next && (next === stream.cursor || stream.used.includes(next));
428
+ if (stream.cursor)
429
+ stream.used = [...stream.used, stream.cursor].slice(-32);
430
+ stream.cursor = next;
431
+ // A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
432
+ const fallover = readFallover(response.meta.fallover);
433
+ if (fallover)
434
+ stream.fallover = fallover;
435
+ const items = response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post }));
436
+ const read = { layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial',
437
+ ...(response.meta.compass_filter ? { compass: response.meta.compass_filter } : {}) };
438
+ if (!this.filtering)
439
+ return { items, ...read };
440
+ head ??= read;
441
+ partial ||= read.partial;
442
+ items.forEach((item, index) => {
443
+ if (this.hiddenFromHot(item) || kept.some(row => row.key === item.key)) {
444
+ filtered = true;
445
+ return;
446
+ }
447
+ kept.push(item);
448
+ layouts.push(windowLayout(read.layout, index));
449
+ });
450
+ if (kept.length >= DISCOVERY_SEEN_MIN_ITEMS || !filtered || stream.done || walked >= DISCOVERY_SEEN_WALK)
451
+ return { items: kept, ...head, partial, layouts };
452
+ }
289
453
  }
290
454
  const validatePosts = (data) => {
291
455
  if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(post => post.protocol !== stream.protocol || post.author.protocol !== stream.protocol))
@@ -308,7 +472,7 @@ export class DiscoveryFeedSession {
308
472
  // not a round later, so the network's first page in a category is not empty by design.
309
473
  const previews = data.items.flatMap(topic => !this.category && topic.preview ? [{ key: key(topic.preview.protocol, topic.preview.nativeId), kind: 'social', post: topic.preview }] : []);
310
474
  if (previews.length || stream.done)
311
- return { items: previews, layout: ordinary, partial };
475
+ return { items: previews, layout: ordinary, partial, ...(previews.length ? { previews: true } : {}) };
312
476
  return this.walkTopics(stream, params, validatePosts, partial, started);
313
477
  }
314
478
  validatePosts(data);
@@ -377,10 +541,21 @@ export class DiscoveryFeedSession {
377
541
  async readPage() {
378
542
  // The radar is one small snapshot read: start it with the page, not after the slowest provider,
379
543
  // and start the lead story's member reads the moment the snapshot names it.
544
+ // The top of the feed starts with them too, and holds the page at most DISCOVERY_TOP_TIMEOUT_MS from here (loadTop).
545
+ // Neither waits for the other: the radar's own pick reads its members as soon as the radar answers (unless the top
546
+ // already named its lead), and a lead the top names reads its own as soon as the top answers (readyTop). A lead that
547
+ // is the radar's pick shares the one read (memberPosts).
548
+ if (this.page === 0 && this.top && !this.topLoading)
549
+ this.topLoading = this.loadTop();
380
550
  if (this.page === 0 && this.radar && !this.radarLoading) {
381
551
  this.radarLoading = this.loadRadar();
382
- void this.radarLoading.then(clusters => { const lead = this.pickCluster(clusters, 0); if (lead)
383
- void this.memberPosts(lead); });
552
+ void this.radarLoading.then(clusters => {
553
+ if (this.topLead)
554
+ return;
555
+ const lead = this.pickCluster(clusters, 0);
556
+ if (lead)
557
+ void this.memberPosts(lead);
558
+ });
384
559
  }
385
560
  // A network that failed for a passing reason sat out the page after its failure; from this one it takes its turn
386
561
  // again. It stays failed, and named in retryingSources, until a page asks it: with more than three networks the
@@ -422,6 +597,9 @@ export class DiscoveryFeedSession {
422
597
  cancel();
423
598
  }
424
599
  }));
600
+ // Page 0 opens with the top of the feed when it answered: its items lead, its lanes stand in for the networks' first reads.
601
+ const top = this.page === 0 && this.topLoading ? await this.topLoading : null;
602
+ const topItems = top ? this.placeTop(top, selected, reads) : [];
425
603
  // Round-robin before grouping: the largest network cannot occupy every slot.
426
604
  // Each item carries the judgment for its own window of its source response,
427
605
  // so one page can feature several modules instead of one per source batch.
@@ -432,18 +610,20 @@ export class DiscoveryFeedSession {
432
610
  if (!item || this.seen.has(item.key))
433
611
  continue;
434
612
  this.seen.add(item.key);
435
- mixed.push({ item, layout: windowLayout(read.layout, index) });
613
+ mixed.push({ item, layout: read.layouts?.[index] ?? windowLayout(read.layout, index) });
436
614
  }
437
615
  // Deduplication memory is bounded independently of the UI's virtual window.
438
616
  while (this.seen.size > 4096)
439
617
  this.seen.delete(this.seen.values().next().value);
440
- const modules = [];
618
+ const modules = topItems.map((item, index) => this.topModule(this.page, index, item));
441
619
  const page = this.page++;
620
+ const topCount = modules.length;
442
621
  let remaining = mixed;
443
- // The radar leads the first page with the top breaking story and returns
622
+ // The radar leads the first page with the top breaking story (the top's lead, when it names one) and returns
444
623
  // every second page with the next one. Its member posts move under the
445
624
  // story, on this page or on any later one, so the hot stream never repeats them.
446
- const cluster = mixed.length ? await this.radarCluster(page) : null;
625
+ // A top that names a story leads with it even when no post came with the top or the reads (Codex review of #4205).
626
+ const cluster = mixed.length || topCount || top?.lead ? await this.radarCluster(page, top) : null;
447
627
  let clusterItems = [];
448
628
  if (cluster) {
449
629
  const members = new Set(cluster.memberKeys);
@@ -495,7 +675,7 @@ export class DiscoveryFeedSession {
495
675
  // the opening modules so the page still leads with content.
496
676
  const recommendation = modules.length ? await this.recommendation(page) : null;
497
677
  if (recommendation)
498
- modules.splice(Math.min(2, modules.length), 0, recommendation);
678
+ modules.splice(Math.min(topCount + 2, modules.length), 0, recommendation);
499
679
  if (cluster)
500
680
  modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
501
681
  const fallover = this.streams.find(stream => stream.protocol === 'minds')?.fallover;
@@ -507,12 +687,20 @@ export class DiscoveryFeedSession {
507
687
  ...(retrying.length ? { retryingSources: retrying } : {}), partialCategories: reads.some(read => read.partial),
508
688
  ...(fallover ? { fallover } : {}), ...(compass ? { compass } : {}) };
509
689
  }
510
- /** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
511
- async radarCluster(page) {
690
+ /**
691
+ * Page 0: the top's lead when it names one, else the top breaking cluster, else the top cluster. Every second later
692
+ * page: the next unused story or viral cluster.
693
+ */
694
+ async radarCluster(page, top = null) {
512
695
  if (!this.radar)
513
696
  return null;
514
697
  if (page === 0)
515
698
  this.clusters = await (this.radarLoading ?? this.loadRadar());
699
+ if (page === 0 && top?.lead) {
700
+ const lead = this.leadOf(this.clusters ?? [], top);
701
+ this.radarUsed.add(lead.id);
702
+ return this.learning && this.learning.choose(lead.kind === 'breaking' ? 'breaking' : 'story', page) === 'posts' ? null : lead;
703
+ }
516
704
  if (!this.clusters?.length || (page !== 0 && page % 2 !== 0))
517
705
  return null;
518
706
  const cluster = this.pickCluster(this.clusters, page);
@@ -524,10 +712,139 @@ export class DiscoveryFeedSession {
524
712
  return null;
525
713
  return cluster;
526
714
  }
527
- /** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster. */
715
+ /** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster (pickRadarCluster). */
528
716
  pickCluster(clusters, page) {
529
- const unused = clusters.filter(candidate => !this.radarUsed.has(candidate.id));
530
- return page === 0 ? unused.find(candidate => candidate.kind === 'breaking') ?? unused[0] : unused.find(candidate => candidate.kind !== 'breaking');
717
+ return pickRadarCluster(clusters, page, { used: this.radarUsed, seenClusters: this.seenClusters, bucket: this.bucket });
718
+ }
719
+ /** Page 0's story: the top's lead (from the radar snapshot when it holds it, so its links and members come along), else the client's pick. */
720
+ leadOf(clusters, top) {
721
+ if (top?.lead)
722
+ return clusters.find(candidate => candidate.id === top.lead.id) ?? top.lead;
723
+ return this.pickCluster(clusters, 0);
724
+ }
725
+ /** One top item as its own module, in the top's order: a video plays as a video module (unless the member's layouts say posts). */
726
+ topModule(page, index, item) {
727
+ const video = !!item.post.media?.some(media => media.type === 'video') && (this.learning?.choose('video', page) ?? 'video') === 'video';
728
+ return { id: `discovery-${page}-top-${index}-${item.key}`, presentation: video ? 'video' : 'posts', source: 'fallback', items: [item],
729
+ spec: { root: 'module', elements: { module: { type: video ? 'DiscoveryVideo' : 'DiscoveryPosts', props: { itemKeys: [item.key] }, children: [] } } } };
730
+ }
731
+ /**
732
+ * The top's read, then the posts its items need, all under one deadline of DISCOVERY_TOP_TIMEOUT_MS from the page's
733
+ * start: the reads of its posts get what is left of it, never a deadline of their own. Null when it failed, timed out
734
+ * or answered nothing usable: the page is then as without a top.
735
+ */
736
+ async loadTop() {
737
+ const top = this.top;
738
+ let timer;
739
+ const deadline = new Promise(resolve => { timer = setTimeout(() => resolve(TOP_LATE), DISCOVERY_TOP_TIMEOUT_MS); });
740
+ try {
741
+ const limit = Number.isSafeInteger(top.limit) && top.limit >= 1 && top.limit <= 25 ? top.limit : DISCOVERY_TOP_LIMIT;
742
+ const answer = await Promise.race([Promise.resolve().then(() => top.read({ ref: top.ref, limit, ...(nonEmpty(top.session) ? { session: top.session } : {}) })), deadline]);
743
+ if (answer === TOP_LATE)
744
+ return null;
745
+ return await this.readyTop(answer, deadline);
746
+ }
747
+ catch {
748
+ return null;
749
+ }
750
+ finally {
751
+ clearTimeout(timer);
752
+ }
753
+ }
754
+ /**
755
+ * Checks a top answer and reads what its items need before `deadline`: Minds posts by id, a story's network posts
756
+ * (lanes come with their cards). A lead it names starts reading its member posts at once.
757
+ */
758
+ async readyTop(answer, deadline) {
759
+ if (!answer || typeof answer !== 'object')
760
+ return null;
761
+ const value = answer;
762
+ if (!Array.isArray(value.items))
763
+ return null;
764
+ // Only what this session reads: a Minds item needs its Minds stream, a network's item or lane that network's stream.
765
+ const streams = new Set(this.streams.map(stream => stream.protocol));
766
+ const rows = [];
767
+ for (const raw of value.items.slice(0, 25)) {
768
+ const row = topRow(raw);
769
+ if (row && !rows.some(existing => existing.key === row.key) && streams.has(row.kind === 'minds' ? 'minds' : JSON.parse(row.key)[0]))
770
+ rows.push(row);
771
+ }
772
+ const lanes = new Map();
773
+ const networks = value.networks && typeof value.networks === 'object' ? value.networks : {};
774
+ for (const protocol of TOP_NETWORKS) {
775
+ const entries = networks[protocol];
776
+ if (!streams.has(protocol) || !Array.isArray(entries))
777
+ continue;
778
+ const lane = [];
779
+ for (const raw of entries.slice(0, 12)) {
780
+ const item = laneItem(raw, protocol);
781
+ if (item && !lane.some(existing => existing.key === item.key))
782
+ lane.push({ ...item, top: { ...item.top, position: lane.length, block: lane.length < 10 ? 'top' : 'below' } });
783
+ }
784
+ if (lane.length)
785
+ lanes.set(protocol, lane);
786
+ }
787
+ const lead = this.radar ? storyCluster(value.lead) : null;
788
+ const impressions = (value.meta?.flag?.impressions) === 'on';
789
+ if (!rows.length && !lanes.size && !lead)
790
+ return null;
791
+ // The page's story is the top's lead: its member reads start now, alongside the reads below, not after them.
792
+ this.topLead = lead?.id ?? null;
793
+ if (lead && this.radarLoading)
794
+ void this.radarLoading.then(clusters => this.memberPosts(clusters.find(candidate => candidate.id === lead.id) ?? lead));
795
+ // The posts the rows need, read at once under the top's one deadline: a Minds post by id; a network post from its
796
+ // lane's card, else from its story's member posts. A row nothing read in time is left out (the first hot page may
797
+ // still carry it).
798
+ const inTime = (read, fallback) => Promise.race([Promise.resolve().then(read).catch(() => fallback), deadline.then(() => fallback)]);
799
+ const posts = new Map();
800
+ for (const lane of lanes.values())
801
+ for (const item of lane)
802
+ posts.set(item.key, { key: item.key, kind: 'social', post: item.post });
803
+ const readPost = this.top?.post ?? this.members?.minds;
804
+ const mindsReads = readPost ? rows.filter(row => row.kind === 'minds').map(row => inTime(() => readPost(row.id).then(({ data }) => data && data.id === row.id ? { key: row.key, kind: 'minds', post: data } : null), null)) : [];
805
+ const stories = [...new Set(rows.filter(row => row.kind === 'social' && !posts.has(row.key) && row.cluster).map(row => row.cluster))].slice(0, TOP_MAX_STORY_READS);
806
+ const wanted = new Set(rows.filter(row => row.kind === 'social').map(row => row.key));
807
+ const social = this.members?.social;
808
+ const storyReads = social ? stories.map(cluster => inTime(() => social({ cluster }).then(({ data }) => !data || data.cluster !== cluster || !Array.isArray(data.items) ? [] : data.items
809
+ .filter(post => post && idString(post.nativeId) && post.author?.protocol === post.protocol && wanted.has(key(post.protocol, post.nativeId)))
810
+ .map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post }))), [])) : [];
811
+ const [minds, network] = await Promise.all([Promise.all(mindsReads), Promise.all(storyReads)]);
812
+ for (const item of [...minds, ...network.flat()])
813
+ if (item && !posts.has(item.key))
814
+ posts.set(item.key, item);
815
+ return { rows, posts, lanes, lead, impressions };
816
+ }
817
+ /**
818
+ * Places the top on page 0: each lane stands in for its network's first read (a topics network's catalog previews; a
819
+ * posts network's read follows its lane), and the top's items, read by id or carried by this page, become the page's
820
+ * first items, marked seen so no hot page repeats them. Without impressions in the top's order, the member's seen
821
+ * items follow the unseen ones.
822
+ */
823
+ placeTop(top, selected, reads) {
824
+ for (const [protocol, lane] of top.lanes) {
825
+ const at = selected.findIndex(stream => stream.protocol === protocol);
826
+ const read = at >= 0 ? reads[at] : null;
827
+ if (read) {
828
+ const kept = read.previews ? [] : read.items;
829
+ reads[at] = { ...read, items: [...lane, ...kept], layouts: [...lane.map(() => ordinary), ...kept.map((_item, index) => read.layouts?.[index] ?? windowLayout(read.layout, index))] };
830
+ }
831
+ else if (this.streams.some(stream => stream.protocol === protocol))
832
+ reads.push({ items: lane, layout: ordinary, partial: false });
833
+ }
834
+ const carried = new Map(reads.flatMap(read => read.items.map(item => [item.key, item])));
835
+ const seen = (row) => !!this.appSeen && row.seenRefs.some(ref => this.appSeen.has(ref));
836
+ const rows = top.impressions || !this.appSeen ? top.rows : [...top.rows.filter(row => !seen(row)), ...top.rows.filter(seen)];
837
+ const placed = [];
838
+ for (const row of rows) {
839
+ const found = top.posts.get(row.key) ?? carried.get(row.key);
840
+ if (!found || this.seen.has(row.key))
841
+ continue;
842
+ const position = placed.length;
843
+ placed.push({ ...found, top: { ref: row.ref, position, block: position < 10 ? 'top' : 'below' } });
844
+ this.seen.add(row.key);
845
+ this.topKeys.add(row.key);
846
+ }
847
+ return placed;
531
848
  }
532
849
  /** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
533
850
  memberPosts(cluster) {
@@ -645,11 +962,13 @@ export class DiscoveryResource {
645
962
  protocols;
646
963
  curator;
647
964
  http;
648
- constructor(posts, protocols, curator, http) {
965
+ feeds;
966
+ constructor(posts, protocols, curator, http, feeds) {
649
967
  this.posts = posts;
650
968
  this.protocols = protocols;
651
969
  this.curator = curator;
652
970
  this.http = http;
971
+ this.feeds = feeds;
653
972
  }
654
973
  /**
655
974
  * Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
@@ -726,12 +1045,23 @@ export class DiscoveryResource {
726
1045
  * Discover's feed session. `compass` (a Politics choice, as `posts.list` takes it) goes on every Minds read, whose
727
1046
  * pages then carry `meta.compass_filter`; the open networks' reads and the radar are not filtered by it, so a view
728
1047
  * that leaves posts out (Hide politics, Choose sides) is best read with `source: 'minds'`.
1048
+ *
1049
+ * `top` opens the first page with the top of the feed (`feeds.top`, with the same `compass`, unless `top.read` is
1050
+ * given); `seen` leaves what the member saw out of the hot pages; `seenClusters` and `bucket` rotate the story when
1051
+ * the top names none (DiscoveryFeedSessionOptions). With none of them the session is what it was before they existed.
729
1052
  */
730
1053
  createFeed(options) {
731
1054
  const compass = options.compass;
732
1055
  const posts = compass ? { list: params => this.posts.list({ ...params, compass }) } : this.posts;
1056
+ const feeds = this.feeds;
1057
+ const http = this.http;
1058
+ const read = options.top?.read ?? (feeds ? params => feeds.top({ ...params, ...(compass ? { compass } : {}) }, NO_RETRY) : undefined);
1059
+ // A top item the first hot page does not carry is read by id once, never retried: the page holds its own deadline.
1060
+ const post = options.top?.post ?? (http ? (id) => http.get(`/posts/${encodeURIComponent(id)}`, undefined, NO_RETRY) : undefined);
1061
+ const top = options.top && read ? { ...options.top, read, ...(post ? { post } : {}) } : undefined;
733
1062
  // The radar rides along by default; an older server answers 404 and the session simply shows no story module.
734
- return new DiscoveryFeedSession(posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend, params => this.protocols.getPublicRadar(params, NO_RETRY), { social: params => this.protocols.getPublicRadarPosts(params, NO_RETRY), minds: id => this.posts.get(id) });
1063
+ return new DiscoveryFeedSession(posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend, params => this.protocols.getPublicRadar(params, NO_RETRY), { social: params => this.protocols.getPublicRadarPosts(params, NO_RETRY), minds: id => this.posts.get(id) }, { ...(top ? { top } : {}), ...(options.seen ? { seen: options.seen } : {}), ...(options.seenClusters ? { seenClusters: options.seenClusters } : {}),
1064
+ ...(options.bucket !== undefined ? { bucket: options.bucket } : {}) });
735
1065
  }
736
1066
  }
737
1067
  /** A read bounded to three seconds; a late or failed one resolves to `fallback`, never an error. */
@@ -0,0 +1,228 @@
1
+ import type { HttpClient } from '../client.js';
2
+ import type { CompassFilterMeta } from '../types.js';
3
+ /**
4
+ * The top of a feed: what the member (or a visitor) is most likely to engage with, rotated by what they have seen.
5
+ * `feeds.top` and `feeds.stories` read it for a member with their own key of the app; `feeds.publicTop` reads a
6
+ * visitor's top keylessly. A feed session (`discovery.createFeed({ top })`) opens its first page with it.
7
+ *
8
+ * Every tap target is in the app: a Minds post by id, an open-network post by `/social/post/<protocol>/<id>`, a story
9
+ * by id. Nothing here is a URL on another network.
10
+ */
11
+ /** The open networks whose lanes a top carries. */
12
+ export type FeedTopNetwork = 'bluesky' | 'activitypub' | 'nostr';
13
+ export declare const FEED_TOP_NETWORKS: readonly FeedTopNetwork[];
14
+ /** Where a tap on an item goes, always in the app. `path` is the app's own page of a network post, never its URL there. */
15
+ export type FeedTopTarget = {
16
+ kind: 'post';
17
+ id: string;
18
+ } | {
19
+ kind: 'network_post';
20
+ protocol: string;
21
+ nativeId: string;
22
+ path: string;
23
+ readRef?: string;
24
+ };
25
+ /** Why an item is where it is. */
26
+ export interface FeedTopExplain {
27
+ score: number;
28
+ engagement: {
29
+ source: string;
30
+ rate: number;
31
+ };
32
+ media: string;
33
+ origin: string;
34
+ /** Where the item came from: the Minds ranking, Watch's list, a radar story, the member's follows, or a network's trends. */
35
+ source: 'minds' | 'watch' | 'radar' | 'following' | 'trends';
36
+ /** 'safe' for everyone; 'open' only for a member who turned safe defaults off. */
37
+ label: 'safe' | 'open';
38
+ cluster: string | null;
39
+ topic: string | null;
40
+ /** 'demoted': the member viewed it in the last 6 hours (it sits below rank 20). A visitor's items are all 'fresh'. */
41
+ impression: 'fresh' | 'demoted';
42
+ }
43
+ /** One item of the top: a Minds post (`ref` is its id) or an open-network post (`ref` is '<protocol>/<nativeId>'). */
44
+ export interface FeedTopItem {
45
+ ref: string;
46
+ kind: 'post' | 'network_post';
47
+ authorId: string;
48
+ target: FeedTopTarget;
49
+ position: number;
50
+ /** 'top' for the first 10: send `block: 'top'` and the position with a visible view of one (an impression). */
51
+ block: 'top' | 'below';
52
+ explain: FeedTopExplain;
53
+ }
54
+ /** What an app draws for an open-network lane entry. No URL on the post's network: the app opens it by `target.path`. */
55
+ export interface FeedTopNetworkCard {
56
+ text: string;
57
+ createdAt: string;
58
+ author: {
59
+ nativeId: string;
60
+ handle: string;
61
+ displayName: string;
62
+ avatarUrl: string | null;
63
+ };
64
+ media: Array<{
65
+ type: 'image' | 'video';
66
+ url: string;
67
+ alt: string;
68
+ poster?: string;
69
+ width?: number;
70
+ height?: number;
71
+ }>;
72
+ counts: {
73
+ likes: number | null;
74
+ replies: number | null;
75
+ reposts: number | null;
76
+ };
77
+ sensitive: boolean;
78
+ /** The post quotes another; read the quote when the post is opened. */
79
+ hasQuote: boolean;
80
+ }
81
+ /** One entry of an open-network lane: up to six per network, ranked by engagement and rotated by impressions. */
82
+ export interface FeedTopNetworkEntry extends FeedTopItem {
83
+ kind: 'network_post';
84
+ protocol: FeedTopNetwork;
85
+ card: FeedTopNetworkCard;
86
+ }
87
+ /** A story as the top names it: its id and in-app target, never an outlet's or a network's URL. */
88
+ export interface FeedStory {
89
+ id: string;
90
+ /** 'cluster:<id>': what an impression of the story names. */
91
+ ref: string;
92
+ target: {
93
+ kind: 'story';
94
+ id: string;
95
+ };
96
+ label: string;
97
+ kind: 'breaking' | 'viral' | 'story';
98
+ breaking: boolean;
99
+ theme: string | null;
100
+ sources: Partial<Record<'outlet' | 'hackernews' | 'bluesky' | 'activitypub' | 'nostr' | 'minds', number>>;
101
+ media: {
102
+ images: number;
103
+ videos: number;
104
+ };
105
+ freshness: {
106
+ newestAt: string;
107
+ oldestAt: string;
108
+ };
109
+ explain: {
110
+ score: number;
111
+ parts: {
112
+ ageHours: number;
113
+ engagement: number;
114
+ media: number;
115
+ kind: number;
116
+ freshness: number;
117
+ };
118
+ impression: 'fresh' | 'demoted';
119
+ };
120
+ }
121
+ export interface FeedTopMeta {
122
+ ref: string;
123
+ poolBuiltAt: string;
124
+ poolSize: number;
125
+ /** Fewer items than asked for (a thin pool, the caps, or only items the member saw recently). */
126
+ short: boolean;
127
+ /** `impressions`: 'on' when the order uses what the member saw; otherwise the order is the pool's, jittered per session. */
128
+ flag: {
129
+ top: 'on';
130
+ impressions: 'on' | 'off' | 'unavailable';
131
+ };
132
+ stoppedBy: 'pool' | 'caps' | 'seen' | null;
133
+ /** 'safe' for a member on safe defaults, 'open' for one who turned them off. */
134
+ safety: 'safe' | 'open';
135
+ mindsSource: 'member' | 'anonymous' | 'none' | 'following';
136
+ networks: Record<FeedTopNetwork, 'ok' | 'unavailable'> | null;
137
+ compass_filter?: CompassFilterMeta;
138
+ }
139
+ /** GET /feeds/top. `networks` is null on a feed with no lanes (Following, a story); `lead` is null when the feed has no story. */
140
+ export interface FeedTopResponse {
141
+ items: FeedTopItem[];
142
+ networks: Record<FeedTopNetwork, FeedTopNetworkEntry[]> | null;
143
+ lead: FeedStory | null;
144
+ meta: FeedTopMeta;
145
+ }
146
+ export interface FeedStoriesResponse {
147
+ data: FeedStory[];
148
+ meta: {
149
+ after: string | null;
150
+ limit: number;
151
+ short: boolean;
152
+ flag: {
153
+ top: 'on';
154
+ impressions: 'on' | 'off' | 'unavailable';
155
+ };
156
+ compass_filter?: CompassFilterMeta;
157
+ };
158
+ }
159
+ export interface PublicFeedTopMeta {
160
+ ref: string;
161
+ /** The 15-minute bucket the page was drawn for: the same for every visitor in it. */
162
+ bucket: number;
163
+ poolBuiltAt: string | null;
164
+ poolSize: number;
165
+ short: boolean;
166
+ flag: {
167
+ top: 'on';
168
+ };
169
+ compass_filter?: CompassFilterMeta;
170
+ }
171
+ /** A visitor's top (`feeds.publicTop`): safe items only, the same for every visitor in one 15-minute bucket. */
172
+ export interface PublicFeedTopResponse {
173
+ items: FeedTopItem[];
174
+ networks: Record<FeedTopNetwork, FeedTopNetworkEntry[]> | null;
175
+ lead: FeedStory | null;
176
+ meta: PublicFeedTopMeta;
177
+ }
178
+ export interface FeedTopParams {
179
+ /** `builtin:all`, `category:<a browse category id>`, `following`, `watch` or `story:<a 64-character cluster id>`. */
180
+ ref: string;
181
+ /** 1..25, default 25. */
182
+ limit?: number;
183
+ /** Opaque per app session ([A-Za-z0-9_-], up to 64): the same token keeps one session's order stable across reads. */
184
+ session?: string;
185
+ /** A Politics choice, as `posts.list` takes it (refused on `following`). */
186
+ compass?: string;
187
+ }
188
+ export interface FeedStoriesParams {
189
+ /** The story the member is on (a 64-character cluster id): left out of the next ones. */
190
+ after?: string;
191
+ /** 1..10, default 10. */
192
+ limit?: number;
193
+ compass?: string;
194
+ }
195
+ export interface PublicFeedTopParams {
196
+ /** The app's project id. */
197
+ projectId: string;
198
+ /** `all` (default), `watch` or `category:<a browse category id>`. */
199
+ ref?: string;
200
+ /** 1..25 (the server's default is 25). */
201
+ limit?: number;
202
+ /** `all` or `hide`: a visitor's Politics choice. */
203
+ compass?: string;
204
+ }
205
+ export declare class FeedsResource {
206
+ private readonly http;
207
+ constructor(http: HttpClient);
208
+ /**
209
+ * The top of a feed for the signed-in member, with their own key of the app: the items they are most likely to engage
210
+ * with, rotated by what they have seen, the open-network lanes and the lead story. Off (404) until the app's operator
211
+ * turns it on; a 503 `feed_top_unavailable` carries Retry-After. Always private: never cached for anyone else.
212
+ */
213
+ top(params: FeedTopParams, options?: {
214
+ retry?: boolean;
215
+ }): Promise<FeedTopResponse>;
216
+ /** The stories the member should meet next (the story tail), by the same ranker as the top's lead. */
217
+ stories(params?: FeedStoriesParams, options?: {
218
+ retry?: boolean;
219
+ }): Promise<FeedStoriesResponse>;
220
+ /**
221
+ * A visitor's top of All, a category or Watch (tRPC `publicDiscovery.feedTop`): safe items only, the same for every
222
+ * visitor in one 15-minute bucket. Read with no credential at all (no key, no cookie: `credentials: 'omit'`), so the
223
+ * edge can hold it; a credentialed read would be private and uncached. Off (404) until the app's operator turns it on.
224
+ */
225
+ publicTop(params: PublicFeedTopParams, options?: {
226
+ timeoutMs?: number;
227
+ }): Promise<PublicFeedTopResponse>;
228
+ }
@@ -0,0 +1,61 @@
1
+ import { NotFoundError, RecursivError } from '../errors.js';
2
+ export const FEED_TOP_NETWORKS = ['bluesky', 'activitypub', 'nostr'];
3
+ export class FeedsResource {
4
+ http;
5
+ constructor(http) {
6
+ this.http = http;
7
+ }
8
+ /**
9
+ * The top of a feed for the signed-in member, with their own key of the app: the items they are most likely to engage
10
+ * with, rotated by what they have seen, the open-network lanes and the lead story. Off (404) until the app's operator
11
+ * turns it on; a 503 `feed_top_unavailable` carries Retry-After. Always private: never cached for anyone else.
12
+ */
13
+ top(params, options) {
14
+ return this.http.get('/feeds/top', { ref: params.ref, limit: params.limit, session: params.session, compass: params.compass }, options);
15
+ }
16
+ /** The stories the member should meet next (the story tail), by the same ranker as the top's lead. */
17
+ stories(params = {}, options) {
18
+ return this.http.get('/feeds/stories', { after: params.after, limit: params.limit, compass: params.compass }, options);
19
+ }
20
+ /**
21
+ * A visitor's top of All, a category or Watch (tRPC `publicDiscovery.feedTop`): safe items only, the same for every
22
+ * visitor in one 15-minute bucket. Read with no credential at all (no key, no cookie: `credentials: 'omit'`), so the
23
+ * edge can hold it; a credentialed read would be private and uncached. Off (404) until the app's operator turns it on.
24
+ */
25
+ async publicTop(params, options) {
26
+ // One fixed key order, so equal reads are one edge-cached URL.
27
+ const input = { projectId: params.projectId, ref: params.ref ?? 'all', ...(params.limit !== undefined ? { limit: params.limit } : {}),
28
+ ...(params.compass ? { compass: params.compass } : {}) };
29
+ const url = `${this.http.baseOrigin}/api/trpc/publicDiscovery.feedTop?input=${encodeURIComponent(JSON.stringify(input))}`;
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), options?.timeoutMs ?? this.http.requestTimeoutMs);
32
+ try {
33
+ let response;
34
+ try {
35
+ response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'omit', signal: controller.signal });
36
+ }
37
+ catch (error) {
38
+ throw new RecursivError(0, { type: 'api_error', code: controller.signal.aborted ? 'timeout' : 'network_error',
39
+ message: controller.signal.aborted ? 'The visitor top did not answer in time.' : `The visitor top could not be reached (${error instanceof Error ? error.message : String(error)}).` });
40
+ }
41
+ let body = null;
42
+ try {
43
+ body = await response.json();
44
+ }
45
+ catch {
46
+ body = null;
47
+ }
48
+ const data = body?.result?.data;
49
+ if (response.ok && data && typeof data === 'object')
50
+ return data;
51
+ const error = body?.error && typeof body.error === 'object' ? body.error : undefined;
52
+ const said = [error?.json?.message, error?.message].find((value) => typeof value === 'string' && value.length > 0);
53
+ const code = typeof error?.data?.code === 'string' ? error.data.code.toLowerCase() : 'unknown_error';
54
+ const failure = { type: response.status === 404 ? 'not_found_error' : 'api_error', code, message: said ?? `HTTP ${response.status}` };
55
+ throw response.status === 404 ? new NotFoundError(failure) : new RecursivError(response.ok ? 502 : response.status, failure);
56
+ }
57
+ finally {
58
+ clearTimeout(timer);
59
+ }
60
+ }
61
+ }
@@ -35,8 +35,9 @@ export interface SearchPerson {
35
35
  pro: boolean;
36
36
  founder: boolean;
37
37
  /**
38
- * The account wears the display name of a verified or founder account it is not: it is listed after every account
39
- * that is not such a copy. Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
38
+ * The account wears the display name of a verified or founder account it is not. Such an account is listed only when
39
+ * the query asks for its handle ("@handle"), after every account that is not such a copy (an older API listed it
40
+ * last for any query). Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
40
41
  */
41
42
  name_matches_verified?: boolean;
42
43
  name_matches_verified_handle?: string | null;
package/dist/types.d.ts CHANGED
@@ -1261,8 +1261,9 @@ export interface ProfileSearchResult extends Profile {
1261
1261
  pro: boolean;
1262
1262
  founder: boolean;
1263
1263
  /**
1264
- * The account wears the display name of a verified or founder account it is not: it is listed after every account
1265
- * that is not such a copy. Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
1264
+ * The account wears the display name of a verified or founder account it is not. Such an account is listed only when
1265
+ * the query asks for its handle ("@handle"), after every account that is not such a copy (an older API listed it
1266
+ * last for any query). Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
1266
1267
  */
1267
1268
  name_matches_verified?: boolean;
1268
1269
  name_matches_verified_handle?: string | null;
package/llm.md CHANGED
@@ -639,6 +639,19 @@ await r.bookmarks.unsave(key) // or uns
639
639
 
640
640
  Newest saved first. Pages can be short while they skip posts the member can no longer read: page on `meta.next_cursor`, not length. Needs the member's own app key (else 403 `project_binding_required`). 503 `bookmarks_unavailable` means the server has no synced bookmarks yet: keep saves on the device (pass `{ retry: false }` to the probe so the 503 is not retried).
641
641
 
642
+ ### r.feeds — The top of a feed (member and visitor)
643
+
644
+ ```typescript
645
+ const top = await r.feeds.top({ ref: 'builtin:all', limit: 10, session }) // member's own app key; category:<id>, following, watch, story:<id>
646
+ // top.items: { ref, kind, target, position, block: 'top' | 'below', explain }; top.networks: { bluesky, activitypub, nostr } lanes with a card each
647
+ // top.lead: the story to open with; every target is in the app (post id, /social/post/<protocol>/<id>, story id), never a URL out
648
+ const { data: next } = await r.feeds.stories({ after: storyId }) // the story tail, same ranker as the lead
649
+ const page = await r.feeds.publicTop({ projectId, ref: 'all' }) // a visitor's top: safe items only, keyless, edge-cacheable
650
+ const feed = r.discovery.createFeed({ networks, top: { ref: 'builtin:all', session }, seen: seenRefs })
651
+ ```
652
+
653
+ Each answers 404 while the app's operator has it off; `feeds.top` is private to the member and 503 `feed_top_unavailable` carries Retry-After. With a visible view of a top item, send `block: 'top'` and its position. In a feed session the top's read starts with page 0's reads and the top holds the page at most 1.5 s from its start, the by-id reads of its posts included; off, failed or late, the session is unchanged, and the radar's story reads its posts as soon as the radar answers either way. Page 0 then opens with the top's lead story and items (each item carries `top: { ref, position, block }`), its lanes replace the networks' first reads, and no hot page repeats a top item. `seen` (Minds ids and `<protocol>/<nativeId>` refs) leaves seen posts out of the hot pages, walking on past a page left under five posts (up to three more). `seenClusters` (`Map<id, { n, clicked }>`) and a visitor's `bucket` rotate the story when the top names none (`pickRadarCluster`).
654
+
642
655
  ### r.search
643
656
 
644
657
  ```typescript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recursiv/sdk",
3
- "version": "0.7.63",
3
+ "version": "0.7.65",
4
4
  "description": "TypeScript SDK for the Recursiv API",
5
5
  "license": "FSL-1.1-ALv2",
6
6
  "type": "module",