@recursiv/sdk 0.7.82 → 0.7.83

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.
@@ -1,4 +1,4 @@
1
- import { RecursivError } from '../errors.js';
1
+ import { RateLimitError, rateLimitDetailsFromResponse, RecursivError } from '../errors.js';
2
2
  import { parseSSE } from '../lib/sse.js';
3
3
  // ── Agent directory (GET /agents/discover) ──
4
4
  /** The orders the agent directory serves (server: features/agents/agentDirectory.ts). */
@@ -83,7 +83,10 @@ export class AgentsResource {
83
83
  delete(id) {
84
84
  return this.client.delete(`/agents/${id}`);
85
85
  }
86
- /** Send a message to an agent (async — agent responds asynchronously) */
86
+ /**
87
+ * Send a message to an agent (async — agent responds asynchronously).
88
+ * `turn: { safe: true }` makes it a safe turn in the caller's Asks thread (see `AgentChatTurn`).
89
+ */
87
90
  chat(id, input) {
88
91
  return this.client.post(`/agents/${id}/chat`, input);
89
92
  }
@@ -224,6 +227,8 @@ export class AgentsResource {
224
227
  *
225
228
  * Requires `ReadableStream` support (Node.js, modern browsers).
226
229
  * For React Native, use `chatStreamText()` instead.
230
+ * `turn: { safe: true }` streams a safe turn in the caller's Asks thread (see `AgentChatTurn`); a refusal throws a
231
+ * `RecursivError` whose code is a `SafeTurnRefusalCode`, before anything streams.
227
232
  */
228
233
  async *chatStream(id, input) {
229
234
  yield* this.streamChunks(id, input);
@@ -286,6 +291,11 @@ export class AgentsResource {
286
291
  code: 'stream_error',
287
292
  });
288
293
  }
294
+ // A 429 is a RateLimitError, as on every other request: with its scope and reset (a safe turn's daily cap is
295
+ // `scope: 'action'`, see SafeTurnRefusalCode).
296
+ if (response.status === 429 && errorBody?.error && typeof errorBody.error === 'object') {
297
+ throw new RateLimitError(errorBody.error, rateLimitDetailsFromResponse(response.headers, errorBody.error));
298
+ }
289
299
  throw new RecursivError(response.status, errorBody.error);
290
300
  }
291
301
  return response;
@@ -68,6 +68,38 @@ export interface E2eeAccountReset {
68
68
  * - `conflict` (409): the device name or key was used before; start the device again with fresh ones.
69
69
  */
70
70
  export type E2eeResetErrorCode = 'confirmation_failed' | 'code_limit' | 'no_email_on_file' | 'email_delivery_unavailable' | 'reset_required' | 'conflict';
71
+ /**
72
+ * One account whose main device a reset of encrypted chat replaced (`peerChanges`): `at` is when the new main
73
+ * device was registered (the relay's clock). Look at its devices again (`devices({ user_id })`).
74
+ */
75
+ export interface E2eePeerChange {
76
+ userId: string;
77
+ at: string;
78
+ }
79
+ /**
80
+ * The realtime `e2ee:peer` event (`r.realtime.onE2eePeer`), sent to each member of an encrypted conversation the
81
+ * account `userId` shares with them, once per such conversation, when that account reset encrypted chat
82
+ * (`reason: 'root_changed'`): its security code changed there. Only to sockets whose credential can read that
83
+ * conversation over the relay. A hint: look at the account's devices again. An app in the background catches up
84
+ * with `peerChanges` when it comes to the foreground.
85
+ */
86
+ export interface E2eePeerRealtimeEvent {
87
+ userId: string;
88
+ reason: 'root_changed';
89
+ conversationId: string;
90
+ }
91
+ /**
92
+ * Codes a message that declares `trust_change` can be refused with in a direct chat whose other person reset
93
+ * encrypted chat, where `status().accountReset` is on (409 `ConflictError` for both):
94
+ * - `peer_not_joined`: no device of this account has added the other person's new device to the chat yet, so the
95
+ * message would be unreadable on it. `details: { userId }` names the person. Add the device, then send again.
96
+ * - `stale_epoch`: encrypted under a key generation before the chat's last key update. Sync, then encrypt it again.
97
+ */
98
+ export type E2eePeerErrorCode = 'peer_not_joined' | 'stale_epoch';
99
+ /** The `details` of a `peer_not_joined` refusal. */
100
+ export interface E2eePeerNotJoinedDetails {
101
+ userId: string;
102
+ }
71
103
  /** Opaque `recursiv-mls-v2-draft1` envelope with base64 MLS bytes. */
72
104
  export interface E2eeEnvelope {
73
105
  profile: 'recursiv-mls-v2-draft1';
@@ -367,6 +399,15 @@ export declare class E2eeResource {
367
399
  * delete. A commit ignores the flag. The relay learns only that the event
368
400
  * is quiet; the flag is not stored. A relay that predates it drops the
369
401
  * key, so the event is simply not quiet there.
402
+ *
403
+ * `trust_change: true` says this device's engine can accept a contact's new
404
+ * main device after they reset encrypted chat. Where `status().accountReset`
405
+ * is on, such a message in a direct chat whose other person reset is refused
406
+ * 409 `peer_not_joined` (`details: { userId }`) until a device of this
407
+ * account added their new device, and 409 `stale_epoch` when encrypted under
408
+ * a key generation before the chat's last key update (E2eePeerErrorCode). A
409
+ * message without it is accepted as before. Never stored; relays without it
410
+ * ignore it.
370
411
  */
371
412
  appendEvent(conversationId: string, input: {
372
413
  device_id: string;
@@ -375,11 +416,26 @@ export declare class E2eeResource {
375
416
  welcome_for_device_id?: string;
376
417
  expected_commit_seq?: number;
377
418
  silent?: boolean;
419
+ trust_change?: boolean;
378
420
  }): Promise<SingleResponse<{
379
421
  seq: number;
380
422
  duplicate: boolean;
381
423
  commitSeq: number;
382
424
  }>>;
425
+ /**
426
+ * The accounts sharing an encrypted conversation with this account (a direct
427
+ * chat, or a group both accepted) whose main device a reset of encrypted chat
428
+ * replaced after `since` (ISO 8601; default and floor: 7 days ago), oldest
429
+ * first, at most 200 (ask again from the last `at` for more). Read it when the
430
+ * app comes to the foreground, then look at each account's devices again; the
431
+ * live form is `r.realtime.onE2eePeer`. Empty where `status().accountReset` is
432
+ * off. Relays without it answer 404 (`NotFoundError`): treat that as no changes.
433
+ */
434
+ peerChanges(params?: {
435
+ since?: string;
436
+ }): Promise<{
437
+ data: E2eePeerChange[];
438
+ }>;
383
439
  events(conversationId: string, params: {
384
440
  device_id: string;
385
441
  after_seq?: number;
@@ -96,11 +96,32 @@ export class E2eeResource {
96
96
  * delete. A commit ignores the flag. The relay learns only that the event
97
97
  * is quiet; the flag is not stored. A relay that predates it drops the
98
98
  * key, so the event is simply not quiet there.
99
+ *
100
+ * `trust_change: true` says this device's engine can accept a contact's new
101
+ * main device after they reset encrypted chat. Where `status().accountReset`
102
+ * is on, such a message in a direct chat whose other person reset is refused
103
+ * 409 `peer_not_joined` (`details: { userId }`) until a device of this
104
+ * account added their new device, and 409 `stale_epoch` when encrypted under
105
+ * a key generation before the chat's last key update (E2eePeerErrorCode). A
106
+ * message without it is accepted as before. Never stored; relays without it
107
+ * ignore it.
99
108
  */
100
109
  appendEvent(conversationId, input) {
101
110
  // Retries are safe: the server is idempotent on (conversation, device, event id).
102
111
  return this.client.post(`/e2ee/conversations/${encodeURIComponent(conversationId)}/events`, input, { retry: true });
103
112
  }
113
+ /**
114
+ * The accounts sharing an encrypted conversation with this account (a direct
115
+ * chat, or a group both accepted) whose main device a reset of encrypted chat
116
+ * replaced after `since` (ISO 8601; default and floor: 7 days ago), oldest
117
+ * first, at most 200 (ask again from the last `at` for more). Read it when the
118
+ * app comes to the foreground, then look at each account's devices again; the
119
+ * live form is `r.realtime.onE2eePeer`. Empty where `status().accountReset` is
120
+ * off. Relays without it answer 404 (`NotFoundError`): treat that as no changes.
121
+ */
122
+ peerChanges(params) {
123
+ return this.client.get('/e2ee/peer-changes', params);
124
+ }
104
125
  events(conversationId, params) {
105
126
  return this.client.get(`/e2ee/conversations/${encodeURIComponent(conversationId)}/events`, params);
106
127
  }
@@ -17,6 +17,30 @@ export interface LinkPreviewResponse {
17
17
  */
18
18
  data: LinkPreview | null;
19
19
  cached?: boolean;
20
+ /**
21
+ * `'ai_link'`: the link is not in the named message in a person's own words,
22
+ * and the caller did not write the same address in that conversation, so the
23
+ * server did not fetch it (it may be an AI's). Show the plain link, and do not
24
+ * ask again for this message. Only on a request that named its message or
25
+ * conversation (`source`).
26
+ */
27
+ refused?: 'ai_link';
28
+ }
29
+ /**
30
+ * Where a chat link came from. Name the message when you have its id; the
31
+ * conversation alone when you do not have it yet (an AI reply still being
32
+ * saved). With the conversation alone, a link is previewed only when the caller
33
+ * wrote it there, so another member's message not yet saved waits for its id.
34
+ * At least one of the two must be a non-empty id: `get` rejects otherwise and
35
+ * never falls back to a request without a source.
36
+ *
37
+ * Not for an encrypted conversation: the server has no readable messages there,
38
+ * so every link would be refused, the member's own included. There the app
39
+ * decides by its own rule (no preview of a link the member did not write).
40
+ */
41
+ export interface LinkPreviewSource {
42
+ messageId?: string;
43
+ conversationId?: string;
20
44
  }
21
45
  export declare class LinkPreviewResource {
22
46
  private client;
@@ -27,6 +51,23 @@ export declare class LinkPreviewResource {
27
51
  * preview (`data: null`). No auth required — the endpoint is intentionally
28
52
  * public so unauthenticated UIs can render link previews. A URL that is not
29
53
  * http(s), or points at a private or internal address, still rejects (422).
54
+ *
55
+ * For a link in a chat message, pass `source`: the server then previews the
56
+ * link only when it is in the named message and a person wrote that message,
57
+ * or when the caller wrote the same address in that conversation (in a
58
+ * message, or as the card link of a Minds post they shared there). Otherwise
59
+ * it answers `{ data: null, refused: 'ai_link' }` without fetching it. A
60
+ * request with `source` is the caller's own: make it on a signed-in client
61
+ * whose key has `chat:read` (a keyless one gets 401). A message or
62
+ * conversation the caller cannot read rejects with 404. A `source` with no
63
+ * id in it (an AI reply whose id is not known yet, say) rejects with a
64
+ * ValidationError and sends nothing. Do not pass `source` in an encrypted
65
+ * conversation (see LinkPreviewSource). Servers before this change ignore
66
+ * `source` and preview the link, so an app keeps its own rule too: no
67
+ * preview of an AI-written link the member did not write.
68
+ *
69
+ * @example
70
+ * const { data, refused } = await r.linkPreview.get(url, { messageId: message.id });
30
71
  */
31
- get(url: string): Promise<LinkPreviewResponse>;
72
+ get(url: string, source?: LinkPreviewSource | null): Promise<LinkPreviewResponse>;
32
73
  }
@@ -1,3 +1,4 @@
1
+ import { ValidationError } from '../errors.js';
1
2
  export class LinkPreviewResource {
2
3
  client;
3
4
  constructor(client) {
@@ -9,8 +10,41 @@ export class LinkPreviewResource {
9
10
  * preview (`data: null`). No auth required — the endpoint is intentionally
10
11
  * public so unauthenticated UIs can render link previews. A URL that is not
11
12
  * http(s), or points at a private or internal address, still rejects (422).
13
+ *
14
+ * For a link in a chat message, pass `source`: the server then previews the
15
+ * link only when it is in the named message and a person wrote that message,
16
+ * or when the caller wrote the same address in that conversation (in a
17
+ * message, or as the card link of a Minds post they shared there). Otherwise
18
+ * it answers `{ data: null, refused: 'ai_link' }` without fetching it. A
19
+ * request with `source` is the caller's own: make it on a signed-in client
20
+ * whose key has `chat:read` (a keyless one gets 401). A message or
21
+ * conversation the caller cannot read rejects with 404. A `source` with no
22
+ * id in it (an AI reply whose id is not known yet, say) rejects with a
23
+ * ValidationError and sends nothing. Do not pass `source` in an encrypted
24
+ * conversation (see LinkPreviewSource). Servers before this change ignore
25
+ * `source` and preview the link, so an app keeps its own rule too: no
26
+ * preview of an AI-written link the member did not write.
27
+ *
28
+ * @example
29
+ * const { data, refused } = await r.linkPreview.get(url, { messageId: message.id });
12
30
  */
13
- get(url) {
14
- return this.client.get('/link-preview', { url });
31
+ async get(url, source) {
32
+ const params = { url };
33
+ if (source != null) {
34
+ // A source with no id must not turn into a bare request: the server would
35
+ // then fetch the link with no check at all.
36
+ if (!source.messageId && !source.conversationId) {
37
+ throw new ValidationError({
38
+ type: 'validation_error',
39
+ code: 'link_preview_source_missing',
40
+ message: 'linkPreview.get: source needs a messageId or a conversationId. Wait for the id, or show the plain link.',
41
+ });
42
+ }
43
+ if (source.messageId)
44
+ params.message_id = source.messageId;
45
+ if (source.conversationId)
46
+ params.conversation_id = source.conversationId;
47
+ }
48
+ return this.client.get('/link-preview', params);
15
49
  }
16
50
  }
@@ -1,5 +1,6 @@
1
1
  import type { HttpClient } from '../client.js';
2
2
  import type { SingleResponse } from '../types.js';
3
+ import type { AssistantSettings } from '../assistant-surfaces.js';
3
4
  interface NetworkConfig {
4
5
  name: string;
5
6
  slug: string | null;
@@ -25,6 +26,11 @@ interface NetworkConfig {
25
26
  auth_methods: string;
26
27
  default_template_name: string | null;
27
28
  locale: string;
29
+ /**
30
+ * The assistant surface switches for the app this key is bound to (absent on servers from before George plan G7a).
31
+ * Read them with `assistantSurfaceOn(config, id)`, which is off for anything but an explicit true.
32
+ */
33
+ assistant?: AssistantSettings;
28
34
  }
29
35
  export declare class NetworkResource {
30
36
  private client;
@@ -1,5 +1,6 @@
1
1
  import type { HttpClient } from '../client.js';
2
2
  import type { SingleResponse } from '../types.js';
3
+ import type { AssistantSettings, AssistantSettingsUpdate } from '../assistant-surfaces.js';
3
4
  export interface BrainTask {
4
5
  id: string;
5
6
  title: string;
@@ -153,6 +154,8 @@ export interface ProjectBrainSettings {
153
154
  maxStepsPerRun?: number;
154
155
  heartbeatTimeoutMinutes?: number;
155
156
  dailySpendingLimitUsd?: number;
157
+ /** The assistant surface switches: every surface id, off unless stored as true (absent on older servers). */
158
+ assistant?: AssistantSettings;
156
159
  [key: string]: unknown;
157
160
  }
158
161
  export interface UpdateProjectSettingsInput {
@@ -161,6 +164,11 @@ export interface UpdateProjectSettingsInput {
161
164
  max_steps_per_run?: number;
162
165
  heartbeat_timeout_minutes?: number;
163
166
  daily_spending_limit_usd?: number;
167
+ /**
168
+ * Turn assistant surfaces on or off for this app (George plan G7a): the ids given are set, the rest kept. The
169
+ * project's owner or an admin only (403 `project_admin_required` otherwise); an unknown id is a 400.
170
+ */
171
+ assistant?: AssistantSettingsUpdate;
164
172
  }
165
173
  export interface BrowserUseRunTaskInput {
166
174
  task: string;
@@ -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<User>>;
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 */
@@ -3,7 +3,7 @@ export class UsersResource {
3
3
  constructor(client) {
4
4
  this.client = client;
5
5
  }
6
- /** Get the authenticated user's profile */
6
+ /** Get the authenticated user's profile, with the app's assistant surface switches */
7
7
  me() {
8
8
  return this.client.get('/users/me');
9
9
  }
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;
@@ -1597,6 +1625,12 @@ export interface ContentPreferences {
1597
1625
  autoplay_video: boolean;
1598
1626
  ai_enabled: boolean;
1599
1627
  default_feed: 'foryou' | 'following';
1628
+ /**
1629
+ * True when the member picked `default_feed` themselves (any `updateContent({ default_feed })` sets it).
1630
+ * When false, `default_feed` is only the default and the app may choose for them. Absent from APIs older than
1631
+ * this field: treat absent as unknown, not as false.
1632
+ */
1633
+ default_feed_chosen?: boolean;
1600
1634
  }
1601
1635
  export interface UpdateNotificationChannelsInput {
1602
1636
  /** Master switch: sets in-app on every kind. */
package/llm.md CHANGED
@@ -214,6 +214,27 @@ 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`, `blocked_by_ids`: server-side inputs, never sent to another member's client; a client passes the server's per-viewer `assist_readable` instead. 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` (text only). 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
+ - 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.
235
+
236
+ ---
237
+
217
238
  ## Evidence — Measure agents (evals, accuracy, cost)
218
239
 
219
240
  ### r.evidence — Author evals, run simulations, read graded results
@@ -564,6 +585,14 @@ const { data: reset } = await r.e2ee.resetAccount({ email_code, device_name, rea
564
585
  // → { reset, resetAt, retiredRoot, revokedDeviceIds, device (the new main device), signedOut: { sessions, keys, connectedApps } }; a retry after a lost answer gets the same result
565
586
  ```
566
587
 
588
+ 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.
589
+
590
+ ```ts
591
+ r.realtime.onE2eePeer(({ userId, conversationId }) => {}); // look at userId's devices again
592
+ const { data: changes } = await r.e2ee.peerChanges({ since }); // [{ userId, at }] after time in the background
593
+ await r.e2ee.appendEvent(conversationId, { device_id, envelope, trust_change: true }); // ConflictError 'peer_not_joined' | 'stale_epoch'
594
+ ```
595
+
567
596
  ### r.realtime — WebSocket Chat Client
568
597
 
569
598
  Provides persistent real-time streaming and chat presence, useful for building frontends (e.g. React Native, Web).
@@ -597,7 +626,7 @@ unsub()
597
626
  const off = r.realtime.on('notification', (n) => refreshBadge(n))
598
627
  ```
599
628
 
600
- Events: `onMessage`, `onMessageEdit`, `onMessageDelete`, `onTyping`, `onAgentThinking`, `onE2eeEvent`, `onE2eeLink`, `onE2eeDevices`, `on(event, fn)`
629
+ Events: `onMessage`, `onMessageEdit`, `onMessageDelete`, `onTyping`, `onAgentThinking`, `onE2eeEvent`, `onE2eeLink`, `onE2eeDevices`, `onE2eePeer`, `on(event, fn)`
601
630
 
602
631
  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
632
 
@@ -794,7 +823,7 @@ Methods: `list`, `get`, `create`, `delete`
794
823
 
795
824
  ## Platform Resources
796
825
 
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? })`. 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.
826
+ - `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? })`; `content.default_feed_chosen` is true once the member set `default_feed` themselves (absent on older APIs). 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
827
  - `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
828
 
800
829
  ```typescript
@@ -855,6 +884,7 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
855
884
  - `r.dispatcher` — Task queue: `tasks()`, `create()`, `claimNext()`, `complete()`, `release()`, `stats()`, `signals()`, `outcomes()`, `staleItems()`, `stuckItems()`, `discoveries()`
856
885
  - `r.dispatcher` (webhooks): `createWebhook()`, `listWebhooks()`, `updateWebhook()`, `deleteWebhook()`, `testWebhook()`, `webhookDeliveries()`, `cleanupWebhookLogs()`
857
886
  - `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).
887
+ - `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
888
 
859
889
  ```typescript
860
890
  // 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.82",
3
+ "version": "0.7.83",
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",