@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.
- package/README.md +86 -2
- package/dist/assistant/index.d.ts +11 -3
- package/dist/assistant/index.js +11 -3
- package/dist/assistant/render.d.ts +25 -2
- package/dist/assistant/render.js +197 -16
- package/dist/assistant/subjectBundle.js +20 -3
- package/dist/assistant/turnCaps.d.ts +3 -0
- package/dist/assistant/turnCaps.js +141 -0
- package/dist/assistant/types.d.ts +223 -7
- package/dist/assistant/types.js +15 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +8 -2
- 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/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/feeds.d.ts +15 -6
- package/dist/resources/feeds.js +2 -1
- package/dist/resources/integrations.d.ts +3 -0
- package/dist/resources/privacy.d.ts +13 -0
- package/dist/resources/privacy.js +14 -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/signals.d.ts +129 -6
- package/dist/resources/signals.js +58 -2
- package/dist/social-protocol-ids.d.ts +28 -0
- package/dist/social-protocol-ids.js +30 -0
- package/dist/types.d.ts +45 -0
- package/llm.md +17 -6
- 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
|
-
*
|
|
106
|
-
* no other, and the router cannot set it, so an error without it is one the API (or an
|
|
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
|
|
139
|
-
* `
|
|
140
|
-
*
|
|
141
|
-
* is
|
|
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
|
}
|
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);
|
|
@@ -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
|
-
/**
|
|
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?:
|
|
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)
|
|
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
|
+
}
|