@recursiv/sdk 0.7.82 → 0.7.89
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 +128 -1
- package/dist/assistant/fixedCardData.d.ts +91 -0
- package/dist/assistant/fixedCardData.js +372 -0
- package/dist/assistant/fixedCards.d.ts +35 -0
- package/dist/assistant/fixedCards.js +904 -0
- package/dist/assistant/hiddenText.d.ts +24 -0
- package/dist/assistant/hiddenText.js +263 -0
- package/dist/assistant/index.d.ts +30 -0
- package/dist/assistant/index.js +30 -0
- package/dist/assistant/render.d.ts +31 -0
- package/dist/assistant/render.js +434 -0
- package/dist/assistant/subjectBundle.d.ts +30 -0
- package/dist/assistant/subjectBundle.js +535 -0
- package/dist/assistant/turnCaps.d.ts +3 -0
- package/dist/assistant/turnCaps.js +141 -0
- package/dist/assistant/types.d.ts +569 -0
- package/dist/assistant/types.js +26 -0
- package/dist/assistant-surfaces.d.ts +42 -0
- package/dist/assistant-surfaces.js +113 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.js +8 -1
- package/dist/privacy/facts.generated.d.ts +79 -0
- package/dist/privacy/facts.generated.js +93 -0
- package/dist/privacy/index.d.ts +48 -0
- package/dist/privacy/index.js +42 -0
- package/dist/realtime.d.ts +10 -3
- package/dist/realtime.js +11 -2
- package/dist/resources/agents.d.ts +6 -1
- package/dist/resources/agents.js +12 -2
- package/dist/resources/discovery-merge.d.ts +17 -1
- package/dist/resources/discovery-merge.js +84 -4
- package/dist/resources/discovery-progressive.d.ts +4 -3
- package/dist/resources/discovery-progressive.js +11 -8
- package/dist/resources/discovery.d.ts +9 -1
- package/dist/resources/discovery.js +62 -15
- package/dist/resources/e2ee.d.ts +56 -0
- package/dist/resources/e2ee.js +21 -0
- package/dist/resources/feeds.d.ts +15 -6
- package/dist/resources/feeds.js +2 -1
- package/dist/resources/integrations.d.ts +3 -0
- package/dist/resources/link-preview.d.ts +42 -1
- package/dist/resources/link-preview.js +36 -2
- package/dist/resources/network.d.ts +6 -0
- package/dist/resources/privacy.d.ts +13 -0
- package/dist/resources/privacy.js +14 -0
- package/dist/resources/project-brain.d.ts +8 -0
- package/dist/resources/protocols.d.ts +99 -11
- package/dist/resources/protocols.js +45 -0
- package/dist/resources/sealed.d.ts +127 -7
- package/dist/resources/sealed.js +33 -6
- package/dist/resources/settings.d.ts +3 -1
- package/dist/resources/settings.js +3 -1
- package/dist/resources/users.d.ts +11 -2
- package/dist/resources/users.js +1 -1
- package/dist/social-protocol-ids.d.ts +28 -0
- package/dist/social-protocol-ids.js +30 -0
- package/dist/types.d.ts +71 -0
- package/llm.md +45 -4
- package/package.json +11 -1
package/dist/resources/sealed.js
CHANGED
|
@@ -10,8 +10,10 @@ export class SealedTransportError extends Error {
|
|
|
10
10
|
/** Set on a refusal the gateway wrote itself; a relayed router answer never carries it. */
|
|
11
11
|
export const SEALED_REFUSAL_HEADER = 'x-sealed-gateway-refusal';
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
* no other, and the router cannot set it, so an error without it is one the API (or an
|
|
13
|
+
* Messages left in the member's allowance today, on a check as on a message. The gateway sets it on every answer it
|
|
14
|
+
* relayed from the router and on no other, and the router cannot set it, so an error without it is one the API (or an
|
|
15
|
+
* edge in front of it) wrote. A relayed answer to a check also carries `X-Sealed-Checks-Remaining` while the gateway
|
|
16
|
+
* has a check allowance, and one to a marked search `X-Sealed-Searches-Remaining`.
|
|
15
17
|
*/
|
|
16
18
|
export const SEALED_ALLOWANCE_REMAINING_HEADER = 'x-sealed-allowance-remaining';
|
|
17
19
|
const HEX_32_BYTES = /^[0-9a-fA-F]{64}$/;
|
|
@@ -95,10 +97,23 @@ export class SealedResource {
|
|
|
95
97
|
* has no nonce, never to be shown as the model's words.
|
|
96
98
|
*
|
|
97
99
|
* Throws a RecursivError for every error the API wrote rather than relayed, whether or not it is marked
|
|
98
|
-
* X-Sealed-Gateway-Refusal: RateLimitError for an allowance used up (`code` `sealed_allowance_used
|
|
99
|
-
* `
|
|
100
|
-
*
|
|
101
|
-
* is
|
|
100
|
+
* X-Sealed-Gateway-Refusal: RateLimitError for an allowance used up (`code` `sealed_allowance_used`,
|
|
101
|
+
* `sealed_search_allowance_used` or `sealed_too_many_requests`, with SealedQuotaDetails in `details`) or the API's own
|
|
102
|
+
* per-IP limit (`rate_limited`), a RecursivError with status 503 and `code` `sealed_paused_budget` while the app's
|
|
103
|
+
* monthly Sealed budget is used up (until the next UTC month), AuthenticationError for a revoked or expired key,
|
|
104
|
+
* NotFoundError while Sealed is off or not on for this app, AuthorizationError (`sealed_search_off`) for a marked
|
|
105
|
+
* search while search is off. Throws SealedTransportError when the router is not listed, the key, purpose or marks
|
|
106
|
+
* are malformed, or a 2xx answer came back unsealed.
|
|
107
|
+
*
|
|
108
|
+
* With `purpose: 'check'` the request counts against the check allowance while the gateway has one (`code`
|
|
109
|
+
* `sealed_check_allowance_used` when it is used up), and as a message otherwise. With `search: true` it counts
|
|
110
|
+
* against the day's searches (`sealed_search_allowance_used`) and spends no message. While the gateway's day lock is
|
|
111
|
+
* on, a member is refused until midnight UTC (`sealed_paused_today`) after a request of theirs that searched the web
|
|
112
|
+
* unmarked (or marked a check), that the router answered with a model `config()` does not offer for it (a check's must
|
|
113
|
+
* be in `checks`; any other's in `models`, or in `checks` while the gateway lets checks sent unmarked through), or
|
|
114
|
+
* that passed the day's count of answers left before their usage line (searches included). The lists can change on
|
|
115
|
+
* the server: read `config()` again from time to time, not once for the app's whole run, or a member whose app still
|
|
116
|
+
* sends to a model taken out of them is paused.
|
|
102
117
|
*/
|
|
103
118
|
async send(input) {
|
|
104
119
|
// Malformed input is refused here, before the config is read, so it never turns into a network error.
|
|
@@ -109,6 +124,15 @@ export class SealedResource {
|
|
|
109
124
|
if (!HEX_32_BYTES.test(input.encapsulatedKey)) {
|
|
110
125
|
throw new SealedTransportError('bad_encapsulated_key', 'The encapsulated key must be 32 bytes of hex. Nothing was sent.');
|
|
111
126
|
}
|
|
127
|
+
if (input.purpose !== undefined && input.purpose !== 'check') {
|
|
128
|
+
throw new SealedTransportError('bad_purpose', 'The purpose must be "check" or left out. Nothing was sent.');
|
|
129
|
+
}
|
|
130
|
+
if (input.search !== undefined && typeof input.search !== 'boolean') {
|
|
131
|
+
throw new SealedTransportError('bad_marks', 'search must be true, false or left out. Nothing was sent.');
|
|
132
|
+
}
|
|
133
|
+
if (input.search === true && input.purpose === 'check') {
|
|
134
|
+
throw new SealedTransportError('bad_marks', 'A check never searches: search cannot go with purpose "check". Nothing was sent.');
|
|
135
|
+
}
|
|
112
136
|
const targets = input.targets ?? (await this.config()).targets;
|
|
113
137
|
if (!targets.some((target) => target.origin === origin && target.kind === 'router')) {
|
|
114
138
|
throw new SealedTransportError('enclave_not_allowed', 'That router is not one the gateway forwards to. Nothing was sent.');
|
|
@@ -137,6 +161,9 @@ export class SealedResource {
|
|
|
137
161
|
// receives a request marked Sealed is meant to refuse it. No server path reads this header yet, so nothing
|
|
138
162
|
// enforces that today; the Sealed gateway strips it before the router.
|
|
139
163
|
'X-Recursiv-Privacy-Tier': 'sealed',
|
|
164
|
+
// Read by the gateway to pick the allowance, and never forwarded to the router.
|
|
165
|
+
...(input.purpose === 'check' ? { 'X-Sealed-Purpose': 'check' } : {}),
|
|
166
|
+
...(input.search === true ? { 'X-Sealed-Search': '1' } : {}),
|
|
140
167
|
},
|
|
141
168
|
body: input.body,
|
|
142
169
|
signal: controller.signal,
|
|
@@ -22,7 +22,9 @@ export declare class SettingsResource {
|
|
|
22
22
|
updateAppearance(input: UpdateAppearanceInput): Promise<SuccessResponse>;
|
|
23
23
|
/**
|
|
24
24
|
* Update per-user content preferences for the app bound to this API key.
|
|
25
|
-
* Requires a project-bound key with `settings:write`.
|
|
25
|
+
* Requires a project-bound key with `settings:write`. `sealed_adult: true`
|
|
26
|
+
* is refused (403 `AuthorizationError`, code `sealed_adult_needs_adult`, and
|
|
27
|
+
* nothing in the request is saved) unless the member answered 18 or older.
|
|
26
28
|
*/
|
|
27
29
|
updateContent(input: UpdateContentPreferencesInput): Promise<SuccessResponse>;
|
|
28
30
|
/** List active sessions */
|
|
@@ -45,7 +45,9 @@ export class SettingsResource {
|
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
47
|
* Update per-user content preferences for the app bound to this API key.
|
|
48
|
-
* Requires a project-bound key with `settings:write`.
|
|
48
|
+
* Requires a project-bound key with `settings:write`. `sealed_adult: true`
|
|
49
|
+
* is refused (403 `AuthorizationError`, code `sealed_adult_needs_adult`, and
|
|
50
|
+
* nothing in the request is saved) unless the member answered 18 or older.
|
|
49
51
|
*/
|
|
50
52
|
updateContent(input) {
|
|
51
53
|
return this.client.put('/settings/preferences/content', input);
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
2
|
import type { PaginatedResponse, SingleResponse, User, FollowUser, PaginationParams } from '../types.js';
|
|
3
|
+
import type { AssistantSettings } from '../assistant-surfaces.js';
|
|
4
|
+
/** The signed-in user as `users.me()` returns it. */
|
|
5
|
+
export interface CurrentUser extends User {
|
|
6
|
+
/**
|
|
7
|
+
* The assistant surface switches for the app this key is bound to (absent on servers from before George plan G7a).
|
|
8
|
+
* Read them with `assistantSurfaceOn(me, id)`, which is off for anything but an explicit true.
|
|
9
|
+
*/
|
|
10
|
+
assistant?: AssistantSettings;
|
|
11
|
+
}
|
|
3
12
|
export declare class UsersResource {
|
|
4
13
|
private client;
|
|
5
14
|
constructor(client: HttpClient);
|
|
6
|
-
/** Get the authenticated user's profile */
|
|
7
|
-
me(): Promise<SingleResponse<
|
|
15
|
+
/** Get the authenticated user's profile, with the app's assistant surface switches */
|
|
16
|
+
me(): Promise<SingleResponse<CurrentUser>>;
|
|
8
17
|
/** Get a user by ID */
|
|
9
18
|
get(id: string): Promise<SingleResponse<User>>;
|
|
10
19
|
/** List a user's followers */
|
package/dist/resources/users.js
CHANGED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every open network id the API may name a post, an account or a source by (`protocol` on a SocialPost, a SocialProfile,
|
|
3
|
+
* a SocialSourceRef, a radar member's `kind`, a feed top's `network_post` and lane). The server's twin is
|
|
4
|
+
* packages/server/src/lib/socialProtocolIds.ts, and its socialProtocolIds test holds the two equal.
|
|
5
|
+
*
|
|
6
|
+
* Readers first (0.7.87): `farcaster` is listed one release before any server reader emits it, so an app on this SDK
|
|
7
|
+
* shows a Farcaster post, radar member or top item the day the API starts serving them, with no app update. Listing an
|
|
8
|
+
* id serves nothing: which networks an app can read is still `publicNetworks()` / `sharedNetworks(projectId)`, and a
|
|
9
|
+
* network the catalog does not list never appears. An id a later SDK adds is skipped by this one, never an error.
|
|
10
|
+
*
|
|
11
|
+
* Farcaster ids, as the server checks them: an account is its FID in decimal ("3"); a cast is its CastId,
|
|
12
|
+
* `<fid>:0x<40 lowercase hex>` (its author's FID and its message hash).
|
|
13
|
+
*/
|
|
14
|
+
export declare const SOCIAL_PROTOCOL_IDS: readonly ["bluesky", "activitypub", "nostr", "farcaster"];
|
|
15
|
+
export type SocialProtocolId = (typeof SOCIAL_PROTOCOL_IDS)[number];
|
|
16
|
+
/** Whether `value` is an open network id this SDK knows. */
|
|
17
|
+
export declare function isSocialProtocolId(value: unknown): value is SocialProtocolId;
|
|
18
|
+
/**
|
|
19
|
+
* The networks a member's native write may act on (0.7.87): the source a reply, follow, like or repost names
|
|
20
|
+
* (`NativeReplyPrepareInput.parent`, `NativeFollowSourceInput`, `NativeLikeSourceInput`, `NativeRepostSourceInput`), and
|
|
21
|
+
* the account lists and results those writes answer. Narrower than SOCIAL_PROTOCOL_IDS on purpose: a network is read
|
|
22
|
+
* before it is written, so a Farcaster post can be shown, muted, hidden or reported (`SocialSourceRef`) while the API
|
|
23
|
+
* still refuses a Farcaster write (400 `invalid_input`). Check `isNativeWriteProtocol(post.protocol)` before offering one.
|
|
24
|
+
*/
|
|
25
|
+
export declare const NATIVE_WRITE_PROTOCOLS: readonly ["bluesky", "activitypub", "nostr"];
|
|
26
|
+
export type NativeWriteProtocol = (typeof NATIVE_WRITE_PROTOCOLS)[number];
|
|
27
|
+
/** Whether a member's native write may act on a source of network `value` (NATIVE_WRITE_PROTOCOLS). */
|
|
28
|
+
export declare function isNativeWriteProtocol(value: unknown): value is NativeWriteProtocol;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every open network id the API may name a post, an account or a source by (`protocol` on a SocialPost, a SocialProfile,
|
|
3
|
+
* a SocialSourceRef, a radar member's `kind`, a feed top's `network_post` and lane). The server's twin is
|
|
4
|
+
* packages/server/src/lib/socialProtocolIds.ts, and its socialProtocolIds test holds the two equal.
|
|
5
|
+
*
|
|
6
|
+
* Readers first (0.7.87): `farcaster` is listed one release before any server reader emits it, so an app on this SDK
|
|
7
|
+
* shows a Farcaster post, radar member or top item the day the API starts serving them, with no app update. Listing an
|
|
8
|
+
* id serves nothing: which networks an app can read is still `publicNetworks()` / `sharedNetworks(projectId)`, and a
|
|
9
|
+
* network the catalog does not list never appears. An id a later SDK adds is skipped by this one, never an error.
|
|
10
|
+
*
|
|
11
|
+
* Farcaster ids, as the server checks them: an account is its FID in decimal ("3"); a cast is its CastId,
|
|
12
|
+
* `<fid>:0x<40 lowercase hex>` (its author's FID and its message hash).
|
|
13
|
+
*/
|
|
14
|
+
export const SOCIAL_PROTOCOL_IDS = Object.freeze(['bluesky', 'activitypub', 'nostr', 'farcaster']);
|
|
15
|
+
/** Whether `value` is an open network id this SDK knows. */
|
|
16
|
+
export function isSocialProtocolId(value) {
|
|
17
|
+
return typeof value === 'string' && SOCIAL_PROTOCOL_IDS.includes(value);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The networks a member's native write may act on (0.7.87): the source a reply, follow, like or repost names
|
|
21
|
+
* (`NativeReplyPrepareInput.parent`, `NativeFollowSourceInput`, `NativeLikeSourceInput`, `NativeRepostSourceInput`), and
|
|
22
|
+
* the account lists and results those writes answer. Narrower than SOCIAL_PROTOCOL_IDS on purpose: a network is read
|
|
23
|
+
* before it is written, so a Farcaster post can be shown, muted, hidden or reported (`SocialSourceRef`) while the API
|
|
24
|
+
* still refuses a Farcaster write (400 `invalid_input`). Check `isNativeWriteProtocol(post.protocol)` before offering one.
|
|
25
|
+
*/
|
|
26
|
+
export const NATIVE_WRITE_PROTOCOLS = Object.freeze(['bluesky', 'activitypub', 'nostr']);
|
|
27
|
+
/** Whether a member's native write may act on a source of network `value` (NATIVE_WRITE_PROTOCOLS). */
|
|
28
|
+
export function isNativeWriteProtocol(value) {
|
|
29
|
+
return typeof value === 'string' && NATIVE_WRITE_PROTOCOLS.includes(value);
|
|
30
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1316,7 +1316,35 @@ export interface AgentChatInput {
|
|
|
1316
1316
|
message: string;
|
|
1317
1317
|
conversation_id?: string;
|
|
1318
1318
|
new_conversation?: boolean;
|
|
1319
|
+
/** Per-turn options. `{ safe: true }` makes this a safe turn (see {@link AgentChatTurn}). */
|
|
1320
|
+
turn?: AgentChatTurn;
|
|
1319
1321
|
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Per-turn options for an agent chat message.
|
|
1324
|
+
*
|
|
1325
|
+
* `safe: true` (George plan G6a) asks about something on the network, typically someone else's post, on the caller's
|
|
1326
|
+
* own personal agent, in their one Asks thread with it (found or made by the server; leave out `new_conversation`, and
|
|
1327
|
+
* send `conversation_id` only for that thread). The turn has no tools whatever the agent's tool mode, reads and saves
|
|
1328
|
+
* no memory, sends OpenRouter `data_collection: 'deny'` whatever the member chose, writes no audit copy of the prompt
|
|
1329
|
+
* or reply (`ai_usage_detail`), sends pictures as alt text and stored descriptions only, and counts against a small
|
|
1330
|
+
* daily allowance. Every later turn in the Asks thread is a safe turn too, however it is sent. The reply
|
|
1331
|
+
* carries `metadata.safe_turn: true`. Refusals answer with a fixed {@link SafeTurnRefusalCode}; none names a plan or
|
|
1332
|
+
* a price, and the server never creates an agent for an ask.
|
|
1333
|
+
*/
|
|
1334
|
+
export interface AgentChatTurn {
|
|
1335
|
+
safe?: boolean;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Why the server refused a safe turn (the `code` of the error):
|
|
1339
|
+
* - `safe_turn_limit` (429): today's asks are used up (an app shows its own "limit" words). Marked
|
|
1340
|
+
* `X-RateLimit-Scope: action`, so the SDK throws it at once as a `RateLimitError` with `scope: 'action'` and
|
|
1341
|
+
* `resetAt` (the next midnight UTC), and does not resend it.
|
|
1342
|
+
* - `safe_turn_needs_personal_agent` (409): the agent is not the caller's own personal agent.
|
|
1343
|
+
* - `safe_turn_needs_app` (403): the key is bound to no app (a personal key, say); an ask goes with the app's own key.
|
|
1344
|
+
* - `safe_turn_ai_off` / `safe_turn_standard_off` (403): the member's AI choices refuse it.
|
|
1345
|
+
* - `safe_turn_conversation` (400): `new_conversation` was sent, or `conversation_id` is not the Asks thread.
|
|
1346
|
+
*/
|
|
1347
|
+
export type SafeTurnRefusalCode = 'safe_turn_limit' | 'safe_turn_needs_personal_agent' | 'safe_turn_needs_app' | 'safe_turn_ai_off' | 'safe_turn_standard_off' | 'safe_turn_conversation';
|
|
1320
1348
|
/** Answer an existing message of yours again, without sending it a second time. */
|
|
1321
1349
|
export interface AgentChatRetryInput {
|
|
1322
1350
|
conversation_id: string;
|
|
@@ -1583,6 +1611,20 @@ export interface UserPreferences {
|
|
|
1583
1611
|
profile_visibility: 'public' | 'network' | 'private';
|
|
1584
1612
|
show_email: boolean;
|
|
1585
1613
|
show_location: boolean;
|
|
1614
|
+
/**
|
|
1615
|
+
* "Who can message me": with 'following', new conversations, message requests and group invites reach the member
|
|
1616
|
+
* only from accounts they follow. A 13-17 account always reads 'following'. Absent on servers that predate it.
|
|
1617
|
+
*/
|
|
1618
|
+
who_can_message?: WhoCanMessage;
|
|
1619
|
+
/** True when the member cannot change `who_can_message` themselves (a 13-17 account). */
|
|
1620
|
+
who_can_message_locked?: boolean;
|
|
1621
|
+
/** Whether the member has answered the age question. The answer itself is never returned. */
|
|
1622
|
+
age_declared?: boolean;
|
|
1623
|
+
/**
|
|
1624
|
+
* True when the member is a known adult: they answered the age question 18 or older. False for a 13-17 answer and
|
|
1625
|
+
* for no answer. Only the member's own preferences carry it. Absent on servers that predate it.
|
|
1626
|
+
*/
|
|
1627
|
+
age_is_adult?: boolean;
|
|
1586
1628
|
};
|
|
1587
1629
|
appearance: {
|
|
1588
1630
|
theme: 'light' | 'dark' | 'system';
|
|
@@ -1597,6 +1639,18 @@ export interface ContentPreferences {
|
|
|
1597
1639
|
autoplay_video: boolean;
|
|
1598
1640
|
ai_enabled: boolean;
|
|
1599
1641
|
default_feed: 'foryou' | 'following';
|
|
1642
|
+
/**
|
|
1643
|
+
* True when the member picked `default_feed` themselves (any `updateContent({ default_feed })` sets it).
|
|
1644
|
+
* When false, `default_feed` is only the default and the app may choose for them. Absent from APIs older than
|
|
1645
|
+
* this field: treat absent as unknown, not as false.
|
|
1646
|
+
*/
|
|
1647
|
+
default_feed_chosen?: boolean;
|
|
1648
|
+
/**
|
|
1649
|
+
* The Sealed adult-content setting. True only while it is on AND the member is a known adult
|
|
1650
|
+
* (`privacy.age_is_adult`): if the account stops being one, it reads false whatever was saved. Absent on servers
|
|
1651
|
+
* that predate it: treat absent as off.
|
|
1652
|
+
*/
|
|
1653
|
+
sealed_adult?: boolean;
|
|
1600
1654
|
}
|
|
1601
1655
|
export interface UpdateNotificationChannelsInput {
|
|
1602
1656
|
/** Master switch: sets in-app on every kind. */
|
|
@@ -1612,10 +1666,21 @@ export interface UpdateNotificationChannelsInput {
|
|
|
1612
1666
|
*/
|
|
1613
1667
|
channels?: Partial<Record<NotificationChannelKind, Partial<NotificationChannelToggles>>> & Record<string, Partial<NotificationChannelToggles>>;
|
|
1614
1668
|
}
|
|
1669
|
+
/** "Who can message me" (`UserPreferences.privacy.who_can_message`). */
|
|
1670
|
+
export type WhoCanMessage = 'everyone' | 'following';
|
|
1671
|
+
/** An answer to the age question (`UpdatePrivacyInput.age_band`). Only a band is ever stored, never a date of birth. */
|
|
1672
|
+
export type AgeAnswer = '18_plus' | '13_17' | 'under_13';
|
|
1615
1673
|
export interface UpdatePrivacyInput {
|
|
1616
1674
|
profile_visibility?: 'public' | 'network' | 'private';
|
|
1617
1675
|
show_email?: boolean;
|
|
1618
1676
|
show_location?: boolean;
|
|
1677
|
+
/** "Who can message me". A 13-17 account stays held to 'following' whatever it saves. */
|
|
1678
|
+
who_can_message?: WhoCanMessage;
|
|
1679
|
+
/**
|
|
1680
|
+
* The age question, answered once. 'under_13' is refused (403 `age_not_eligible`) and writes nothing in the request;
|
|
1681
|
+
* a different second answer is refused (409 `age_already_set`); the same answer again succeeds and changes nothing.
|
|
1682
|
+
*/
|
|
1683
|
+
age_band?: AgeAnswer;
|
|
1619
1684
|
}
|
|
1620
1685
|
export interface UpdateAppearanceInput {
|
|
1621
1686
|
theme?: 'light' | 'dark' | 'system';
|
|
@@ -1627,6 +1692,12 @@ export interface UpdateContentPreferencesInput {
|
|
|
1627
1692
|
autoplay_video?: boolean;
|
|
1628
1693
|
ai_enabled?: boolean;
|
|
1629
1694
|
default_feed?: 'foryou' | 'following';
|
|
1695
|
+
/**
|
|
1696
|
+
* The Sealed adult-content setting. `true` is accepted only from a known adult (an 18+ answer): anyone else gets 403
|
|
1697
|
+
* `AuthorizationError` with code `sealed_adult_needs_adult`, and nothing in the request is saved. `false` is always
|
|
1698
|
+
* accepted.
|
|
1699
|
+
*/
|
|
1700
|
+
sealed_adult?: boolean;
|
|
1630
1701
|
}
|
|
1631
1702
|
export interface Session {
|
|
1632
1703
|
id: string;
|
package/llm.md
CHANGED
|
@@ -214,6 +214,29 @@ Methods: `tasks`, `stats`, `decisions`, `milestones`, `agents`, `teamActivity`,
|
|
|
214
214
|
|
|
215
215
|
---
|
|
216
216
|
|
|
217
|
+
## Assistant subjects — `@recursiv/sdk/assistant` (unreleased)
|
|
218
|
+
|
|
219
|
+
Pure, no network: the same input on a server, a phone or the web. Build the bundle from data you already hold, then render it; never paste post text into a prompt by hand.
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
import { buildSubjectBundle, renderBundleForModel } from '@recursiv/sdk/assistant'
|
|
223
|
+
const { bundle, refused, excluded } = buildSubjectBundle({ mode: 'standard', asker: { id, blocked_ids, muted_ids }, post, parent, quoted, replies }) // server: also blocked_by_ids
|
|
224
|
+
if (bundle) {
|
|
225
|
+
const { system, user, references } = renderBundleForModel(bundle, 'summarise', { boundary }) // boundary: fresh random, 16+ chars, per call
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
- `bundle` is null when the post itself may not be read: author asks off (`ai_ask_allowed` `'off'`, or `'sealed_only'` in Standard), the asker blocked or muted the author or the author blocked the asker, known-minor author (unless asker is author), child-safety hold, moderator suppression, `assist_readable: 'none'`. Make no model call.
|
|
230
|
+
- `refused` and `excluded` (`{ role, reason, count }[]`, never ids) are safe to show the asker: blocks, minors, holds and suppression all read `'unavailable'`. `audit` has the detail: server log only, never to a client.
|
|
231
|
+
- `known_minor`, `child_safety_hold`, `assistant_suppressed`, media `held: true`, `blocked_by_ids`: server-side inputs, never sent to another member's client (held media is left out of that payload, and so is the post's `ai_caption` whenever any of its media is held: a client cannot see media it was never sent); a client passes the server's per-viewer `assist_readable` instead. Exception: a picture's `held: false`, the payload's "not held" mark beside `checked_at`. Flags fail closed (anything but false/null/absent is set); an unreadable id list throws.
|
|
232
|
+
- Replies, parent and quote are left out by the same rules. Media go as alt text and stored caption only; picture and media addresses never reach `references`. Hidden text is stripped; top 20 replies; per-field and total caps (lower them with `limits`, never raise).
|
|
233
|
+
- Intents: `explain`, `fact_context` (post, parent, quote), `summarise` (plus replies), `translate` (needs `language`, a BCP 47 tag), `describe`. Output filters allow only `references` ids, handles and URLs, as plain links (never render an image the model wrote). Render only bundles you built from your own reads.
|
|
234
|
+
- Post pictures: pass media `id`, the payload's `checked_at` and its `held: false` mark to the builder (`checked_at` is kept only with `held` exactly `false`). `max` = `Math.max(0, 3 - phonePictures)`. `listed = bundlePictures(bundle, intent, { now: Date.now(), max })`, run your checks, then `renderBundleForModel(bundle, intent, { boundary, pictures: { now: Date.now(), max, allowed: passed.map((x) => x.picture), cached: (p) => passed.find((x) => same(x.picture, p))?.prepared_jpeg ?? null } })` (a fresh clock: it can only take pictures away). Your checks load each listed picture from the image cache, prepare it first (longest side ≤ 1536 px, re-encoded as JPEG, metadata stripped; spec 2.2), check the prepared bytes and return `passed` as `{ picture, prepared_jpeg }`; `cached` returns that `prepared_jpeg` (`same` matches `item_id`, `media_id` and `number`): never a second read of the cache, which could return a picture nobody checked and still carries its metadata. The SDK only checks the bytes look like a JPEG. Only pictures on the post, parent or quote (never replies), marked `held: false`, checked under 24 h ago by `now` (5 min ahead allowed), with a plain id, on `allowed` and with a cached base64 JPEG go, as `image_url` data parts inside the content block in `user_parts`; at most `max` (≤ 3), none for `translate`; a picture stale since listing is not replaced; everything else stays alt text, and output is unchanged when none qualifies. With pictures, send `system` + `user_parts` (not `user`/`text`). Never fetch in `cached`; `references` never holds a picture address.
|
|
235
|
+
- `sealedTurnCaps({ mode: 'sealed' | 'both', others_content, phone_pictures, temporary, searched_earlier, phone_pictures_earlier, search, earlier_messages, memory, memory_in_chat, memory_tick, about_you, page_opens_limited })` → `{ search, history, memory, about_you, phone_pictures, suggest_memory, reasons }`. `others_content` (`[]` for own words), `phone_pictures` (`0` for none), `temporary`, `searched_earlier`, `phone_pictures_earlier` and `earlier_messages` are required (absent/null throws; `false` is none). `mode: 'both'` with `'encrypted_chat'` throws (G21: Sealed only; Both's Standard half would send it to Minds and the AI company). Searching messages carry no memory/About you/phone pictures (search turns off: `search.off_mix`) and no history (`search.question_only`); someone else's public post blocks search until `page_opens_limited` (Tinfoil ask 3, default false; `search.off_subject`); members-only posts and chat messages never search (`search.off_members`), encrypted-chat messages never (`search.e2ee`) and carry no phone pictures; Both never searches or carries phone pictures (`both.no_search`, `both.no_photo`); temporary chats and encrypted-chat asks get no memory. With others' content only `memory_tick` sends memory/About you (the chip `memory_in_chat` counts only in the member's own chat). No `suggest_memory` with phone pictures in the message (a screenshot can carry someone else's words), or after a search or a phone picture earlier in the chat. Only `true` turns a sending choice on; unknown `mode`/content kinds and picture counts that are not 0 to 3 throw.
|
|
236
|
+
- Fixed cards, before any model call: `matchFixedCard(memberWords, { country, locale })` → `{ kind: 'crisis' | 'election', card } | null`. Crisis (self-harm, suicide risk, immediate danger) always shows the card first, then the turn may answer; election logistics only (never candidates, results, fraud claims, votes on a post or poll), and `fixedCardAnswer('election', Date.now())` is `'card_only'` until 2026-11-04T12:00Z. English and Spanish; every number and URL is sourced data (`CRISIS_LINES`, `ELECTION_SITES`), never generated. Pass the member's words, not the post they ask about.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
217
240
|
## Evidence — Measure agents (evals, accuracy, cost)
|
|
218
241
|
|
|
219
242
|
### r.evidence — Author evals, run simulations, read graded results
|
|
@@ -564,6 +587,14 @@ const { data: reset } = await r.e2ee.resetAccount({ email_code, device_name, rea
|
|
|
564
587
|
// → { reset, resetAt, retiredRoot, revokedDeviceIds, device (the new main device), signedOut: { sessions, keys, connectedApps } }; a retry after a lost answer gets the same result
|
|
565
588
|
```
|
|
566
589
|
|
|
590
|
+
A contact's reset (D2 R2, 0.7.83; where `accountReset` is on). Each member of an encrypted conversation the account shares (a direct chat, or a group both accepted) gets `e2ee:peer` `{ userId, reason: 'root_changed', conversationId }`, once per shared conversation, and each direct-chat contact one push (at most one a day per person who reset; it does not name them): "A contact reset encrypted chat. Open Minds to keep chatting." `peerChanges({ since })` lists the accounts whose main device a reset replaced since then (default and floor 7 days, oldest first, at most 200; a relay without it answers 404: treat it as none). An engine that can accept a contact's new main device sends `trust_change: true` with its messages; in a direct chat with someone who reset, such a message gets 409 `peer_not_joined` (`details.userId`) until a device of this account added their new device, and 409 `stale_epoch` under a key generation before the chat's last key update. Messages without it are accepted as before.
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
r.realtime.onE2eePeer(({ userId, conversationId }) => {}); // look at userId's devices again
|
|
594
|
+
const { data: changes } = await r.e2ee.peerChanges({ since }); // [{ userId, at }] after time in the background
|
|
595
|
+
await r.e2ee.appendEvent(conversationId, { device_id, envelope, trust_change: true }); // ConflictError 'peer_not_joined' | 'stale_epoch'
|
|
596
|
+
```
|
|
597
|
+
|
|
567
598
|
### r.realtime — WebSocket Chat Client
|
|
568
599
|
|
|
569
600
|
Provides persistent real-time streaming and chat presence, useful for building frontends (e.g. React Native, Web).
|
|
@@ -597,7 +628,7 @@ unsub()
|
|
|
597
628
|
const off = r.realtime.on('notification', (n) => refreshBadge(n))
|
|
598
629
|
```
|
|
599
630
|
|
|
600
|
-
Events: `onMessage`, `onMessageEdit`, `onMessageDelete`, `onTyping`, `onAgentThinking`, `onE2eeEvent`, `onE2eeLink`, `onE2eeDevices`, `on(event, fn)`
|
|
631
|
+
Events: `onMessage`, `onMessageEdit`, `onMessageDelete`, `onTyping`, `onAgentThinking`, `onE2eeEvent`, `onE2eeLink`, `onE2eeDevices`, `onE2eePeer`, `on(event, fn)`
|
|
601
632
|
|
|
602
633
|
Reconnects on its own (transport drops, token expiry, refused handshakes) with jittered backoff and a fresh single-use token per attempt; listeners and joined rooms carry over, so no app-side reconnect shim is needed. It stops only on `disconnect()` or a 401/403 from the token source. `connect()` is safe to call repeatedly.
|
|
603
634
|
|
|
@@ -794,13 +825,21 @@ Methods: `list`, `get`, `create`, `delete`
|
|
|
794
825
|
|
|
795
826
|
## Platform Resources
|
|
796
827
|
|
|
797
|
-
- `r.settings` — User preferences, sessions, password changes, account deletion. `revokeOtherSessions({ keep_session_token? })` signs out every other device and keeps this key (and the proven session); `revokeAllSessions()` also revokes this key. Accounts with no password confirm `requestEmailChange({ new_email, email_code })` with a code from `requestConfirmationCode('email_change')`. With a project-bound key, `getPreferences()` includes app-scoped `content`; persist changes with `updateContent({ show_nsfw?, autoplay_video?, ai_enabled?, default_feed? })
|
|
828
|
+
- `r.settings` — User preferences, sessions, password changes, account deletion. `revokeOtherSessions({ keep_session_token? })` signs out every other device and keeps this key (and the proven session); `revokeAllSessions()` also revokes this key. Accounts with no password confirm `requestEmailChange({ new_email, email_code })` with a code from `requestConfirmationCode('email_change')`. With a project-bound key, `getPreferences()` includes app-scoped `content`; persist changes with `updateContent({ show_nsfw?, autoplay_video?, ai_enabled?, default_feed?, sealed_adult? })`; `content.default_feed_chosen` is true once the member set `default_feed` themselves (absent on older APIs). `privacy.age_is_adult` is true only when the member answered the age question 18 or older (false for 13-17 and for no answer; only their own preferences carry it). `content.sealed_adult` is the Sealed adult-content setting: it reads true only while it is on and the member is a known adult, and `updateContent({ sealed_adult: true })` from anyone else throws `AuthorizationError` with code `sealed_adult_needs_adult` and saves nothing in that request; `sealed_adult: false` is always accepted. Both are absent on older APIs (treat as false). Notifications (0.7.72): `getPreferences()` returns `notifications.channels`, each kind (`post_mention`, `post_reply`, `post_repost`, `post_reaction`, `follow`, `message`, and the operator kinds) with `{ inApp, push, email }`; `updateNotifications({ channels: { post_reaction: { push: false } } })` changes that kind only, while `in_app`/`push`/`email` stay master switches over every kind. `disableAccount({ password })` (0.7.71) deactivates the account (hidden, nothing deleted; signing back in restores it), asked once; an API that does not serve deactivation answers 404 `NotFoundError`, so keep the control hidden until yours does.
|
|
798
829
|
- `r.signals` (0.7.71) — The member's engagement signals for their own ranking (For You), under their app-bound key (`agents:write`): `batch(events, { keepalive?, retry? })` sends up to `SIGNAL_BATCH_MAX` (100) `{ event_type, post_id?, dwell_ms?, metadata? }` to POST /signals/batch; `record(event)` sends one to POST /signals/post (for an API before the batch route, which answers that one 404). `event_type`: `view`, `click`, `dwell`, `react_up`, `react_down`, `save`, `hide`, `share`, `mute_source`, `open_chat`, `ask_agent`, `more_like`, `unhide`. Best-effort: a signal the server cannot store answers 200 with `recorded` 0 (or false) and a `reason` (`no_project_scope`, `project_db_unavailable`). `keepalive: true` is for a flush as the page is hidden or unloads: the request is on the wire before the call returns and is asked once.
|
|
799
830
|
|
|
800
831
|
```typescript
|
|
801
832
|
await r.signals.batch([{ event_type: 'view', post_id: post.id }, { event_type: 'dwell', post_id: post.id, dwell_ms: 4200 }])
|
|
802
833
|
addEventListener('pagehide', () => { void r.signals.batch(pending.splice(0, SIGNAL_BATCH_MAX), { keepalive: true }).catch(() => {}) })
|
|
803
834
|
```
|
|
835
|
+
- `r.privacy` (unreleased) — `capabilities()` reads GET /privacy/capabilities: `{ api_commit, facts }`, the platform privacy facts the deployed API's build marks enforced (any API key). `@recursiv/sdk/privacy` exports `PRIVACY_FACTS` (each `{ id, status, enforcement, capability, step, says }`, generated from the platform's claims registry), `privacyFact(id)`, `isFactEnforced(id)`, `isFactLive(id, capabilities, { deviceFacts? })` and `liveFacts(capabilities, { deviceFacts? })`. Show a privacy sentence only when every fact it rests on is live: enforced in this SDK and listed by the API, and, for a fact held on the phone (`enforcement: 'device'`), also named in `deviceFacts` by the app for the native module it ships. A device fact says what the module does, not what the app does with it (for `sealed.attest_before_send` the app chooses how long a checked router is reused).
|
|
836
|
+
|
|
837
|
+
```typescript
|
|
838
|
+
import { isFactLive } from '@recursiv/sdk/privacy'
|
|
839
|
+
const { data: capabilities } = await r.privacy.capabilities()
|
|
840
|
+
const showNoPushLine = isFactLive('sealed.no_push', capabilities)
|
|
841
|
+
const showCheckedLine = isFactLive('sealed.attest_before_send', capabilities, { deviceFacts: ['sealed.attest_before_send'] })
|
|
842
|
+
```
|
|
804
843
|
- `r.billing` — Usage tracking, checkout sessions, portal sessions, metered billing
|
|
805
844
|
- `r.appSubscriptions` — Project-scoped consumer plans: `checkout` (409 `subscription_exists` when the member already pays), `status`, `createPortalSession({ return_url, flow?: 'cancel' })` (`flow: 'cancel'` opens Stripe's cancel confirmation; 409 `no_subscription` when there is no plan to cancel), `previewTierChange({ tier })`, `changeTier({ tier, proration_date? })` (in-place, prorated, never a second subscription)
|
|
806
845
|
- `r.boosts` — Promoted posts (micro-USD): `create`, `mine`, `balance`, `pause`, `resume`, `cancel`, `recordEvent`; buy credit with `topup({ amount_cents, return_url })` then `confirmTopup(sessionId)` (credits once); admin `listPendingReview`, `review`, `grant`
|
|
@@ -833,11 +872,12 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
|
|
|
833
872
|
- Native follows: `nativeFollowState({ accountId, protocol, nativeId })`, `prepareNativeFollow({ clientMutationId, accountId, protocol, nativeId, action: "follow" | "unfollow" })`, `submitNativeFollow(id, { approve: true, event? })`, `nativeFollowStatus(id)`. Show the acting identity and native following/requested/unfollowed state separately from the Minds feed subscription. Nostr must sign the complete review event without dropping other contacts; stale lists require a fresh review. Keep mutation IDs and reconcile ambiguous outcomes without replay. Requires current follow scopes and API `NATIVE_SOCIAL_FOLLOW_PROJECTS` plus source controls.
|
|
834
873
|
- Native reposts: `nativeRepostState({ accountId, protocol, nativeId })`, `prepareNativeRepost({ clientMutationId, accountId, protocol, nativeId, action: "repost" | "unrepost" })`, `submitNativeRepost(id, { approve: true, event? })`, `nativeRepostStatus(id)`. Requires `NATIVE_SOCIAL_REPOST_PROJECTS`, account capability and explicit approval; Nostr signs the exact review (kind 6 or own kind 5). Unknown attempts only reconcile. Bluesky connections need the new repost scope; ActivityPub uses write:statuses. Native likes: `nativeLikeState({ accountId, protocol, nativeId })`, `prepareNativeLike({ clientMutationId, accountId, protocol, nativeId, action: "like" | "unlike" })`, `submitNativeLike(id, { approve: true, event? })`, and `nativeLikeStatus(id)`. Display the selected account, source and action before approval. Only Nostr supplies `event`: sign the exact public `signing.event` in the review; never transmit a private key. Preserve mutation IDs on ambiguous responses and refresh unknown results instead of resending. Read capabilities before offering actions; old reply-only connections need reconnecting for likes. Nostr results describe the connected relay, not every public relay. The app-specific `NATIVE_SOCIAL_LIKE_PROJECTS` switch and source controls must be enabled on the API.
|
|
835
874
|
- Delegated social accounts: `r.protocols.nativeSocialAccounts()`, `startNativeSocialConnection({ protocol, identifier })`, `finishNativeSocialConnection({ protocol, state, code?, iss?, error? })`, `disconnectNativeSocialAccount(id)`. Example: `const { data } = await r.protocols.startNativeSocialConnection({ protocol: "bluesky", identifier: "alice.bsky.social" })`; open `data.authorizationUrl`, then complete with the same signed-in app user. Never log callback codes, send passwords, or auto-retry OAuth callbacks. Shared replies (off until schema/API deployment and live verification): `prepareNativeReply({ clientMutationId, text, parent: { protocol, nativeId, readRef? }, accountIds, media?, linkedPostAccountIds?, recoverFrom?: { batchId, deliveryIds } })`, `submitNativeReply(id, { approve: true, deliveryIds })`, `nativeReplyStatus(id)`, `nativeReplyHistory({ limit?, cursor? })`, `prepareNativeReplyRemoval(id, { deliveryIds })`, `removeNativeReply(id, { approve: true, removals })`. Review account, destination, `kind`, source, exact `wireText` and every reviewed `media` item with its alt text; connection is not publication approval. `media` takes the same `NativeMediaInput[]` as `prepareNativePost` and needs the account's `publish_media` capability. `linkedPostAccountIds` explicitly selects different-network standalone posts with a source link, never matching threaded replies or a same-network denial fallback. Preserve mutation IDs, show per-destination results and reconcile uncertain attempts without reposting. To review selected unsent or definitively rejected deliveries again, use recoverFrom with a new mutation ID, the same source/accounts and freshly reviewed text; preserve that ID on uncertain preparation. Only saved recoverable destinations qualify, current gates still apply, and replacedBy disables old sends atomically. Never restart unknown, accepted or published deliveries. The new preview still needs explicit approval. Saved delegated drafts/results: `const { data: history } = await r.protocols.nativeReplyHistory({ limit: 20 })`; use its opaque cursor for the next page and `nativeReplyStatus(item.id)` for details. This is a private read, not a write or provider refresh, and includes Nostr results marked type=nostr. Use nativeNostrReplyResult(item.id) to check that recorded event on its relay without signing or resending. Discard page state on owner/app changes. See README for examples and the callback, revocation and removal contracts.
|
|
836
|
-
- `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. The author's own thread (0.7.81): `thread` holds the focus author's continuation below the post, oldest first (a "1/" post's 2/, 3/, 4/ ..., at most 20 parts, each held to the same moderation and member controls as a reply); its first part is not repeated in `replies`, so render `focus`, `thread`, then `replies` (`thread ?? []`: older APIs omit it, and a `replies: 'none'` read answers none). A card (a post shared into a chat, a link preview) asks for the post alone, 0.7.78: `getPublicConversation({ protocol, post, replies: 'none' })` (also `getSharedConversation` and `getConnectedSocialConversation`) answers `focus` with no `ancestors` or `replies` and `repliesStatus: 'not_requested'`; a Nostr note answers from one relay read instead of a whole thread's. An older API answers 400 `invalid_input`: read without it. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
|
|
875
|
+
- `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. Open network ids (0.7.87, readers first): `import { SOCIAL_PROTOCOL_IDS, isSocialProtocolId } from '@recursiv/sdk'` lists every network a post, account or `SocialSourceRef` may name: bluesky, activitypub, nostr and farcaster (a Farcaster account by its FID, "3"; a cast by `<fid>:0x<40 lowercase hex>`). Listing an id serves nothing: read only what `publicNetworks()` lists, and skip an id this SDK does not know. Writes stay narrower: a native reply, follow, like or repost takes only `NATIVE_WRITE_PROTOCOLS` (bluesky, activitypub, nostr; `isNativeWriteProtocol(post.protocol)` before offering one), and a feed top's `networks` always names those three while a Farcaster lane may be absent (`networks.farcaster ?? []`). 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`. `getFollowingFeed` sources: a slow account is `late` (status `late`, or `ok` with `late: true` while its held first page is shown; older APIs say `unavailable` with `late: true`), never a failure: read the next page for its posts (`lateNetworks` names its network). Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. The author's own thread (0.7.81): `thread` holds the focus author's continuation below the post, oldest first (a "1/" post's 2/, 3/, 4/ ..., at most 20 parts, each held to the same moderation and member controls as a reply); its first part is not repeated in `replies`, so render `focus`, `thread`, then `replies` (`thread ?? []`: older APIs omit it, and a `replies: 'none'` read answers none). A card (a post shared into a chat, a link preview) asks for the post alone, 0.7.78: `getPublicConversation({ protocol, post, replies: 'none' })` (also `getSharedConversation` and `getConnectedSocialConversation`) answers `focus` with no `ancestors` or `replies` and `repliesStatus: 'not_requested'`; a Nostr note answers from one relay read instead of a whole thread's. An older API answers 400 `invalid_input`: read without it. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
|
|
837
876
|
- 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.
|
|
877
|
+
- Native networks (0.7.89): every eligible member is on ActivityPub, AT Protocol and Nostr by rule. `const { data } = await r.protocols.networks()` lists `{ network, state, reason, mode, handle, engageDefault }` (read-only; `ready` = set up on their first post or interaction, show "Will be set up when you first post or interact"). `activateNetwork(network)` = "Set up now" (`pending` + `retryAt` over budget; 409 `network_off` after the member's off switch). `setNetwork(network, { enabled: false, removePast: true })` turns it off and removes what was shared; `{ enabled: true }` turns it back on (403 `network_locked`, 409 `not_rolled_out`/`age_band_required`). Needs users:read (+ users:write to change). A linked outside account is a Settings choice, never a step in a main flow.
|
|
838
878
|
- 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.
|
|
839
879
|
- Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`; `feed` with `ref` is declared in 0.7.60 but not sent yet, so skip kinds you cannot open). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`. Watch's Minds videos: `const { data, meta } = await r.discovery.videos({ limit: 12 })` returns public video posts in hot order from a list shared per app (rebuilt about once a minute), each re-checked per call for the viewer; page on `meta.next_cursor`. With no category about one row in four is an open-network clip (0.7.78): `isWatchNetworkClip(row)` tells `{ kind: 'social', id, post: SocialPost }` from a `Post`; open and react to it as the network's post (its `id` is never a Minds id).
|
|
840
|
-
- Progressive Discover feed (0.7.71): `const feed = r.discovery.createProgressiveFeed({ networks, category, recommend, top: { ref: 'builtin:all' }, seen, seenClusters }); const round = await feed.next(part => draw(part))` opens one `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`) and hands each source's page on as a `DiscoveryPart` (`{ source, round, modules, composed?, mixed? }`) the moment it lands, instead of a page that waits for its slowest network (up to 15 s); a round waits `DISCOVERY_STRAGGLER_MS` past its first answer and rejects only when every source failed. The first load is held until Minds and one other network answered (or `FIRST_SCREEN_MS`, 2.5 s, after the first answer; `firstScreenMs: VISITOR_FIRST_SCREEN_MS` for a visitor) and arrives as one `composed: true` part from `composeFirstScreen`: the top's first post when it is a playable video, else a picture lead from the best 8 by engagement (never a story, rail, video strip, column, `isSensitivePost` post or `linksAdultSite` porn link; 0.7.80: never a post with no picture or clip and no engagement while the screen holds one with either, which then leads: media and engagement, else media, else engagement, as `mergeDiscoveryArrival` leads a feed's top and the feed after a line), then every network's posts by engagement (`DiscoveryEngagementMerge`) with row 2 from another network than the lead's when one is left (`FIRST_SCREEN_NETWORK_ROWS`), a story as row 3 and a rail as row 5, nothing dropped. Merge later `mixed` parts with `mergeDiscoveryArrival(rows, arriving, reachedIds)`. `top`/`seen`/`seenClusters`/`bucket` reach every session; the top is read once per open. Also `retryFailed()`, `onStatus(listener)`, `setNetworks(catalog)`; settings `perSource`, `firstScreen`, `onLanded`. For you: `createProgressiveFeed({ createFeed: o => r.discovery.createPersonalFeed(o) }, options, { perSource: false })`. A session's `seen` now also leaves seen network posts out of trend pages, previews and topic pages; in a category a topic page with nothing new ends its topic and after `DISCOVERY_EMPTY_TOPIC_PAGES` (3) in a row the network's other topics are skipped.
|
|
880
|
+
- Progressive Discover feed (0.7.71): `const feed = r.discovery.createProgressiveFeed({ networks, category, recommend, top: { ref: 'builtin:all' }, seen, seenClusters }); const round = await feed.next(part => draw(part))` opens one `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`) and hands each source's page on as a `DiscoveryPart` (`{ source, round, modules, composed?, mixed? }`) the moment it lands, instead of a page that waits for its slowest network (up to 15 s); a round waits `DISCOVERY_STRAGGLER_MS` past its first answer and rejects only when every source failed. The first load is held until Minds and one other network answered (or `FIRST_SCREEN_MS`, 2.5 s, after the first answer; `firstScreenMs: VISITOR_FIRST_SCREEN_MS` for a visitor) and arrives as one `composed: true` part from `composeFirstScreen`: the top's first post when it is a playable video, else a picture lead from the best 8 by engagement (never a story, rail, video strip, column, `isSensitivePost` post or `linksAdultSite` porn link; 0.7.80: never a post with no picture or clip and no engagement while the screen holds one with either, which then leads: media and engagement, else media, else engagement, as `mergeDiscoveryArrival` leads a feed's top and the feed after a line), then every network's posts by engagement (`DiscoveryEngagementMerge`) with row 2 from another network than the lead's when one is left (`FIRST_SCREEN_NETWORK_ROWS`), a story as row 3 and a rail as row 5, nothing dropped. Merge later `mixed` parts with `mergeDiscoveryArrival(rows, arriving, reachedIds)`. `top`/`seen`/`seenClusters`/`bucket` reach every session; the top is read once per open. Also `retryFailed()`, `onStatus(listener)`, `setNetworks(catalog)`; settings `perSource`, `firstScreen`, `onLanded`. For you: `createProgressiveFeed({ createFeed: o => r.discovery.createPersonalFeed(o) }, options, { perSource: false })`. A session's `seen` now also leaves seen network posts out of trend pages, previews and topic pages; in a category a topic page with nothing new ends its topic and after `DISCOVERY_EMPTY_TOPIC_PAGES` (3) in a row the network's other topics are skipped. 0.7.84: a featured module carries `theme` only when its lead window was themed so and most of its own posts were judged into that category (a category feed's posts, by the read's category filter); a feed with no category titles every module by its kind alone ("Videos", not "Videos in Technology" over an unjudged Nostr clip). A post is shown once by every identity (`discoveryItemIdentities(item)`: its key, an open-network post's network id, and each media file by address and by content hash, so a second note carrying the same clip is the same post) across a session's pages, a progressive feed's sources (`withoutSeenItems` takes identities or keys), `mergeDiscoveryArrival` and gallery pooling; `withoutRepeatedDiscoveryItems(rows, shown)` does the same for an app's own rows.
|
|
841
881
|
- 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.
|
|
842
882
|
- Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass?, media? })` (`compass: 'hide'` leaves out politics, 0.7.62; `media: 'video'`, 0.7.73, Bluesky: the network's own window of video posts, a `kind: posts` page paged by its cursor, no category or layout, 404 `unsupported_protocol` for a network without one, 400 on an API that predates it; keyless `getSharedTrends(projectId, { ..., media: 'video' })` holds only clips a visitor may see and counts the rest in `mediaFilter.withheld`); render `kind: topics` as source topics and `kind: posts` as source posts. With `trend_feed`, open only a returned current topic using `getPublicTrendFeed({ protocol, topic: trend.nativeId, limit?, cursor? })`. Show provider, observedAt and provider_ranked_window coverage; null rankingWindow is unknown, not a daily/global ranking. Preserve source order, canonical IDs and readRef, and opaque cursors. Topic catalogs do not paginate; Mastodon trends stop at 200 source positions. Both methods require users:read plus posts:read on the current human app key; Nostr trends are unsupported. Propagate errors; trend_unavailable means refresh the topic list. No ingestion or native writes.
|
|
843
883
|
- One outlet count per story (0.7.71): a radar story (list, story read, archive), a World Today story (`outlets`, with `outlet_count` its total and `lane_mix` its lanes), a category strip story, a `feeds.top`/`feeds.stories` story and a search story carry `outlets: { total, mainstream, independent, other }`, the distinct outlets of the articles the story lists (an allow-listed outlet once, an unlisted host in `other`; the buckets sum to `total`). Show `outlets.total`; `sources.outlet` counts articles. `DiscoveryCluster.outletCount` reads it. An older server omits it.
|
|
@@ -855,6 +895,7 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
|
|
|
855
895
|
- `r.dispatcher` — Task queue: `tasks()`, `create()`, `claimNext()`, `complete()`, `release()`, `stats()`, `signals()`, `outcomes()`, `staleItems()`, `stuckItems()`, `discoveries()`
|
|
856
896
|
- `r.dispatcher` (webhooks): `createWebhook()`, `listWebhooks()`, `updateWebhook()`, `deleteWebhook()`, `testWebhook()`, `webhookDeliveries()`, `cleanupWebhookLogs()`
|
|
857
897
|
- `r.goals` — Org goals that dispatcher tasks roll up into: `list()`, `get()`, `create()`, `update()`, `archive()`. Tasks link via `goal_id` on `r.dispatcher.create()/update()`; `tasks({ goal_id })` filters (use `'none'` for goal-less tasks).
|
|
898
|
+
- `r.linkPreview` — Open Graph card for a public link: `get(url)` answers `{ data: LinkPreview | null }` (null: no preview, show the plain link); keyless clients may call it. For a link in a chat message pass where it came from, `get(url, { messageId })` (or `{ conversationId }` before the message has an id), on a signed-in key with `chat:read`: the link is fetched only when it is in the named message and a person wrote that message, or when the caller wrote the same address in that conversation (a message, or the card link of a Minds post they shared there), else `{ data: null, refused: 'ai_link' }` with nothing fetched; 401 without a key, 404 for a message or conversation the caller cannot read. A `source` with no id rejects with `ValidationError` and sends nothing. Never pass `source` in an encrypted conversation: the server cannot read it, so every link would be refused; the app's own rule decides there.
|
|
858
899
|
|
|
859
900
|
```typescript
|
|
860
901
|
// Create a goal and group tasks under it (personal keys: pass organization_id)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@recursiv/sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.89",
|
|
4
4
|
"description": "TypeScript SDK for the Recursiv API",
|
|
5
5
|
"license": "FSL-1.1-ALv2",
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
"import": "./dist/index.js",
|
|
14
14
|
"default": "./dist/index.js"
|
|
15
15
|
},
|
|
16
|
+
"./assistant": {
|
|
17
|
+
"types": "./dist/assistant/index.d.ts",
|
|
18
|
+
"import": "./dist/assistant/index.js",
|
|
19
|
+
"default": "./dist/assistant/index.js"
|
|
20
|
+
},
|
|
16
21
|
"./react": {
|
|
17
22
|
"types": "./dist/react/index.d.ts",
|
|
18
23
|
"import": "./dist/react/index.js",
|
|
@@ -22,6 +27,11 @@
|
|
|
22
27
|
"types": "./dist/next/index.d.ts",
|
|
23
28
|
"import": "./dist/next/index.js",
|
|
24
29
|
"default": "./dist/next/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./privacy": {
|
|
32
|
+
"types": "./dist/privacy/index.d.ts",
|
|
33
|
+
"import": "./dist/privacy/index.js",
|
|
34
|
+
"default": "./dist/privacy/index.js"
|
|
25
35
|
}
|
|
26
36
|
},
|
|
27
37
|
"files": [
|