@recursiv/sdk 0.7.68 → 0.7.70
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 +19 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +1 -0
- package/dist/realtime.d.ts +12 -3
- package/dist/realtime.js +10 -3
- package/dist/resources/chat.d.ts +4 -1
- package/dist/resources/chat.js +4 -1
- package/dist/resources/curator.d.ts +168 -0
- package/dist/resources/curator.js +96 -0
- package/dist/resources/e2ee.d.ts +41 -0
- package/dist/resources/e2ee.js +33 -0
- package/dist/resources/posts.d.ts +2 -1
- package/dist/resources/posts.js +2 -1
- package/dist/types.d.ts +67 -5
- package/llm.md +17 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
|
|
|
75
75
|
| Resource | Key methods |
|
|
76
76
|
|----------|------------|
|
|
77
77
|
| `r.agents` | `create`, `update`, `chat`, `chatStream`, `retryChatStream`, `grantProjectAccess`, `conversations` |
|
|
78
|
-
| `r.e2ee` | `status`, `registerDevice`, `devices`, `revokeDevice` (`{ cascade }` to also revoke devices approved through it; 409 `has_dependents` otherwise), `requestResetCode` and `resetAccount` (reset encrypted chat with an emailed code where `status().accountReset`), `publishKeyPackages`, `keyPackageStatus`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge`, `conversations`; device linking by scan: `createLink`, `link`, `scannedLink`, `approveLink`, `completeLink`, `linkProgress`, `cancelLink` (flag-gated E2EE v2 transport; its doorbell is `r.realtime.onE2eeEvent`, which carries a small message's stored ciphertext to the owner's own sockets; link and device changes arrive on `r.realtime.onE2eeLink` and `onE2eeDevices`) |
|
|
78
|
+
| `r.e2ee` | `status`, `registerDevice`, `devices`, `revokeDevice` (`{ cascade }` to also revoke devices approved through it; 409 `has_dependents` otherwise), `requestResetCode` and `resetAccount` (reset encrypted chat with an emailed code where `status().accountReset`), `publishKeyPackages`, `keyPackageStatus`, `claimKeyPackage`, `appendEvent`, `events`, `acknowledge`, `conversations`, `requestStart` and `cancelStartRequest` (ask the other person to encrypt a chat when they have no device yet: a message with `metadata.kind` `e2ee_start_request`; the live ones are `ConversationDetail.encryption_requests`); device linking by scan: `createLink`, `link`, `scannedLink`, `approveLink`, `completeLink`, `linkProgress`, `cancelLink` (flag-gated E2EE v2 transport; its doorbell is `r.realtime.onE2eeEvent`, which carries a small message's stored ciphertext to the owner's own sockets; link and device changes arrive on `r.realtime.onE2eeLink` and `onE2eeDevices`) |
|
|
79
79
|
| `r.brain` | `sendMessage` |
|
|
80
80
|
| `r.commands` | `execute`, `gatePrompt` |
|
|
81
81
|
| `r.projectBrain` | `tasks`, `decisions`, `milestones`, `completeTask`, `usage` |
|
|
@@ -99,6 +99,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
|
|
|
99
99
|
| `r.communities` | `create`, `join`, `search`, `members` |
|
|
100
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) |
|
|
101
101
|
| `r.tags` | `create`, `list` |
|
|
102
|
+
| `r.curator` | `today` (World Today; a world story can carry `story_key` and `discussion`, its count across networks), `storyPosts` / `memberStoryPosts` (one page of a story's posts, keyless or under the member's key), `storyPostPages` (walk every page), `forYou`, `run`, `refreshIfStale`, `seedDeck` |
|
|
102
103
|
| `r.uploads` | `uploadMedia` — upload post/chat bytes end to end |
|
|
103
104
|
| `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` |
|
|
104
105
|
|
|
@@ -682,7 +683,9 @@ interests, no interest over 40%, videos weighted up), and `blocks` lay the page
|
|
|
682
683
|
`stories`, `lead_video`, `clips`, `posts`, `people`, `groups`, `topics`, `viral` and
|
|
683
684
|
`categories`, never the same type twice in a row, each naming the refs its views should send.
|
|
684
685
|
Every story and item carries `reasons` (`interest:<category>`, `followed:@<handle>`, `breaking`,
|
|
685
|
-
`general`, `for_you`, `engaging`, `breadth`).
|
|
686
|
+
`general`, `for_you`, `engaging`, `breadth`). An open-network item carries its `card` (text,
|
|
687
|
+
author, media and counts, no URL on its network) to draw it from; a Minds item's is null (read
|
|
688
|
+
the post by id). `part: 'stories'` and `part: 'items'` read the
|
|
686
689
|
two halves on their own, so an app can draw each as it lands; a part=items answer keeps the
|
|
687
690
|
stories rail as a `deferred` block. `meta.interests` are category ids for chips (never put them
|
|
688
691
|
in a URL), `meta.partial` names a source that missed its deadline, and `meta.foryou_cursor`
|
|
@@ -740,6 +743,20 @@ a legislature, or a party or politician. Hide politics leaves those out whatever
|
|
|
740
743
|
`feeds.top()` carry `political` too (0.7.67), and those reads and `curator.forYouWorld()` leave political stories out
|
|
741
744
|
under Hide politics.
|
|
742
745
|
|
|
746
|
+
A World Today story's discussion across networks (0.7.69; servers with World Story Volume on): `sdk.curator.today()`
|
|
747
|
+
gives each world story the server indexed a `story_key` and, when the reader's pool holds a post, a `discussion`:
|
|
748
|
+
`total`, `people`, `by_network` (`minds`, `bluesky`, `activitypub`, `nostr`), `capped` (the pool reached 1,000: show
|
|
749
|
+
"1,000+") and up to two open-network `previews`; `meta.story_volume` says how the pools stand. Without them (an older
|
|
750
|
+
server, or the feature off) show the story as before. The posts behind the count:
|
|
751
|
+
`sdk.curator.storyPosts({ storyKey, network?, sort?: 'top' | 'latest', cursor?, limit? })` keyless (a visitor's view)
|
|
752
|
+
or `sdk.curator.memberStoryPosts(...)` under the member's key (their own blocks, mutes and hidden posts leave each
|
|
753
|
+
page), and `for await (const page of sdk.curator.storyPostPages({ storyKey }, { reader: 'member' }))` walks every
|
|
754
|
+
page. Each item is `{ kind: 'minds', post: Post }` or `{ kind: 'social', post: SocialPost }` (narrow on `kind`; open
|
|
755
|
+
a social post inside the app, never link out); `text_only` means show its words only. A post removed since the build is
|
|
756
|
+
counted in the page's `withheld`, never replaced, so the pages' items plus `withheld` add up to the page's `matched`:
|
|
757
|
+
`discussion.total` for `network: 'all'`, that network's `discussion.by_network` count for one network (`discussion`
|
|
758
|
+
always counts the whole pool). A cursor whose build is gone answers `status: 'expired'`: start again from the first page.
|
|
759
|
+
|
|
743
760
|
`sdk.discovery.recommendations({ kind, category?, limit?, session?, exclude? })` is
|
|
744
761
|
the app's one recommender for suggestion rails: `kind` is `member`, `group`, `tag`,
|
|
745
762
|
`network_account` or `story`, and `category` is `all` or a browse category id. It
|
package/dist/index.d.ts
CHANGED
|
@@ -158,7 +158,7 @@ export { UsersResource } from './resources/users.js';
|
|
|
158
158
|
export { CommunitiesResource } from './resources/communities.js';
|
|
159
159
|
export { ChatResource } from './resources/chat.js';
|
|
160
160
|
export { E2eeResource } from './resources/e2ee.js';
|
|
161
|
-
export type { E2eeAccountReset, E2eeDevice, E2eeDevicesRealtimeEvent, E2eeEnvelope, E2eeEvent, E2eeHasDependentsDetails, E2eeKeyPackageStatus, E2eeLink, E2eeLinkCancelledBy, E2eeLinkProgress, E2eeLinkRealtimeEvent, E2eeLinkState, E2eeObjectDownload, E2eeObjectUpload, E2eeRealtimeEvent, E2eeResetCodeSent, E2eeResetErrorCode, E2eeRevokeResponse, E2eeStatus, E2eeStatusReason, } from './resources/e2ee.js';
|
|
161
|
+
export type { E2eeAccountReset, E2eeDevice, E2eeDevicesRealtimeEvent, E2eeEnvelope, E2eeEvent, E2eeHasDependentsDetails, E2eeKeyPackageStatus, E2eeLink, E2eeLinkCancelledBy, E2eeLinkProgress, E2eeLinkRealtimeEvent, E2eeLinkState, E2eeObjectDownload, E2eeObjectUpload, E2eeRealtimeEvent, E2eeResetCodeSent, E2eeResetErrorCode, E2eeRevokeResponse, E2eeStartRequest, E2eeStatus, E2eeStatusReason, } from './resources/e2ee.js';
|
|
162
162
|
export { ProjectsResource } from './resources/projects.js';
|
|
163
163
|
export { AgentsResource, SUPPORTED_MODELS } from './resources/agents.js';
|
|
164
164
|
export { EvidenceResource } from './resources/evidence.js';
|
|
@@ -195,7 +195,8 @@ export { VideoResource } from './resources/video.js';
|
|
|
195
195
|
export type { VideoUploadCreds, CreateVideoUploadResult, VideoStatusInfo, VideoPlaybackToken, VideoEntitlement, } from './resources/video.js';
|
|
196
196
|
export { AppSubscriptionsResource } from './resources/app-subscriptions.js';
|
|
197
197
|
export type { AppSubscriptionBoostCredit, AppSubscriptionCheckoutResult, AppSubscriptionPortalResult, AppSubscriptionStatusResult, AppSubscriptionTierChangePreview, AppSubscriptionTierChangeResult, } from './resources/app-subscriptions.js';
|
|
198
|
-
export type { CuratorSource, CuratorPrompt, CuratorRunInput, CuratorRunResult, CuratorTodayEdition, CuratorTodayExcerpt, CuratorTodayLeadImage, CuratorTodayParams, CuratorTodayRadar, CuratorTodayReaction, CuratorTodayResponse, CuratorTodaySource, CuratorTodayStory, ForYouMeta, ForYouParams, ForYouSurface, ForYouWorldBlock, ForYouWorldBlockType, ForYouWorldCategoryShortcut, ForYouWorldItem, ForYouWorldMeta, ForYouWorldParams, ForYouWorldPart, ForYouWorldResponse, ForYouWorldStory, ForYouWorldStoryTarget, ForYouWorldViral, } from './resources/curator.js';
|
|
198
|
+
export type { CuratorSource, CuratorPrompt, CuratorRunInput, CuratorRunResult, CuratorTodayEdition, CuratorTodayExcerpt, CuratorTodayLeadImage, CuratorTodayParams, CuratorTodayRadar, CuratorTodayReaction, CuratorTodayResponse, CuratorTodaySource, CuratorTodayStory, CuratorTodayDiscussion, CuratorStoryDiscussion, CuratorStoryLaneOutcome, CuratorStoryMindsPost, CuratorStoryPostItem, CuratorStoryPostPagesOptions, CuratorStoryPostsPage, CuratorStoryPostsParams, CuratorStoryReader, CuratorStorySocialPost, CuratorStoryVolumeLanes, CuratorStoryVolumeMeta, StoryPostNetwork, StoryPostPool, StoryPostSort, ForYouMeta, ForYouParams, ForYouSurface, ForYouWorldBlock, ForYouWorldBlockType, ForYouWorldCategoryShortcut, ForYouWorldItem, ForYouWorldMeta, ForYouWorldParams, ForYouWorldPart, ForYouWorldResponse, ForYouWorldStory, ForYouWorldStoryTarget, ForYouWorldViral, } from './resources/curator.js';
|
|
199
|
+
export { STORY_POOL_MAX_POSTS, STORY_POST_NETWORKS, STORY_POSTS_PAGE_MAX } from './resources/curator.js';
|
|
199
200
|
export { ReportsResource } from './resources/reports.js';
|
|
200
201
|
export type { ReportTargetType, ReportStatus, CreateReportInput, ReportRecord, ResolveReportInput, } from './resources/reports.js';
|
|
201
202
|
export { ModerationResource } from './resources/moderation.js';
|
package/dist/index.js
CHANGED
|
@@ -253,6 +253,7 @@ export { JobsResource } from './resources/jobs.js';
|
|
|
253
253
|
export { CuratorResource } from './resources/curator.js';
|
|
254
254
|
export { VideoResource } from './resources/video.js';
|
|
255
255
|
export { AppSubscriptionsResource } from './resources/app-subscriptions.js';
|
|
256
|
+
export { STORY_POOL_MAX_POSTS, STORY_POST_NETWORKS, STORY_POSTS_PAGE_MAX } from './resources/curator.js';
|
|
256
257
|
export { ReportsResource } from './resources/reports.js';
|
|
257
258
|
export { ModerationResource } from './resources/moderation.js';
|
|
258
259
|
export { LinkPreviewResource } from './resources/link-preview.js';
|
package/dist/realtime.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface ChatMessageEvent {
|
|
|
14
14
|
isAi?: boolean;
|
|
15
15
|
};
|
|
16
16
|
createdAt: string;
|
|
17
|
+
/** The message's metadata as stored; `kind: 'e2ee_start_request'` is an encryption request (MessageMetadata). */
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
17
19
|
}
|
|
18
20
|
export interface ChatMessageEditEvent {
|
|
19
21
|
id: string;
|
|
@@ -163,11 +165,18 @@ export declare class RealtimeClient {
|
|
|
163
165
|
onAgentThinking(callback: (event: AgentThinkingEvent) => void): (() => void) | undefined;
|
|
164
166
|
/**
|
|
165
167
|
* A private group's people changed (0.7.59): `{ conversationId, change, userIds, actorId }`,
|
|
166
|
-
* change `added`, `removed`, `left` or `invites_cancelled
|
|
167
|
-
* and to the people it is about
|
|
168
|
+
* change `added`, `accepted`, `removed`, `left` or `invites_cancelled`, sent to everyone in the group
|
|
169
|
+
* and to the people it is about; or `device` (a member's device can be added to the encrypted group
|
|
170
|
+
* now), sent to the group's admin only, on a connection that may read encrypted chat for the group.
|
|
171
|
+
* Read the conversation again; in an encrypted group, check its keys.
|
|
168
172
|
*/
|
|
169
173
|
onChatMembers(callback: (event: ChatMembersEvent) => void): () => void;
|
|
170
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* A conversation changed: a private group was renamed (0.7.59,
|
|
176
|
+
* `{ conversationId, change: 'renamed', name, actorId }`), or someone
|
|
177
|
+
* withdrew their request to encrypt a one-on-one chat
|
|
178
|
+
* (`encryption_request_withdrawn`, with `actorId`). Read it again.
|
|
179
|
+
*/
|
|
171
180
|
onChatConversation(callback: (event: ChatConversationEvent) => void): () => void;
|
|
172
181
|
/**
|
|
173
182
|
* The E2EE doorbell after each append to an encrypted conversation this
|
package/dist/realtime.js
CHANGED
|
@@ -429,13 +429,20 @@ export class RealtimeClient {
|
|
|
429
429
|
}
|
|
430
430
|
/**
|
|
431
431
|
* A private group's people changed (0.7.59): `{ conversationId, change, userIds, actorId }`,
|
|
432
|
-
* change `added`, `removed`, `left` or `invites_cancelled
|
|
433
|
-
* and to the people it is about
|
|
432
|
+
* change `added`, `accepted`, `removed`, `left` or `invites_cancelled`, sent to everyone in the group
|
|
433
|
+
* and to the people it is about; or `device` (a member's device can be added to the encrypted group
|
|
434
|
+
* now), sent to the group's admin only, on a connection that may read encrypted chat for the group.
|
|
435
|
+
* Read the conversation again; in an encrypted group, check its keys.
|
|
434
436
|
*/
|
|
435
437
|
onChatMembers(callback) {
|
|
436
438
|
return this.on('chat:members', callback);
|
|
437
439
|
}
|
|
438
|
-
/**
|
|
440
|
+
/**
|
|
441
|
+
* A conversation changed: a private group was renamed (0.7.59,
|
|
442
|
+
* `{ conversationId, change: 'renamed', name, actorId }`), or someone
|
|
443
|
+
* withdrew their request to encrypt a one-on-one chat
|
|
444
|
+
* (`encryption_request_withdrawn`, with `actorId`). Read it again.
|
|
445
|
+
*/
|
|
439
446
|
onChatConversation(callback) {
|
|
440
447
|
return this.on('chat:conversation', callback);
|
|
441
448
|
}
|
package/dist/resources/chat.d.ts
CHANGED
|
@@ -93,7 +93,10 @@ export declare class ChatResource {
|
|
|
93
93
|
* only stops listening for live events.
|
|
94
94
|
*/
|
|
95
95
|
leave(conversationId: string): Promise<SingleResponse<LeaveConversationResult>>;
|
|
96
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Accept a message request (`request_status: 'pending'`). Idempotent; also reverses a decline. In a
|
|
98
|
+
* private group, everyone in it gets the realtime `chat:members` event (`accepted`); a repeat sends none.
|
|
99
|
+
*/
|
|
97
100
|
acceptRequest(conversationId: string): Promise<SingleResponse<ConversationRequestResult>>;
|
|
98
101
|
/**
|
|
99
102
|
* Decline a message request. The conversation leaves the caller's lists and
|
package/dist/resources/chat.js
CHANGED
|
@@ -108,7 +108,10 @@ export class ChatResource {
|
|
|
108
108
|
leave(conversationId) {
|
|
109
109
|
return this.client.post(`/chat/conversations/${conversationId}/leave`);
|
|
110
110
|
}
|
|
111
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Accept a message request (`request_status: 'pending'`). Idempotent; also reverses a decline. In a
|
|
113
|
+
* private group, everyone in it gets the realtime `chat:members` event (`accepted`); a repeat sends none.
|
|
114
|
+
*/
|
|
112
115
|
acceptRequest(conversationId) {
|
|
113
116
|
return this.client.post(`/chat/conversations/${conversationId}/accept`);
|
|
114
117
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
2
|
import type { CompassFilterMeta, DiscoveryLayout, PaginatedResponse, PaginationMeta, Post, SingleResponse } from '../types.js';
|
|
3
3
|
import type { FeedTopNetworkCard, FeedTopTarget } from './feeds.js';
|
|
4
|
+
import type { SocialPost } from './protocols.js';
|
|
4
5
|
/** Which surface is asking: Home's For You (default) or Discover's For you. */
|
|
5
6
|
export type ForYouSurface = 'home' | 'discover';
|
|
6
7
|
export type ForYouParams = {
|
|
@@ -102,6 +103,8 @@ export interface ForYouWorldItem {
|
|
|
102
103
|
rate: number;
|
|
103
104
|
};
|
|
104
105
|
};
|
|
106
|
+
/** An open-network post's card (no URL on its network): draw the post from it. Null for a Minds post, read by id. Every open-network item carries one. */
|
|
107
|
+
card: FeedTopNetworkCard | null;
|
|
105
108
|
}
|
|
106
109
|
/** A viral post in the viral block. `card` is an open-network post's card (no URL on its network); null for a Minds post, read by id. */
|
|
107
110
|
export interface ForYouWorldViral {
|
|
@@ -375,6 +378,18 @@ export interface CuratorTodayStory {
|
|
|
375
378
|
lead_image?: CuratorTodayLeadImage | null;
|
|
376
379
|
/** World stories only: the radar cluster the story seeded, or null. */
|
|
377
380
|
radar?: CuratorTodayRadar | null;
|
|
381
|
+
/**
|
|
382
|
+
* World Story Volume (servers with it on): the story's stable key, which survives the edition's regenerations. Its
|
|
383
|
+
* posts page is `curator.storyPosts({ storyKey })`. Absent on a Minds story, on a world story the builder has not
|
|
384
|
+
* indexed, and on every story of a server without it.
|
|
385
|
+
*/
|
|
386
|
+
story_key?: string;
|
|
387
|
+
/**
|
|
388
|
+
* World Story Volume: how many posts discuss the story across Minds, Bluesky, the Fediverse and Nostr, and up to two
|
|
389
|
+
* of its open-network posts. Present only when the reader's pool holds a post (never `{ total: 0 }`). `posts` stays
|
|
390
|
+
* the Minds previews it was. Absent: show the story as before.
|
|
391
|
+
*/
|
|
392
|
+
discussion?: CuratorTodayDiscussion;
|
|
378
393
|
}
|
|
379
394
|
export interface CuratorTodayEdition {
|
|
380
395
|
id: string;
|
|
@@ -393,6 +408,8 @@ export interface CuratorTodayResponse {
|
|
|
393
408
|
meta: {
|
|
394
409
|
generating: boolean;
|
|
395
410
|
compass_filter?: CompassFilterMeta;
|
|
411
|
+
/** World Story Volume: how the story pools stand for this app. Absent on a server without it, or with it off. */
|
|
412
|
+
story_volume?: CuratorStoryVolumeMeta;
|
|
396
413
|
};
|
|
397
414
|
}
|
|
398
415
|
export interface CuratorTodayParams {
|
|
@@ -402,6 +419,124 @@ export interface CuratorTodayParams {
|
|
|
402
419
|
*/
|
|
403
420
|
compass?: string;
|
|
404
421
|
}
|
|
422
|
+
/** The networks a story's discussion counts posts on. A mirrored `name@host` Minds account counts as `activitypub`. */
|
|
423
|
+
export declare const STORY_POST_NETWORKS: readonly ["minds", "bluesky", "activitypub", "nostr"];
|
|
424
|
+
export type StoryPostNetwork = (typeof STORY_POST_NETWORKS)[number];
|
|
425
|
+
/** `safe`: posts a cached verdict clears (visitors, and members until their content-filter tiers ship); `open`: words of the rest too. */
|
|
426
|
+
export type StoryPostPool = 'safe' | 'open';
|
|
427
|
+
export type StoryPostSort = 'top' | 'latest';
|
|
428
|
+
/** Posts a story page holds at most (`limit`, the default too). */
|
|
429
|
+
export declare const STORY_POSTS_PAGE_MAX = 25;
|
|
430
|
+
/** Posts a story's pool holds at most; a pool that reaches it is `capped` (shown as "1,000+"). */
|
|
431
|
+
export declare const STORY_POOL_MAX_POSTS = 1000;
|
|
432
|
+
/** A story's pool counts: what a card shows ("312 posts · Bluesky 248 · Nostr 31 · Minds 24 · Fediverse 9"). */
|
|
433
|
+
export interface CuratorStoryDiscussion {
|
|
434
|
+
/** Posts in the reader's pool. */
|
|
435
|
+
total: number;
|
|
436
|
+
/** Distinct authors among them. */
|
|
437
|
+
people: number;
|
|
438
|
+
by_network: Record<StoryPostNetwork, number>;
|
|
439
|
+
pool: StoryPostPool;
|
|
440
|
+
/** The pool reached STORY_POOL_MAX_POSTS: show "1,000+". */
|
|
441
|
+
capped: boolean;
|
|
442
|
+
/** ISO time the pool was built (every ten minutes). */
|
|
443
|
+
built_at: string;
|
|
444
|
+
}
|
|
445
|
+
/** The discussion a world story carries on `curator.today()`. */
|
|
446
|
+
export interface CuratorTodayDiscussion extends CuratorStoryDiscussion {
|
|
447
|
+
/** Up to two of the pool's open-network posts, as the reader may see them (none when they could not be read in time). */
|
|
448
|
+
previews: CuratorStoryPostItem[];
|
|
449
|
+
}
|
|
450
|
+
/** A Minds post discussing the story, in the shape `curator.today()` serves its `posts`. */
|
|
451
|
+
export interface CuratorStoryMindsPost {
|
|
452
|
+
/** JSON `["minds", id]`. */
|
|
453
|
+
key: string;
|
|
454
|
+
kind: 'minds';
|
|
455
|
+
/** Where the author is from: a mirrored `name@host` Minds account counts as `activitypub`. */
|
|
456
|
+
network: StoryPostNetwork;
|
|
457
|
+
post: Post;
|
|
458
|
+
/** No verdict clears the post (the `open` pool only): its words are served, never its media. */
|
|
459
|
+
text_only?: true;
|
|
460
|
+
}
|
|
461
|
+
/** An open-network post discussing the story. Open it inside the app; never link out to its network. */
|
|
462
|
+
export interface CuratorStorySocialPost {
|
|
463
|
+
/** JSON `[protocol, nativeId]`. */
|
|
464
|
+
key: string;
|
|
465
|
+
kind: 'social';
|
|
466
|
+
network: StoryPostNetwork;
|
|
467
|
+
post: SocialPost;
|
|
468
|
+
/** No verdict clears the post (the `open` pool only): its words are served, never its media. */
|
|
469
|
+
text_only?: true;
|
|
470
|
+
}
|
|
471
|
+
/** One post on a story page, or a preview on a card: narrow on `kind`. */
|
|
472
|
+
export type CuratorStoryPostItem = CuratorStoryMindsPost | CuratorStorySocialPost;
|
|
473
|
+
/** `meta.story_volume` on `curator.today()`. */
|
|
474
|
+
export interface CuratorStoryVolumeMeta {
|
|
475
|
+
/** `building`: no build yet (or the last expired); `unavailable`: the counts could not be read. */
|
|
476
|
+
status: 'ok' | 'building' | 'unavailable';
|
|
477
|
+
pool: StoryPostPool;
|
|
478
|
+
built_at: string | null;
|
|
479
|
+
/** False for the minutes after a regeneration, while stories the builder has not read yet carry no key. */
|
|
480
|
+
edition_current: boolean;
|
|
481
|
+
/** World stories indexed, those whose reader's pool holds a post, and those whose build lost an input. */
|
|
482
|
+
stories: number;
|
|
483
|
+
pooled: number;
|
|
484
|
+
degraded: number;
|
|
485
|
+
}
|
|
486
|
+
/** What one lane of a story's build did: counts only. */
|
|
487
|
+
export interface CuratorStoryLaneOutcome {
|
|
488
|
+
state: 'ok' | 'empty' | 'failed' | 'off';
|
|
489
|
+
items: number;
|
|
490
|
+
}
|
|
491
|
+
export interface CuratorStoryVolumeLanes {
|
|
492
|
+
minds: CuratorStoryLaneOutcome;
|
|
493
|
+
source_index: CuratorStoryLaneOutcome;
|
|
494
|
+
attach: CuratorStoryLaneOutcome;
|
|
495
|
+
}
|
|
496
|
+
export interface CuratorStoryPostsParams {
|
|
497
|
+
/** The story's `story_key` from `curator.today()` (a UUID). */
|
|
498
|
+
storyKey: string;
|
|
499
|
+
/** One network's posts, or `all` (default). */
|
|
500
|
+
network?: 'all' | StoryPostNetwork;
|
|
501
|
+
/** `top` (default): the pool's rank; `latest`: newest first. */
|
|
502
|
+
sort?: StoryPostSort;
|
|
503
|
+
/** The previous page's `cursor`, unchanged. It is bound to its network and sort (and build), not to `limit`. */
|
|
504
|
+
cursor?: string;
|
|
505
|
+
/** 1 to STORY_POSTS_PAGE_MAX (default 25). */
|
|
506
|
+
limit?: number;
|
|
507
|
+
}
|
|
508
|
+
/** One page of a story's posts. */
|
|
509
|
+
export interface CuratorStoryPostsPage {
|
|
510
|
+
/** `expired`: the build the cursor walks is gone (kept 30 minutes); the list ends here: start again from the first page. */
|
|
511
|
+
status: 'ok' | 'expired';
|
|
512
|
+
story_key: string;
|
|
513
|
+
pool: StoryPostPool;
|
|
514
|
+
network: 'all' | StoryPostNetwork;
|
|
515
|
+
sort: StoryPostSort;
|
|
516
|
+
items: CuratorStoryPostItem[];
|
|
517
|
+
/** The next page's cursor; null on the last page. */
|
|
518
|
+
cursor: string | null;
|
|
519
|
+
exhausted: boolean;
|
|
520
|
+
/** Places on this page whose post was not listed (removed or edited out since the build, or hidden by the member's own filters): never replaced. */
|
|
521
|
+
withheld: number;
|
|
522
|
+
/** The pool's posts this network filter covers: what walking every page lists, less `withheld`. */
|
|
523
|
+
matched: number;
|
|
524
|
+
/** The reader's pool's counts, as `curator.today()` carries them (without previews); null when the pool is empty. */
|
|
525
|
+
discussion: CuratorStoryDiscussion | null;
|
|
526
|
+
built_at: string;
|
|
527
|
+
/** A lane failed or the cached verdicts could not be read during the build: the pool may be smaller than the story's discussion. */
|
|
528
|
+
degraded: boolean;
|
|
529
|
+
lanes: CuratorStoryVolumeLanes;
|
|
530
|
+
}
|
|
531
|
+
/** Who reads a story page: a visitor (`storyPosts`, the default) or the signed-in member (`memberStoryPosts`). */
|
|
532
|
+
export type CuratorStoryReader = 'visitor' | 'member';
|
|
533
|
+
export interface CuratorStoryPostPagesOptions {
|
|
534
|
+
reader?: CuratorStoryReader;
|
|
535
|
+
/** Stop after this many pages. Default: as many as a capped pool can fill at this `limit`. */
|
|
536
|
+
maxPages?: number;
|
|
537
|
+
/** `false` asks each page once (no retry on a 5xx or 429), as `posts.list` takes it. */
|
|
538
|
+
retry?: boolean;
|
|
539
|
+
}
|
|
405
540
|
export declare class CuratorResource {
|
|
406
541
|
private client;
|
|
407
542
|
constructor(client: HttpClient);
|
|
@@ -411,6 +546,39 @@ export declare class CuratorResource {
|
|
|
411
546
|
* conversation stories follow. Requires `posts:read`.
|
|
412
547
|
*/
|
|
413
548
|
today(params?: CuratorTodayParams): Promise<CuratorTodayResponse>;
|
|
549
|
+
/**
|
|
550
|
+
* One page of the posts discussing a World Today story across Minds, Bluesky, the Fediverse and Nostr, as a visitor
|
|
551
|
+
* reads them (the safe pool, floored again on every page). Works keyless: build the client with
|
|
552
|
+
* `new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false })`, since `anonymous` alone still sends
|
|
553
|
+
* `RECURSIV_API_KEY` when the process has one (a shared cache may hold the keyless answer for a minute). With a key it
|
|
554
|
+
* is that app's visitor answer, never cached. For a signed-in member use `memberStoryPosts`. Throws ValidationError
|
|
555
|
+
* (no request) for a key that is not a story key; the server answers 404 while it does not serve story volume and for
|
|
556
|
+
* a story with no pool, and 503 (retry shortly) when it cannot floor the page.
|
|
557
|
+
*/
|
|
558
|
+
storyPosts(params: CuratorStoryPostsParams, options?: {
|
|
559
|
+
retry?: boolean;
|
|
560
|
+
}): Promise<SingleResponse<CuratorStoryPostsPage>>;
|
|
561
|
+
/**
|
|
562
|
+
* `storyPosts` for the signed-in member under the app's own key (`users:read` and `posts:read`): their own blocks,
|
|
563
|
+
* mutes and hidden posts leave each page (counted in `withheld`). Never cached.
|
|
564
|
+
*/
|
|
565
|
+
memberStoryPosts(params: CuratorStoryPostsParams, options?: {
|
|
566
|
+
retry?: boolean;
|
|
567
|
+
}): Promise<SingleResponse<CuratorStoryPostsPage>>;
|
|
568
|
+
/**
|
|
569
|
+
* Walk a story's pages from `params.cursor` (or the first page), yielding each page: the walk keeps one network and
|
|
570
|
+
* sort, and ends on the last page, on an `expired` page (yielded, so the reader can start again) or at `maxPages`.
|
|
571
|
+
* Over a walk to the last page, `items.length + withheld` sums to the page's `matched`: `discussion.total` for
|
|
572
|
+
* network `all`, and that network's `discussion.by_network` count for one network (`discussion` is the whole pool's).
|
|
573
|
+
* Throws ValidationError (no request) for a bad story key, a `limit` outside 1 to STORY_POSTS_PAGE_MAX, or a
|
|
574
|
+
* `maxPages` that is not a whole number of at least 1 (`Infinity` walks to the last page).
|
|
575
|
+
*
|
|
576
|
+
* ```ts
|
|
577
|
+
* for await (const page of sdk.curator.storyPostPages({ storyKey, network: 'bluesky' })) render(page.items)
|
|
578
|
+
* ```
|
|
579
|
+
*/
|
|
580
|
+
storyPostPages(params: CuratorStoryPostsParams, options?: CuratorStoryPostPagesOptions): AsyncGenerator<CuratorStoryPostsPage, void, undefined>;
|
|
581
|
+
private readStoryPosts;
|
|
414
582
|
/**
|
|
415
583
|
* Fetch the supplied sources in parallel, annotate each top item with
|
|
416
584
|
* the supplied prompt template, and insert audience-scoped posts into
|
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
import { ValidationError } from '../errors.js';
|
|
2
|
+
// ── World Story Volume: a World Today story's discussion across networks ──
|
|
3
|
+
//
|
|
4
|
+
// GET /curator/today gives each world story the builder indexed a `story_key` and, when the reader's pool holds a post,
|
|
5
|
+
// a `discussion`. The posts behind the count are paged by `storyPosts` (keyless: a signed-out visitor, or a key's app
|
|
6
|
+
// as a visitor reads it) and `memberStoryPosts` (the signed-in member's own blocks, mutes and hidden posts applied).
|
|
7
|
+
// Walking every page lists exactly the page's `matched`, less each page's `withheld`: `discussion.total` for network
|
|
8
|
+
// `all`, that network's `discussion.by_network` count for one network (`discussion` counts the whole pool on every page).
|
|
9
|
+
/** The networks a story's discussion counts posts on. A mirrored `name@host` Minds account counts as `activitypub`. */
|
|
10
|
+
export const STORY_POST_NETWORKS = Object.freeze(['minds', 'bluesky', 'activitypub', 'nostr']);
|
|
11
|
+
/** Posts a story page holds at most (`limit`, the default too). */
|
|
12
|
+
export const STORY_POSTS_PAGE_MAX = 25;
|
|
13
|
+
/** Posts a story's pool holds at most; a pool that reaches it is `capped` (shown as "1,000+"). */
|
|
14
|
+
export const STORY_POOL_MAX_POSTS = 1000;
|
|
15
|
+
/** Story keys are UUIDs; anything else never reaches a path. */
|
|
16
|
+
const STORY_KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
17
|
+
function storyPostsPath(reader, storyKey) {
|
|
18
|
+
if (typeof storyKey !== 'string' || !STORY_KEY.test(storyKey)) {
|
|
19
|
+
throw new ValidationError({ type: 'validation_error', code: 'invalid_story_key', message: "Pass a world story's story_key from curator.today()." });
|
|
20
|
+
}
|
|
21
|
+
return reader === 'member' ? `/protocols/social/stories/${storyKey}/posts` : `/curator/today/stories/${storyKey}/posts`;
|
|
22
|
+
}
|
|
23
|
+
/** Only the four fields the story page reads: the server's query is strict, and a caller's extras never reach it. */
|
|
24
|
+
function storyPostsQuery(params) {
|
|
25
|
+
const query = {};
|
|
26
|
+
if (params.network !== undefined)
|
|
27
|
+
query.network = params.network;
|
|
28
|
+
if (params.sort !== undefined)
|
|
29
|
+
query.sort = params.sort;
|
|
30
|
+
if (params.cursor !== undefined)
|
|
31
|
+
query.cursor = params.cursor;
|
|
32
|
+
if (params.limit !== undefined)
|
|
33
|
+
query.limit = params.limit;
|
|
34
|
+
return query;
|
|
35
|
+
}
|
|
1
36
|
export class CuratorResource {
|
|
2
37
|
client;
|
|
3
38
|
constructor(client) {
|
|
@@ -11,6 +46,67 @@ export class CuratorResource {
|
|
|
11
46
|
today(params = {}) {
|
|
12
47
|
return params.compass ? this.client.get('/curator/today', { compass: params.compass }) : this.client.get('/curator/today');
|
|
13
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* One page of the posts discussing a World Today story across Minds, Bluesky, the Fediverse and Nostr, as a visitor
|
|
51
|
+
* reads them (the safe pool, floored again on every page). Works keyless: build the client with
|
|
52
|
+
* `new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false })`, since `anonymous` alone still sends
|
|
53
|
+
* `RECURSIV_API_KEY` when the process has one (a shared cache may hold the keyless answer for a minute). With a key it
|
|
54
|
+
* is that app's visitor answer, never cached. For a signed-in member use `memberStoryPosts`. Throws ValidationError
|
|
55
|
+
* (no request) for a key that is not a story key; the server answers 404 while it does not serve story volume and for
|
|
56
|
+
* a story with no pool, and 503 (retry shortly) when it cannot floor the page.
|
|
57
|
+
*/
|
|
58
|
+
storyPosts(params, options) {
|
|
59
|
+
return this.readStoryPosts('visitor', params, options);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `storyPosts` for the signed-in member under the app's own key (`users:read` and `posts:read`): their own blocks,
|
|
63
|
+
* mutes and hidden posts leave each page (counted in `withheld`). Never cached.
|
|
64
|
+
*/
|
|
65
|
+
memberStoryPosts(params, options) {
|
|
66
|
+
return this.readStoryPosts('member', params, options);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Walk a story's pages from `params.cursor` (or the first page), yielding each page: the walk keeps one network and
|
|
70
|
+
* sort, and ends on the last page, on an `expired` page (yielded, so the reader can start again) or at `maxPages`.
|
|
71
|
+
* Over a walk to the last page, `items.length + withheld` sums to the page's `matched`: `discussion.total` for
|
|
72
|
+
* network `all`, and that network's `discussion.by_network` count for one network (`discussion` is the whole pool's).
|
|
73
|
+
* Throws ValidationError (no request) for a bad story key, a `limit` outside 1 to STORY_POSTS_PAGE_MAX, or a
|
|
74
|
+
* `maxPages` that is not a whole number of at least 1 (`Infinity` walks to the last page).
|
|
75
|
+
*
|
|
76
|
+
* ```ts
|
|
77
|
+
* for await (const page of sdk.curator.storyPostPages({ storyKey, network: 'bluesky' })) render(page.items)
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
async *storyPostPages(params, options = {}) {
|
|
81
|
+
const reader = options.reader ?? 'visitor';
|
|
82
|
+
storyPostsPath(reader, params.storyKey);
|
|
83
|
+
// A limit or maxPages the loop cannot count by (NaN, Infinity, 0) would end the walk before its first request and
|
|
84
|
+
// look like an empty story, so it throws here, before any request, as a bad story key does.
|
|
85
|
+
const limit = params.limit ?? STORY_POSTS_PAGE_MAX;
|
|
86
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > STORY_POSTS_PAGE_MAX) {
|
|
87
|
+
throw new ValidationError({ type: 'validation_error', code: 'invalid_limit', message: `limit must be a whole number from 1 to ${STORY_POSTS_PAGE_MAX}.` });
|
|
88
|
+
}
|
|
89
|
+
const maxPages = options.maxPages ?? Math.ceil(STORY_POOL_MAX_POSTS / limit);
|
|
90
|
+
if (!(maxPages === Number.POSITIVE_INFINITY || (Number.isInteger(maxPages) && maxPages >= 1))) {
|
|
91
|
+
throw new ValidationError({ type: 'validation_error', code: 'invalid_max_pages', message: 'maxPages must be a whole number of pages, at least 1.' });
|
|
92
|
+
}
|
|
93
|
+
const readOptions = options.retry === undefined ? undefined : { retry: options.retry };
|
|
94
|
+
let cursor = params.cursor;
|
|
95
|
+
const seen = new Set(cursor ? [cursor] : []);
|
|
96
|
+
for (let pages = 0; pages < maxPages; pages += 1) {
|
|
97
|
+
const { data: page } = await this.readStoryPosts(reader, { ...params, cursor }, readOptions);
|
|
98
|
+
yield page;
|
|
99
|
+
if (page.status !== 'ok' || page.exhausted || !page.cursor || seen.has(page.cursor))
|
|
100
|
+
return;
|
|
101
|
+
seen.add(page.cursor);
|
|
102
|
+
cursor = page.cursor;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
readStoryPosts(reader, params, options) {
|
|
106
|
+
const path = storyPostsPath(reader, params.storyKey);
|
|
107
|
+
const query = storyPostsQuery(params);
|
|
108
|
+
return options?.retry === undefined ? this.client.get(path, query) : this.client.get(path, query, { retry: options.retry });
|
|
109
|
+
}
|
|
14
110
|
/**
|
|
15
111
|
* Fetch the supplied sources in parallel, annotate each top item with
|
|
16
112
|
* the supplied prompt template, and insert audience-scoped posts into
|
package/dist/resources/e2ee.d.ts
CHANGED
|
@@ -232,6 +232,16 @@ export interface E2eeHasDependentsDetails {
|
|
|
232
232
|
dependents: string[];
|
|
233
233
|
root: boolean;
|
|
234
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* A live "Encrypt this chat" request (`e2ee.requestStart`): the message that
|
|
237
|
+
* carries it in the chat, and when it was made, by the relay's clock.
|
|
238
|
+
*/
|
|
239
|
+
export interface E2eeStartRequest {
|
|
240
|
+
message_id: string;
|
|
241
|
+
requested_at: string;
|
|
242
|
+
/** False when this request was live already, so nothing was sent. Absent from older relays. */
|
|
243
|
+
created?: boolean;
|
|
244
|
+
}
|
|
235
245
|
/** `revokeDevice`'s answer: the device, and every device the call revoked (the target first). */
|
|
236
246
|
export interface E2eeRevokeResponse extends SingleResponse<E2eeDevice> {
|
|
237
247
|
/** Absent on relays before 0.7.58. Empty when the device was already revoked and nothing else was. */
|
|
@@ -467,6 +477,37 @@ export declare class E2eeResource {
|
|
|
467
477
|
device_id?: string;
|
|
468
478
|
reason?: 'cancelled' | 'declined';
|
|
469
479
|
}): Promise<SingleResponse<E2eeLink>>;
|
|
480
|
+
/**
|
|
481
|
+
* "Encrypt this chat" when the other person has no encrypted-chat device
|
|
482
|
+
* yet. The relay writes the request into the chat as a message from this
|
|
483
|
+
* account, with `metadata: { kind: 'e2ee_start_request', v: 1 }` (see
|
|
484
|
+
* `Message.metadata`), so it reaches the other person as a message does:
|
|
485
|
+
* their open chat, chat list and unread count, and one push, "<name> wants
|
|
486
|
+
* to encrypt your chat" (at most one a day per chat). Their device then
|
|
487
|
+
* starts the encryption as usual, with this account's devices. So this
|
|
488
|
+
* account needs an active device first (`no_root`, 409, otherwise).
|
|
489
|
+
*
|
|
490
|
+
* Answers this account's live request, `{ message_id, requested_at,
|
|
491
|
+
* created }`; asking again changes nothing and sends nothing (`created`
|
|
492
|
+
* false). The other person's app finds live requests in
|
|
493
|
+
* `chat.conversation()`'s `encryption_requests`. Refused where encryption
|
|
494
|
+
* cannot start: `conversation_not_eligible` (409), `blocked` (403),
|
|
495
|
+
* `forbidden` (403: not a member this key reaches); `conflict` (409) when
|
|
496
|
+
* the chat is already encrypted; `request_limit` (409, with
|
|
497
|
+
* `details.retryAfterSeconds`) after three requests in a day in this chat.
|
|
498
|
+
* Relays without it answer 404.
|
|
499
|
+
*/
|
|
500
|
+
requestStart(conversationId: string): Promise<SingleResponse<E2eeStartRequest>>;
|
|
501
|
+
/**
|
|
502
|
+
* Withdraw this account's request to encrypt the chat: `{ cancelled }`,
|
|
503
|
+
* false when none of its requests was live (answered by a start, or
|
|
504
|
+
* withdrawn already). The request leaves the chat's history; open chats get
|
|
505
|
+
* `chat_message_delete`, and both people's sockets `chat:conversation`
|
|
506
|
+
* `encryption_request_withdrawn`. Relays without it answer 404.
|
|
507
|
+
*/
|
|
508
|
+
cancelStartRequest(conversationId: string): Promise<SingleResponse<{
|
|
509
|
+
cancelled: boolean;
|
|
510
|
+
}>>;
|
|
470
511
|
/**
|
|
471
512
|
* The ids of the encrypted conversations this account belongs to, in this
|
|
472
513
|
* key's scope, ordered by id: pass `next` as `after` for the next page
|
package/dist/resources/e2ee.js
CHANGED
|
@@ -178,6 +178,39 @@ export class E2eeResource {
|
|
|
178
178
|
cancelLink(id, input) {
|
|
179
179
|
return this.client.post(`/e2ee/links/${encodeURIComponent(id)}/cancel`, input ?? {});
|
|
180
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* "Encrypt this chat" when the other person has no encrypted-chat device
|
|
183
|
+
* yet. The relay writes the request into the chat as a message from this
|
|
184
|
+
* account, with `metadata: { kind: 'e2ee_start_request', v: 1 }` (see
|
|
185
|
+
* `Message.metadata`), so it reaches the other person as a message does:
|
|
186
|
+
* their open chat, chat list and unread count, and one push, "<name> wants
|
|
187
|
+
* to encrypt your chat" (at most one a day per chat). Their device then
|
|
188
|
+
* starts the encryption as usual, with this account's devices. So this
|
|
189
|
+
* account needs an active device first (`no_root`, 409, otherwise).
|
|
190
|
+
*
|
|
191
|
+
* Answers this account's live request, `{ message_id, requested_at,
|
|
192
|
+
* created }`; asking again changes nothing and sends nothing (`created`
|
|
193
|
+
* false). The other person's app finds live requests in
|
|
194
|
+
* `chat.conversation()`'s `encryption_requests`. Refused where encryption
|
|
195
|
+
* cannot start: `conversation_not_eligible` (409), `blocked` (403),
|
|
196
|
+
* `forbidden` (403: not a member this key reaches); `conflict` (409) when
|
|
197
|
+
* the chat is already encrypted; `request_limit` (409, with
|
|
198
|
+
* `details.retryAfterSeconds`) after three requests in a day in this chat.
|
|
199
|
+
* Relays without it answer 404.
|
|
200
|
+
*/
|
|
201
|
+
requestStart(conversationId) {
|
|
202
|
+
return this.client.put(`/e2ee/conversations/${encodeURIComponent(conversationId)}/start-request`, {});
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Withdraw this account's request to encrypt the chat: `{ cancelled }`,
|
|
206
|
+
* false when none of its requests was live (answered by a start, or
|
|
207
|
+
* withdrawn already). The request leaves the chat's history; open chats get
|
|
208
|
+
* `chat_message_delete`, and both people's sockets `chat:conversation`
|
|
209
|
+
* `encryption_request_withdrawn`. Relays without it answer 404.
|
|
210
|
+
*/
|
|
211
|
+
cancelStartRequest(conversationId) {
|
|
212
|
+
return this.client.delete(`/e2ee/conversations/${encodeURIComponent(conversationId)}/start-request`);
|
|
213
|
+
}
|
|
181
214
|
/**
|
|
182
215
|
* The ids of the encrypted conversations this account belongs to, in this
|
|
183
216
|
* key's scope, ordered by id: pass `next` as `after` for the next page
|
|
@@ -64,7 +64,8 @@ export declare class PostsResource {
|
|
|
64
64
|
delete(id: string): Promise<DeleteResponse>;
|
|
65
65
|
/**
|
|
66
66
|
* Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
|
|
67
|
-
* the promotion safety floor; `sort: 'new'` lists newest first
|
|
67
|
+
* the promotion safety floor; `sort: 'new'` lists newest first; `media: 'video'` keeps only posts with a
|
|
68
|
+
* video that plays (a Watch feed of the matches, relevance only). Page with `meta.next_cursor` as
|
|
68
69
|
* `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
|
|
69
70
|
*/
|
|
70
71
|
search(params: SearchPostsParams): Promise<SearchPostsResponse>;
|
package/dist/resources/posts.js
CHANGED
|
@@ -66,7 +66,8 @@ export class PostsResource {
|
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
68
|
* Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
|
|
69
|
-
* the promotion safety floor; `sort: 'new'` lists newest first
|
|
69
|
+
* the promotion safety floor; `sort: 'new'` lists newest first; `media: 'video'` keeps only posts with a
|
|
70
|
+
* video that plays (a Watch feed of the matches, relevance only). Page with `meta.next_cursor` as
|
|
70
71
|
* `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
|
|
71
72
|
*/
|
|
72
73
|
search(params) {
|
package/dist/types.d.ts
CHANGED
|
@@ -425,6 +425,13 @@ export interface SearchPostsParams extends PaginationParams {
|
|
|
425
425
|
tag_ids?: string;
|
|
426
426
|
/** `relevance` (default) or `new` (newest first). */
|
|
427
427
|
sort?: 'relevance' | 'new';
|
|
428
|
+
/**
|
|
429
|
+
* `video` (0.7.69): only posts with a video that plays, ranked as the relevance search ranks them, in the page shape Watch
|
|
430
|
+
* reads (`data`, `meta.next_cursor`, `meta.has_more`), so a player can play a query. A post whose every video cannot
|
|
431
|
+
* play (a legacy file that is gone, an upload still encoding) is left out; the cursor moves past it, so a page can
|
|
432
|
+
* be short and still have `next_cursor`. Relevance only: with `sort: 'new'` it answers 400 `invalid_input`.
|
|
433
|
+
*/
|
|
434
|
+
media?: 'video';
|
|
428
435
|
since?: string;
|
|
429
436
|
until?: string;
|
|
430
437
|
/**
|
|
@@ -753,6 +760,25 @@ export interface ConversationDetail {
|
|
|
753
760
|
* older APIs.
|
|
754
761
|
*/
|
|
755
762
|
last_activity_at?: string;
|
|
763
|
+
/**
|
|
764
|
+
* The live "Encrypt this chat" requests (`e2ee.requestStart`) that can
|
|
765
|
+
* still be answered, oldest first, at most one per person, however far
|
|
766
|
+
* back in the history their messages are. Served by `conversation()` on a
|
|
767
|
+
* plaintext one-on-one chat only: `[]` when there is none (or the chat is
|
|
768
|
+
* not accepted by both, or a block stands). A request is listed only while
|
|
769
|
+
* both people are inside the encryption pilot and its sender has an active
|
|
770
|
+
* device. Absent on other chats and from older APIs.
|
|
771
|
+
*/
|
|
772
|
+
encryption_requests?: ConversationEncryptionRequest[];
|
|
773
|
+
}
|
|
774
|
+
/** One entry of `ConversationDetail.encryption_requests`. */
|
|
775
|
+
export interface ConversationEncryptionRequest {
|
|
776
|
+
/** Who asked. The other person answers by starting encryption as usual, from their own device. */
|
|
777
|
+
sender_id: string;
|
|
778
|
+
/** The request's message (`metadata.kind` `e2ee_start_request`). */
|
|
779
|
+
message_id: string;
|
|
780
|
+
/** When it was asked, by the relay's clock. */
|
|
781
|
+
requested_at: string;
|
|
756
782
|
}
|
|
757
783
|
export type ChatMemberRole = 'admin' | 'member';
|
|
758
784
|
/**
|
|
@@ -800,21 +826,39 @@ export interface UpdateGroupResult {
|
|
|
800
826
|
* the conversation again (and, in an encrypted group, check its keys).
|
|
801
827
|
* `actorId` is the admin for `added` and `removed`, and the leaver for `left`
|
|
802
828
|
* and `invites_cancelled` (the invites an encrypted group's admin sent, which
|
|
803
|
-
* went with them).
|
|
829
|
+
* went with them). `accepted`: the person in `userIds` (and `actorId`)
|
|
830
|
+
* accepted their invite; everyone in the group is told. `device`: one of that
|
|
831
|
+
* person's devices can be added to the encrypted group now (it has key
|
|
832
|
+
* packages after it had none); only the group's admin is told, since only the
|
|
833
|
+
* admin's device adds someone it invited, and only on a connection that may
|
|
834
|
+
* read encrypted chat for the group (a session, or a key for the group's app;
|
|
835
|
+
* never an agent's key). Read `change` defensively: an older API never sends
|
|
836
|
+
* the last two, and a newer one may add more.
|
|
804
837
|
*/
|
|
805
838
|
export interface ChatMembersEvent {
|
|
806
839
|
conversationId: string;
|
|
807
|
-
change: 'added' | 'removed' | 'left' | 'invites_cancelled';
|
|
840
|
+
change: 'added' | 'accepted' | 'removed' | 'left' | 'invites_cancelled' | 'device';
|
|
808
841
|
userIds: string[];
|
|
809
842
|
actorId: string;
|
|
810
843
|
}
|
|
811
|
-
/**
|
|
812
|
-
|
|
844
|
+
/**
|
|
845
|
+
* Realtime `chat:conversation`. Ids only, except a group's new name:
|
|
846
|
+
* - `renamed`: a private group was renamed by its admin (`name`).
|
|
847
|
+
* - `encryption_request_withdrawn`: `actorId` withdrew their request to
|
|
848
|
+
* encrypt this one-on-one chat (`e2ee.cancelStartRequest`); it left the
|
|
849
|
+
* history. Read the conversation and its messages again.
|
|
850
|
+
* Treat any other `change` as "read the conversation again".
|
|
851
|
+
*/
|
|
852
|
+
export type ChatConversationEvent = {
|
|
813
853
|
conversationId: string;
|
|
814
854
|
change: 'renamed';
|
|
815
855
|
name: string;
|
|
816
856
|
actorId: string;
|
|
817
|
-
}
|
|
857
|
+
} | {
|
|
858
|
+
conversationId: string;
|
|
859
|
+
change: 'encryption_request_withdrawn';
|
|
860
|
+
actorId: string;
|
|
861
|
+
};
|
|
818
862
|
export interface MessageReaction {
|
|
819
863
|
type: string;
|
|
820
864
|
user_id: string;
|
|
@@ -827,6 +871,20 @@ export interface MessageMedia {
|
|
|
827
871
|
mime_type: string | null;
|
|
828
872
|
file_name: string | null;
|
|
829
873
|
}
|
|
874
|
+
/**
|
|
875
|
+
* What a message the relay wrote on someone's behalf is, by `kind`. Only such
|
|
876
|
+
* messages carry it.
|
|
877
|
+
* - `e2ee_start_request` (`v` 1): the sender asked to turn on end-to-end
|
|
878
|
+
* encryption for this one-on-one chat (`e2ee.requestStart`). Show it as
|
|
879
|
+
* that request, not as its `content`, which is a fallback for apps that do
|
|
880
|
+
* not know the kind. It is live while it is in the history and the chat is
|
|
881
|
+
* not encrypted; a withdrawn one leaves the history. The person who did not
|
|
882
|
+
* ask answers it by starting encryption as usual, from their own device.
|
|
883
|
+
*/
|
|
884
|
+
export interface MessageMetadata {
|
|
885
|
+
kind: 'e2ee_start_request';
|
|
886
|
+
v: number;
|
|
887
|
+
}
|
|
830
888
|
export interface Message {
|
|
831
889
|
id: string;
|
|
832
890
|
content: string;
|
|
@@ -835,6 +893,8 @@ export interface Message {
|
|
|
835
893
|
media: MessageMedia[];
|
|
836
894
|
reactions: MessageReaction[];
|
|
837
895
|
created_at: string;
|
|
896
|
+
/** Absent on a person's own message, and from older APIs: see MessageMetadata. */
|
|
897
|
+
metadata?: MessageMetadata;
|
|
838
898
|
}
|
|
839
899
|
export interface SendMessageInput {
|
|
840
900
|
conversation_id: string;
|
|
@@ -854,6 +914,8 @@ export interface ChatMessageEvent {
|
|
|
854
914
|
isAi?: boolean;
|
|
855
915
|
};
|
|
856
916
|
createdAt: string;
|
|
917
|
+
/** The message's metadata as stored; `kind: 'e2ee_start_request'` is an encryption request (MessageMetadata). */
|
|
918
|
+
metadata?: Record<string, unknown>;
|
|
857
919
|
}
|
|
858
920
|
export interface ChatMessageEditEvent {
|
|
859
921
|
id: string;
|
package/llm.md
CHANGED
|
@@ -522,6 +522,9 @@ await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope }); //
|
|
|
522
522
|
await r.e2ee.appendEvent(conversationId, { device_id: device.id, envelope: reaction, silent: true }); // no push, no move up the chat list; every member's doorbell still rings
|
|
523
523
|
const { data: page } = await r.e2ee.events(conversationId, { device_id: device.id, after_seq: 0 });
|
|
524
524
|
await r.e2ee.acknowledge(conversationId, { device_id: device.id, seq: page.headSeq });
|
|
525
|
+
// The other person has no device yet: ask them (0.7.70). The relay writes it into the chat as a message with metadata { kind: 'e2ee_start_request', v: 1 }; their start answers it.
|
|
526
|
+
await r.e2ee.requestStart(conversationId); // → { message_id, requested_at, created }; cancelStartRequest(conversationId) withdraws it
|
|
527
|
+
// The live requests, however far back: (await r.chat.conversation(conversationId)).data.encryption_requests → [{ sender_id, message_id, requested_at }]
|
|
525
528
|
// 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.
|
|
526
529
|
// The policy is required: policyForConversationType(conversation.type) from @recursiv/e2ee ('direct' or 'group').
|
|
527
530
|
r.realtime.onE2eeEvent(({ conversationId, event }) => (event ? controller.applyPushed(conversationId, event, policy) : controller.sync(conversationId, undefined, policy)));
|
|
@@ -623,7 +626,7 @@ Methods: `list`, `get`, `create`, `update`, `delete`, `search`, `replies(id, { l
|
|
|
623
626
|
|
|
624
627
|
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'`.
|
|
625
628
|
|
|
626
|
-
`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.
|
|
629
|
+
`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; `media: 'video'` (0.7.69) keeps only posts with a video that plays (relevance only), in the page shape Watch reads, so a player can play a query. 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.
|
|
627
630
|
|
|
628
631
|
### r.bookmarks — Synced saves (per member, per app)
|
|
629
632
|
|
|
@@ -664,7 +667,7 @@ await r.bookmarks.collections.remove(list.id)
|
|
|
664
667
|
const head = r.curator.forYouWorld({ part: 'stories', session }) // member's own app key; read the two halves at once
|
|
665
668
|
const page = await r.curator.forYouWorld({ part: 'items', session, snapshot }) // items + blocks; snapshot = a previous meta.snapshot
|
|
666
669
|
// stories: { ref, id, source: today|radar|viral, target, label, kind, breaking, general, theme, counts, reasons, ... }
|
|
667
|
-
// items: { ref, kind, target, position, block: 'top' | 'below', media, topic, reasons, explain }
|
|
670
|
+
// items: { ref, kind, target, position, block: 'top' | 'below', media, topic, reasons, explain, card } // card: an open-network post's (no URL), null for Minds
|
|
668
671
|
// blocks: [{ id, type: stories|lead_video|clips|posts|people|groups|topics|viral|categories, refs, deferred?, entities?, categories?, viral? }]
|
|
669
672
|
```
|
|
670
673
|
|
|
@@ -694,6 +697,18 @@ for (const section of answer.order) render(section, answer.sections[section])
|
|
|
694
697
|
|
|
695
698
|
`trending` joins when the answer is thin or old, so a search never dead-ends. `top_posts` is floored as `r.posts.search`'s relevance page is; `people` rows carry `followers_count`, `posts_count`, `empty` and `verified` (see `r.profiles.search`). Page further with `r.posts.search`. Needs `posts:read`; `people` needs `users:read`, `groups` `communities:read`; `open_network` and `stories` need `users:read` plus the caller's own app key on its network, `trending` that app key. What the key cannot have is in `omitted` (`scope` / `unavailable`).
|
|
696
699
|
|
|
700
|
+
### r.curator — World Today and its story pages
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
const { data: edition } = await r.curator.today() // keyless works: new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false })
|
|
704
|
+
const story = edition?.stories.find(s => s.discussion) // story_key + discussion only when the server serves them (0.7.69)
|
|
705
|
+
const { data: page } = await r.curator.storyPosts({ storyKey: story!.story_key!, network: 'bluesky', sort: 'latest' })
|
|
706
|
+
const more = await r.curator.storyPosts({ storyKey: page.story_key, network: 'bluesky', sort: 'latest', cursor: page.cursor! })
|
|
707
|
+
for await (const p of r.curator.storyPostPages({ storyKey: page.story_key }, { reader: 'member' })) render(p.items)
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
`discussion` = `{ total, people, by_network: { minds, bluesky, activitypub, nostr }, pool, capped, built_at, previews }` (≤2 network previews); absent means show the story as before. `storyPosts` is the visitor view (keyless, cached a minute); `memberStoryPosts` is the member's own view (their blocks, mutes and hidden posts removed; `users:read` + `posts:read`). Items narrow on `kind` (`'minds'` → `Post`, `'social'` → `SocialPost`); `text_only` = words only. A removed post counts in `withheld` and is never replaced, so items + `withheld` over every page = the page's `matched` (`discussion.total` for `network: 'all'`, `discussion.by_network[network]` for one network; `discussion` always counts the whole pool). `status: 'expired'` = the cursor's build is gone: restart from page one. 404 while the server does not serve story volume; a non-UUID key throws `ValidationError` before any request.
|
|
711
|
+
|
|
697
712
|
### r.communities
|
|
698
713
|
|
|
699
714
|
```typescript
|