@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
package/dist/types.d.ts
CHANGED
|
@@ -1611,6 +1611,20 @@ export interface UserPreferences {
|
|
|
1611
1611
|
profile_visibility: 'public' | 'network' | 'private';
|
|
1612
1612
|
show_email: boolean;
|
|
1613
1613
|
show_location: boolean;
|
|
1614
|
+
/**
|
|
1615
|
+
* "Who can message me": with 'following', new conversations, message requests and group invites reach the member
|
|
1616
|
+
* only from accounts they follow. A 13-17 account always reads 'following'. Absent on servers that predate it.
|
|
1617
|
+
*/
|
|
1618
|
+
who_can_message?: WhoCanMessage;
|
|
1619
|
+
/** True when the member cannot change `who_can_message` themselves (a 13-17 account). */
|
|
1620
|
+
who_can_message_locked?: boolean;
|
|
1621
|
+
/** Whether the member has answered the age question. The answer itself is never returned. */
|
|
1622
|
+
age_declared?: boolean;
|
|
1623
|
+
/**
|
|
1624
|
+
* True when the member is a known adult: they answered the age question 18 or older. False for a 13-17 answer and
|
|
1625
|
+
* for no answer. Only the member's own preferences carry it. Absent on servers that predate it.
|
|
1626
|
+
*/
|
|
1627
|
+
age_is_adult?: boolean;
|
|
1614
1628
|
};
|
|
1615
1629
|
appearance: {
|
|
1616
1630
|
theme: 'light' | 'dark' | 'system';
|
|
@@ -1631,6 +1645,18 @@ export interface ContentPreferences {
|
|
|
1631
1645
|
* this field: treat absent as unknown, not as false.
|
|
1632
1646
|
*/
|
|
1633
1647
|
default_feed_chosen?: boolean;
|
|
1648
|
+
/**
|
|
1649
|
+
* The Sealed adult-content setting. True only while it is on AND the member is a known adult
|
|
1650
|
+
* (`privacy.age_is_adult`): if the account stops being one, it reads false whatever was saved. Absent on servers
|
|
1651
|
+
* that predate it: treat absent as off.
|
|
1652
|
+
*/
|
|
1653
|
+
sealed_adult?: boolean;
|
|
1654
|
+
/**
|
|
1655
|
+
* "Use what I watch and read to personalize": on by default. Off, the server stores only views (so a post the member
|
|
1656
|
+
* saw is not served again) and their explicit choices, and no ranking reads what they watched or read. Absent on
|
|
1657
|
+
* servers that predate it: treat absent as on.
|
|
1658
|
+
*/
|
|
1659
|
+
personalize_from_viewing?: boolean;
|
|
1634
1660
|
}
|
|
1635
1661
|
export interface UpdateNotificationChannelsInput {
|
|
1636
1662
|
/** Master switch: sets in-app on every kind. */
|
|
@@ -1646,10 +1672,21 @@ export interface UpdateNotificationChannelsInput {
|
|
|
1646
1672
|
*/
|
|
1647
1673
|
channels?: Partial<Record<NotificationChannelKind, Partial<NotificationChannelToggles>>> & Record<string, Partial<NotificationChannelToggles>>;
|
|
1648
1674
|
}
|
|
1675
|
+
/** "Who can message me" (`UserPreferences.privacy.who_can_message`). */
|
|
1676
|
+
export type WhoCanMessage = 'everyone' | 'following';
|
|
1677
|
+
/** An answer to the age question (`UpdatePrivacyInput.age_band`). Only a band is ever stored, never a date of birth. */
|
|
1678
|
+
export type AgeAnswer = '18_plus' | '13_17' | 'under_13';
|
|
1649
1679
|
export interface UpdatePrivacyInput {
|
|
1650
1680
|
profile_visibility?: 'public' | 'network' | 'private';
|
|
1651
1681
|
show_email?: boolean;
|
|
1652
1682
|
show_location?: boolean;
|
|
1683
|
+
/** "Who can message me". A 13-17 account stays held to 'following' whatever it saves. */
|
|
1684
|
+
who_can_message?: WhoCanMessage;
|
|
1685
|
+
/**
|
|
1686
|
+
* The age question, answered once. 'under_13' is refused (403 `age_not_eligible`) and writes nothing in the request;
|
|
1687
|
+
* a different second answer is refused (409 `age_already_set`); the same answer again succeeds and changes nothing.
|
|
1688
|
+
*/
|
|
1689
|
+
age_band?: AgeAnswer;
|
|
1653
1690
|
}
|
|
1654
1691
|
export interface UpdateAppearanceInput {
|
|
1655
1692
|
theme?: 'light' | 'dark' | 'system';
|
|
@@ -1661,6 +1698,14 @@ export interface UpdateContentPreferencesInput {
|
|
|
1661
1698
|
autoplay_video?: boolean;
|
|
1662
1699
|
ai_enabled?: boolean;
|
|
1663
1700
|
default_feed?: 'foryou' | 'following';
|
|
1701
|
+
/**
|
|
1702
|
+
* The Sealed adult-content setting. `true` is accepted only from a known adult (an 18+ answer): anyone else gets 403
|
|
1703
|
+
* `AuthorizationError` with code `sealed_adult_needs_adult`, and nothing in the request is saved. `false` is always
|
|
1704
|
+
* accepted.
|
|
1705
|
+
*/
|
|
1706
|
+
sealed_adult?: boolean;
|
|
1707
|
+
/** "Use what I watch and read to personalize". `false` is the member's opt-out; `true` turns it back on. */
|
|
1708
|
+
personalize_from_viewing?: boolean;
|
|
1664
1709
|
}
|
|
1665
1710
|
export interface Session {
|
|
1666
1711
|
id: string;
|
package/llm.md
CHANGED
|
@@ -228,9 +228,11 @@ if (bundle) {
|
|
|
228
228
|
|
|
229
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
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.
|
|
231
|
+
- `known_minor`, `child_safety_hold`, `assistant_suppressed`, media `held: true`, `blocked_by_ids`: server-side inputs, never sent to another member's client (held media is left out of that payload, and so is the post's `ai_caption` whenever any of its media is held: a client cannot see media it was never sent); a client passes the server's per-viewer `assist_readable` instead. Exception: a picture's `held: false`, the payload's "not held" mark beside `checked_at`. Flags fail closed (anything but false/null/absent is set); an unreadable id list throws.
|
|
232
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
|
|
233
|
+
- Intents: `explain`, `fact_context` (post, parent, quote), `summarise` (plus replies), `translate` (needs `language`, a BCP 47 tag), `describe`. Output filters allow only `references` ids, handles and URLs, as plain links (never render an image the model wrote). Render only bundles you built from your own reads.
|
|
234
|
+
- Post pictures: pass media `id`, the payload's `checked_at` and its `held: false` mark to the builder (`checked_at` is kept only with `held` exactly `false`). `max` = `Math.max(0, 3 - phonePictures)`. `listed = bundlePictures(bundle, intent, { now: Date.now(), max })`, run your checks, then `renderBundleForModel(bundle, intent, { boundary, pictures: { now: Date.now(), max, allowed: passed.map((x) => x.picture), cached: (p) => passed.find((x) => same(x.picture, p))?.prepared_jpeg ?? null } })` (a fresh clock: it can only take pictures away). Your checks load each listed picture from the image cache, prepare it first (longest side ≤ 1536 px, re-encoded as JPEG, metadata stripped; spec 2.2), check the prepared bytes and return `passed` as `{ picture, prepared_jpeg }`; `cached` returns that `prepared_jpeg` (`same` matches `item_id`, `media_id` and `number`): never a second read of the cache, which could return a picture nobody checked and still carries its metadata. The SDK only checks the bytes look like a JPEG. Only pictures on the post, parent or quote (never replies), marked `held: false`, checked under 24 h ago by `now` (5 min ahead allowed), with a plain id, on `allowed` and with a cached base64 JPEG go, as `image_url` data parts inside the content block in `user_parts`; at most `max` (≤ 3), none for `translate`; a picture stale since listing is not replaced; everything else stays alt text, and output is unchanged when none qualifies. With pictures, send `system` + `user_parts` (not `user`/`text`). Never fetch in `cached`; `references` never holds a picture address.
|
|
235
|
+
- `sealedTurnCaps({ mode: 'sealed' | 'both', others_content, phone_pictures, temporary, searched_earlier, phone_pictures_earlier, search, earlier_messages, memory, memory_in_chat, memory_tick, about_you, page_opens_limited })` → `{ search, history, memory, about_you, phone_pictures, suggest_memory, reasons }`. `others_content` (`[]` for own words), `phone_pictures` (`0` for none), `temporary`, `searched_earlier`, `phone_pictures_earlier` and `earlier_messages` are required (absent/null throws; `false` is none). `mode: 'both'` with `'encrypted_chat'` throws (G21: Sealed only; Both's Standard half would send it to Minds and the AI company). Searching messages carry no memory/About you/phone pictures (search turns off: `search.off_mix`) and no history (`search.question_only`); someone else's public post blocks search until `page_opens_limited` (Tinfoil ask 3, default false; `search.off_subject`); members-only posts and chat messages never search (`search.off_members`), encrypted-chat messages never (`search.e2ee`) and carry no phone pictures; Both never searches or carries phone pictures (`both.no_search`, `both.no_photo`); temporary chats and encrypted-chat asks get no memory. With others' content only `memory_tick` sends memory/About you (the chip `memory_in_chat` counts only in the member's own chat). No `suggest_memory` with phone pictures in the message (a screenshot can carry someone else's words), or after a search or a phone picture earlier in the chat. Only `true` turns a sending choice on; unknown `mode`/content kinds and picture counts that are not 0 to 3 throw.
|
|
234
236
|
- Fixed cards, before any model call: `matchFixedCard(memberWords, { country, locale })` → `{ kind: 'crisis' | 'election', card } | null`. Crisis (self-harm, suicide risk, immediate danger) always shows the card first, then the turn may answer; election logistics only (never candidates, results, fraud claims, votes on a post or poll), and `fixedCardAnswer('election', Date.now())` is `'card_only'` until 2026-11-04T12:00Z. English and Spanish; every number and URL is sourced data (`CRISIS_LINES`, `ELECTION_SITES`), never generated. Pass the member's words, not the post they ask about.
|
|
235
237
|
|
|
236
238
|
---
|
|
@@ -823,13 +825,21 @@ Methods: `list`, `get`, `create`, `delete`
|
|
|
823
825
|
|
|
824
826
|
## Platform Resources
|
|
825
827
|
|
|
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.
|
|
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.
|
|
828
|
+
- `r.settings` — User preferences, sessions, password changes, account deletion. `revokeOtherSessions({ keep_session_token? })` signs out every other device and keeps this key (and the proven session); `revokeAllSessions()` also revokes this key. Accounts with no password confirm `requestEmailChange({ new_email, email_code })` with a code from `requestConfirmationCode('email_change')`. With a project-bound key, `getPreferences()` includes app-scoped `content`; persist changes with `updateContent({ show_nsfw?, autoplay_video?, ai_enabled?, default_feed?, sealed_adult? })`; `content.default_feed_chosen` is true once the member set `default_feed` themselves (absent on older APIs). `privacy.age_is_adult` is true only when the member answered the age question 18 or older (false for 13-17 and for no answer; only their own preferences carry it). `content.sealed_adult` is the Sealed adult-content setting: it reads true only while it is on and the member is a known adult, and `updateContent({ sealed_adult: true })` from anyone else throws `AuthorizationError` with code `sealed_adult_needs_adult` and saves nothing in that request; `sealed_adult: false` is always accepted. Both are absent on older APIs (treat as false). Notifications (0.7.72): `getPreferences()` returns `notifications.channels`, each kind (`post_mention`, `post_reply`, `post_repost`, `post_reaction`, `follow`, `message`, and the operator kinds) with `{ inApp, push, email }`; `updateNotifications({ channels: { post_reaction: { push: false } } })` changes that kind only, while `in_app`/`push`/`email` stay master switches over every kind. `disableAccount({ password })` (0.7.71) deactivates the account (hidden, nothing deleted; signing back in restores it), asked once; an API that does not serve deactivation answers 404 `NotFoundError`, so keep the control hidden until yours does.
|
|
829
|
+
- `r.signals` (0.7.71) — The member's engagement signals for their own ranking (For You), under their app-bound key (`agents:write`): `batch(events, { keepalive?, retry? })` sends up to `SIGNAL_BATCH_MAX` (100) `{ event_type, post_id?, dwell_ms?, metadata? }` to POST /signals/batch; `record(event)` sends one to POST /signals/post (for an API before the batch route, which answers that one 404). `event_type`: `view`, `click`, `dwell`, `react_up`, `react_down`, `save`, `hide`, `share`, `mute_source`, `open_chat`, `ask_agent`, `more_like`, `unhide`. Best-effort: a signal the server cannot store answers 200 with `recorded` 0 (or false) and a `reason` (`no_project_scope`, `project_db_unavailable`, `viewing_off`, `rate_limited`, or for `record` `duplicate`). `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. Only from a public place, never a private message: a `metadata` key or placement (`surface`, `feed`, `source`, `context`, `route`...) naming a chat, DM, inbox, encrypted or Sealed conversation by any spelling (`chatList`, `dmThread`, `/messages/1`) answers 400 (`private_surface`) and nothing in that request is stored; this is read on the metadata as sent, before any key is dropped. An app names its own public places (`{ source: 'discover' }`). In the Minds app a `surface` must be one of `SignalSurface`'s names other than `other` (each a public place the server lists), a `feed`, `source`, `via` or `layoutSource` one the server lists (its feeds, Discover, Watch, stories, the pager and post cards), and any other key naming a place (`context`) is refused there too. With the member's "Use what I watch and read to personalize" off (`settings.updateContent({ personalize_from_viewing: false })`, on by default), only views and explicit choices are stored and `withheld` counts the dwell and opens left out. `clearViewingHistory()` (DELETE /signals/history) is "Clear my viewing history": the member's views, dwell, Watch time and opens in this app are deleted; their hides, "More like this", saves, shares and votes stay. Ingest contract (0.7.93): `metadata` is `SignalMetadata`; the server keeps only its keys (`surface`, `feed`, `block`, `position`, `slot`, `category`, `ref`, `watch_ms`, `duration_ms`, `completed`, `loops`, `sound_on`, `exit` (`swipe`/`leave`/`hide`/`tap`), `visible_count`, `autoplay`, `client_event_id`, `session_seq`, `via`, `source`, `presentation`) and drops the rest; a listed key of the wrong type or past its limit refuses that event (batch: `rejected` + the first ten `rejections` `{ index, reason, key }`, the rest stored; record: 400 `invalid_signal`). `batch`/`record` add a `client_event_id` to each event without one (the same id each time that object is sent), so a retry is stored once (`duplicates`). An open-network item is named by `metadata.ref` (`<protocol>/<native id>`, `cluster:<id>`, `today:<uuid>`). Watch time is held to `duration_ms` x (`loops` + 1) and to the wall clock, a card's dwell to 5 min; a watch under 1 s ending in `exit: 'swipe'` is stored as a skip. Past 600 events a minute per member the rest are `limited` (record: `reason: 'rate_limited'`).
|
|
828
830
|
|
|
829
831
|
```typescript
|
|
830
832
|
await r.signals.batch([{ event_type: 'view', post_id: post.id }, { event_type: 'dwell', post_id: post.id, dwell_ms: 4200 }])
|
|
831
833
|
addEventListener('pagehide', () => { void r.signals.batch(pending.splice(0, SIGNAL_BATCH_MAX), { keepalive: true }).catch(() => {}) })
|
|
832
834
|
```
|
|
835
|
+
- `r.privacy` (unreleased) — `capabilities()` reads GET /privacy/capabilities: `{ api_commit, facts }`, the platform privacy facts the deployed API's build marks enforced (any API key). `@recursiv/sdk/privacy` exports `PRIVACY_FACTS` (each `{ id, status, enforcement, capability, step, says }`, generated from the platform's claims registry), `privacyFact(id)`, `isFactEnforced(id)`, `isFactLive(id, capabilities, { deviceFacts? })` and `liveFacts(capabilities, { deviceFacts? })`. Show a privacy sentence only when every fact it rests on is live: enforced in this SDK and listed by the API, and, for a fact held on the phone (`enforcement: 'device'`), also named in `deviceFacts` by the app for the native module it ships. A device fact says what the module does, not what the app does with it (for `sealed.attest_before_send` the app chooses how long a checked router is reused).
|
|
836
|
+
|
|
837
|
+
```typescript
|
|
838
|
+
import { isFactLive } from '@recursiv/sdk/privacy'
|
|
839
|
+
const { data: capabilities } = await r.privacy.capabilities()
|
|
840
|
+
const showNoPushLine = isFactLive('sealed.no_push', capabilities)
|
|
841
|
+
const showCheckedLine = isFactLive('sealed.attest_before_send', capabilities, { deviceFacts: ['sealed.attest_before_send'] })
|
|
842
|
+
```
|
|
833
843
|
- `r.billing` — Usage tracking, checkout sessions, portal sessions, metered billing
|
|
834
844
|
- `r.appSubscriptions` — Project-scoped consumer plans: `checkout` (409 `subscription_exists` when the member already pays), `status`, `createPortalSession({ return_url, flow?: 'cancel' })` (`flow: 'cancel'` opens Stripe's cancel confirmation; 409 `no_subscription` when there is no plan to cancel), `previewTierChange({ tier })`, `changeTier({ tier, proration_date? })` (in-place, prorated, never a second subscription)
|
|
835
845
|
- `r.boosts` — Promoted posts (micro-USD): `create`, `mine`, `balance`, `pause`, `resume`, `cancel`, `recordEvent`; buy credit with `topup({ amount_cents, return_url })` then `confirmTopup(sessionId)` (credits once); admin `listPendingReview`, `review`, `grant`
|
|
@@ -862,11 +872,12 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
|
|
|
862
872
|
- Native follows: `nativeFollowState({ accountId, protocol, nativeId })`, `prepareNativeFollow({ clientMutationId, accountId, protocol, nativeId, action: "follow" | "unfollow" })`, `submitNativeFollow(id, { approve: true, event? })`, `nativeFollowStatus(id)`. Show the acting identity and native following/requested/unfollowed state separately from the Minds feed subscription. Nostr must sign the complete review event without dropping other contacts; stale lists require a fresh review. Keep mutation IDs and reconcile ambiguous outcomes without replay. Requires current follow scopes and API `NATIVE_SOCIAL_FOLLOW_PROJECTS` plus source controls.
|
|
863
873
|
- Native reposts: `nativeRepostState({ accountId, protocol, nativeId })`, `prepareNativeRepost({ clientMutationId, accountId, protocol, nativeId, action: "repost" | "unrepost" })`, `submitNativeRepost(id, { approve: true, event? })`, `nativeRepostStatus(id)`. Requires `NATIVE_SOCIAL_REPOST_PROJECTS`, account capability and explicit approval; Nostr signs the exact review (kind 6 or own kind 5). Unknown attempts only reconcile. Bluesky connections need the new repost scope; ActivityPub uses write:statuses. Native likes: `nativeLikeState({ accountId, protocol, nativeId })`, `prepareNativeLike({ clientMutationId, accountId, protocol, nativeId, action: "like" | "unlike" })`, `submitNativeLike(id, { approve: true, event? })`, and `nativeLikeStatus(id)`. Display the selected account, source and action before approval. Only Nostr supplies `event`: sign the exact public `signing.event` in the review; never transmit a private key. Preserve mutation IDs on ambiguous responses and refresh unknown results instead of resending. Read capabilities before offering actions; old reply-only connections need reconnecting for likes. Nostr results describe the connected relay, not every public relay. The app-specific `NATIVE_SOCIAL_LIKE_PROJECTS` switch and source controls must be enabled on the API.
|
|
864
874
|
- Delegated social accounts: `r.protocols.nativeSocialAccounts()`, `startNativeSocialConnection({ protocol, identifier })`, `finishNativeSocialConnection({ protocol, state, code?, iss?, error? })`, `disconnectNativeSocialAccount(id)`. Example: `const { data } = await r.protocols.startNativeSocialConnection({ protocol: "bluesky", identifier: "alice.bsky.social" })`; open `data.authorizationUrl`, then complete with the same signed-in app user. Never log callback codes, send passwords, or auto-retry OAuth callbacks. Shared replies (off until schema/API deployment and live verification): `prepareNativeReply({ clientMutationId, text, parent: { protocol, nativeId, readRef? }, accountIds, media?, linkedPostAccountIds?, recoverFrom?: { batchId, deliveryIds } })`, `submitNativeReply(id, { approve: true, deliveryIds })`, `nativeReplyStatus(id)`, `nativeReplyHistory({ limit?, cursor? })`, `prepareNativeReplyRemoval(id, { deliveryIds })`, `removeNativeReply(id, { approve: true, removals })`. Review account, destination, `kind`, source, exact `wireText` and every reviewed `media` item with its alt text; connection is not publication approval. `media` takes the same `NativeMediaInput[]` as `prepareNativePost` and needs the account's `publish_media` capability. `linkedPostAccountIds` explicitly selects different-network standalone posts with a source link, never matching threaded replies or a same-network denial fallback. Preserve mutation IDs, show per-destination results and reconcile uncertain attempts without reposting. To review selected unsent or definitively rejected deliveries again, use recoverFrom with a new mutation ID, the same source/accounts and freshly reviewed text; preserve that ID on uncertain preparation. Only saved recoverable destinations qualify, current gates still apply, and replacedBy disables old sends atomically. Never restart unknown, accepted or published deliveries. The new preview still needs explicit approval. Saved delegated drafts/results: `const { data: history } = await r.protocols.nativeReplyHistory({ limit: 20 })`; use its opaque cursor for the next page and `nativeReplyStatus(item.id)` for details. This is a private read, not a write or provider refresh, and includes Nostr results marked type=nostr. Use nativeNostrReplyResult(item.id) to check that recorded event on its relay without signing or resending. Discard page state on owner/app changes. See README for examples and the callback, revocation and removal contracts.
|
|
865
|
-
- `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. The author's own thread (0.7.81): `thread` holds the focus author's continuation below the post, oldest first (a "1/" post's 2/, 3/, 4/ ..., at most 20 parts, each held to the same moderation and member controls as a reply); its first part is not repeated in `replies`, so render `focus`, `thread`, then `replies` (`thread ?? []`: older APIs omit it, and a `replies: 'none'` read answers none). A card (a post shared into a chat, a link preview) asks for the post alone, 0.7.78: `getPublicConversation({ protocol, post, replies: 'none' })` (also `getSharedConversation` and `getConnectedSocialConversation`) answers `focus` with no `ancestors` or `replies` and `repliesStatus: 'not_requested'`; a Nostr note answers from one relay read instead of a whole thread's. An older API answers 400 `invalid_input`: read without it. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
|
|
875
|
+
- `r.protocols` — Private app safety: `sourceControls` returns current `enabled` plus paged saved choices; `setSourceControl`, `onSourceControlChange` and `reportSource` share app scope. Example: `const choices = new Map(); const stop = r.protocols.onSourceControlChange(change => choices.set(JSON.stringify([change.source, change.control]), change.enabled));` then `await r.protocols.setSourceControl({ source: { protocol: "bluesky", kind: "actor", nativeId: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" }, control: "mute", enabled: true, scope: "minds" });` and call `stop(); choices.clear()` on account change/unmount. Notifications follow an exact server confirmation, never an uncertain response. These preferences and reports apply in Minds, not on the remote network. Open network ids (0.7.87, readers first): `import { SOCIAL_PROTOCOL_IDS, isSocialProtocolId } from '@recursiv/sdk'` lists every network a post, account or `SocialSourceRef` may name: bluesky, activitypub, nostr and farcaster (a Farcaster account by its FID, "3"; a cast by `<fid>:0x<40 lowercase hex>`). Listing an id serves nothing: read only what `publicNetworks()` lists, and skip an id this SDK does not know. Writes stay narrower: a native reply, follow, like or repost takes only `NATIVE_WRITE_PROTOCOLS` (bluesky, activitypub, nostr; `isNativeWriteProtocol(post.protocol)` before offering one), and a feed top's `networks` always names those three while a Farcaster lane may be absent (`networks.farcaster ?? []`). Keyless post links: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data: thread } = await visitor.protocols.getSharedConversation(projectId, { protocol: 'nostr', post: publicEventId })`; `sharedNetworks(projectId)` lists public conversation capabilities. Only configured apps; app moderation and rate limits apply, no member state or write authority. Member public reading: `publicNetworks`, `searchPublicProfiles`, `getPublicProfile`, `getPublicAuthorFeed`, `getFollowingFeed`, `getPublicConversation`, `getPublicTrends`, `getPublicTrendFeed`; private app subscriptions: `listSubscriptions`, `subscribeToProfile`, `unsubscribeFromProfile`, `onSubscriptionChange`. `getFollowingFeed` sources: a slow account is `late` (status `late`, or `ok` with `late: true` while its held first page is shown; older APIs say `unavailable` with `late: true`), never a failure: read the next page for its posts (`lateNetworks` names its network). Example after selecting a returned source post: `const { data: thread } = await r.protocols.getPublicConversation({ protocol: post.protocol, post: post.nativeId, readRef: post.readRef })`. Render `focus`, oldest-first `ancestors` and direct `replies`; `public_provider_window` never promises every reply, and `limited` marks known omissions. The author's own thread (0.7.81): `thread` holds the focus author's continuation below the post, oldest first (a "1/" post's 2/, 3/, 4/ ..., at most 20 parts, each held to the same moderation and member controls as a reply); its first part is not repeated in `replies`, so render `focus`, `thread`, then `replies` (`thread ?? []`: older APIs omit it, and a `replies: 'none'` read answers none). A card (a post shared into a chat, a link preview) asks for the post alone, 0.7.78: `getPublicConversation({ protocol, post, replies: 'none' })` (also `getSharedConversation` and `getConnectedSocialConversation`) answers `focus` with no `ancestors` or `replies` and `repliesStatus: 'not_requested'`; a Nostr note answers from one relay read instead of a whole thread's. An older API answers 400 `invalid_input`: read without it. Requires both `users:read` and `posts:read` on the current human app key. Protocol discovery/settings: `const { data: adapters } = await r.protocols.list()` returns bounded `{ id, name, icon, color, maturity, capabilities, limitations }` metadata; `supported` means implemented, not configured, healthy, authorized, or enabled. Methods: `list`, `status`, `listCandidates`, `getCandidate`, `search`, `getSettings`, `refresh`, `updateSettings`.
|
|
866
876
|
- Native Nostr: `const { data: preview } = await r.protocols.prepareNativeNostr(identityId, { action: "publish", content: draft })`; review and sign the exact public event, then `submitNativeNostr(identityId, { intent: preview.intent, event, approve: true })`. Preparation/submission never auto-retry. After uncertainty, use `getNativeNostrPost(identityId, preview.postId ?? preview.eventId)` before a user-selected retry of the identical intent/event: deletion checks the original note's `postId`, publication checks `eventId`. Check `nativeNostrAccount()` enablement first; keys stay with the signer. For replies, after `nativeNostrAccount()` advertises `reply_text_note`, use `prepareNativeNostr(identityId, { action: "reply", content: draft, parentId: post.nativeId })`. Review returned parent/root/participants/relay and exact event, sign with the connected client signer, then `submitNativeNostr(identityId, { intent, event, approve: true })`. Requires users:read, posts:read and posts:write. Kind1 public notes only; reply enablement and shared UI/live proof remain pending. Do not inherit arbitrary notification tags or relay hints. Signing mutations never auto-retry; check `getNativeNostrPost(identityId, eventId)` after uncertainty before a user-selected identical-event retry.
|
|
877
|
+
- Native networks (0.7.89): every eligible member is on ActivityPub, AT Protocol and Nostr by rule. `const { data } = await r.protocols.networks()` lists `{ network, state, reason, mode, handle, engageDefault }` (read-only; `ready` = set up on their first post or interaction, show "Will be set up when you first post or interact"). `activateNetwork(network)` = "Set up now" (`pending` + `retryAt` over budget; 409 `network_off` after the member's off switch). `setNetwork(network, { enabled: false, removePast: true })` turns it off and removes what was shared; `{ enabled: true }` turns it back on (403 `network_locked`, 409 `not_rolled_out`/`age_band_required`). Needs users:read (+ users:write to change). A linked outside account is a Settings choice, never a step in a main flow.
|
|
867
878
|
- Hosted Bluesky account on the app's own PDS: `const { data } = await r.protocols.createHostedIdentity({ protocol: 'atproto', handle: 'alice', email })`. Only the label is sent; `data.handle` carries the domain and `data.password` is returned this once, never stored. Never auto-retried. Either the account is finished or it is removed again. 409 `handle_unavailable` = choose another label; 503 `handle_dns_unavailable`/`handle_unresolvable`/`handle_unverified` = operator setup, retry later unchanged.
|
|
868
879
|
- Anonymous discovery: `const visitor = new Recursiv({ anonymous: true, inheritEnvironmentApiKey: false }); const { data } = await visitor.protocols.getSharedTrends(projectId, { protocol: 'nostr', limit: 20, discovery: true })`. Use `sharedNetworks(projectId)` capabilities; open a returned topic with `getSharedTrendFeed(projectId, { protocol: 'bluesky', topic: trend.nativeId, cursor })`. Configured apps only; no account authority or writes. App moderation and per-IP/app rate limits apply. Categories and Jev layouts reuse cached judgments without initiating paid inference; handle partial categories and finite provider windows. Category depth: `r.posts.list({ category: 'science', sort: 'hot', discovery: true })` pages the hot window by offset and then by keyset (`meta.next` is `{ offset }`, `{ cursor }` or `null`; pass a cursor back as `cursor`); when a stage ends, `meta.fallover` lists the next feeds in order (`category_older`, `category_network`, `category` with `via`, `for_you`, `all`; `feed` with `ref` is declared in 0.7.60 but not sent yet, so skip kinds you cannot open). One call for a category's modules: `const { data } = await r.discovery.category({ category: 'science' })` returns `stories`, `network`, `clips`, `people`, `groups`, each `{ items, ms, reason? }`. Watch's Minds videos: `const { data, meta } = await r.discovery.videos({ limit: 12 })` returns public video posts in hot order from a list shared per app (rebuilt about once a minute), each re-checked per call for the viewer; page on `meta.next_cursor`. With no category about one row in four is an open-network clip (0.7.78): `isWatchNetworkClip(row)` tells `{ kind: 'social', id, post: SocialPost }` from a `Post`; open and react to it as the network's post (its `id` is never a Minds id).
|
|
869
|
-
- Progressive Discover feed (0.7.71): `const feed = r.discovery.createProgressiveFeed({ networks, category, recommend, top: { ref: 'builtin:all' }, seen, seenClusters }); const round = await feed.next(part => draw(part))` opens one `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`) and hands each source's page on as a `DiscoveryPart` (`{ source, round, modules, composed?, mixed? }`) the moment it lands, instead of a page that waits for its slowest network (up to 15 s); a round waits `DISCOVERY_STRAGGLER_MS` past its first answer and rejects only when every source failed. The first load is held until Minds and one other network answered (or `FIRST_SCREEN_MS`, 2.5 s, after the first answer; `firstScreenMs: VISITOR_FIRST_SCREEN_MS` for a visitor) and arrives as one `composed: true` part from `composeFirstScreen`: the top's first post when it is a playable video, else a picture lead from the best 8 by engagement (never a story, rail, video strip, column, `isSensitivePost` post or `linksAdultSite` porn link; 0.7.80: never a post with no picture or clip and no engagement while the screen holds one with either, which then leads: media and engagement, else media, else engagement, as `mergeDiscoveryArrival` leads a feed's top and the feed after a line), then every network's posts by engagement (`DiscoveryEngagementMerge`) with row 2 from another network than the lead's when one is left (`FIRST_SCREEN_NETWORK_ROWS`), a story as row 3 and a rail as row 5, nothing dropped. Merge later `mixed` parts with `mergeDiscoveryArrival(rows, arriving, reachedIds)`. `top`/`seen`/`seenClusters`/`bucket` reach every session; the top is read once per open. Also `retryFailed()`, `onStatus(listener)`, `setNetworks(catalog)`; settings `perSource`, `firstScreen`, `onLanded`. For you: `createProgressiveFeed({ createFeed: o => r.discovery.createPersonalFeed(o) }, options, { perSource: false })`. A session's `seen` now also leaves seen network posts out of trend pages, previews and topic pages; in a category a topic page with nothing new ends its topic and after `DISCOVERY_EMPTY_TOPIC_PAGES` (3) in a row the network's other topics are skipped.
|
|
880
|
+
- Progressive Discover feed (0.7.71): `const feed = r.discovery.createProgressiveFeed({ networks, category, recommend, top: { ref: 'builtin:all' }, seen, seenClusters }); const round = await feed.next(part => draw(part))` opens one `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`) and hands each source's page on as a `DiscoveryPart` (`{ source, round, modules, composed?, mixed? }`) the moment it lands, instead of a page that waits for its slowest network (up to 15 s); a round waits `DISCOVERY_STRAGGLER_MS` past its first answer and rejects only when every source failed. The first load is held until Minds and one other network answered (or `FIRST_SCREEN_MS`, 2.5 s, after the first answer; `firstScreenMs: VISITOR_FIRST_SCREEN_MS` for a visitor) and arrives as one `composed: true` part from `composeFirstScreen`: the top's first post when it is a playable video, else a picture lead from the best 8 by engagement (never a story, rail, video strip, column, `isSensitivePost` post or `linksAdultSite` porn link; 0.7.80: never a post with no picture or clip and no engagement while the screen holds one with either, which then leads: media and engagement, else media, else engagement, as `mergeDiscoveryArrival` leads a feed's top and the feed after a line), then every network's posts by engagement (`DiscoveryEngagementMerge`) with row 2 from another network than the lead's when one is left (`FIRST_SCREEN_NETWORK_ROWS`), a story as row 3 and a rail as row 5, nothing dropped. Merge later `mixed` parts with `mergeDiscoveryArrival(rows, arriving, reachedIds)`. `top`/`seen`/`seenClusters`/`bucket` reach every session; the top is read once per open. Also `retryFailed()`, `onStatus(listener)`, `setNetworks(catalog)`; settings `perSource`, `firstScreen`, `onLanded`. For you: `createProgressiveFeed({ createFeed: o => r.discovery.createPersonalFeed(o) }, options, { perSource: false })`. A session's `seen` now also leaves seen network posts out of trend pages, previews and topic pages; in a category a topic page with nothing new ends its topic and after `DISCOVERY_EMPTY_TOPIC_PAGES` (3) in a row the network's other topics are skipped. 0.7.84: a featured module carries `theme` only when its lead window was themed so and most of its own posts were judged into that category (a category feed's posts, by the read's category filter); a feed with no category titles every module by its kind alone ("Videos", not "Videos in Technology" over an unjudged Nostr clip). A post is shown once by every identity (`discoveryItemIdentities(item)`: its key, an open-network post's network id, and each media file by address and by content hash, so a second note carrying the same clip is the same post) across a session's pages, a progressive feed's sources (`withoutSeenItems` takes identities or keys), `mergeDiscoveryArrival` and gallery pooling; `withoutRepeatedDiscoveryItems(rows, shown)` does the same for an app's own rows.
|
|
870
881
|
- Viral read (0.7.59; off until the server enables it for the app): `const { data } = await r.protocols.getPublicViral({ media: 'video', category: 'science', limit: 20 })` (member key, users:read + posts:read, never cached) or keyless `visitor.protocols.getSharedViral(projectId, { media: 'video', compass: 'hide' })` (visitors send compass `all` or `hide` only). Params, all optional: `network` (all, minds, bluesky, activitypub, nostr), `category`, `media: 'video'`, `limit` 5/10/20/40, `cursor` (the last page's; a cursor whose 30-minute build expired ends the list), `compass`. Each item: `key` (stable; drop ones already shown), `protocol`, `post` (checking `protocol` narrows it: `ViralMindsCard` for minds, open at /post/:id; otherwise a `SocialPost`), `label` viral/rising/hot, `score`, `rank`, `category`, `velocity` (show counts with `countsReportedBy` as reported, never verified), `spread`. A visitor's read serves media only where the server cleared it, so even with `media: 'video'` some items arrive with the video withheld (empty `media`): `import { viralItemHasVideo } from '@recursiv/sdk'` and keep `data.items.filter(viralItemHasVideo)` before a vertical video player. `status` ok/building/screening/off: off resolves with no items, and `getSharedViral` also resolves an older API's 401 `missing_api_key` as off; `getPublicViral` throws every 401/403 (never off), so catch it and skip that step.
|
|
871
882
|
- Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass?, media? })` (`compass: 'hide'` leaves out politics, 0.7.62; `media: 'video'`, 0.7.73, Bluesky: the network's own window of video posts, a `kind: posts` page paged by its cursor, no category or layout, 404 `unsupported_protocol` for a network without one, 400 on an API that predates it; keyless `getSharedTrends(projectId, { ..., media: 'video' })` holds only clips a visitor may see and counts the rest in `mediaFilter.withheld`); render `kind: topics` as source topics and `kind: posts` as source posts. With `trend_feed`, open only a returned current topic using `getPublicTrendFeed({ protocol, topic: trend.nativeId, limit?, cursor? })`. Show provider, observedAt and provider_ranked_window coverage; null rankingWindow is unknown, not a daily/global ranking. Preserve source order, canonical IDs and readRef, and opaque cursors. Topic catalogs do not paginate; Mastodon trends stop at 200 source positions. Both methods require users:read plus posts:read on the current human app key; Nostr trends are unsupported. Propagate errors; trend_unavailable means refresh the topic list. No ingestion or native writes.
|
|
872
883
|
- One outlet count per story (0.7.71): a radar story (list, story read, archive), a World Today story (`outlets`, with `outlet_count` its total and `lane_mix` its lanes), a category strip story, a `feeds.top`/`feeds.stories` story and a search story carry `outlets: { total, mainstream, independent, other }`, the distinct outlets of the articles the story lists (an allow-listed outlet once, an unlisted host in `other`; the buckets sum to `total`). Show `outlets.total`; `sources.outlet` counts articles. `DiscoveryCluster.outletCount` reads it. An older server omits it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@recursiv/sdk",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.93",
|
|
4
4
|
"description": "TypeScript SDK for the Recursiv API",
|
|
5
5
|
"license": "FSL-1.1-ALv2",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
"types": "./dist/next/index.d.ts",
|
|
28
28
|
"import": "./dist/next/index.js",
|
|
29
29
|
"default": "./dist/next/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./privacy": {
|
|
32
|
+
"types": "./dist/privacy/index.d.ts",
|
|
33
|
+
"import": "./dist/privacy/index.js",
|
|
34
|
+
"default": "./dist/privacy/index.js"
|
|
30
35
|
}
|
|
31
36
|
},
|
|
32
37
|
"files": [
|