@recursiv/sdk 0.7.59 → 0.7.63
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 +18 -7
- package/dist/feed-definition.d.ts +129 -0
- package/dist/feed-definition.js +46 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/realtime.d.ts +11 -1
- package/dist/realtime.js +14 -1
- package/dist/resources/chat.d.ts +40 -6
- package/dist/resources/chat.js +41 -2
- package/dist/resources/discovery.d.ts +36 -0
- package/dist/resources/discovery.js +104 -11
- package/dist/resources/dispatcher.d.ts +56 -0
- package/dist/resources/dispatcher.js +13 -0
- package/dist/resources/e2ee.d.ts +7 -3
- package/dist/resources/human-input.d.ts +4 -0
- package/dist/resources/protocols.d.ts +28 -2
- package/dist/resources/protocols.js +9 -4
- package/dist/resources/video.d.ts +20 -0
- package/dist/resources/video.js +12 -0
- package/dist/types.d.ts +93 -0
- package/llm.md +20 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,10 +96,10 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
|
|
|
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
98
|
| `r.communities` | `create`, `join`, `search`, `members` |
|
|
99
|
-
| `r.chat` | `send`, `dm`, `createGroup
|
|
99
|
+
| `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
100
|
| `r.tags` | `create`, `list` |
|
|
101
101
|
| `r.uploads` | `uploadMedia` — upload post/chat bytes end to end |
|
|
102
|
-
| `r.video` | `entitlement` (may the caller publish video, and how much is left), `createUpload`, `getStatus`, `getPlaybackToken` |
|
|
102
|
+
| `r.video` | `entitlement` (may the caller publish video, and how much is left), `createUpload`, `renewUpload` (the creator's fresh TUS credentials for the same video, with `createUpload`'s `resumeGrant`, so a resume past the hour continues it; 0.7.63), `getStatus`, `getPlaybackToken` |
|
|
103
103
|
|
|
104
104
|
### Platform
|
|
105
105
|
| Resource | Purpose |
|
|
@@ -653,8 +653,13 @@ sources; create a new session to refresh exhausted source windows. A topics netw
|
|
|
653
653
|
(Bluesky) is read one topic page at a time; in a category the catalog's previews are not
|
|
654
654
|
judged to be in it, so a read walks into the topics at once, and walks past a topic the
|
|
655
655
|
provider no longer lists (`trend_unavailable`) or one with nothing in the category, up
|
|
656
|
-
to five topic pages a read, so a page reaches a topic that carries posts.
|
|
657
|
-
|
|
656
|
+
to five topic pages a read, so a page reaches a topic that carries posts. A network read
|
|
657
|
+
that fails for a passing reason (past the 15 s page deadline, a dropped connection, a 429
|
|
658
|
+
or a 5xx) leaves the network out of that page and the next, named in `retryingSources`
|
|
659
|
+
until a later page asks it again (from the page after, when its turn comes), up to two
|
|
660
|
+
times in a row. Any other failure, or one past that, marks the source failed
|
|
661
|
+
(`failedSources`) until `retryFailed()`. The Minds read is asked once and is never
|
|
662
|
+
retried this way: any failure of it is in `failedSources` on that page.
|
|
658
663
|
|
|
659
664
|
`sdk.discovery.createPersonalFeed({ networks, category?, learning?, recommend? })`
|
|
660
665
|
builds the same kind of session for a member's For you tab: its Minds slot reads
|
|
@@ -671,6 +676,10 @@ Both take a Politics choice, `compass` (`all`, `mix`, `hide`, or cells in the or
|
|
|
671
676
|
page carries its Minds read's answer as `page.compass` (`unavailable`: the page is unfiltered). Only Minds
|
|
672
677
|
posts are filtered, so a view that leaves posts out reads best with `source: 'minds'`.
|
|
673
678
|
The catalog lists `compass_filter` on every network when the filter is served to the viewer.
|
|
679
|
+
Hide politics (`compass: 'hide'`) also applies on its own, with or without compass judgments on the server, to
|
|
680
|
+
`sdk.discovery.videos`, `protocols.getPublicTrends` and the radar (`getPublicRadar`, keyless `getSharedRadar`; 0.7.62):
|
|
681
|
+
it leaves out what is categorised politics (a story not themed yet, or `mixed`, too) and says what it did in
|
|
682
|
+
`meta.compass_filter`. These reads answer a side or Mix unfiltered, marked `unavailable`.
|
|
674
683
|
|
|
675
684
|
`sdk.discovery.recommendations({ kind, category?, limit?, session?, exclude? })` is
|
|
676
685
|
the app's one recommender for suggestion rails: `kind` is `member`, `group`, `tag`,
|
|
@@ -690,14 +699,16 @@ history: every page's `meta.next` is `{ offset }`, `{ cursor }` or `null`, and w
|
|
|
690
699
|
stage ends `meta.fallover` lists where to go, in order: `category_older` (the keyset
|
|
691
700
|
cursor `next` also carries), `category_network` (the same category on the open
|
|
692
701
|
networks), the member's next categories (`via` names the order they came from), `for_you`
|
|
693
|
-
and `all`. A `
|
|
694
|
-
|
|
702
|
+
and `all`. A `feed` step (`ref`, the next feed's ref; 0.7.60) is declared but not sent yet:
|
|
703
|
+
skip a kind your app cannot open. A `DiscoveryFeedSession` follows the cursor itself and
|
|
704
|
+
reports the Minds feed's `fallover` on its pages, leaving out any step this SDK does not
|
|
705
|
+
know or a malformed one. `sdk.discovery.category({ category, session? })` answers a
|
|
695
706
|
category's surface in one call: `stories`, `network` and `clips` (each shared per app and
|
|
696
707
|
category, refreshed after 30 s, kept up to 300 s and warmed as the warmer reaches the category, then filtered for the viewer on every call: the cached safety verdicts of story members, network posts and clips again, the term list, the viewer's blocks and mutes over clips, and the member's own hidden sources over network posts and story members) and the
|
|
697
708
|
viewer's `people` and `groups`; a section that timed out, failed or is off is empty with a
|
|
698
709
|
`reason` for that request only (a failure is never cached, so asking again retries it), and one this
|
|
699
710
|
key may not read is empty with `refused` (`stories` and `network` need the member's own app
|
|
700
|
-
key with `users:read`; `people` and `groups` need `users:read`). `sdk.discovery.videos({ category?, limit?, cursor?, offset? })`
|
|
711
|
+
key with `users:read`; `people` and `groups` need `users:read`). `sdk.discovery.videos({ category?, limit?, cursor?, offset?, compass? })`
|
|
701
712
|
is Watch's Minds read: the app's public video posts in hot order, from one list the server shares among every viewer of
|
|
702
713
|
the app and rebuilds about once a minute, each post checked again on every call under the public floors and the viewer's
|
|
703
714
|
blocks and mutes, in the `Post` shape with the viewer's own `user_reaction` and `reposted_by_viewer_id` (page on
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FeedDefinition v1, as apps read and write it: the one recipe behind every feed (a list of sources read as one union,
|
|
3
|
+
* a ranking mode, optional filters and exclusions). Types and constants only; the server
|
|
4
|
+
* (packages/server/src/features/feeds/FeedDefinition.ts) validates, canonicalises and digests a definition, and its
|
|
5
|
+
* FeedDefinitionSdkContract test holds these constants equal to its own.
|
|
6
|
+
*
|
|
7
|
+
* Nothing serves a definition yet: no route reads one before the cutover. Discover's built-in categories, All and
|
|
8
|
+
* For you are definitions held in code on the server (refs `builtin:category:<id>`, `builtin:all`, `builtin:for_you`).
|
|
9
|
+
*
|
|
10
|
+
* A definition never holds the viewer's safe/open setting, blocks or mutes, or a compass Mix or Choose-sides choice:
|
|
11
|
+
* `filters.politics: 'hide'` is the only Politics value a feed stores.
|
|
12
|
+
*/
|
|
13
|
+
export declare const FEED_DEFINITION_VERSION: 1;
|
|
14
|
+
/** Sources a definition may name, read as one union. Only `builtin:all` and `builtin:for_you` have none. */
|
|
15
|
+
export declare const FEED_SOURCES_MAX = 50;
|
|
16
|
+
/** The largest canonical definition the server keeps, in bytes. */
|
|
17
|
+
export declare const FEED_DEFINITION_MAX_BYTES: number;
|
|
18
|
+
export declare const FEED_SOURCE_KINDS: readonly ["category", "tag", "account", "community", "collection", "list", "story", "bsky_feed", "bsky_list", "bsky_starterpack", "nostr_set"];
|
|
19
|
+
export type FeedSourceKind = typeof FEED_SOURCE_KINDS[number];
|
|
20
|
+
export declare const FEED_RANKING_MODES: readonly ["hot", "new", "top", "for_you", "viral"];
|
|
21
|
+
export type FeedRankingMode = typeof FEED_RANKING_MODES[number];
|
|
22
|
+
export declare const FEED_RANKING_RANGES: readonly ["24h", "7d", "30d", "1y"];
|
|
23
|
+
export type FeedRankingRange = typeof FEED_RANKING_RANGES[number];
|
|
24
|
+
export declare const FEED_MEDIA: readonly ["any", "images", "video", "longread"];
|
|
25
|
+
export type FeedMedia = typeof FEED_MEDIA[number];
|
|
26
|
+
export declare const FEED_NETWORKS: readonly ["minds", "bluesky", "activitypub", "nostr"];
|
|
27
|
+
export type FeedNetwork = typeof FEED_NETWORKS[number];
|
|
28
|
+
/** Source kinds a topical feed may hold (with a hot, new or top ranking); any other feed is 'wide'. */
|
|
29
|
+
export declare const FEED_TOPICAL_SOURCE_KINDS: readonly ["category", "tag", "account", "community", "list", "collection", "story"];
|
|
30
|
+
export declare const FEED_TOPICAL_MODES: readonly ["hot", "new", "top"];
|
|
31
|
+
/** The one field that names what each source kind reads. */
|
|
32
|
+
export declare const FEED_SOURCE_KEY: {
|
|
33
|
+
readonly category: "id";
|
|
34
|
+
readonly tag: "slug";
|
|
35
|
+
readonly account: "ref";
|
|
36
|
+
readonly community: "id";
|
|
37
|
+
readonly collection: "id";
|
|
38
|
+
readonly list: "id";
|
|
39
|
+
readonly story: "clusterId";
|
|
40
|
+
readonly bsky_feed: "uri";
|
|
41
|
+
readonly bsky_list: "uri";
|
|
42
|
+
readonly bsky_starterpack: "uri";
|
|
43
|
+
readonly nostr_set: "naddr";
|
|
44
|
+
};
|
|
45
|
+
/** An account by its native id: a Minds user, an atproto DID, a Nostr hex pubkey or a WebFinger account. */
|
|
46
|
+
export type FeedAccountRef = `user:${string}` | `did:${string}` | `nostr:${string}` | `acct:${string}`;
|
|
47
|
+
export type FeedSource =
|
|
48
|
+
/** A Discover category id, as `posts.categories()` lists them. */
|
|
49
|
+
{
|
|
50
|
+
kind: 'category';
|
|
51
|
+
id: string;
|
|
52
|
+
}
|
|
53
|
+
/** A hashtag, without the `#`. */
|
|
54
|
+
| {
|
|
55
|
+
kind: 'tag';
|
|
56
|
+
slug: string;
|
|
57
|
+
} | {
|
|
58
|
+
kind: 'account';
|
|
59
|
+
ref: FeedAccountRef;
|
|
60
|
+
} | {
|
|
61
|
+
kind: 'community';
|
|
62
|
+
id: string;
|
|
63
|
+
}
|
|
64
|
+
/** A member's collection of saved posts, or a list (after the cutover). */
|
|
65
|
+
| {
|
|
66
|
+
kind: 'collection';
|
|
67
|
+
id: string;
|
|
68
|
+
} | {
|
|
69
|
+
kind: 'list';
|
|
70
|
+
id: string;
|
|
71
|
+
}
|
|
72
|
+
/** A World Today story, by its radar cluster id. */
|
|
73
|
+
| {
|
|
74
|
+
kind: 'story';
|
|
75
|
+
clusterId: string;
|
|
76
|
+
}
|
|
77
|
+
/** Bluesky feeds, lists and starter packs by their at:// record URI (after the cutover). */
|
|
78
|
+
| {
|
|
79
|
+
kind: 'bsky_feed';
|
|
80
|
+
uri: string;
|
|
81
|
+
} | {
|
|
82
|
+
kind: 'bsky_list';
|
|
83
|
+
uri: string;
|
|
84
|
+
} | {
|
|
85
|
+
kind: 'bsky_starterpack';
|
|
86
|
+
uri: string;
|
|
87
|
+
}
|
|
88
|
+
/** A Nostr people, interest, bookmark or curation set by its NIP-19 naddr (after the cutover). */
|
|
89
|
+
| {
|
|
90
|
+
kind: 'nostr_set';
|
|
91
|
+
naddr: string;
|
|
92
|
+
};
|
|
93
|
+
export interface FeedRanking {
|
|
94
|
+
mode: FeedRankingMode;
|
|
95
|
+
range?: FeedRankingRange;
|
|
96
|
+
}
|
|
97
|
+
export interface FeedFilters {
|
|
98
|
+
/** Absent means any media. */
|
|
99
|
+
media?: FeedMedia;
|
|
100
|
+
/** Absent means every network. */
|
|
101
|
+
networks?: FeedNetwork[];
|
|
102
|
+
/** The only Politics value a feed stores. Mix and Choose sides stay the viewer's own. */
|
|
103
|
+
politics?: 'hide';
|
|
104
|
+
/** BCP 47 language tags such as `en` or `pt-br`; at most 10. */
|
|
105
|
+
languages?: string[];
|
|
106
|
+
}
|
|
107
|
+
export interface FeedExclude {
|
|
108
|
+
/** At most 50 tags. */
|
|
109
|
+
tags?: string[];
|
|
110
|
+
/** At most 50 accounts. */
|
|
111
|
+
accounts?: FeedAccountRef[];
|
|
112
|
+
}
|
|
113
|
+
export interface FeedDefinition {
|
|
114
|
+
v: typeof FEED_DEFINITION_VERSION;
|
|
115
|
+
/** 1 to 50, read as one union; empty only for `builtin:all` and `builtin:for_you`. */
|
|
116
|
+
sources: FeedSource[];
|
|
117
|
+
ranking: FeedRanking;
|
|
118
|
+
filters?: FeedFilters;
|
|
119
|
+
exclude?: FeedExclude;
|
|
120
|
+
}
|
|
121
|
+
export type BuiltinFeedRef = `builtin:category:${string}` | 'builtin:all' | 'builtin:for_you';
|
|
122
|
+
/** A feed's address: a built-in, a member feed's current version (`feed:<uuid>`), or one version of it. */
|
|
123
|
+
export type FeedRef = BuiltinFeedRef | `feed:${string}` | `feed:${string}@${number}`;
|
|
124
|
+
/** Computed by the server, never stored: 'anon' feeds are ranked once and may be shared; 'viewer' feeds are ranked per viewer. */
|
|
125
|
+
export type FeedAudience = 'anon' | 'viewer';
|
|
126
|
+
/** Computed by the server: 'wide' feeds leave out Bluesky authors who asked not to be recommended. */
|
|
127
|
+
export type FeedConsentClass = 'topical' | 'wide';
|
|
128
|
+
/** The built-in feed for a Discover category. */
|
|
129
|
+
export declare function builtinCategoryFeedRef(category: string): BuiltinFeedRef;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FeedDefinition v1, as apps read and write it: the one recipe behind every feed (a list of sources read as one union,
|
|
3
|
+
* a ranking mode, optional filters and exclusions). Types and constants only; the server
|
|
4
|
+
* (packages/server/src/features/feeds/FeedDefinition.ts) validates, canonicalises and digests a definition, and its
|
|
5
|
+
* FeedDefinitionSdkContract test holds these constants equal to its own.
|
|
6
|
+
*
|
|
7
|
+
* Nothing serves a definition yet: no route reads one before the cutover. Discover's built-in categories, All and
|
|
8
|
+
* For you are definitions held in code on the server (refs `builtin:category:<id>`, `builtin:all`, `builtin:for_you`).
|
|
9
|
+
*
|
|
10
|
+
* A definition never holds the viewer's safe/open setting, blocks or mutes, or a compass Mix or Choose-sides choice:
|
|
11
|
+
* `filters.politics: 'hide'` is the only Politics value a feed stores.
|
|
12
|
+
*/
|
|
13
|
+
export const FEED_DEFINITION_VERSION = 1;
|
|
14
|
+
/** Sources a definition may name, read as one union. Only `builtin:all` and `builtin:for_you` have none. */
|
|
15
|
+
export const FEED_SOURCES_MAX = 50;
|
|
16
|
+
/** The largest canonical definition the server keeps, in bytes. */
|
|
17
|
+
export const FEED_DEFINITION_MAX_BYTES = 16 * 1024;
|
|
18
|
+
export const FEED_SOURCE_KINDS = [
|
|
19
|
+
'category', 'tag', 'account', 'community', 'collection', 'list', 'story',
|
|
20
|
+
'bsky_feed', 'bsky_list', 'bsky_starterpack', 'nostr_set',
|
|
21
|
+
];
|
|
22
|
+
export const FEED_RANKING_MODES = ['hot', 'new', 'top', 'for_you', 'viral'];
|
|
23
|
+
export const FEED_RANKING_RANGES = ['24h', '7d', '30d', '1y'];
|
|
24
|
+
export const FEED_MEDIA = ['any', 'images', 'video', 'longread'];
|
|
25
|
+
export const FEED_NETWORKS = ['minds', 'bluesky', 'activitypub', 'nostr'];
|
|
26
|
+
/** Source kinds a topical feed may hold (with a hot, new or top ranking); any other feed is 'wide'. */
|
|
27
|
+
export const FEED_TOPICAL_SOURCE_KINDS = ['category', 'tag', 'account', 'community', 'list', 'collection', 'story'];
|
|
28
|
+
export const FEED_TOPICAL_MODES = ['hot', 'new', 'top'];
|
|
29
|
+
/** The one field that names what each source kind reads. */
|
|
30
|
+
export const FEED_SOURCE_KEY = {
|
|
31
|
+
category: 'id',
|
|
32
|
+
tag: 'slug',
|
|
33
|
+
account: 'ref',
|
|
34
|
+
community: 'id',
|
|
35
|
+
collection: 'id',
|
|
36
|
+
list: 'id',
|
|
37
|
+
story: 'clusterId',
|
|
38
|
+
bsky_feed: 'uri',
|
|
39
|
+
bsky_list: 'uri',
|
|
40
|
+
bsky_starterpack: 'uri',
|
|
41
|
+
nostr_set: 'naddr',
|
|
42
|
+
};
|
|
43
|
+
/** The built-in feed for a Discover category. */
|
|
44
|
+
export function builtinCategoryFeedRef(category) {
|
|
45
|
+
return `builtin:category:${category}`;
|
|
46
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './resources/discovery-learning.js';
|
|
2
2
|
import { DiscoveryResource } from './resources/discovery.js';
|
|
3
3
|
export * from './resources/discovery.js';
|
|
4
|
+
export * from './feed-definition.js';
|
|
4
5
|
import { AuthResource } from './resources/auth.js';
|
|
5
6
|
import { PostsResource } from './resources/posts.js';
|
|
6
7
|
import { UsersResource } from './resources/users.js';
|
|
@@ -234,7 +235,7 @@ export type { Brief, BriefEmail, MyBrief, MyBriefEntry, BriefParams, BriefShippe
|
|
|
234
235
|
export type { HumanAsk, HumanAskStatus, CreateHumanAskInput, ListHumanAsksParams } from './resources/human-asks.js';
|
|
235
236
|
export { HumanInputResource } from './resources/human-input.js';
|
|
236
237
|
export type { Goal, GoalTask, CreateGoalInput, UpdateGoalInput, ListGoalsParams, ReorderGoalTasksInput } from './resources/goals.js';
|
|
237
|
-
export type { DispatcherTask, TaskClaim, ClaimRecord, DispatcherStats, CreateTaskInput, UpdateTaskInput, ListTasksParams, SignalRecord, OutcomeRecord, PauseState, PauseInput, SignalsParams, OutcomesParams, DiscoveryParams, StaleStuckParams } from './resources/dispatcher.js';
|
|
238
|
+
export type { DispatcherTask, TaskClaim, ClaimRecord, DispatcherStats, CreateTaskInput, UpdateTaskInput, ListTasksParams, SignalRecord, OutcomeRecord, PauseState, PauseInput, SignalsParams, OutcomesParams, DiscoveryParams, StaleStuckParams, TaskPlan, TaskPlanStep, SetTaskPlanInput, UpdateTaskPlanStepInput } from './resources/dispatcher.js';
|
|
238
239
|
export { SwarmsResource } from './resources/swarms.js';
|
|
239
240
|
export type { Swarm, SwarmMember, CreateSwarmInput, ListSwarmsParams, ScheduledTask, ScheduleTaskInput } from './resources/swarms.js';
|
|
240
241
|
export { TemplatesResource } from './resources/templates.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './resources/discovery-learning.js';
|
|
2
2
|
import { DiscoveryResource } from './resources/discovery.js';
|
|
3
3
|
export * from './resources/discovery.js';
|
|
4
|
+
export * from './feed-definition.js';
|
|
4
5
|
import { HttpClient } from './client.js';
|
|
5
6
|
import { AuthResource } from './resources/auth.js';
|
|
6
7
|
import { PostsResource } from './resources/posts.js';
|
package/dist/realtime.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Socket } from 'socket.io-client';
|
|
2
2
|
import { HttpClient } from './client.js';
|
|
3
3
|
import type { E2eeDevicesRealtimeEvent, E2eeLinkRealtimeEvent, E2eeRealtimeEvent } from './resources/e2ee.js';
|
|
4
|
+
import type { ChatConversationEvent, ChatMembersEvent } from './types.js';
|
|
4
5
|
export interface ChatMessageEvent {
|
|
5
6
|
id: string;
|
|
6
7
|
conversationId: string;
|
|
@@ -147,7 +148,8 @@ export declare class RealtimeClient {
|
|
|
147
148
|
*
|
|
148
149
|
* Server events include `notification`, `feed_update`,
|
|
149
150
|
* `conversation_created`, `chat_message`, `chat_typing`, `agent_thinking`,
|
|
150
|
-
* `e2ee:event`, `e2ee:link` and
|
|
151
|
+
* `chat:members`, `chat:conversation`, `e2ee:event`, `e2ee:link` and
|
|
152
|
+
* `e2ee:devices`.
|
|
151
153
|
*/
|
|
152
154
|
on<T = unknown>(event: string, listener: (payload: T) => void): () => void;
|
|
153
155
|
/** Join a conversation room now, and again after every reconnect, until you leave it. */
|
|
@@ -159,6 +161,14 @@ export declare class RealtimeClient {
|
|
|
159
161
|
onMessageDelete(callback: (msg: ChatMessageDeleteEvent) => void): (() => void) | undefined;
|
|
160
162
|
onTyping(callback: (event: TypingEvent) => void): (() => void) | undefined;
|
|
161
163
|
onAgentThinking(callback: (event: AgentThinkingEvent) => void): (() => void) | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* A private group's people changed (0.7.59): `{ conversationId, change, userIds, actorId }`,
|
|
166
|
+
* change `added`, `removed`, `left` or `invites_cancelled`. Sent to everyone in the group
|
|
167
|
+
* and to the people it is about. Read the conversation again; in an encrypted group, check its keys.
|
|
168
|
+
*/
|
|
169
|
+
onChatMembers(callback: (event: ChatMembersEvent) => void): () => void;
|
|
170
|
+
/** A private group was renamed (0.7.59): `{ conversationId, change: 'renamed', name, actorId }`. */
|
|
171
|
+
onChatConversation(callback: (event: ChatConversationEvent) => void): () => void;
|
|
162
172
|
/**
|
|
163
173
|
* The E2EE doorbell after each append to an encrypted conversation this
|
|
164
174
|
* account belongs to: `{ conversationId, seq }`, plus the stored `event`
|
package/dist/realtime.js
CHANGED
|
@@ -369,7 +369,8 @@ export class RealtimeClient {
|
|
|
369
369
|
*
|
|
370
370
|
* Server events include `notification`, `feed_update`,
|
|
371
371
|
* `conversation_created`, `chat_message`, `chat_typing`, `agent_thinking`,
|
|
372
|
-
* `e2ee:event`, `e2ee:link` and
|
|
372
|
+
* `chat:members`, `chat:conversation`, `e2ee:event`, `e2ee:link` and
|
|
373
|
+
* `e2ee:devices`.
|
|
373
374
|
*/
|
|
374
375
|
on(event, listener) {
|
|
375
376
|
// Every call is its own subscription, even for a function that is already
|
|
@@ -426,6 +427,18 @@ export class RealtimeClient {
|
|
|
426
427
|
onAgentThinking(callback) {
|
|
427
428
|
return this.on('agent_thinking', callback);
|
|
428
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* A private group's people changed (0.7.59): `{ conversationId, change, userIds, actorId }`,
|
|
432
|
+
* change `added`, `removed`, `left` or `invites_cancelled`. Sent to everyone in the group
|
|
433
|
+
* and to the people it is about. Read the conversation again; in an encrypted group, check its keys.
|
|
434
|
+
*/
|
|
435
|
+
onChatMembers(callback) {
|
|
436
|
+
return this.on('chat:members', callback);
|
|
437
|
+
}
|
|
438
|
+
/** A private group was renamed (0.7.59): `{ conversationId, change: 'renamed', name, actorId }`. */
|
|
439
|
+
onChatConversation(callback) {
|
|
440
|
+
return this.on('chat:conversation', callback);
|
|
441
|
+
}
|
|
429
442
|
/**
|
|
430
443
|
* The E2EE doorbell after each append to an encrypted conversation this
|
|
431
444
|
* account belongs to: `{ conversationId, seq }`, plus the stored `event`
|
package/dist/resources/chat.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
|
-
import type { PaginatedResponse, SingleResponse, SuccessResponse, Conversation, ConversationDetail, Message, SendMessageInput, SentMessage, DmInput, DmResult, PaginationParams, ConversationRequestResult, LeaveConversationResult } from '../types.js';
|
|
2
|
+
import type { PaginatedResponse, SingleResponse, SuccessResponse, Conversation, ConversationDetail, Message, SendMessageInput, SentMessage, DmInput, DmResult, PaginationParams, ConversationRequestResult, LeaveConversationResult, CreateGroupInput, AddGroupMembersResult, RemoveGroupMemberResult, UpdateGroupResult } from '../types.js';
|
|
3
3
|
export declare class ChatResource {
|
|
4
4
|
private client;
|
|
5
5
|
constructor(client: HttpClient);
|
|
@@ -37,11 +37,42 @@ export declare class ChatResource {
|
|
|
37
37
|
}): Promise<SingleResponse<SentMessage>>;
|
|
38
38
|
/** Get or create a DM conversation with a user */
|
|
39
39
|
dm(input: DmInput): Promise<SingleResponse<DmResult>>;
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Create a group conversation. Invitees who do not follow you get it as a
|
|
42
|
+
* message request (`request_status: 'pending'`); you are its admin
|
|
43
|
+
* (`role: 'admin'`).
|
|
44
|
+
*
|
|
45
|
+
* `encrypted: true` (0.7.59) creates an end-to-end encrypted private group:
|
|
46
|
+
* people only, all inside the encryption pilot, at most 8 counting you.
|
|
47
|
+
* Otherwise it throws 409 `conversation_not_eligible` with `details.reason`
|
|
48
|
+
* (EncryptedGroupRefusalReason) and creates nothing: never a plaintext group
|
|
49
|
+
* instead. Pass `device_id` when you have an encrypted-chat device; your
|
|
50
|
+
* device then sets up the group's keys (only the admin's device may).
|
|
51
|
+
*/
|
|
52
|
+
createGroup(input: CreateGroupInput): Promise<SingleResponse<ConversationDetail>>;
|
|
53
|
+
/**
|
|
54
|
+
* Add people to a private group (0.7.59). Only its admin may (403
|
|
55
|
+
* `not_admin`). Someone who does not follow you is added as a message
|
|
56
|
+
* request (`pending`); someone already in it is listed in
|
|
57
|
+
* `already_members`. In an encrypted group the group's rule must still
|
|
58
|
+
* hold afterwards, or nobody is added (409 `conversation_not_eligible`,
|
|
59
|
+
* `details.reason`); a plaintext group holds at most 101 people (409 `group_full`).
|
|
60
|
+
* Everyone in the group gets the realtime `chat:members` event.
|
|
61
|
+
*/
|
|
62
|
+
addMembers(conversationId: string, input: {
|
|
43
63
|
member_ids: string[];
|
|
44
|
-
}): Promise<SingleResponse<
|
|
64
|
+
}): Promise<SingleResponse<AddGroupMembersResult>>;
|
|
65
|
+
/**
|
|
66
|
+
* Remove someone from a private group, or cancel their invite (0.7.59).
|
|
67
|
+
* Only its admin may (403 `not_admin`); to remove yourself, `leave()` (409
|
|
68
|
+
* `use_leave`). They lose the conversation at once; in an encrypted group
|
|
69
|
+
* the members' devices then take them out of its keys before sending again.
|
|
70
|
+
*/
|
|
71
|
+
removeMember(conversationId: string, userId: string): Promise<SingleResponse<RemoveGroupMemberResult>>;
|
|
72
|
+
/** Rename a private group (0.7.59). Only its admin may (403 `not_admin`). */
|
|
73
|
+
updateGroup(conversationId: string, input: {
|
|
74
|
+
name: string;
|
|
75
|
+
}): Promise<SingleResponse<UpdateGroupResult>>;
|
|
45
76
|
/**
|
|
46
77
|
* Delete a conversation for every member. Allowed when the conversation's
|
|
47
78
|
* `can_delete` is true: the caller started it, or every other member is an
|
|
@@ -53,7 +84,10 @@ export declare class ChatResource {
|
|
|
53
84
|
* Remove the caller from a conversation (a group, or a room whose other
|
|
54
85
|
* members are all agents). The last person to leave removes the room. A
|
|
55
86
|
* one-on-one with another person cannot be left (409 `dm_leave_unsupported`);
|
|
56
|
-
* check `can_leave` on the conversation first.
|
|
87
|
+
* check `can_leave` on the conversation first. When a group's admin leaves,
|
|
88
|
+
* the longest-standing person left becomes its admin; in an encrypted group
|
|
89
|
+
* the invites the admin sent that nobody accepted are cancelled
|
|
90
|
+
* (`invites_cancelled`).
|
|
57
91
|
*
|
|
58
92
|
* This changes membership. It is not `realtime.leaveConversation()`, which
|
|
59
93
|
* only stops listening for live events.
|
package/dist/resources/chat.js
CHANGED
|
@@ -44,10 +44,46 @@ export class ChatResource {
|
|
|
44
44
|
dm(input) {
|
|
45
45
|
return this.client.post('/chat/dm', input);
|
|
46
46
|
}
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Create a group conversation. Invitees who do not follow you get it as a
|
|
49
|
+
* message request (`request_status: 'pending'`); you are its admin
|
|
50
|
+
* (`role: 'admin'`).
|
|
51
|
+
*
|
|
52
|
+
* `encrypted: true` (0.7.59) creates an end-to-end encrypted private group:
|
|
53
|
+
* people only, all inside the encryption pilot, at most 8 counting you.
|
|
54
|
+
* Otherwise it throws 409 `conversation_not_eligible` with `details.reason`
|
|
55
|
+
* (EncryptedGroupRefusalReason) and creates nothing: never a plaintext group
|
|
56
|
+
* instead. Pass `device_id` when you have an encrypted-chat device; your
|
|
57
|
+
* device then sets up the group's keys (only the admin's device may).
|
|
58
|
+
*/
|
|
48
59
|
createGroup(input) {
|
|
49
60
|
return this.client.post('/chat/conversations/group', input);
|
|
50
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Add people to a private group (0.7.59). Only its admin may (403
|
|
64
|
+
* `not_admin`). Someone who does not follow you is added as a message
|
|
65
|
+
* request (`pending`); someone already in it is listed in
|
|
66
|
+
* `already_members`. In an encrypted group the group's rule must still
|
|
67
|
+
* hold afterwards, or nobody is added (409 `conversation_not_eligible`,
|
|
68
|
+
* `details.reason`); a plaintext group holds at most 101 people (409 `group_full`).
|
|
69
|
+
* Everyone in the group gets the realtime `chat:members` event.
|
|
70
|
+
*/
|
|
71
|
+
addMembers(conversationId, input) {
|
|
72
|
+
return this.client.post(`/chat/conversations/${conversationId}/members`, input);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Remove someone from a private group, or cancel their invite (0.7.59).
|
|
76
|
+
* Only its admin may (403 `not_admin`); to remove yourself, `leave()` (409
|
|
77
|
+
* `use_leave`). They lose the conversation at once; in an encrypted group
|
|
78
|
+
* the members' devices then take them out of its keys before sending again.
|
|
79
|
+
*/
|
|
80
|
+
removeMember(conversationId, userId) {
|
|
81
|
+
return this.client.delete(`/chat/conversations/${conversationId}/members/${userId}`);
|
|
82
|
+
}
|
|
83
|
+
/** Rename a private group (0.7.59). Only its admin may (403 `not_admin`). */
|
|
84
|
+
updateGroup(conversationId, input) {
|
|
85
|
+
return this.client.patch(`/chat/conversations/${conversationId}`, input);
|
|
86
|
+
}
|
|
51
87
|
/**
|
|
52
88
|
* Delete a conversation for every member. Allowed when the conversation's
|
|
53
89
|
* `can_delete` is true: the caller started it, or every other member is an
|
|
@@ -61,7 +97,10 @@ export class ChatResource {
|
|
|
61
97
|
* Remove the caller from a conversation (a group, or a room whose other
|
|
62
98
|
* members are all agents). The last person to leave removes the room. A
|
|
63
99
|
* one-on-one with another person cannot be left (409 `dm_leave_unsupported`);
|
|
64
|
-
* check `can_leave` on the conversation first.
|
|
100
|
+
* check `can_leave` on the conversation first. When a group's admin leaves,
|
|
101
|
+
* the longest-standing person left becomes its admin; in an encrypted group
|
|
102
|
+
* the invites the admin sent that nobody accepted are cancelled
|
|
103
|
+
* (`invites_cancelled`).
|
|
65
104
|
*
|
|
66
105
|
* This changes membership. It is not `realtime.leaveConversation()`, which
|
|
67
106
|
* only stops listening for live events.
|
|
@@ -15,6 +15,13 @@ export interface WatchVideosParams {
|
|
|
15
15
|
cursor?: string;
|
|
16
16
|
/** A position in the list to start a first read at (0 to 400); a later page sends `cursor`. */
|
|
17
17
|
offset?: number;
|
|
18
|
+
/**
|
|
19
|
+
* A Politics choice, spelled as `posts.list` takes it. Only Hide politics (`hide`) is applied here: the videos whose
|
|
20
|
+
* browse category is politics (or whose compass judgment places them in politics) leave the page and the next ones
|
|
21
|
+
* fill it, with or without compass judgments on the server. A side or Mix is answered unfiltered, marked
|
|
22
|
+
* `unavailable`. The page says what it did in `meta.compass_filter`.
|
|
23
|
+
*/
|
|
24
|
+
compass?: string;
|
|
18
25
|
}
|
|
19
26
|
/** A media item as `discovery.videos` serves it: the poster, and the player's size, length and HLS address when known. */
|
|
20
27
|
export interface WatchVideoMedia extends MediaItem {
|
|
@@ -62,6 +69,8 @@ export interface WatchVideosResponse {
|
|
|
62
69
|
built_at: string | null;
|
|
63
70
|
/** `no_app`: the API key is bound to no app, so there is no list. */
|
|
64
71
|
reason?: 'no_app';
|
|
72
|
+
/** Present when the request named a filtering `compass`: what the Politics choice did to this page. */
|
|
73
|
+
compass_filter?: CompassFilterMeta;
|
|
65
74
|
};
|
|
66
75
|
}
|
|
67
76
|
/** What GET /discover/recommendations suggests: members, groups, tags, open-network accounts or World Today stories. */
|
|
@@ -306,6 +315,23 @@ export declare const isDiscoveryPost: (item: DiscoveryItem) => item is Discovery
|
|
|
306
315
|
export declare const RECOMMENDATION_MAX_ITEMS = 6;
|
|
307
316
|
/** Clusters read from the radar per session; the server never holds more. */
|
|
308
317
|
export declare const RADAR_MAX_CLUSTERS = 40;
|
|
318
|
+
/** How long one source's read may take before the page goes on without it (DiscoveryFeedSession). */
|
|
319
|
+
export declare const DISCOVERY_SOURCE_TIMEOUT_MS = 15000;
|
|
320
|
+
/**
|
|
321
|
+
* Times in a row an open network whose read failed for a passing reason (a timeout, a dropped connection, a 429 or a
|
|
322
|
+
* 5xx) sits out one page and is asked again, before it is left out until `retryFailed()`. Before, one such failure left
|
|
323
|
+
* the network out of the rest of the session: one Bluesky blip and Watch or Discover never read Bluesky again. The Minds
|
|
324
|
+
* read is not retried this way: its failure is named in `failedSources` at once (#4047), which the Minds app shows as
|
|
325
|
+
* "Minds posts didn't load" with Try again and which its Politics fallback (minds#1212) waits for.
|
|
326
|
+
*/
|
|
327
|
+
export declare const DISCOVERY_SOURCE_RETRIES = 2;
|
|
328
|
+
/**
|
|
329
|
+
* Whether a source's read failed for a reason that passes: it ran past its deadline, its call never got an answer (a
|
|
330
|
+
* failed connection or an abort, as the session's read reports it), or the server answered 408, 425, 429 or 5xx (or no
|
|
331
|
+
* status at all, 0). A response the session refuses (the wrong shape or protocol, including a TypeError thrown while
|
|
332
|
+
* checking it) and any other 4xx are the source's own failure and are not asked again.
|
|
333
|
+
*/
|
|
334
|
+
export declare function transientSourceFailure(error: unknown): boolean;
|
|
309
335
|
/** json-render Spec: values are references to authenticated SDK results, never
|
|
310
336
|
* arbitrary executable components, links, generated text or provider markup. */
|
|
311
337
|
export interface DiscoveryModule {
|
|
@@ -331,7 +357,17 @@ export interface DiscoveryModule {
|
|
|
331
357
|
export interface DiscoveryFeedPage {
|
|
332
358
|
modules: DiscoveryModule[];
|
|
333
359
|
hasMore: boolean;
|
|
360
|
+
/**
|
|
361
|
+
* Sources left out of the rest of the session (until `retryFailed()`): any failed Minds read (#4047), a network read
|
|
362
|
+
* the session refused, or a network's passing failures past DISCOVERY_SOURCE_RETRIES.
|
|
363
|
+
*/
|
|
334
364
|
failedSources: string[];
|
|
365
|
+
/**
|
|
366
|
+
* Open networks whose read failed for a passing reason (a timeout, a dropped connection, a 429 or a 5xx): missing from
|
|
367
|
+
* this page and the next, and named here until a later page asks them again (from the page after the next, when the
|
|
368
|
+
* round-robin reaches them). Never 'minds'. Absent when there are none, and on SDKs that predate it.
|
|
369
|
+
*/
|
|
370
|
+
retryingSources?: string[];
|
|
335
371
|
partialCategories: boolean;
|
|
336
372
|
/**
|
|
337
373
|
* The Minds feed's fall-through, once its category feed has ended a stage (the server's
|
|
@@ -32,6 +32,53 @@ const TOPIC_WALK_MS = 8000;
|
|
|
32
32
|
* new counts as an empty topic.
|
|
33
33
|
*/
|
|
34
34
|
const INDEX_CURSOR = 'idx:tf:';
|
|
35
|
+
/** How long one source's read may take before the page goes on without it (DiscoveryFeedSession). */
|
|
36
|
+
export const DISCOVERY_SOURCE_TIMEOUT_MS = 15_000;
|
|
37
|
+
/**
|
|
38
|
+
* Times in a row an open network whose read failed for a passing reason (a timeout, a dropped connection, a 429 or a
|
|
39
|
+
* 5xx) sits out one page and is asked again, before it is left out until `retryFailed()`. Before, one such failure left
|
|
40
|
+
* the network out of the rest of the session: one Bluesky blip and Watch or Discover never read Bluesky again. The Minds
|
|
41
|
+
* read is not retried this way: its failure is named in `failedSources` at once (#4047), which the Minds app shows as
|
|
42
|
+
* "Minds posts didn't load" with Try again and which its Politics fallback (minds#1212) waits for.
|
|
43
|
+
*/
|
|
44
|
+
export const DISCOVERY_SOURCE_RETRIES = 2;
|
|
45
|
+
/** The page deadline a source's read ran past. */
|
|
46
|
+
class SourceTimedOut extends Error {
|
|
47
|
+
constructor() { super('Discovery source timed out'); this.name = 'SourceTimedOut'; }
|
|
48
|
+
}
|
|
49
|
+
/** A source's own call that never got an answer: the connection failed (fetch's TypeError) or was aborted. */
|
|
50
|
+
class SourceUnreachable extends Error {
|
|
51
|
+
reason;
|
|
52
|
+
constructor(reason) {
|
|
53
|
+
super('Discovery source unreachable');
|
|
54
|
+
this.reason = reason;
|
|
55
|
+
this.name = 'SourceUnreachable';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A source's own call, with a failed connection told apart from a TypeError thrown while checking its answer (an
|
|
60
|
+
* answer with no `items` throws one at `data.items.length`): only the call's own TypeError or abort is unreachable.
|
|
61
|
+
*/
|
|
62
|
+
function reach(call) {
|
|
63
|
+
return call.catch((error) => {
|
|
64
|
+
const name = typeof error === 'object' && error !== null ? error.name : undefined;
|
|
65
|
+
throw name === 'TypeError' || name === 'AbortError' || name === 'TimeoutError' ? new SourceUnreachable(error) : error;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether a source's read failed for a reason that passes: it ran past its deadline, its call never got an answer (a
|
|
70
|
+
* failed connection or an abort, as the session's read reports it), or the server answered 408, 425, 429 or 5xx (or no
|
|
71
|
+
* status at all, 0). A response the session refuses (the wrong shape or protocol, including a TypeError thrown while
|
|
72
|
+
* checking it) and any other 4xx are the source's own failure and are not asked again.
|
|
73
|
+
*/
|
|
74
|
+
export function transientSourceFailure(error) {
|
|
75
|
+
if (error instanceof SourceTimedOut || error instanceof SourceUnreachable)
|
|
76
|
+
return true;
|
|
77
|
+
if (typeof error !== 'object' || error === null)
|
|
78
|
+
return false;
|
|
79
|
+
const { status } = error;
|
|
80
|
+
return typeof status === 'number' && (status === 0 || status === 408 || status === 425 || status === 429 || status >= 500);
|
|
81
|
+
}
|
|
35
82
|
const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
|
|
36
83
|
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
|
|
37
84
|
function recommendationItem(kind, candidate) {
|
|
@@ -121,6 +168,32 @@ function radarCluster(value) {
|
|
|
121
168
|
return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount: mainstream + independent, laneMix: { mainstream, independent }, sources,
|
|
122
169
|
velocity: { normalized: typeof normalized === 'number' && Number.isFinite(normalized) && normalized >= 0 ? normalized : 0 }, newestAt, ...(lead ? { lead } : {}), links, memberKeys };
|
|
123
170
|
}
|
|
171
|
+
const FALLOVER_VIA = new Set(['personal', 'popular', 'static']);
|
|
172
|
+
const filled = (value) => typeof value === 'string' && value.length > 0;
|
|
173
|
+
/**
|
|
174
|
+
* The Minds read's `meta.fallover`, step by step. A step of a kind this SDK knows (`feed` included, 0.7.60) that carries
|
|
175
|
+
* the fields its kind declares is kept as the server sent it; a step of a kind a later server added, or a malformed
|
|
176
|
+
* one, is left out and never guessed at, so every step a page reports is a FalloverStep. Undefined when the page has
|
|
177
|
+
* no list (a server before the contract, or a feed that is not a category's).
|
|
178
|
+
*/
|
|
179
|
+
function readFallover(value) {
|
|
180
|
+
if (!Array.isArray(value))
|
|
181
|
+
return undefined;
|
|
182
|
+
return value.filter((raw) => {
|
|
183
|
+
if (!raw || typeof raw !== 'object')
|
|
184
|
+
return false;
|
|
185
|
+
const step = raw;
|
|
186
|
+
switch (step.kind) {
|
|
187
|
+
case 'category_older': return filled(step.category) && filled(step.cursor);
|
|
188
|
+
case 'category_network': return filled(step.category);
|
|
189
|
+
case 'category': return filled(step.category) && typeof step.via === 'string' && FALLOVER_VIA.has(step.via);
|
|
190
|
+
case 'for_you':
|
|
191
|
+
case 'all': return true;
|
|
192
|
+
case 'feed': return filled(step.ref);
|
|
193
|
+
default: return false;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
124
197
|
const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
|
|
125
198
|
const key = (protocol, id) => JSON.stringify([protocol, id]);
|
|
126
199
|
const presentations = new Set(['posts', 'visual', 'conversation', 'video', 'thread', 'longread']);
|
|
@@ -175,8 +248,11 @@ export class DiscoveryFeedSession {
|
|
|
175
248
|
this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
|
|
176
249
|
.map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
|
|
177
250
|
}
|
|
178
|
-
retryFailed() { for (const stream of this.streams)
|
|
179
|
-
stream.failed = false;
|
|
251
|
+
retryFailed() { for (const stream of this.streams) {
|
|
252
|
+
stream.failed = false;
|
|
253
|
+
stream.failures = 0;
|
|
254
|
+
stream.retryAt = undefined;
|
|
255
|
+
} }
|
|
180
256
|
next() {
|
|
181
257
|
if (this.pending)
|
|
182
258
|
return this.pending;
|
|
@@ -204,8 +280,9 @@ export class DiscoveryFeedSession {
|
|
|
204
280
|
stream.used = [...stream.used, stream.cursor].slice(-32);
|
|
205
281
|
stream.cursor = next;
|
|
206
282
|
// A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
|
|
207
|
-
|
|
208
|
-
|
|
283
|
+
const fallover = readFallover(response.meta.fallover);
|
|
284
|
+
if (fallover)
|
|
285
|
+
stream.fallover = fallover;
|
|
209
286
|
return { items: response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post })),
|
|
210
287
|
layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial',
|
|
211
288
|
...(response.meta.compass_filter ? { compass: response.meta.compass_filter } : {}) };
|
|
@@ -217,7 +294,7 @@ export class DiscoveryFeedSession {
|
|
|
217
294
|
const started = Date.now();
|
|
218
295
|
if (stream.topics)
|
|
219
296
|
return this.walkTopics(stream, params, validatePosts, false, started);
|
|
220
|
-
const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY);
|
|
297
|
+
const { data } = await reach(this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY));
|
|
221
298
|
if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
|
|
222
299
|
throw new Error('Invalid discovery source response');
|
|
223
300
|
if (data.kind === 'topics') {
|
|
@@ -260,7 +337,7 @@ export class DiscoveryFeedSession {
|
|
|
260
337
|
// continuation, not the network: the stream may start again from a later topic's page. A provider topic
|
|
261
338
|
// read that fails any other way than gone is still the network's failure.
|
|
262
339
|
const indexContinuation = !!topic.cursor?.startsWith(INDEX_CURSOR);
|
|
263
|
-
const response = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY)
|
|
340
|
+
const response = await reach(this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY))
|
|
264
341
|
.catch((error) => {
|
|
265
342
|
if (isGone(error))
|
|
266
343
|
return null;
|
|
@@ -305,11 +382,18 @@ export class DiscoveryFeedSession {
|
|
|
305
382
|
void this.radarLoading.then(clusters => { const lead = this.pickCluster(clusters, 0); if (lead)
|
|
306
383
|
void this.memberPosts(lead); });
|
|
307
384
|
}
|
|
308
|
-
|
|
385
|
+
// A network that failed for a passing reason sat out the page after its failure; from this one it takes its turn
|
|
386
|
+
// again. It stays failed, and named in retryingSources, until a page asks it: with more than three networks the
|
|
387
|
+
// round-robin may pass it over on the page it is due (Codex review of #4037).
|
|
388
|
+
const active = this.streams.filter(stream => !stream.done && (!stream.failed || (stream.retryAt !== undefined && this.page >= stream.retryAt)));
|
|
309
389
|
const native = active.find(stream => stream.protocol === 'minds');
|
|
310
390
|
const remote = active.filter(stream => stream.protocol !== 'minds');
|
|
311
391
|
const selected = [...(native ? [native] : []), ...Array.from({ length: Math.min(native ? 3 : 4, remote.length) }, (_, index) => remote[(this.nextSource + index) % remote.length])];
|
|
312
392
|
this.nextSource += 3;
|
|
393
|
+
for (const stream of selected) {
|
|
394
|
+
stream.failed = false;
|
|
395
|
+
stream.retryAt = undefined;
|
|
396
|
+
}
|
|
313
397
|
const reads = await Promise.all(selected.map(async (stream) => {
|
|
314
398
|
// Commit continuation state only if this page finishes within its UI
|
|
315
399
|
// deadline. A late provider cannot move the cursor past unseen content.
|
|
@@ -317,14 +401,21 @@ export class DiscoveryFeedSession {
|
|
|
317
401
|
let cancel = () => { };
|
|
318
402
|
try {
|
|
319
403
|
const read = await Promise.race([this.read(candidate), new Promise((_resolve, reject) => {
|
|
320
|
-
const timer = setTimeout(() => reject(new
|
|
404
|
+
const timer = setTimeout(() => reject(new SourceTimedOut()), DISCOVERY_SOURCE_TIMEOUT_MS);
|
|
321
405
|
cancel = () => clearTimeout(timer);
|
|
322
406
|
})]);
|
|
323
407
|
Object.assign(stream, candidate);
|
|
408
|
+
stream.failures = 0;
|
|
324
409
|
return read;
|
|
325
410
|
}
|
|
326
|
-
catch {
|
|
411
|
+
catch (error) {
|
|
412
|
+
// A network's passing failure sits out the next page and is asked again from the one after, up to
|
|
413
|
+
// DISCOVERY_SOURCE_RETRIES times in a row; any other failure, or one past that, leaves the source out until
|
|
414
|
+
// retryFailed(). The Minds read fails at once, whatever the reason (#4047): the app's "Minds posts didn't load"
|
|
415
|
+
// notice, its Try again and its Politics fallback (minds#1212) all wait for 'minds' in failedSources.
|
|
327
416
|
stream.failed = true;
|
|
417
|
+
stream.failures = (stream.failures ?? 0) + 1;
|
|
418
|
+
stream.retryAt = stream.protocol !== 'minds' && transientSourceFailure(error) && stream.failures <= DISCOVERY_SOURCE_RETRIES ? this.page + 2 : undefined;
|
|
328
419
|
return { items: [], layout: ordinary, partial: false };
|
|
329
420
|
}
|
|
330
421
|
finally {
|
|
@@ -410,8 +501,10 @@ export class DiscoveryFeedSession {
|
|
|
410
501
|
const fallover = this.streams.find(stream => stream.protocol === 'minds')?.fallover;
|
|
411
502
|
// Only the Minds read carries what a Politics choice did (the open networks' reads are not filtered by it).
|
|
412
503
|
const compass = reads.find(read => read.compass)?.compass;
|
|
413
|
-
|
|
414
|
-
|
|
504
|
+
const retrying = this.streams.filter(stream => stream.failed && stream.retryAt !== undefined).map(stream => stream.protocol);
|
|
505
|
+
return { modules, hasMore: this.streams.some(stream => !stream.done && (!stream.failed || stream.retryAt !== undefined)),
|
|
506
|
+
failedSources: this.streams.filter(stream => stream.failed && stream.retryAt === undefined).map(stream => stream.protocol),
|
|
507
|
+
...(retrying.length ? { retryingSources: retrying } : {}), partialCategories: reads.some(read => read.partial),
|
|
415
508
|
...(fallover ? { fallover } : {}), ...(compass ? { compass } : {}) };
|
|
416
509
|
}
|
|
417
510
|
/** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
|
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
2
|
import type { SingleResponse, DeleteResponse, PaginationParams } from '../types.js';
|
|
3
|
+
/** One step of a task's plan, as the checklist card shows it. */
|
|
4
|
+
export interface TaskPlanStep {
|
|
5
|
+
position: number;
|
|
6
|
+
title: string;
|
|
7
|
+
status: 'pending' | 'in_progress' | 'done' | 'skipped' | 'blocked';
|
|
8
|
+
/** Why a step is blocked or was skipped. */
|
|
9
|
+
reason: string | null;
|
|
10
|
+
/** The specialist agent doing the step. */
|
|
11
|
+
agent: {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
} | null;
|
|
15
|
+
/** The approval or question a blocked step waits on, in the plan's conversation. */
|
|
16
|
+
waitingOn: {
|
|
17
|
+
executionId: string | null;
|
|
18
|
+
messageId: string | null;
|
|
19
|
+
} | null;
|
|
20
|
+
startedAt: string | null;
|
|
21
|
+
finishedAt: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface TaskPlan {
|
|
24
|
+
taskId: string;
|
|
25
|
+
title: string;
|
|
26
|
+
/** The chat the plan's card lives in. */
|
|
27
|
+
conversationId: string | null;
|
|
28
|
+
steps: TaskPlanStep[];
|
|
29
|
+
}
|
|
30
|
+
export interface SetTaskPlanInput {
|
|
31
|
+
agent?: string;
|
|
32
|
+
/** The chat to post the card in. Only the first plan that names one posts it. */
|
|
33
|
+
conversation_id?: string | null;
|
|
34
|
+
steps: Array<{
|
|
35
|
+
title: string;
|
|
36
|
+
agent_id?: string | null;
|
|
37
|
+
}>;
|
|
38
|
+
organization_id?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface UpdateTaskPlanStepInput {
|
|
41
|
+
agent?: string;
|
|
42
|
+
status: TaskPlanStep['status'];
|
|
43
|
+
title?: string;
|
|
44
|
+
/** Kept only for 'blocked' and 'skipped'. */
|
|
45
|
+
reason?: string | null;
|
|
46
|
+
/** Only for 'blocked': the approval it waits on. */
|
|
47
|
+
waiting_on_execution_id?: string | null;
|
|
48
|
+
/** Only for 'blocked': the question message it waits on. */
|
|
49
|
+
waiting_on_message_id?: string | null;
|
|
50
|
+
organization_id?: string;
|
|
51
|
+
}
|
|
3
52
|
export interface DispatcherTask {
|
|
4
53
|
id: string;
|
|
5
54
|
title: string;
|
|
@@ -275,6 +324,13 @@ export declare class DispatcherResource {
|
|
|
275
324
|
}): Promise<SingleResponse<{
|
|
276
325
|
ok: true;
|
|
277
326
|
}>>;
|
|
327
|
+
/**
|
|
328
|
+
* Write a claimed task's step plan (the checklist card). Replaces every step
|
|
329
|
+
* that has not finished; done and skipped steps stay. Requires the claim.
|
|
330
|
+
*/
|
|
331
|
+
setPlan(taskId: string, input: SetTaskPlanInput): Promise<SingleResponse<TaskPlan>>;
|
|
332
|
+
/** Move one step of a claimed task's plan. Started and finished times are stamped server-side. */
|
|
333
|
+
updatePlanStep(taskId: string, position: number, input: UpdateTaskPlanStepInput): Promise<SingleResponse<TaskPlan>>;
|
|
278
334
|
/**
|
|
279
335
|
* Mark a task as done. A task with a thresholded check that has no passing
|
|
280
336
|
* run refuses with 409 and the failing check names — run
|
|
@@ -28,6 +28,19 @@ export class DispatcherResource {
|
|
|
28
28
|
const { organization_id, ...body } = input;
|
|
29
29
|
return this.client.post(`/dispatcher/heartbeat/${encodeURIComponent(taskId)}${orgQuery(organization_id)}`, body);
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Write a claimed task's step plan (the checklist card). Replaces every step
|
|
33
|
+
* that has not finished; done and skipped steps stay. Requires the claim.
|
|
34
|
+
*/
|
|
35
|
+
setPlan(taskId, input) {
|
|
36
|
+
const { organization_id, ...body } = input;
|
|
37
|
+
return this.client.put(`/dispatcher/tasks/${encodeURIComponent(taskId)}/plan${orgQuery(organization_id)}`, body);
|
|
38
|
+
}
|
|
39
|
+
/** Move one step of a claimed task's plan. Started and finished times are stamped server-side. */
|
|
40
|
+
updatePlanStep(taskId, position, input) {
|
|
41
|
+
const { organization_id, ...body } = input;
|
|
42
|
+
return this.client.patch(`/dispatcher/tasks/${encodeURIComponent(taskId)}/plan/steps/${position}${orgQuery(organization_id)}`, body);
|
|
43
|
+
}
|
|
31
44
|
/**
|
|
32
45
|
* Mark a task as done. A task with a thresholded check that has no passing
|
|
33
46
|
* run refuses with 409 and the failing check names — run
|
package/dist/resources/e2ee.d.ts
CHANGED
|
@@ -95,9 +95,13 @@ export interface E2eeEvent {
|
|
|
95
95
|
* quiet one; never a commit, never a Welcome, never one whose envelope is over
|
|
96
96
|
* 16 KB), and only on a socket whose credential could read it with `events()`:
|
|
97
97
|
* one authenticated by the account owner's session or by an owner key whose
|
|
98
|
-
* app or organization binding reaches the conversation
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* app or organization binding reaches the conversation, once that sign-in has
|
|
99
|
+
* called `registerDevice`, `publishKeyPackages`, `keyPackageStatus`,
|
|
100
|
+
* `events`, `acknowledge` or `appendEvent` for one of the account's active
|
|
101
|
+
* devices since the socket connected, as a sync after connecting does. A
|
|
102
|
+
* socket opened with an agent-scoped key, or whose sign-in spoke only for a
|
|
103
|
+
* device since revoked, gets the content-free form only. When present it is
|
|
104
|
+
* the stored event exactly as `events()` returns it, JSON byte for byte.
|
|
101
105
|
*
|
|
102
106
|
* With the shared engine, hand `event` to `applyPushed(conversationId, event)`
|
|
103
107
|
* on the device session: it applies the event without a relay read when it is
|
|
@@ -11,6 +11,10 @@ export interface RequestHumanInputInput {
|
|
|
11
11
|
steps: string;
|
|
12
12
|
/** One-tap answers (2-4 short words) — rendered as buttons on the phone. */
|
|
13
13
|
options?: string[];
|
|
14
|
+
/** The option you recommend; must be one of `options`. Marked "Recommended" on the card. */
|
|
15
|
+
recommended?: string;
|
|
16
|
+
/** You keep working on `recommended` unless the human answers otherwise. Needs `recommended`. */
|
|
17
|
+
continuing_with_default?: boolean;
|
|
14
18
|
}
|
|
15
19
|
export interface HumanInputRequest {
|
|
16
20
|
conversation_id: string;
|
|
@@ -982,6 +982,13 @@ export interface SocialPost {
|
|
|
982
982
|
author: Omit<SocialProfile, 'subscription'>;
|
|
983
983
|
text: string;
|
|
984
984
|
createdAt: string;
|
|
985
|
+
/**
|
|
986
|
+
* The language the post declares, as the lower-case primary subtag of its BCP-47 tag ("de", "en", "pt"): Bluesky's
|
|
987
|
+
* record `langs[0]`, a Mastodon status's `language`, a Nostr note's NIP-32 `ISO-639-1` self-label. Absent when the post
|
|
988
|
+
* declares none (most Nostr notes) or a malformed one, and on posts served from the open-network source index; the
|
|
989
|
+
* server never guesses it from the text and never drops a post for it. Use it to order the viewer's language first.
|
|
990
|
+
*/
|
|
991
|
+
language?: string;
|
|
985
992
|
replyCount: number | null;
|
|
986
993
|
likeCount: number | null;
|
|
987
994
|
repostCount: number | null;
|
|
@@ -1120,7 +1127,12 @@ export type SocialTrends = SocialTrendProvenance & ({
|
|
|
1120
1127
|
safetyFilter?: {
|
|
1121
1128
|
withheld: number;
|
|
1122
1129
|
};
|
|
1123
|
-
})
|
|
1130
|
+
}) & {
|
|
1131
|
+
/** Present when `getPublicTrends` named a filtering `compass`: what Hide politics did to this page (counts only). */
|
|
1132
|
+
meta?: {
|
|
1133
|
+
compass_filter?: CompassFilterMeta;
|
|
1134
|
+
};
|
|
1135
|
+
};
|
|
1124
1136
|
export interface SocialTrendFeed extends SocialTrendProvenance {
|
|
1125
1137
|
trend: SocialTrend;
|
|
1126
1138
|
items: SocialPost[];
|
|
@@ -1246,10 +1258,20 @@ export interface SocialRadar {
|
|
|
1246
1258
|
selected: string;
|
|
1247
1259
|
status: 'complete' | 'partial';
|
|
1248
1260
|
};
|
|
1261
|
+
/** Present when the read named a filtering `compass`: what Hide politics did to the stories listed (counts only). */
|
|
1262
|
+
meta?: {
|
|
1263
|
+
compass_filter?: CompassFilterMeta;
|
|
1264
|
+
};
|
|
1249
1265
|
}
|
|
1250
1266
|
export interface SocialRadarParams {
|
|
1251
1267
|
category?: string;
|
|
1252
1268
|
limit?: number;
|
|
1269
|
+
/**
|
|
1270
|
+
* A Politics choice. Hide politics (`hide`) leaves out the stories whose theme is politics, and the ones not themed yet
|
|
1271
|
+
* or themed `mixed` (a story has no compass placement, so it fails closed); the stories after them fill the list.
|
|
1272
|
+
* A side or Mix is answered unfiltered, marked `unavailable`; the keyless `getSharedRadar` takes only `all` or `hide`.
|
|
1273
|
+
*/
|
|
1274
|
+
compass?: string;
|
|
1253
1275
|
}
|
|
1254
1276
|
/** One radar story's social member posts, in the cluster's member order. Empty once the story leaves the snapshot. */
|
|
1255
1277
|
export interface SocialRadarPosts {
|
|
@@ -1643,7 +1665,10 @@ export declare class ProtocolsResource {
|
|
|
1643
1665
|
}): Promise<SingleResponse<SocialConversation>>;
|
|
1644
1666
|
/**
|
|
1645
1667
|
* Current provider-ranked topics or posts, with explicit source and coverage. Does not ingest content.
|
|
1646
|
-
* Every page, previews included, gets the safety floor; `discovery` only asks for a layout.
|
|
1668
|
+
* Every page, previews included, gets the safety floor; `discovery` only asks for a layout. `compass` (a Politics
|
|
1669
|
+
* choice, spelled as `posts.list` takes it): Hide politics (`hide`) leaves out the topics and posts whose cached
|
|
1670
|
+
* browse category is politics, where the filter is served to the member; a side or Mix is answered unfiltered. The
|
|
1671
|
+
* page then says what it did in `meta.compass_filter`.
|
|
1647
1672
|
*/
|
|
1648
1673
|
getPublicTrends(params: {
|
|
1649
1674
|
protocol: string;
|
|
@@ -1652,6 +1677,7 @@ export declare class ProtocolsResource {
|
|
|
1652
1677
|
includePreviews?: boolean;
|
|
1653
1678
|
category?: string;
|
|
1654
1679
|
discovery?: boolean;
|
|
1680
|
+
compass?: string;
|
|
1655
1681
|
}, options?: SocialReadOptions): Promise<SingleResponse<SocialTrends>>;
|
|
1656
1682
|
/** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. Screened like `getPublicTrends`. */
|
|
1657
1683
|
getPublicTrendFeed(params: {
|
|
@@ -42,11 +42,12 @@ function hostedIdentityPath(id) {
|
|
|
42
42
|
export function viralItemHasVideo(item) {
|
|
43
43
|
return item.post.media.some(media => media.type === 'video');
|
|
44
44
|
}
|
|
45
|
-
/** Only the
|
|
45
|
+
/** Only the radar's own fields travel; caller scope or destination extras never reach the query string. */
|
|
46
46
|
/** Forwarded only when given, so a default read makes exactly the call it always made. */
|
|
47
47
|
const readOptions = (options) => options ? [options] : [];
|
|
48
48
|
function radarQuery(params) {
|
|
49
|
-
return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {})
|
|
49
|
+
return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}),
|
|
50
|
+
...(params.compass !== undefined ? { compass: params.compass } : {}) };
|
|
50
51
|
}
|
|
51
52
|
/** Only the six Viral read fields travel, each only when given: the read answers any other parameter with a 400. */
|
|
52
53
|
function viralQuery(params) {
|
|
@@ -204,13 +205,17 @@ export class ProtocolsResource {
|
|
|
204
205
|
}
|
|
205
206
|
/**
|
|
206
207
|
* Current provider-ranked topics or posts, with explicit source and coverage. Does not ingest content.
|
|
207
|
-
* Every page, previews included, gets the safety floor; `discovery` only asks for a layout.
|
|
208
|
+
* Every page, previews included, gets the safety floor; `discovery` only asks for a layout. `compass` (a Politics
|
|
209
|
+
* choice, spelled as `posts.list` takes it): Hide politics (`hide`) leaves out the topics and posts whose cached
|
|
210
|
+
* browse category is politics, where the filter is served to the member; a side or Mix is answered unfiltered. The
|
|
211
|
+
* page then says what it did in `meta.compass_filter`.
|
|
208
212
|
*/
|
|
209
213
|
getPublicTrends(params, options) {
|
|
210
214
|
return this.client.get('/protocols/social/trends', { protocol: params.protocol, limit: params.limit, cursor: params.cursor,
|
|
211
215
|
...(params.includePreviews !== undefined ? { includePreviews: params.includePreviews } : {}),
|
|
212
216
|
...(params.category !== undefined ? { category: params.category } : {}),
|
|
213
|
-
...(params.discovery !== undefined ? { discovery: params.discovery } : {})
|
|
217
|
+
...(params.discovery !== undefined ? { discovery: params.discovery } : {}),
|
|
218
|
+
...(params.compass !== undefined ? { compass: params.compass } : {}) }, ...readOptions(options));
|
|
214
219
|
}
|
|
215
220
|
/** Open a topic currently listed by the provider. Use its nativeId as topic and preserve the opaque cursor. Screened like `getPublicTrends`. */
|
|
216
221
|
getPublicTrendFeed(params, options) {
|
|
@@ -13,6 +13,12 @@ export interface CreateVideoUploadResult {
|
|
|
13
13
|
upload: VideoUploadCreds;
|
|
14
14
|
/** The HLS URL the video will play from once encoding completes. */
|
|
15
15
|
hlsUrl: string;
|
|
16
|
+
/**
|
|
17
|
+
* The creator's proof for `renewUpload` (valid 24 h): pass it back to get
|
|
18
|
+
* fresh credentials for this same video when a resume outlives the hour
|
|
19
|
+
* `upload` is signed for. Absent from servers older than 0.7.63's API.
|
|
20
|
+
*/
|
|
21
|
+
resumeGrant?: string;
|
|
16
22
|
}
|
|
17
23
|
export interface VideoStatusInfo {
|
|
18
24
|
status: string | null;
|
|
@@ -59,6 +65,20 @@ export declare class VideoResource {
|
|
|
59
65
|
}): Promise<{
|
|
60
66
|
data: CreateVideoUploadResult;
|
|
61
67
|
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Fresh upload credentials for a video this member started, so a TUS resume
|
|
70
|
+
* that outlives the hour `upload` is signed for continues the same video.
|
|
71
|
+
* Pass the `resumeGrant` from `createUpload`. 404 for anyone else's video, an
|
|
72
|
+
* expired grant or an older API without the route; 409 `upload_finished` once
|
|
73
|
+
* a post carries the video or Bunny holds the whole file; 402 as `createUpload`;
|
|
74
|
+
* 503 when the video provider could not answer: keep the partial upload and
|
|
75
|
+
* retry (it never means the video is gone).
|
|
76
|
+
*/
|
|
77
|
+
renewUpload(playbackId: string, input: {
|
|
78
|
+
grant: string;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
data: CreateVideoUploadResult;
|
|
81
|
+
}>;
|
|
62
82
|
/** Encode status + metadata for a video. */
|
|
63
83
|
getStatus(playbackId: string): Promise<{
|
|
64
84
|
data: VideoStatusInfo;
|
package/dist/resources/video.js
CHANGED
|
@@ -19,6 +19,18 @@ export class VideoResource {
|
|
|
19
19
|
createUpload(input) {
|
|
20
20
|
return this.client.post('/video/create-upload', input ?? {});
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Fresh upload credentials for a video this member started, so a TUS resume
|
|
24
|
+
* that outlives the hour `upload` is signed for continues the same video.
|
|
25
|
+
* Pass the `resumeGrant` from `createUpload`. 404 for anyone else's video, an
|
|
26
|
+
* expired grant or an older API without the route; 409 `upload_finished` once
|
|
27
|
+
* a post carries the video or Bunny holds the whole file; 402 as `createUpload`;
|
|
28
|
+
* 503 when the video provider could not answer: keep the partial upload and
|
|
29
|
+
* retry (it never means the video is gone).
|
|
30
|
+
*/
|
|
31
|
+
renewUpload(playbackId, input) {
|
|
32
|
+
return this.client.post(`/video/${encodeURIComponent(playbackId)}/renew-upload`, input);
|
|
33
|
+
}
|
|
22
34
|
/** Encode status + metadata for a video. */
|
|
23
35
|
getStatus(playbackId) {
|
|
24
36
|
return this.client.get(`/video/${playbackId}/status`);
|
package/dist/types.d.ts
CHANGED
|
@@ -37,6 +37,10 @@ export type FeedNext = {
|
|
|
37
37
|
* cursor; the same continuation `next` carries), the same category on the open networks, the member's
|
|
38
38
|
* next categories (`via`: the order they came from, the one `posts.categories()` serves), For you
|
|
39
39
|
* (signed in), All.
|
|
40
|
+
*
|
|
41
|
+
* `feed` (0.7.60) names the next feed by its ref (e.g. `builtin:category:science`, or a member's `feed:<id>`). No
|
|
42
|
+
* server sends it yet: it comes with feeds. Skip a step whose kind your app cannot open yet; a feed session
|
|
43
|
+
* (`discovery.createFeed`) already leaves out any step this SDK does not know, or a malformed one.
|
|
40
44
|
*/
|
|
41
45
|
export type FalloverStep = {
|
|
42
46
|
kind: 'category_older';
|
|
@@ -53,6 +57,9 @@ export type FalloverStep = {
|
|
|
53
57
|
kind: 'for_you';
|
|
54
58
|
} | {
|
|
55
59
|
kind: 'all';
|
|
60
|
+
} | {
|
|
61
|
+
kind: 'feed';
|
|
62
|
+
ref: string;
|
|
56
63
|
};
|
|
57
64
|
/**
|
|
58
65
|
* What a Politics choice (`compass`) did to a page, on the feeds that take one (`posts.list` on discovery, category
|
|
@@ -634,6 +641,25 @@ export interface ConversationMember {
|
|
|
634
641
|
username: string;
|
|
635
642
|
image: string | null;
|
|
636
643
|
is_ai: boolean;
|
|
644
|
+
/**
|
|
645
|
+
* `admin` for a private group's admin: the one person who adds, removes and
|
|
646
|
+
* renames, and whose device sets up an encrypted group's keys. The creator,
|
|
647
|
+
* then, once they leave, the longest-standing person left. `member` for
|
|
648
|
+
* everyone else. Omitted by APIs before SDK 0.7.59's group routes.
|
|
649
|
+
*/
|
|
650
|
+
role?: ChatMemberRole;
|
|
651
|
+
/**
|
|
652
|
+
* Each member's message request, in a private group or a one-on-one chat.
|
|
653
|
+
* Someone who follows the person who wrote (or who made the group), and an
|
|
654
|
+
* agent, start `accepted`, as the person who wrote does; a chat started
|
|
655
|
+
* without a request reads `accepted` for both. Anyone else starts `pending`
|
|
656
|
+
* and reads `accepted` once they accept or reply, or, in a one-on-one chat,
|
|
657
|
+
* start a chat back. So `accepted` alone does not mean they accepted
|
|
658
|
+
* anything. Declining stays private: to anyone else a member who declined
|
|
659
|
+
* still reads `pending`. Omitted in community rooms and by older APIs
|
|
660
|
+
* (one-on-one chats: before this field was served for them).
|
|
661
|
+
*/
|
|
662
|
+
request_status?: ChatRequestStatus;
|
|
637
663
|
}
|
|
638
664
|
export interface ConversationMemberDetail extends ConversationMember {
|
|
639
665
|
joined_at: string;
|
|
@@ -683,6 +709,12 @@ export interface LeaveConversationResult {
|
|
|
683
709
|
left: true;
|
|
684
710
|
/** True when the caller was the last person in it, so the room was removed. */
|
|
685
711
|
conversation_deleted: boolean;
|
|
712
|
+
/**
|
|
713
|
+
* When the admin of an encrypted group leaves: the invitees who had not
|
|
714
|
+
* accepted, whose invites were cancelled with them (the next admin
|
|
715
|
+
* re-invites). Omitted when there were none.
|
|
716
|
+
*/
|
|
717
|
+
invites_cancelled?: string[];
|
|
686
718
|
}
|
|
687
719
|
export interface ConversationDetail {
|
|
688
720
|
/** Missing on older APIs: treat as unknown, never as plaintext permission. */
|
|
@@ -702,6 +734,67 @@ export interface ConversationDetail {
|
|
|
702
734
|
can_leave?: boolean;
|
|
703
735
|
created_at: string;
|
|
704
736
|
}
|
|
737
|
+
export type ChatMemberRole = 'admin' | 'member';
|
|
738
|
+
/**
|
|
739
|
+
* Why an encrypted group refused someone: `details.reason` of a 409
|
|
740
|
+
* `conversation_not_eligible` (`RecursivError`) from
|
|
741
|
+
* `chat.createGroup({ encrypted: true })` or `chat.addMembers`, beside
|
|
742
|
+
* `details.max_people`. The group must be a private group (not a DM, not a
|
|
743
|
+
* community room) of people only (no agents), all inside the encryption
|
|
744
|
+
* pilot, and at most `max_people` (8 at launch) counting pending invitees.
|
|
745
|
+
*/
|
|
746
|
+
export type EncryptedGroupRefusalReason = 'not_a_private_group' | 'community' | 'too_many_people' | 'agent' | 'outside_pilot';
|
|
747
|
+
export interface CreateGroupInput {
|
|
748
|
+
name: string;
|
|
749
|
+
/** Up to 100 people, invited in this order. */
|
|
750
|
+
member_ids: string[];
|
|
751
|
+
/**
|
|
752
|
+
* End-to-end encrypted. The group and its encryption record are created
|
|
753
|
+
* together or not at all: a refusal never leaves a plaintext group behind.
|
|
754
|
+
*/
|
|
755
|
+
encrypted?: boolean;
|
|
756
|
+
/** With `encrypted`: the creator's active encrypted-chat device, when they have one. */
|
|
757
|
+
device_id?: string;
|
|
758
|
+
}
|
|
759
|
+
export interface AddGroupMembersResult {
|
|
760
|
+
id: string;
|
|
761
|
+
/** Who was added, in the order asked; `pending` for someone who does not follow you (a message request). */
|
|
762
|
+
added: Array<{
|
|
763
|
+
id: string;
|
|
764
|
+
request_status: ChatRequestStatus;
|
|
765
|
+
}>;
|
|
766
|
+
/** Asked for but already in the group: nothing changed for them. */
|
|
767
|
+
already_members: string[];
|
|
768
|
+
}
|
|
769
|
+
export interface RemoveGroupMemberResult {
|
|
770
|
+
id: string;
|
|
771
|
+
user_id: string;
|
|
772
|
+
removed: true;
|
|
773
|
+
}
|
|
774
|
+
export interface UpdateGroupResult {
|
|
775
|
+
id: string;
|
|
776
|
+
name: string;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Realtime `chat:members`: a private group's people changed. Ids only: read
|
|
780
|
+
* the conversation again (and, in an encrypted group, check its keys).
|
|
781
|
+
* `actorId` is the admin for `added` and `removed`, and the leaver for `left`
|
|
782
|
+
* and `invites_cancelled` (the invites an encrypted group's admin sent, which
|
|
783
|
+
* went with them).
|
|
784
|
+
*/
|
|
785
|
+
export interface ChatMembersEvent {
|
|
786
|
+
conversationId: string;
|
|
787
|
+
change: 'added' | 'removed' | 'left' | 'invites_cancelled';
|
|
788
|
+
userIds: string[];
|
|
789
|
+
actorId: string;
|
|
790
|
+
}
|
|
791
|
+
/** Realtime `chat:conversation`: a private group was renamed by its admin. */
|
|
792
|
+
export interface ChatConversationEvent {
|
|
793
|
+
conversationId: string;
|
|
794
|
+
change: 'renamed';
|
|
795
|
+
name: string;
|
|
796
|
+
actorId: string;
|
|
797
|
+
}
|
|
705
798
|
export interface MessageReaction {
|
|
706
799
|
type: string;
|
|
707
800
|
user_id: string;
|
package/llm.md
CHANGED
|
@@ -498,10 +498,12 @@ const { data: group } = await r.chat.createGroup({ name: 'Team', member_ids: ['u
|
|
|
498
498
|
const { data: msgs } = await r.chat.messages(dm.id, { limit: 50 })
|
|
499
499
|
```
|
|
500
500
|
|
|
501
|
-
Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
|
|
501
|
+
Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `addMembers`, `removeMember`, `updateGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
|
|
502
502
|
|
|
503
503
|
Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growing `offset`. Conversations carry `can_delete` / `can_leave`; check them before offering Delete or Leave.
|
|
504
504
|
|
|
505
|
+
Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group or a one-on-one chat, `request_status`: `accepted` from the start for the person who wrote, anyone who follows them and agents, else `pending` until that person accepts or replies (a decline reads `pending` to anyone else), so `accepted` alone does not mean they accepted anything. Only the admin (the creator, then the longest-standing person left) may `addMembers(id, { member_ids })`, `removeMember(id, userId)` and `updateGroup(id, { name })` (else 403 `not_admin`). `createGroup({ name, member_ids, encrypted: true, device_id? })` makes an end-to-end encrypted group of people in the pilot, at most 8 counting pending invitees, or throws 409 `conversation_not_eligible` with `details.reason` and creates nothing. `r.realtime.onChatMembers` / `onChatConversation` tell you when people or the name change.
|
|
506
|
+
|
|
505
507
|
### r.e2ee — End-to-end encryption transport (flag-gated)
|
|
506
508
|
|
|
507
509
|
Relays opaque MLS envelopes, public device credentials and one-time key packages for the shared E2EE core; the server never sees keys or plaintext. Requires the `e2eeV2` network feature flag (otherwise `feature_disabled`) and a key acting as the account owner (agent-scoped keys get `agent_key_not_allowed`). The realm is the network id. Only a direct conversation between two people with an accepted request can start encryption (`conversation_not_eligible` otherwise), and every later append to it is checked the same way again (both people still accepted, still in the pilot, still only the two of them), so a client need not read the policy before each send; a resend of an event already stored still returns `duplicate: true`, even once a Welcome's target device is gone; blocks apply (`blocked`), to resends too; commits must send `expected_commit_seq`.
|
|
@@ -519,7 +521,8 @@ await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope: react
|
|
|
519
521
|
const { data: page } = await r.e2ee.events(conversationId, { device_id: device.id, after_seq: 0 });
|
|
520
522
|
await r.e2ee.acknowledge(conversationId, { device_id: device.id, seq: page.headSeq });
|
|
521
523
|
// Doorbell after every append: { conversationId, seq, event? }. `event` (a small application message, as events() returns it) comes only to a socket of the owner's session or an owner key, never an agent-scoped key.
|
|
522
|
-
|
|
524
|
+
// The policy is required: policyForConversationType(conversation.type) from @recursiv/e2ee ('direct' or 'group').
|
|
525
|
+
r.realtime.onE2eeEvent(({ conversationId, event }) => (event ? controller.applyPushed(conversationId, event, policy) : controller.sync(conversationId, undefined, policy)));
|
|
523
526
|
```
|
|
524
527
|
|
|
525
528
|
Link a new device by scanning (0.7.57; `status()` reports `links: true` when the relay can). The new device N shows the link id in a QR code; an approved device A of the same account scans it. The relay is a short-lived mailbox (180 s, 120 s more once scanned; one open link per sign-in, at most 3 per account; 10 creates per sign-in and 20 per account an hour, refused with 409 `link_limit`, never 429). `approval` and `tag` go only to the sign-in that created the link. Reserved labels get 400 `invalid_label`; a 503 `storage_unavailable` is temporary, so keep polling until `expiresAt`. Own devices carry `createdAt` and `lastSeenAt`.
|
|
@@ -616,6 +619,8 @@ const { data: results } = await r.posts.search({ query: 'shipped', community_id:
|
|
|
616
619
|
|
|
617
620
|
Methods: `list`, `get`, `create`, `update`, `delete`, `search`, `replies(id, { limit, cursor })` (after `replies_next_cursor` on `get`), `react`, `unreact`, `liked`, `bookmarked`, `bookmark`, `unbookmark`
|
|
618
621
|
|
|
622
|
+
Feed definitions (types only; no route serves one yet): `FeedDefinition` is `{ v: 1, sources, ranking: { mode, range? }, filters?, exclude? }`, with 1-`FEED_SOURCES_MAX` (50) sources read as one union (`FEED_SOURCE_KINDS`; `FEED_SOURCE_KEY[kind]` names each kind's one field, e.g. `{ kind: 'tag', slug: 'rust' }`, `{ kind: 'account', ref: 'did:plc:…' }`). `filters.politics` stores only `'hide'`; viewer settings, blocks, mutes and compass Mix/Sides never go in a definition. Built-in feeds: `builtinCategoryFeedRef('science')` → `'builtin:category:science'`, plus `'builtin:all'` and `'builtin:for_you'`.
|
|
623
|
+
|
|
619
624
|
`search` ranks relevance x recency x engagement (recency falls as a power of age: this month outranks years ago) through the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as `cursor`: later relevance pages are slices of the ranking the first page kept for ten minutes (nothing skipped or repeated while it lives; after that, the page continues past the last post's score boundary), newest-first pages continue by (created_at, id). A cursor is bound to its search and sort: another search's, or the other sort's, is 400 `invalid_cursor`. The first relevance page carries `latest` (up to three of the newest matches not on the page, one per author, for a Latest strip); the last page carries `fall_over.lanes` (`new`, `open_network`, `tag`): draw a way on, not a wall.
|
|
620
625
|
|
|
621
626
|
### r.bookmarks — Synced saves (per member, per app)
|
|
@@ -680,7 +685,13 @@ the presign/PUT sequence in application code.
|
|
|
680
685
|
|
|
681
686
|
Video: check `const { data } = await r.video.entitlement()` before offering the
|
|
682
687
|
picker (`data.allowed`, `data.reason` 'tier' | 'quota', `data.remainingSeconds`),
|
|
683
|
-
then `r.video.createUpload()`, which runs the same check.
|
|
688
|
+
then `r.video.createUpload()`, which runs the same check. Upload in chunks
|
|
689
|
+
(tus `chunkSize`) and keep the result for the attached file: a retry resumes the
|
|
690
|
+
same video. The TUS signature lasts an hour; past it, call
|
|
691
|
+
`r.video.renewUpload(playbackId, { grant: result.resumeGrant })` (0.7.63) for fresh
|
|
692
|
+
credentials for the same video. 404 (not yours, expired grant, older API) or 409
|
|
693
|
+
`upload_finished` means start a new upload instead. 503 means the video provider
|
|
694
|
+
could not answer: keep the partial upload and ask again later (`Retry-After`).
|
|
684
695
|
|
|
685
696
|
### r.chat — Messaging
|
|
686
697
|
|
|
@@ -691,10 +702,12 @@ const { data: group } = await r.chat.createGroup({ name: 'Team', member_ids: ['u
|
|
|
691
702
|
const { data: msgs } = await r.chat.messages(dm.id, { limit: 50 })
|
|
692
703
|
```
|
|
693
704
|
|
|
694
|
-
Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
|
|
705
|
+
Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `send`, `dm`, `createGroup`, `addMembers`, `removeMember`, `updateGroup`, `deleteConversation`, `leave`, `acceptRequest`, `declineRequest`, `reactToMessage`, `editMessage`, `deleteMessage`, `markAsRead`, `unreadCount`
|
|
695
706
|
|
|
696
707
|
Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growing `offset`. Conversations carry `can_delete` / `can_leave`; check them before offering Delete or Leave.
|
|
697
708
|
|
|
709
|
+
Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group or a one-on-one chat, `request_status`: `accepted` from the start for the person who wrote, anyone who follows them and agents, else `pending` until that person accepts or replies (a decline reads `pending` to anyone else), so `accepted` alone does not mean they accepted anything. Only the admin (the creator, then the longest-standing person left) may `addMembers(id, { member_ids })`, `removeMember(id, userId)` and `updateGroup(id, { name })` (else 403 `not_admin`). `createGroup({ name, member_ids, encrypted: true, device_id? })` makes an end-to-end encrypted group of people in the pilot, at most 8 counting pending invitees, or throws 409 `conversation_not_eligible` with `details.reason` and creates nothing. `r.realtime.onChatMembers` / `onChatConversation` tell you when people or the name change.
|
|
710
|
+
|
|
698
711
|
### r.tags
|
|
699
712
|
|
|
700
713
|
```typescript
|
|
@@ -742,9 +755,10 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
|
|
|
742
755
|
- `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
|
|
743
756
|
- 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.
|
|
744
757
|
- 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.
|
|
745
|
-
- 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`). 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`.
|
|
758
|
+
- 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`.
|
|
746
759
|
- 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.
|
|
747
|
-
- Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor? })
|
|
760
|
+
- Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass? })` (`compass: 'hide'` leaves out politics, 0.7.62); 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.
|
|
761
|
+
- Post language: a `SocialPost` may carry `language`, the lower-case primary subtag the post declares (`'de'`, `'en'`; Bluesky `langs[0]`, Mastodon `language`, a Nostr NIP-32 `ISO-639-1` label). It is absent when the post declares none (most Nostr notes) and on posts from the source index; it is never guessed and no post is dropped for it. Order by it (the viewer's language first, undeclared next), never filter on it.
|
|
748
762
|
- `listCandidates({ protocol?, limit?, cursor? })` and `getCandidate(id)` are read-only quarantine review, not published-post search. Both require `admin` scope, a project-bound key, a live admin role, and existing project-admin access. The key supplies app/network scope; neither method accepts another project or network. They never invoke refresh, change settings, or publish.
|
|
749
763
|
- Lists return `{ data: ProtocolQuarantinedCandidate[], meta: { limit, has_more, next_cursor } }`; use the opaque `next_cursor`, not offset pagination. The API defaults `limit` to 20 and permits at most 50. Details return `{ data: ProtocolQuarantinedCandidate }`. Every candidate has `state: 'quarantined'` and `content_format: 'plain'`; render its bounded `content` as text, respect `content_truncated`, and distinguish upstream `published_at` from local `observed_at` / `created_at`.
|
|
750
764
|
- Safe source IDs are preserved byte-exact. `external_id: null` means unsafe or malformed provenance was withheld, not an invented ID; `author.native_id` may likewise be absent or withheld. Preserve nulls in both list and detail views, and use the candidate's own `id` for `getCandidate`.
|