@recursiv/sdk 0.7.83 → 0.7.93

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.
Files changed (40) hide show
  1. package/README.md +86 -2
  2. package/dist/assistant/index.d.ts +11 -3
  3. package/dist/assistant/index.js +11 -3
  4. package/dist/assistant/render.d.ts +25 -2
  5. package/dist/assistant/render.js +197 -16
  6. package/dist/assistant/subjectBundle.js +20 -3
  7. package/dist/assistant/turnCaps.d.ts +3 -0
  8. package/dist/assistant/turnCaps.js +141 -0
  9. package/dist/assistant/types.d.ts +223 -7
  10. package/dist/assistant/types.js +15 -0
  11. package/dist/index.d.ts +12 -6
  12. package/dist/index.js +8 -2
  13. package/dist/privacy/facts.generated.d.ts +79 -0
  14. package/dist/privacy/facts.generated.js +93 -0
  15. package/dist/privacy/index.d.ts +48 -0
  16. package/dist/privacy/index.js +42 -0
  17. package/dist/resources/discovery-merge.d.ts +17 -1
  18. package/dist/resources/discovery-merge.js +84 -4
  19. package/dist/resources/discovery-progressive.d.ts +4 -3
  20. package/dist/resources/discovery-progressive.js +11 -8
  21. package/dist/resources/discovery.d.ts +9 -1
  22. package/dist/resources/discovery.js +62 -15
  23. package/dist/resources/feeds.d.ts +15 -6
  24. package/dist/resources/feeds.js +2 -1
  25. package/dist/resources/integrations.d.ts +3 -0
  26. package/dist/resources/privacy.d.ts +13 -0
  27. package/dist/resources/privacy.js +14 -0
  28. package/dist/resources/protocols.d.ts +99 -11
  29. package/dist/resources/protocols.js +45 -0
  30. package/dist/resources/sealed.d.ts +127 -7
  31. package/dist/resources/sealed.js +33 -6
  32. package/dist/resources/settings.d.ts +3 -1
  33. package/dist/resources/settings.js +3 -1
  34. package/dist/resources/signals.d.ts +129 -6
  35. package/dist/resources/signals.js +58 -2
  36. package/dist/social-protocol-ids.d.ts +28 -0
  37. package/dist/social-protocol-ids.js +30 -0
  38. package/dist/types.d.ts +45 -0
  39. package/llm.md +17 -6
  40. package/package.json +6 -1
@@ -25,6 +25,12 @@ export interface SealedTarget {
25
25
  /** Exact origin, e.g. `https://router-0.tinfoil.sh`. */
26
26
  origin: string;
27
27
  kind: SealedTargetKind;
28
+ /**
29
+ * Web search works through this target: seal a message that may search only to a target that says true. Set on
30
+ * `SealedConfig.targets` only, never in a model's own list (look the origin up there). Absent (an older gateway)
31
+ * means no.
32
+ */
33
+ search?: boolean;
28
34
  }
29
35
  /** A model the app offers in Sealed. */
30
36
  export interface SealedModel {
@@ -36,6 +42,8 @@ export interface SealedModel {
36
42
  * when one has moved to another target, so read it per model.
37
43
  */
38
44
  targets: SealedTarget[];
45
+ /** The app may send pictures to this model. Absent (an older gateway) means no. */
46
+ vision?: boolean;
39
47
  }
40
48
  /** What `config()` returns. */
41
49
  export interface SealedConfig {
@@ -45,11 +53,73 @@ export interface SealedConfig {
45
53
  attestation_path: string;
46
54
  /** Every target the gateway forwards to, chat and attestation alike. */
47
55
  targets: SealedTarget[];
48
- /** The models the app offers in Sealed, each with the targets that serve it. */
56
+ /** The models the app offers to chat with in Sealed, each with the targets that serve it. */
49
57
  models: SealedModel[];
58
+ /**
59
+ * The models the app's sealed checks run on (a safeguard model for the message and answer checks, a vision model for
60
+ * the picture check), each with the targets that serve it. Never offer one of these as a chat model unless `models`
61
+ * lists it too. Send a check with `send({ purpose: 'check' })`. Absent on an older gateway.
62
+ */
63
+ checks?: SealedModel[];
64
+ /**
65
+ * Server switch (George plan G7a): web search through the router. Send a message that may search with
66
+ * `send({ search: true })`, which marks it `X-Sealed-Search: 1`; the gateway refuses a marked message while this is
67
+ * off (403 `sealed_search_off`), and pauses the member's day when an unmarked one searches. Off unless the gateway
68
+ * says true: treat it absent, or a config you could not read, as off.
69
+ */
70
+ search?: boolean;
71
+ /** Server switch (G7a): pictures from the phone, off unless true. Post pictures need only the model's `vision`. */
72
+ phone_pictures?: boolean;
73
+ /**
74
+ * Server switch: on-phone memory, off unless true. The memory lives on the phone and the gateway never sees it; this
75
+ * is the server's switch for the feature, so it can be turned off for everyone at once. Absent (an older gateway), or
76
+ * a config you could not read, means off.
77
+ */
78
+ memory?: boolean;
50
79
  /** The attestation policy version the app's verifier must pin; an older pinned policy means the app needs updating. */
51
80
  verifier_policy_version: number;
81
+ /** The member's allowance: its daily numbers are their plan's (`plan`). */
52
82
  allowance: SealedAllowance;
83
+ /** The plan the daily numbers in `allowance` follow. Free when the gateway could not read it. Absent on an older gateway. */
84
+ plan?: SealedPlan;
85
+ /** False when the gateway could not read the member's plan and applied Free's numbers: do not tell them they are on Free. */
86
+ plan_known?: boolean;
87
+ /** What is left of each daily allowance now; `checks` only while checks count apart. Absent when it could not be read. */
88
+ remaining_today?: {
89
+ requests: number;
90
+ searches: number;
91
+ checks?: number;
92
+ };
93
+ /**
94
+ * Every plan's daily numbers, so the app can say truthfully whether a plan gives more. It lists plans the gateway
95
+ * knows, Max among them, whether or not anyone sells them: offer only the plans the app sells.
96
+ */
97
+ plan_allowances?: Record<SealedPlan, {
98
+ requests_per_day: number;
99
+ searches_per_day: number;
100
+ checks_per_day?: number;
101
+ }>;
102
+ /**
103
+ * `budget` while the app's monthly Sealed budget is used up (sends get 503 `sealed_paused_budget` until the next UTC
104
+ * month), `unavailable` while the gateway cannot tell (sends get 503 `sealed_unavailable`), null otherwise.
105
+ */
106
+ paused?: 'budget' | 'unavailable' | null;
107
+ }
108
+ /** The plans the gateway knows, lowest first. */
109
+ export type SealedPlan = 'free' | 'plus' | 'pro' | 'max';
110
+ /**
111
+ * `details` on a refused send whose allowance ran out (RateLimitError.details): which limit, the member's plan, whether
112
+ * that plan was read (`plan_known`; false: Free's numbers were applied because the read failed), that plan's daily
113
+ * number (null for `rate`, the per-minute and open-request caps no plan changes), and the plans that give more of it
114
+ * (empty for `rate` and while the plan is not known). Say "upgrade for more" only when a plan the app sells is in
115
+ * `higher_plans`.
116
+ */
117
+ export interface SealedQuotaDetails {
118
+ limit: 'messages' | 'searches' | 'checks' | 'rate';
119
+ plan: SealedPlan;
120
+ plan_known: boolean;
121
+ per_day: number | null;
122
+ higher_plans: SealedPlan[];
53
123
  }
54
124
  export interface SealedAllowance {
55
125
  requests_per_day: number;
@@ -57,7 +127,25 @@ export interface SealedAllowance {
57
127
  max_concurrent: number;
58
128
  /** The cap on one sealed request body, in bytes. */
59
129
  max_request_bytes: number;
130
+ /**
131
+ * Checks (`send({ purpose: 'check' })`) per UTC day, counted instead of `requests_per_day`. Absent while the gateway
132
+ * counts checks as messages (its check allowance is off), and on an older gateway: a check then spends a message.
133
+ */
134
+ checks_per_day?: number;
135
+ /** Checks per minute, counted instead of `requests_per_minute`. Absent when `checks_per_day` is. */
136
+ checks_per_minute?: number;
137
+ /**
138
+ * Messages marked as searches per UTC day. A marked search spends a search and no message; a gateway from before plans
139
+ * counted it against `requests_per_day` as well.
140
+ */
141
+ searches_per_day?: number;
60
142
  }
143
+ /**
144
+ * What a sealed request is for, sent as `X-Sealed-Purpose`, which the gateway reads and never forwards. `check`: one of
145
+ * the app's sealed checks, counted against the check allowance instead of the message allowance while the gateway has
146
+ * one (`config().allowance.checks_per_day`), and as a message otherwise.
147
+ */
148
+ export type SealedPurpose = 'check';
61
149
  export interface SealedSendInput {
62
150
  /** The router the app verified and sealed to: the origin of a `router` target from `config()`, exactly. */
63
151
  enclaveOrigin: string;
@@ -67,6 +155,19 @@ export interface SealedSendInput {
67
155
  body: Uint8Array | ArrayBuffer | ReadableStream<Uint8Array>;
68
156
  /** Ask for a streamed (server-sent events) answer. */
69
157
  stream?: boolean;
158
+ /**
159
+ * `check` for one of the app's sealed checks (a model from `config().checks`): sent as `X-Sealed-Purpose: check`, so
160
+ * it counts against the check allowance while the gateway lists one. Omit it for a message.
161
+ */
162
+ purpose?: SealedPurpose;
163
+ /**
164
+ * True for a message that may search the web: sent as `X-Sealed-Search: 1`, which the gateway reads and never
165
+ * forwards. It counts against the day's searches instead of its messages, and the gateway refuses it while
166
+ * `config().search` is off. Only a marked message may carry search options in its sealed body: the gateway pauses the
167
+ * member's day when an unmarked request searches. Never with `purpose: 'check'` (a check never searches): that is
168
+ * refused before anything is sent.
169
+ */
170
+ search?: boolean;
70
171
  /** The targets from a config already read. When omitted, `send()` reads the config first. */
71
172
  targets?: readonly SealedTarget[];
72
173
  signal?: AbortSignal;
@@ -93,6 +194,10 @@ export type SealedTransportErrorCode =
93
194
  | 'bad_encapsulated_key'
94
195
  /** The attestation nonce is not 32 bytes of hex: nothing was sent. */
95
196
  | 'bad_nonce'
197
+ /** The purpose is not one the gateway takes: nothing was sent. */
198
+ | 'bad_purpose'
199
+ /** The marks do not go together (`search` on a check), or `search` is not a boolean: nothing was sent. */
200
+ | 'bad_marks'
96
201
  /** A 2xx answer without a valid Ehbp-Response-Nonce: its body was discarded unread. */
97
202
  | 'unsealed_answer';
98
203
  export declare class SealedTransportError extends Error {
@@ -102,8 +207,10 @@ export declare class SealedTransportError extends Error {
102
207
  /** Set on a refusal the gateway wrote itself; a relayed router answer never carries it. */
103
208
  export declare const SEALED_REFUSAL_HEADER = "x-sealed-gateway-refusal";
104
209
  /**
105
- * Requests left in the member's allowance today. The gateway sets it on every answer it relayed from the router and on
106
- * no other, and the router cannot set it, so an error without it is one the API (or an edge in front of it) wrote.
210
+ * Messages left in the member's allowance today, on a check as on a message. The gateway sets it on every answer it
211
+ * 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
212
+ * edge in front of it) wrote. A relayed answer to a check also carries `X-Sealed-Checks-Remaining` while the gateway
213
+ * has a check allowance, and one to a marked search `X-Sealed-Searches-Remaining`.
107
214
  */
108
215
  export declare const SEALED_ALLOWANCE_REMAINING_HEADER = "x-sealed-allowance-remaining";
109
216
  export declare class SealedResource {
@@ -135,10 +242,23 @@ export declare class SealedResource {
135
242
  * has no nonce, never to be shown as the model's words.
136
243
  *
137
244
  * Throws a RecursivError for every error the API wrote rather than relayed, whether or not it is marked
138
- * X-Sealed-Gateway-Refusal: RateLimitError for an allowance used up (`code` `sealed_allowance_used` or
139
- * `sealed_too_many_requests`) or the API's own per-IP limit (`rate_limited`), AuthenticationError for a revoked or
140
- * expired key, NotFoundError while Sealed is off or not on for this app. Throws SealedTransportError when the router
141
- * is not listed, the key is malformed, or a 2xx answer came back unsealed.
245
+ * X-Sealed-Gateway-Refusal: RateLimitError for an allowance used up (`code` `sealed_allowance_used`,
246
+ * `sealed_search_allowance_used` or `sealed_too_many_requests`, with SealedQuotaDetails in `details`) or the API's own
247
+ * per-IP limit (`rate_limited`), a RecursivError with status 503 and `code` `sealed_paused_budget` while the app's
248
+ * monthly Sealed budget is used up (until the next UTC month), AuthenticationError for a revoked or expired key,
249
+ * NotFoundError while Sealed is off or not on for this app, AuthorizationError (`sealed_search_off`) for a marked
250
+ * search while search is off. Throws SealedTransportError when the router is not listed, the key, purpose or marks
251
+ * are malformed, or a 2xx answer came back unsealed.
252
+ *
253
+ * With `purpose: 'check'` the request counts against the check allowance while the gateway has one (`code`
254
+ * `sealed_check_allowance_used` when it is used up), and as a message otherwise. With `search: true` it counts
255
+ * against the day's searches (`sealed_search_allowance_used`) and spends no message. While the gateway's day lock is
256
+ * on, a member is refused until midnight UTC (`sealed_paused_today`) after a request of theirs that searched the web
257
+ * unmarked (or marked a check), that the router answered with a model `config()` does not offer for it (a check's must
258
+ * be in `checks`; any other's in `models`, or in `checks` while the gateway lets checks sent unmarked through), or
259
+ * that passed the day's count of answers left before their usage line (searches included). The lists can change on
260
+ * the server: read `config()` again from time to time, not once for the app's whole run, or a member whose app still
261
+ * sends to a model taken out of them is paused.
142
262
  */
143
263
  send(input: SealedSendInput): Promise<Response>;
144
264
  }
@@ -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
- * Requests left in the member's allowance today. The gateway sets it on every answer it relayed from the router and on
14
- * no other, and the router cannot set it, so an error without it is one the API (or an edge in front of it) wrote.
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` or
99
- * `sealed_too_many_requests`) or the API's own per-IP limit (`rate_limited`), AuthenticationError for a revoked or
100
- * expired key, NotFoundError while Sealed is off or not on for this app. Throws SealedTransportError when the router
101
- * is not listed, the key is malformed, or a 2xx answer came back unsealed.
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);
@@ -6,24 +6,117 @@ import type { SingleResponse } from '../types.js';
6
6
  * one, so send them in a request of their own if the API may be older.
7
7
  */
8
8
  export type SignalEventType = 'view' | 'click' | 'dwell' | 'react_up' | 'react_down' | 'save' | 'hide' | 'share' | 'mute_source' | 'open_chat' | 'ask_agent' | 'more_like' | 'unhide';
9
- /** One engagement signal. An open-network post has no Minds id: name it in `metadata` (e.g. `{ ref }`) instead. */
9
+ /**
10
+ * Where a signal happened: each one a public place. In the Minds app the server lists these (and Discover's older
11
+ * `discover_featured`, `discover_hot`, `discover_rediscover`) and refuses any other name, `other` included, with a 400
12
+ * (`private_surface`). Another app's well-formed name the server does not list is stored as `other`, so a new surface
13
+ * still lands there; add it here (and to the server's SIGNAL_SURFACES) to keep it apart. There is no `group`: a group
14
+ * can be an encrypted chat, which never sends a signal.
15
+ */
16
+ export type SignalSurface = 'home_feed' | 'discovery' | 'discovery_story' | 'discovery_module' | 'discover_category' | 'discover_for_you' | 'postcard' | 'story' | 'swipe' | 'watch' | 'clip_strip' | 'discover_videos' | 'story_cluster' | 'headline_gallery' | 'post_clip' | 'search_videos' | 'profile' | 'search' | 'post' | 'other';
17
+ /** How a watch, or a card's time on screen, ended. */
18
+ export type SignalExit = 'swipe' | 'leave' | 'hide' | 'tap';
19
+ /**
20
+ * A signal's metadata: the only keys the server keeps (0.7.93, the ingest contract). A key not listed here is dropped.
21
+ * A listed key with a value of the wrong type or past its limit refuses that event (named in `rejections`, never
22
+ * stored); `null` or `''` reads as absent. Times are milliseconds.
23
+ */
24
+ export interface SignalMetadata {
25
+ /**
26
+ * Where it happened (up to 64 characters). In the Minds app one of SignalSurface's names other than `other`;
27
+ * elsewhere an unlisted name is stored as `other`.
28
+ */
29
+ surface?: SignalSurface | (string & {});
30
+ /** Which feed of the surface, e.g. `all`, `category`, `for-you:world` (up to 64). */
31
+ feed?: string;
32
+ /** The block of the feed, e.g. `top` (up to 32). */
33
+ block?: string;
34
+ /** The 0-based row in the feed (0 to 100,000). */
35
+ position?: number;
36
+ /** The place within a module that holds several (0 to 10,000). */
37
+ slot?: number;
38
+ /** A category feed's category (up to 64). */
39
+ category?: string;
40
+ /**
41
+ * An item with no Minds post id: an open-network post `<protocol>/<native id>` (native id up to 2,048 characters, no
42
+ * whitespace), a story lead `cluster:<64 hex>` or a World Today story `today:<uuid>`. Stored in an indexed column.
43
+ */
44
+ ref?: string;
45
+ /** How long the clip played (0 to one hour). Held to `duration_ms` x (`loops` + 1) and to the wall clock. */
46
+ watch_ms?: number;
47
+ /** The clip's length (0 to six hours). */
48
+ duration_ms?: number;
49
+ completed?: boolean;
50
+ /** Times the clip looped (0 to 1,000). */
51
+ loops?: number;
52
+ sound_on?: boolean;
53
+ /** How it ended. A watch under a second that ended in a `swipe` is stored as a skip. */
54
+ exit?: SignalExit;
55
+ /** Cards on screen at once in a grid (1 to 200). Readers divide a grid card's dwell by it; it never divides watch time. */
56
+ visible_count?: number;
57
+ autoplay?: boolean;
58
+ /**
59
+ * The client's id for this event (a letter or digit, then up to 63 of `A-Za-z0-9_.:-`): an event sent again with the
60
+ * same id is stored once. `batch` and `record` add one to an event that has none.
61
+ */
62
+ client_event_id?: string;
63
+ /** The event's place in the client's session (0 to 2^31 - 1). */
64
+ session_seq?: number;
65
+ /** How the action was taken, e.g. `less_like_this`, `module_hide` (up to 64). */
66
+ via?: string;
67
+ /** What the action came from, e.g. `postcard_bookmark` (up to 64). */
68
+ source?: string;
69
+ /** How the item was shown, e.g. `video` (up to 32). */
70
+ presentation?: string;
71
+ }
72
+ /** One engagement signal. An open-network post has no Minds id: name it by `metadata.ref` instead. */
10
73
  export interface SignalEventInput {
11
74
  event_type: SignalEventType;
12
75
  /** The Minds post's id (a UUID). */
13
76
  post_id?: string;
14
- /** Milliseconds on screen, for `dwell` (0 to one hour). */
77
+ /** Milliseconds on screen, for `dwell` (0 to one hour; a card's counts up to five minutes). */
15
78
  dwell_ms?: number;
16
- metadata?: Record<string, unknown>;
79
+ metadata?: SignalMetadata;
17
80
  }
18
81
  /**
19
82
  * Why nothing was stored, on a 200: `no_project_scope` (the key is not bound to an app, so there is no app database
20
- * for the signal) or `project_db_unavailable` (the app database did not take it; signals are best-effort).
83
+ * for the signal), `project_db_unavailable` (the app database did not take it; signals are best-effort),
84
+ * `viewing_off` (the member turned off "Use what I watch and read to personalize", so a dwell or open is not kept),
85
+ * `rate_limited` (past the member's events a minute) or `duplicate` (`record` only: an event with this
86
+ * `client_event_id` is already stored).
21
87
  */
22
- export type SignalNotRecordedReason = 'no_project_scope' | 'project_db_unavailable';
88
+ export type SignalNotRecordedReason = 'no_project_scope' | 'project_db_unavailable' | 'viewing_off' | 'rate_limited' | 'duplicate';
89
+ /** Why the server refused one event of a batch: a metadata value of the wrong type or past its limit. */
90
+ export interface SignalRejection {
91
+ /** The event's index in the batch. */
92
+ index: number;
93
+ reason: 'invalid_metadata' | 'invalid_ref' | 'invalid_client_event_id';
94
+ /** The metadata key. */
95
+ key: string;
96
+ }
23
97
  export interface SignalBatchResult {
24
- /** Events stored. */
98
+ /** Events stored by this call. */
25
99
  recorded: number;
100
+ /**
101
+ * Events the server did not keep because the member's viewing personalization is off: dwell and opens. Views and
102
+ * explicit choices are always kept. Absent when none were left out.
103
+ */
104
+ withheld?: number;
26
105
  reason?: SignalNotRecordedReason;
106
+ /** Events already stored (the same `client_event_id`, in this batch or an earlier one): stored once. */
107
+ duplicates?: number;
108
+ /** Events past the member's rate (600 a minute by default): not stored. */
109
+ limited?: number;
110
+ /** Events refused by the contract: not stored. The rest of the batch is. */
111
+ rejected?: number;
112
+ /** The first ten refused events, and why. */
113
+ rejections?: SignalRejection[];
114
+ }
115
+ /** What "Clear my viewing history" did: the member's views, dwell, Watch time and opens in this app are gone. */
116
+ export interface ClearViewingHistoryResult {
117
+ cleared: true;
118
+ /** Rows deleted. */
119
+ deleted: number;
27
120
  }
28
121
  export interface SignalRecordResult {
29
122
  recorded: boolean;
@@ -44,15 +137,45 @@ export declare const SIGNAL_BATCH_MAX = 100;
44
137
  * Engagement signals (views, dwell, hides, "More like this") for the member's own ranking, under the member's
45
138
  * app-bound key (`agents:write`). They land in the app's database and feed For You. Best-effort: a signal the server
46
139
  * cannot store still answers 200 with `recorded` 0 or false and a `reason`.
140
+ *
141
+ * Only from a public place, never a private message: a `metadata` key or placement (`surface`, `feed`, `source`,
142
+ * `context`, `route`...) that names a chat, DM, inbox, encrypted or Sealed conversation, by any spelling (`chatList`,
143
+ * `dmThread`, `/messages/1`), is refused with a 400 (`private_surface`), and nothing in that request is stored. An app
144
+ * names its own public places (`{ source: 'discover' }`). In the Minds app the placements (`surface`, `feed`, `source`,
145
+ * `via`, `layoutSource`) must be ones the server lists (its feeds, Discover, Watch, stories, the pager and post cards),
146
+ * and any other key that names a place (`context`) is refused there too.
147
+ *
148
+ * That check reads the metadata as sent. The server then keeps only SignalMetadata's keys, each within its limit; a
149
+ * batch names any event it refused and stores the rest. `record` answers 400 (`invalid_signal`) for an event it refuses.
47
150
  */
48
151
  export declare class SignalsResource {
49
152
  private client;
153
+ /**
154
+ * The id each event object was sent with. An event sent again (a retry of the same request, or the app sending the
155
+ * same object after a failed flush) carries the same id, so the server stores it once. Weak: an event the app has let
156
+ * go is not held here.
157
+ */
158
+ private readonly eventIds;
50
159
  constructor(client: HttpClient);
51
160
  /**
52
161
  * Store up to SIGNAL_BATCH_MAX events in one request (POST /signals/batch). The server reads a post's newest hide or
53
162
  * unhide, so send a batch only after the one before it has landed when order matters. An empty array stores nothing.
163
+ * Each event without a `metadata.client_event_id` is sent with one (the same one each time that object is sent).
54
164
  */
55
165
  batch(events: SignalEventInput[], options?: SignalSendOptions): Promise<SingleResponse<SignalBatchResult>>;
56
166
  /** Store one event (POST /signals/post). Prefer `batch`; this is for an API that predates it (404 there). */
57
167
  record(event: SignalEventInput, options?: SignalSendOptions): Promise<SingleResponse<SignalRecordResult>>;
168
+ /**
169
+ * "Clear my viewing history" (DELETE /signals/history): the member's views, dwell, Watch time and opens in this app
170
+ * are deleted, and what was built from them is rebuilt. Their explicit choices (hides, "More like this", saves,
171
+ * shares, votes) stay. A delete that could not be done is an error (503), never a success.
172
+ */
173
+ clearViewingHistory(): Promise<SingleResponse<ClearViewingHistoryResult>>;
174
+ /** The event as sent: the caller's object is never changed. */
175
+ private withEventId;
58
176
  }
177
+ /**
178
+ * A fresh event id the server's grammar takes: a random UUID where the platform has one, otherwise the time and about
179
+ * 104 random bits (React Native's Hermes has no crypto.randomUUID). Unique per member is all it needs to be.
180
+ */
181
+ export declare function newClientEventId(): string;
@@ -4,23 +4,79 @@ export const SIGNAL_BATCH_MAX = 100;
4
4
  * Engagement signals (views, dwell, hides, "More like this") for the member's own ranking, under the member's
5
5
  * app-bound key (`agents:write`). They land in the app's database and feed For You. Best-effort: a signal the server
6
6
  * cannot store still answers 200 with `recorded` 0 or false and a `reason`.
7
+ *
8
+ * Only from a public place, never a private message: a `metadata` key or placement (`surface`, `feed`, `source`,
9
+ * `context`, `route`...) that names a chat, DM, inbox, encrypted or Sealed conversation, by any spelling (`chatList`,
10
+ * `dmThread`, `/messages/1`), is refused with a 400 (`private_surface`), and nothing in that request is stored. An app
11
+ * names its own public places (`{ source: 'discover' }`). In the Minds app the placements (`surface`, `feed`, `source`,
12
+ * `via`, `layoutSource`) must be ones the server lists (its feeds, Discover, Watch, stories, the pager and post cards),
13
+ * and any other key that names a place (`context`) is refused there too.
14
+ *
15
+ * That check reads the metadata as sent. The server then keeps only SignalMetadata's keys, each within its limit; a
16
+ * batch names any event it refused and stores the rest. `record` answers 400 (`invalid_signal`) for an event it refuses.
7
17
  */
8
18
  export class SignalsResource {
9
19
  client;
20
+ /**
21
+ * The id each event object was sent with. An event sent again (a retry of the same request, or the app sending the
22
+ * same object after a failed flush) carries the same id, so the server stores it once. Weak: an event the app has let
23
+ * go is not held here.
24
+ */
25
+ eventIds = new WeakMap();
10
26
  constructor(client) {
11
27
  this.client = client;
12
28
  }
13
29
  /**
14
30
  * Store up to SIGNAL_BATCH_MAX events in one request (POST /signals/batch). The server reads a post's newest hide or
15
31
  * unhide, so send a batch only after the one before it has landed when order matters. An empty array stores nothing.
32
+ * Each event without a `metadata.client_event_id` is sent with one (the same one each time that object is sent).
16
33
  */
17
34
  batch(events, options) {
18
- return this.client.post('/signals/batch', { events }, sendOptions(options));
35
+ return this.client.post('/signals/batch', { events: events.map((event) => this.withEventId(event)) }, sendOptions(options));
19
36
  }
20
37
  /** Store one event (POST /signals/post). Prefer `batch`; this is for an API that predates it (404 there). */
21
38
  record(event, options) {
22
- return this.client.post('/signals/post', event, sendOptions(options));
39
+ return this.client.post('/signals/post', this.withEventId(event), sendOptions(options));
40
+ }
41
+ /**
42
+ * "Clear my viewing history" (DELETE /signals/history): the member's views, dwell, Watch time and opens in this app
43
+ * are deleted, and what was built from them is rebuilt. Their explicit choices (hides, "More like this", saves,
44
+ * shares, votes) stay. A delete that could not be done is an error (503), never a success.
45
+ */
46
+ clearViewingHistory() {
47
+ return this.client.delete('/signals/history');
48
+ }
49
+ /** The event as sent: the caller's object is never changed. */
50
+ withEventId(event) {
51
+ if (!event || typeof event !== 'object')
52
+ return event;
53
+ const given = event.metadata?.client_event_id;
54
+ if (typeof given === 'string' && given)
55
+ return event;
56
+ let id = this.eventIds.get(event);
57
+ if (!id) {
58
+ id = newClientEventId();
59
+ this.eventIds.set(event, id);
60
+ }
61
+ return { ...event, metadata: { ...event.metadata, client_event_id: id } };
62
+ }
63
+ }
64
+ /**
65
+ * A fresh event id the server's grammar takes: a random UUID where the platform has one, otherwise the time and about
66
+ * 104 random bits (React Native's Hermes has no crypto.randomUUID). Unique per member is all it needs to be.
67
+ */
68
+ export function newClientEventId() {
69
+ const cryptoApi = globalThis.crypto;
70
+ if (typeof cryptoApi?.randomUUID === 'function') {
71
+ try {
72
+ return cryptoApi.randomUUID();
73
+ }
74
+ catch {
75
+ // A non-secure context can refuse it: fall through.
76
+ }
23
77
  }
78
+ const random = () => Math.floor(Math.random() * 2 ** 52).toString(36).padStart(11, '0');
79
+ return `${Date.now().toString(36)}-${random()}${random()}`;
24
80
  }
25
81
  function sendOptions(options) {
26
82
  if (!options)
@@ -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
+ }