@recursiv/sdk 0.7.62 → 0.7.63
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 +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/resources/dispatcher.d.ts +56 -0
- package/dist/resources/dispatcher.js +13 -0
- package/dist/resources/e2ee.d.ts +7 -3
- package/dist/resources/human-input.d.ts +4 -0
- package/dist/resources/protocols.d.ts +7 -0
- package/dist/resources/video.d.ts +20 -0
- package/dist/resources/video.js +12 -0
- package/dist/types.d.ts +9 -3
- package/llm.md +10 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
|
|
|
99
99
|
| `r.chat` | `send`, `dm`, `createGroup` (`encrypted: true` for an end-to-end encrypted group, 0.7.59), `messages` (cursor paging), `acceptRequest`, `declineRequest`, `leave`, `deleteConversation`; a private group's admin: `addMembers`, `removeMember`, `updateGroup` (0.7.59) |
|
|
100
100
|
| `r.tags` | `create`, `list` |
|
|
101
101
|
| `r.uploads` | `uploadMedia` — upload post/chat bytes end to end |
|
|
102
|
-
| `r.video` | `entitlement` (may the caller publish video, and how much is left), `createUpload`, `getStatus`, `getPlaybackToken` |
|
|
102
|
+
| `r.video` | `entitlement` (may the caller publish video, and how much is left), `createUpload`, `renewUpload` (the creator's fresh TUS credentials for the same video, with `createUpload`'s `resumeGrant`, so a resume past the hour continues it; 0.7.63), `getStatus`, `getPlaybackToken` |
|
|
103
103
|
|
|
104
104
|
### Platform
|
|
105
105
|
| Resource | Purpose |
|
package/dist/index.d.ts
CHANGED
|
@@ -235,7 +235,7 @@ export type { Brief, BriefEmail, MyBrief, MyBriefEntry, BriefParams, BriefShippe
|
|
|
235
235
|
export type { HumanAsk, HumanAskStatus, CreateHumanAskInput, ListHumanAsksParams } from './resources/human-asks.js';
|
|
236
236
|
export { HumanInputResource } from './resources/human-input.js';
|
|
237
237
|
export type { Goal, GoalTask, CreateGoalInput, UpdateGoalInput, ListGoalsParams, ReorderGoalTasksInput } from './resources/goals.js';
|
|
238
|
-
export type { DispatcherTask, TaskClaim, ClaimRecord, DispatcherStats, CreateTaskInput, UpdateTaskInput, ListTasksParams, SignalRecord, OutcomeRecord, PauseState, PauseInput, SignalsParams, OutcomesParams, DiscoveryParams, StaleStuckParams } from './resources/dispatcher.js';
|
|
238
|
+
export type { DispatcherTask, TaskClaim, ClaimRecord, DispatcherStats, CreateTaskInput, UpdateTaskInput, ListTasksParams, SignalRecord, OutcomeRecord, PauseState, PauseInput, SignalsParams, OutcomesParams, DiscoveryParams, StaleStuckParams, TaskPlan, TaskPlanStep, SetTaskPlanInput, UpdateTaskPlanStepInput } from './resources/dispatcher.js';
|
|
239
239
|
export { SwarmsResource } from './resources/swarms.js';
|
|
240
240
|
export type { Swarm, SwarmMember, CreateSwarmInput, ListSwarmsParams, ScheduledTask, ScheduleTaskInput } from './resources/swarms.js';
|
|
241
241
|
export { TemplatesResource } from './resources/templates.js';
|
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
2
|
import type { SingleResponse, DeleteResponse, PaginationParams } from '../types.js';
|
|
3
|
+
/** One step of a task's plan, as the checklist card shows it. */
|
|
4
|
+
export interface TaskPlanStep {
|
|
5
|
+
position: number;
|
|
6
|
+
title: string;
|
|
7
|
+
status: 'pending' | 'in_progress' | 'done' | 'skipped' | 'blocked';
|
|
8
|
+
/** Why a step is blocked or was skipped. */
|
|
9
|
+
reason: string | null;
|
|
10
|
+
/** The specialist agent doing the step. */
|
|
11
|
+
agent: {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
} | null;
|
|
15
|
+
/** The approval or question a blocked step waits on, in the plan's conversation. */
|
|
16
|
+
waitingOn: {
|
|
17
|
+
executionId: string | null;
|
|
18
|
+
messageId: string | null;
|
|
19
|
+
} | null;
|
|
20
|
+
startedAt: string | null;
|
|
21
|
+
finishedAt: string | null;
|
|
22
|
+
}
|
|
23
|
+
export interface TaskPlan {
|
|
24
|
+
taskId: string;
|
|
25
|
+
title: string;
|
|
26
|
+
/** The chat the plan's card lives in. */
|
|
27
|
+
conversationId: string | null;
|
|
28
|
+
steps: TaskPlanStep[];
|
|
29
|
+
}
|
|
30
|
+
export interface SetTaskPlanInput {
|
|
31
|
+
agent?: string;
|
|
32
|
+
/** The chat to post the card in. Only the first plan that names one posts it. */
|
|
33
|
+
conversation_id?: string | null;
|
|
34
|
+
steps: Array<{
|
|
35
|
+
title: string;
|
|
36
|
+
agent_id?: string | null;
|
|
37
|
+
}>;
|
|
38
|
+
organization_id?: string;
|
|
39
|
+
}
|
|
40
|
+
export interface UpdateTaskPlanStepInput {
|
|
41
|
+
agent?: string;
|
|
42
|
+
status: TaskPlanStep['status'];
|
|
43
|
+
title?: string;
|
|
44
|
+
/** Kept only for 'blocked' and 'skipped'. */
|
|
45
|
+
reason?: string | null;
|
|
46
|
+
/** Only for 'blocked': the approval it waits on. */
|
|
47
|
+
waiting_on_execution_id?: string | null;
|
|
48
|
+
/** Only for 'blocked': the question message it waits on. */
|
|
49
|
+
waiting_on_message_id?: string | null;
|
|
50
|
+
organization_id?: string;
|
|
51
|
+
}
|
|
3
52
|
export interface DispatcherTask {
|
|
4
53
|
id: string;
|
|
5
54
|
title: string;
|
|
@@ -275,6 +324,13 @@ export declare class DispatcherResource {
|
|
|
275
324
|
}): Promise<SingleResponse<{
|
|
276
325
|
ok: true;
|
|
277
326
|
}>>;
|
|
327
|
+
/**
|
|
328
|
+
* Write a claimed task's step plan (the checklist card). Replaces every step
|
|
329
|
+
* that has not finished; done and skipped steps stay. Requires the claim.
|
|
330
|
+
*/
|
|
331
|
+
setPlan(taskId: string, input: SetTaskPlanInput): Promise<SingleResponse<TaskPlan>>;
|
|
332
|
+
/** Move one step of a claimed task's plan. Started and finished times are stamped server-side. */
|
|
333
|
+
updatePlanStep(taskId: string, position: number, input: UpdateTaskPlanStepInput): Promise<SingleResponse<TaskPlan>>;
|
|
278
334
|
/**
|
|
279
335
|
* Mark a task as done. A task with a thresholded check that has no passing
|
|
280
336
|
* run refuses with 409 and the failing check names — run
|
|
@@ -28,6 +28,19 @@ export class DispatcherResource {
|
|
|
28
28
|
const { organization_id, ...body } = input;
|
|
29
29
|
return this.client.post(`/dispatcher/heartbeat/${encodeURIComponent(taskId)}${orgQuery(organization_id)}`, body);
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Write a claimed task's step plan (the checklist card). Replaces every step
|
|
33
|
+
* that has not finished; done and skipped steps stay. Requires the claim.
|
|
34
|
+
*/
|
|
35
|
+
setPlan(taskId, input) {
|
|
36
|
+
const { organization_id, ...body } = input;
|
|
37
|
+
return this.client.put(`/dispatcher/tasks/${encodeURIComponent(taskId)}/plan${orgQuery(organization_id)}`, body);
|
|
38
|
+
}
|
|
39
|
+
/** Move one step of a claimed task's plan. Started and finished times are stamped server-side. */
|
|
40
|
+
updatePlanStep(taskId, position, input) {
|
|
41
|
+
const { organization_id, ...body } = input;
|
|
42
|
+
return this.client.patch(`/dispatcher/tasks/${encodeURIComponent(taskId)}/plan/steps/${position}${orgQuery(organization_id)}`, body);
|
|
43
|
+
}
|
|
31
44
|
/**
|
|
32
45
|
* Mark a task as done. A task with a thresholded check that has no passing
|
|
33
46
|
* run refuses with 409 and the failing check names — run
|
package/dist/resources/e2ee.d.ts
CHANGED
|
@@ -95,9 +95,13 @@ export interface E2eeEvent {
|
|
|
95
95
|
* quiet one; never a commit, never a Welcome, never one whose envelope is over
|
|
96
96
|
* 16 KB), and only on a socket whose credential could read it with `events()`:
|
|
97
97
|
* one authenticated by the account owner's session or by an owner key whose
|
|
98
|
-
* app or organization binding reaches the conversation
|
|
99
|
-
*
|
|
100
|
-
*
|
|
98
|
+
* app or organization binding reaches the conversation, once that sign-in has
|
|
99
|
+
* called `registerDevice`, `publishKeyPackages`, `keyPackageStatus`,
|
|
100
|
+
* `events`, `acknowledge` or `appendEvent` for one of the account's active
|
|
101
|
+
* devices since the socket connected, as a sync after connecting does. A
|
|
102
|
+
* socket opened with an agent-scoped key, or whose sign-in spoke only for a
|
|
103
|
+
* device since revoked, gets the content-free form only. When present it is
|
|
104
|
+
* the stored event exactly as `events()` returns it, JSON byte for byte.
|
|
101
105
|
*
|
|
102
106
|
* With the shared engine, hand `event` to `applyPushed(conversationId, event)`
|
|
103
107
|
* on the device session: it applies the event without a relay read when it is
|
|
@@ -11,6 +11,10 @@ export interface RequestHumanInputInput {
|
|
|
11
11
|
steps: string;
|
|
12
12
|
/** One-tap answers (2-4 short words) — rendered as buttons on the phone. */
|
|
13
13
|
options?: string[];
|
|
14
|
+
/** The option you recommend; must be one of `options`. Marked "Recommended" on the card. */
|
|
15
|
+
recommended?: string;
|
|
16
|
+
/** You keep working on `recommended` unless the human answers otherwise. Needs `recommended`. */
|
|
17
|
+
continuing_with_default?: boolean;
|
|
14
18
|
}
|
|
15
19
|
export interface HumanInputRequest {
|
|
16
20
|
conversation_id: string;
|
|
@@ -982,6 +982,13 @@ export interface SocialPost {
|
|
|
982
982
|
author: Omit<SocialProfile, 'subscription'>;
|
|
983
983
|
text: string;
|
|
984
984
|
createdAt: string;
|
|
985
|
+
/**
|
|
986
|
+
* The language the post declares, as the lower-case primary subtag of its BCP-47 tag ("de", "en", "pt"): Bluesky's
|
|
987
|
+
* record `langs[0]`, a Mastodon status's `language`, a Nostr note's NIP-32 `ISO-639-1` self-label. Absent when the post
|
|
988
|
+
* declares none (most Nostr notes) or a malformed one, and on posts served from the open-network source index; the
|
|
989
|
+
* server never guesses it from the text and never drops a post for it. Use it to order the viewer's language first.
|
|
990
|
+
*/
|
|
991
|
+
language?: string;
|
|
985
992
|
replyCount: number | null;
|
|
986
993
|
likeCount: number | null;
|
|
987
994
|
repostCount: number | null;
|
|
@@ -13,6 +13,12 @@ export interface CreateVideoUploadResult {
|
|
|
13
13
|
upload: VideoUploadCreds;
|
|
14
14
|
/** The HLS URL the video will play from once encoding completes. */
|
|
15
15
|
hlsUrl: string;
|
|
16
|
+
/**
|
|
17
|
+
* The creator's proof for `renewUpload` (valid 24 h): pass it back to get
|
|
18
|
+
* fresh credentials for this same video when a resume outlives the hour
|
|
19
|
+
* `upload` is signed for. Absent from servers older than 0.7.63's API.
|
|
20
|
+
*/
|
|
21
|
+
resumeGrant?: string;
|
|
16
22
|
}
|
|
17
23
|
export interface VideoStatusInfo {
|
|
18
24
|
status: string | null;
|
|
@@ -59,6 +65,20 @@ export declare class VideoResource {
|
|
|
59
65
|
}): Promise<{
|
|
60
66
|
data: CreateVideoUploadResult;
|
|
61
67
|
}>;
|
|
68
|
+
/**
|
|
69
|
+
* Fresh upload credentials for a video this member started, so a TUS resume
|
|
70
|
+
* that outlives the hour `upload` is signed for continues the same video.
|
|
71
|
+
* Pass the `resumeGrant` from `createUpload`. 404 for anyone else's video, an
|
|
72
|
+
* expired grant or an older API without the route; 409 `upload_finished` once
|
|
73
|
+
* a post carries the video or Bunny holds the whole file; 402 as `createUpload`;
|
|
74
|
+
* 503 when the video provider could not answer: keep the partial upload and
|
|
75
|
+
* retry (it never means the video is gone).
|
|
76
|
+
*/
|
|
77
|
+
renewUpload(playbackId: string, input: {
|
|
78
|
+
grant: string;
|
|
79
|
+
}): Promise<{
|
|
80
|
+
data: CreateVideoUploadResult;
|
|
81
|
+
}>;
|
|
62
82
|
/** Encode status + metadata for a video. */
|
|
63
83
|
getStatus(playbackId: string): Promise<{
|
|
64
84
|
data: VideoStatusInfo;
|
package/dist/resources/video.js
CHANGED
|
@@ -19,6 +19,18 @@ export class VideoResource {
|
|
|
19
19
|
createUpload(input) {
|
|
20
20
|
return this.client.post('/video/create-upload', input ?? {});
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Fresh upload credentials for a video this member started, so a TUS resume
|
|
24
|
+
* that outlives the hour `upload` is signed for continues the same video.
|
|
25
|
+
* Pass the `resumeGrant` from `createUpload`. 404 for anyone else's video, an
|
|
26
|
+
* expired grant or an older API without the route; 409 `upload_finished` once
|
|
27
|
+
* a post carries the video or Bunny holds the whole file; 402 as `createUpload`;
|
|
28
|
+
* 503 when the video provider could not answer: keep the partial upload and
|
|
29
|
+
* retry (it never means the video is gone).
|
|
30
|
+
*/
|
|
31
|
+
renewUpload(playbackId, input) {
|
|
32
|
+
return this.client.post(`/video/${encodeURIComponent(playbackId)}/renew-upload`, input);
|
|
33
|
+
}
|
|
22
34
|
/** Encode status + metadata for a video. */
|
|
23
35
|
getStatus(playbackId) {
|
|
24
36
|
return this.client.get(`/video/${playbackId}/status`);
|
package/dist/types.d.ts
CHANGED
|
@@ -649,9 +649,15 @@ export interface ConversationMember {
|
|
|
649
649
|
*/
|
|
650
650
|
role?: ChatMemberRole;
|
|
651
651
|
/**
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
652
|
+
* Each member's message request, in a private group or a one-on-one chat.
|
|
653
|
+
* Someone who follows the person who wrote (or who made the group), and an
|
|
654
|
+
* agent, start `accepted`, as the person who wrote does; a chat started
|
|
655
|
+
* without a request reads `accepted` for both. Anyone else starts `pending`
|
|
656
|
+
* and reads `accepted` once they accept or reply, or, in a one-on-one chat,
|
|
657
|
+
* start a chat back. So `accepted` alone does not mean they accepted
|
|
658
|
+
* anything. Declining stays private: to anyone else a member who declined
|
|
659
|
+
* still reads `pending`. Omitted in community rooms and by older APIs
|
|
660
|
+
* (one-on-one chats: before this field was served for them).
|
|
655
661
|
*/
|
|
656
662
|
request_status?: ChatRequestStatus;
|
|
657
663
|
}
|
package/llm.md
CHANGED
|
@@ -502,7 +502,7 @@ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `
|
|
|
502
502
|
|
|
503
503
|
Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growing `offset`. Conversations carry `can_delete` / `can_leave`; check them before offering Delete or Leave.
|
|
504
504
|
|
|
505
|
-
Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group, `request_status
|
|
505
|
+
Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group or a one-on-one chat, `request_status`: `accepted` from the start for the person who wrote, anyone who follows them and agents, else `pending` until that person accepts or replies (a decline reads `pending` to anyone else), so `accepted` alone does not mean they accepted anything. Only the admin (the creator, then the longest-standing person left) may `addMembers(id, { member_ids })`, `removeMember(id, userId)` and `updateGroup(id, { name })` (else 403 `not_admin`). `createGroup({ name, member_ids, encrypted: true, device_id? })` makes an end-to-end encrypted group of people in the pilot, at most 8 counting pending invitees, or throws 409 `conversation_not_eligible` with `details.reason` and creates nothing. `r.realtime.onChatMembers` / `onChatConversation` tell you when people or the name change.
|
|
506
506
|
|
|
507
507
|
### r.e2ee — End-to-end encryption transport (flag-gated)
|
|
508
508
|
|
|
@@ -685,7 +685,13 @@ the presign/PUT sequence in application code.
|
|
|
685
685
|
|
|
686
686
|
Video: check `const { data } = await r.video.entitlement()` before offering the
|
|
687
687
|
picker (`data.allowed`, `data.reason` 'tier' | 'quota', `data.remainingSeconds`),
|
|
688
|
-
then `r.video.createUpload()`, which runs the same check.
|
|
688
|
+
then `r.video.createUpload()`, which runs the same check. Upload in chunks
|
|
689
|
+
(tus `chunkSize`) and keep the result for the attached file: a retry resumes the
|
|
690
|
+
same video. The TUS signature lasts an hour; past it, call
|
|
691
|
+
`r.video.renewUpload(playbackId, { grant: result.resumeGrant })` (0.7.63) for fresh
|
|
692
|
+
credentials for the same video. 404 (not yours, expired grant, older API) or 409
|
|
693
|
+
`upload_finished` means start a new upload instead. 503 means the video provider
|
|
694
|
+
could not answer: keep the partial upload and ask again later (`Retry-After`).
|
|
689
695
|
|
|
690
696
|
### r.chat — Messaging
|
|
691
697
|
|
|
@@ -700,7 +706,7 @@ Methods: `conversations`, `communityConversation`, `conversation`, `messages`, `
|
|
|
700
706
|
|
|
701
707
|
Scroll back with `messages(id, { cursor: page.meta.next_cursor })`, not a growing `offset`. Conversations carry `can_delete` / `can_leave`; check them before offering Delete or Leave.
|
|
702
708
|
|
|
703
|
-
Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group, `request_status
|
|
709
|
+
Private groups (0.7.59): each member has `role` (`admin` or `member`) and, in a group or a one-on-one chat, `request_status`: `accepted` from the start for the person who wrote, anyone who follows them and agents, else `pending` until that person accepts or replies (a decline reads `pending` to anyone else), so `accepted` alone does not mean they accepted anything. Only the admin (the creator, then the longest-standing person left) may `addMembers(id, { member_ids })`, `removeMember(id, userId)` and `updateGroup(id, { name })` (else 403 `not_admin`). `createGroup({ name, member_ids, encrypted: true, device_id? })` makes an end-to-end encrypted group of people in the pilot, at most 8 counting pending invitees, or throws 409 `conversation_not_eligible` with `details.reason` and creates nothing. `r.realtime.onChatMembers` / `onChatConversation` tell you when people or the name change.
|
|
704
710
|
|
|
705
711
|
### r.tags
|
|
706
712
|
|
|
@@ -752,6 +758,7 @@ await r.appSubscriptions.changeTier({ tier: 'pro', proration_date: quote.prorati
|
|
|
752
758
|
- 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`.
|
|
753
759
|
- 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.
|
|
754
760
|
- Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor?, compass? })` (`compass: 'hide'` leaves out politics, 0.7.62); 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.
|
|
761
|
+
- Post language: a `SocialPost` may carry `language`, the lower-case primary subtag the post declares (`'de'`, `'en'`; Bluesky `langs[0]`, Mastodon `language`, a Nostr NIP-32 `ISO-639-1` label). It is absent when the post declares none (most Nostr notes) and on posts from the source index; it is never guessed and no post is dropped for it. Order by it (the viewer's language first, undeclared next), never filter on it.
|
|
755
762
|
- `listCandidates({ protocol?, limit?, cursor? })` and `getCandidate(id)` are read-only quarantine review, not published-post search. Both require `admin` scope, a project-bound key, a live admin role, and existing project-admin access. The key supplies app/network scope; neither method accepts another project or network. They never invoke refresh, change settings, or publish.
|
|
756
763
|
- Lists return `{ data: ProtocolQuarantinedCandidate[], meta: { limit, has_more, next_cursor } }`; use the opaque `next_cursor`, not offset pagination. The API defaults `limit` to 20 and permits at most 50. Details return `{ data: ProtocolQuarantinedCandidate }`. Every candidate has `state: 'quarantined'` and `content_format: 'plain'`; render its bounded `content` as text, respect `content_truncated`, and distinguish upstream `published_at` from local `observed_at` / `created_at`.
|
|
757
764
|
- Safe source IDs are preserved byte-exact. `external_id: null` means unsafe or malformed provenance was withheld, not an invented ID; `author.native_id` may likewise be absent or withheld. Preserve nulls in both list and detail views, and use the candidate's own `id` for `getCandidate`.
|