@recursiv/sdk 0.7.41 → 0.7.44
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/dist/resources/discovery-learning.d.ts +2 -1
- package/dist/resources/discovery-learning.js +5 -3
- package/dist/resources/discovery.d.ts +63 -4
- package/dist/resources/discovery.js +115 -15
- package/dist/resources/protocols.d.ts +18 -0
- package/dist/resources/protocols.js +10 -0
- package/dist/types.d.ts +13 -1
- package/llm.md +1 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DiscoveryLayout } from '../types.js';
|
|
2
|
-
|
|
2
|
+
/** Layout presentations plus the recommendation module kinds, so viewers who keep hiding a kind stop seeing it. */
|
|
3
|
+
export type DiscoveryPresentation = DiscoveryLayout['presentation'] | 'groups' | 'people' | 'tags';
|
|
3
4
|
type Counts = {
|
|
4
5
|
views: number;
|
|
5
6
|
engagements: number;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
const names = ['posts', 'visual', 'conversation', 'video'];
|
|
1
|
+
const names = ['posts', 'visual', 'conversation', 'video', 'thread', 'longread', 'groups', 'people', 'tags'];
|
|
2
2
|
const fresh = (now) => ({ version: 1, startedAt: now, layouts: {
|
|
3
3
|
video: { views: 0, engagements: 0, hides: 0 }, posts: { views: 0, engagements: 0, hides: 0 }, visual: { views: 0, engagements: 0, hides: 0 }, conversation: { views: 0, engagements: 0, hides: 0 },
|
|
4
|
+
thread: { views: 0, engagements: 0, hides: 0 }, longread: { views: 0, engagements: 0, hides: 0 },
|
|
5
|
+
groups: { views: 0, engagements: 0, hides: 0 }, people: { views: 0, engagements: 0, hides: 0 }, tags: { views: 0, engagements: 0, hides: 0 },
|
|
4
6
|
} });
|
|
5
7
|
/** Local, per-viewer experimental presentation preferences. Stores counts only,
|
|
6
8
|
* never source text, author IDs or inferred traits. It cannot promote content,
|
|
@@ -15,10 +17,10 @@ export class DiscoveryLearning {
|
|
|
15
17
|
const time = now();
|
|
16
18
|
this.value = value?.version === 1 && Number.isFinite(value.startedAt) && value.startedAt <= time
|
|
17
19
|
&& time - value.startedAt < 14 * 86400_000 && names.every(name => {
|
|
18
|
-
const c = value.layouts?.[name];
|
|
20
|
+
const c = value.layouts?.[name] ?? { views: 0, engagements: 0, hides: 0 };
|
|
19
21
|
return c && [c.views, c.engagements, c.hides].every(n => Number.isSafeInteger(n) && n >= 0 && n <= 10000)
|
|
20
22
|
&& c.engagements <= c.views && c.hides <= c.views;
|
|
21
|
-
}) ? JSON.parse(JSON.stringify(value)) : fresh(time);
|
|
23
|
+
}) ? { ...fresh(time), ...JSON.parse(JSON.stringify(value)), layouts: { ...fresh(time).layouts, ...JSON.parse(JSON.stringify(value.layouts)) } } : fresh(time);
|
|
22
24
|
}
|
|
23
25
|
observe(id, presentation, event) {
|
|
24
26
|
if (!names.includes(presentation) || !id || id.length > 8192)
|
|
@@ -2,7 +2,43 @@ import type { DiscoveryLearning } from './discovery-learning.js';
|
|
|
2
2
|
import type { DiscoveryLayout, Post } from '../types.js';
|
|
3
3
|
import type { PostsResource } from './posts.js';
|
|
4
4
|
import type { ProtocolsResource, SocialNetwork, SocialPost } from './protocols.js';
|
|
5
|
-
export type
|
|
5
|
+
export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
|
|
6
|
+
export interface DiscoveryGroupCandidate {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string | null;
|
|
10
|
+
image?: string | null;
|
|
11
|
+
memberCount?: number | null;
|
|
12
|
+
postCount?: number | null;
|
|
13
|
+
isMember?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface DiscoveryPersonCandidate {
|
|
16
|
+
id: string;
|
|
17
|
+
name?: string | null;
|
|
18
|
+
username?: string | null;
|
|
19
|
+
image?: string | null;
|
|
20
|
+
bio?: string | null;
|
|
21
|
+
followers?: number | null;
|
|
22
|
+
isFollowing?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface DiscoveryTagCandidate {
|
|
25
|
+
id?: string | null;
|
|
26
|
+
name: string;
|
|
27
|
+
slug?: string | null;
|
|
28
|
+
postCount?: number | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* App-supplied candidate lists for recommendation modules. The session decides
|
|
32
|
+
* when to show one, which kind, dedupes across pages, and drops candidates the
|
|
33
|
+
* viewer already follows or joined. Each call is bounded to three seconds and a
|
|
34
|
+
* failure simply means no recommendation on that page.
|
|
35
|
+
*/
|
|
36
|
+
export interface DiscoveryRecommendationSources {
|
|
37
|
+
groups?: () => Promise<DiscoveryGroupCandidate[]>;
|
|
38
|
+
people?: () => Promise<DiscoveryPersonCandidate[]>;
|
|
39
|
+
tags?: () => Promise<DiscoveryTagCandidate[]>;
|
|
40
|
+
}
|
|
41
|
+
export type DiscoveryPostItem = {
|
|
6
42
|
key: string;
|
|
7
43
|
kind: 'minds';
|
|
8
44
|
post: Post;
|
|
@@ -11,18 +47,36 @@ export type DiscoveryItem = {
|
|
|
11
47
|
kind: 'social';
|
|
12
48
|
post: SocialPost;
|
|
13
49
|
};
|
|
50
|
+
export type DiscoveryItem = DiscoveryPostItem | {
|
|
51
|
+
key: string;
|
|
52
|
+
kind: 'group';
|
|
53
|
+
group: DiscoveryGroupCandidate;
|
|
54
|
+
} | {
|
|
55
|
+
key: string;
|
|
56
|
+
kind: 'person';
|
|
57
|
+
person: DiscoveryPersonCandidate;
|
|
58
|
+
} | {
|
|
59
|
+
key: string;
|
|
60
|
+
kind: 'tag';
|
|
61
|
+
tag: DiscoveryTagCandidate;
|
|
62
|
+
};
|
|
63
|
+
export declare const isDiscoveryPost: (item: DiscoveryItem) => item is DiscoveryPostItem;
|
|
64
|
+
/** At most this many candidates per recommendation module. */
|
|
65
|
+
export declare const RECOMMENDATION_MAX_ITEMS = 6;
|
|
14
66
|
/** json-render Spec: values are references to authenticated SDK results, never
|
|
15
67
|
* arbitrary executable components, links, generated text or provider markup. */
|
|
16
68
|
export interface DiscoveryModule {
|
|
17
69
|
id: string;
|
|
18
|
-
presentation: DiscoveryLayout['presentation'];
|
|
70
|
+
presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind;
|
|
19
71
|
source: DiscoveryLayout['source'];
|
|
72
|
+
/** Subject of the featured window (a browse-category id or `mixed`), for specific module copy. */
|
|
73
|
+
theme?: string;
|
|
20
74
|
items: DiscoveryItem[];
|
|
21
75
|
spec: {
|
|
22
76
|
root: 'module';
|
|
23
77
|
elements: {
|
|
24
78
|
module: {
|
|
25
|
-
type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo';
|
|
79
|
+
type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo' | 'DiscoveryThread' | 'DiscoveryLongRead' | 'DiscoveryGroups' | 'DiscoveryPeople' | 'DiscoveryTags';
|
|
26
80
|
props: {
|
|
27
81
|
itemKeys: string[];
|
|
28
82
|
};
|
|
@@ -37,6 +91,8 @@ export interface DiscoveryFeedPage {
|
|
|
37
91
|
failedSources: string[];
|
|
38
92
|
partialCategories: boolean;
|
|
39
93
|
}
|
|
94
|
+
/** A gallery strip holds up to twelve image posts gathered from the whole page. */
|
|
95
|
+
export declare const GALLERY_MAX_ITEMS = 12;
|
|
40
96
|
/** Per-viewer feed session. Create a new session when user, app, category or
|
|
41
97
|
* source changes. All reads retain existing server authorization/moderation.
|
|
42
98
|
* At most four requests per page; providers fail independently. */
|
|
@@ -45,16 +101,18 @@ export declare class DiscoveryFeedSession {
|
|
|
45
101
|
private protocols;
|
|
46
102
|
private category?;
|
|
47
103
|
private learning?;
|
|
104
|
+
private recommend?;
|
|
48
105
|
private streams;
|
|
49
106
|
private pending;
|
|
50
107
|
private seen;
|
|
51
108
|
private page;
|
|
52
109
|
private nextSource;
|
|
53
|
-
constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined);
|
|
110
|
+
constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined);
|
|
54
111
|
retryFailed(): void;
|
|
55
112
|
next(): Promise<DiscoveryFeedPage>;
|
|
56
113
|
private read;
|
|
57
114
|
private readPage;
|
|
115
|
+
private recommendation;
|
|
58
116
|
}
|
|
59
117
|
export declare class DiscoveryResource {
|
|
60
118
|
private posts;
|
|
@@ -65,5 +123,6 @@ export declare class DiscoveryResource {
|
|
|
65
123
|
category?: string;
|
|
66
124
|
source?: string;
|
|
67
125
|
learning?: DiscoveryLearning;
|
|
126
|
+
recommend?: DiscoveryRecommendationSources;
|
|
68
127
|
}): DiscoveryFeedSession;
|
|
69
128
|
}
|
|
@@ -1,5 +1,43 @@
|
|
|
1
|
+
export const isDiscoveryPost = (item) => item.kind === 'minds' || item.kind === 'social';
|
|
2
|
+
/** At most this many candidates per recommendation module. */
|
|
3
|
+
export const RECOMMENDATION_MAX_ITEMS = 6;
|
|
4
|
+
const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
|
|
5
|
+
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
|
|
6
|
+
function recommendationItem(kind, candidate) {
|
|
7
|
+
if (!candidate || typeof candidate !== 'object')
|
|
8
|
+
return null;
|
|
9
|
+
const value = candidate;
|
|
10
|
+
if (kind === 'groups') {
|
|
11
|
+
if (!nonEmpty(value.id) || !nonEmpty(value.name) || value.isMember === true)
|
|
12
|
+
return null;
|
|
13
|
+
return { key: `rec:group:${value.id}`, kind: 'group', group: value };
|
|
14
|
+
}
|
|
15
|
+
if (kind === 'people') {
|
|
16
|
+
if (!nonEmpty(value.id) || value.isFollowing === true)
|
|
17
|
+
return null;
|
|
18
|
+
return { key: `rec:person:${value.id}`, kind: 'person', person: value };
|
|
19
|
+
}
|
|
20
|
+
if (!nonEmpty(value.name))
|
|
21
|
+
return null;
|
|
22
|
+
return { key: `rec:tag:${(nonEmpty(value.slug) ? value.slug : value.name).toLowerCase()}`, kind: 'tag', tag: value };
|
|
23
|
+
}
|
|
1
24
|
const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
|
|
2
25
|
const key = (protocol, id) => JSON.stringify([protocol, id]);
|
|
26
|
+
const presentations = new Set(['posts', 'visual', 'conversation', 'video', 'thread', 'longread']);
|
|
27
|
+
/** A gallery strip holds up to twelve image posts gathered from the whole page. */
|
|
28
|
+
export const GALLERY_MAX_ITEMS = 12;
|
|
29
|
+
/** Minimum text for a reading card; mirrors the server's DISCOVERY_LONGREAD_MIN_CHARS. */
|
|
30
|
+
const LONGREAD_MIN_CHARS = 600;
|
|
31
|
+
const quotesAnother = (item) => item.kind === 'social' ? item.post.quote?.state === 'available' : !!item.post.reposted_from && !!item.post.content?.trim();
|
|
32
|
+
const textLength = (item) => item.kind === 'social' ? item.post.text.length : item.post.content?.length ?? 0;
|
|
33
|
+
/** The layout for one response position: its window's judgment when the server provided windows, else the batch-wide one. */
|
|
34
|
+
function windowLayout(layout, index) {
|
|
35
|
+
const window = layout.windows?.find(candidate => Number.isInteger(candidate.start) && Number.isInteger(candidate.end) && index >= candidate.start && index < candidate.end);
|
|
36
|
+
if (!window || !presentations.has(window.presentation) || !['jev', 'fallback'].includes(window.source))
|
|
37
|
+
return layout;
|
|
38
|
+
const theme = typeof window.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(window.theme) ? window.theme : undefined;
|
|
39
|
+
return { version: layout.version, presentation: window.presentation, source: window.source, ...(theme ? { theme } : {}) };
|
|
40
|
+
}
|
|
3
41
|
/** Per-viewer feed session. Create a new session when user, app, category or
|
|
4
42
|
* source changes. All reads retain existing server authorization/moderation.
|
|
5
43
|
* At most four requests per page; providers fail independently. */
|
|
@@ -8,16 +46,18 @@ export class DiscoveryFeedSession {
|
|
|
8
46
|
protocols;
|
|
9
47
|
category;
|
|
10
48
|
learning;
|
|
49
|
+
recommend;
|
|
11
50
|
streams;
|
|
12
51
|
pending = null;
|
|
13
52
|
seen = new Set();
|
|
14
53
|
page = 0;
|
|
15
54
|
nextSource = 0;
|
|
16
|
-
constructor(posts, protocols, networks, category, source, learning) {
|
|
55
|
+
constructor(posts, protocols, networks, category, source, learning, recommend) {
|
|
17
56
|
this.posts = posts;
|
|
18
57
|
this.protocols = protocols;
|
|
19
58
|
this.category = category;
|
|
20
59
|
this.learning = learning;
|
|
60
|
+
this.recommend = recommend;
|
|
21
61
|
const sources = [...new Set(networks.filter(n => n.capabilities.includes('public_trends')).map(n => n.protocol))].slice(0, 16);
|
|
22
62
|
this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
|
|
23
63
|
.map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
|
|
@@ -31,7 +71,7 @@ export class DiscoveryFeedSession {
|
|
|
31
71
|
return this.pending;
|
|
32
72
|
}
|
|
33
73
|
async read(stream) {
|
|
34
|
-
const params = { limit:
|
|
74
|
+
const params = { limit: 25, ...(this.category ? { category: this.category } : {}), discovery: true };
|
|
35
75
|
if (stream.protocol === 'minds') {
|
|
36
76
|
const response = await this.posts.list({ ...params, sort: 'new', ...(stream.cursor ? { cursor: stream.cursor } : { offset: stream.offset }) });
|
|
37
77
|
stream.offset += response.meta.scanned ?? response.meta.limit;
|
|
@@ -110,6 +150,8 @@ export class DiscoveryFeedSession {
|
|
|
110
150
|
}
|
|
111
151
|
}));
|
|
112
152
|
// Round-robin before grouping: the largest network cannot occupy every slot.
|
|
153
|
+
// Each item carries the judgment for its own window of its source response,
|
|
154
|
+
// so one page can feature several modules instead of one per source batch.
|
|
113
155
|
const mixed = [];
|
|
114
156
|
for (let index = 0; index < Math.max(0, ...reads.map(read => read.items.length)); index++)
|
|
115
157
|
for (const read of reads) {
|
|
@@ -117,7 +159,7 @@ export class DiscoveryFeedSession {
|
|
|
117
159
|
if (!item || this.seen.has(item.key))
|
|
118
160
|
continue;
|
|
119
161
|
this.seen.add(item.key);
|
|
120
|
-
mixed.push({ item, layout: read.layout });
|
|
162
|
+
mixed.push({ item, layout: windowLayout(read.layout, index) });
|
|
121
163
|
}
|
|
122
164
|
// Deduplication memory is bounded independently of the UI's virtual window.
|
|
123
165
|
while (this.seen.size > 4096)
|
|
@@ -125,31 +167,89 @@ export class DiscoveryFeedSession {
|
|
|
125
167
|
const modules = [];
|
|
126
168
|
const page = this.page++;
|
|
127
169
|
let remaining = mixed;
|
|
170
|
+
// One gallery strip per page: it may gather images from the whole page, so a
|
|
171
|
+
// second one would only sweep up leftovers into a stub.
|
|
172
|
+
let galleryMinted = false;
|
|
128
173
|
while (remaining.length) {
|
|
129
174
|
const index = modules.length;
|
|
130
175
|
const first = remaining[0];
|
|
131
176
|
const proposal = first.layout.source === 'jev' ? first.layout.presentation : 'posts';
|
|
132
177
|
const preferred = this.learning?.choose(proposal, page) ?? proposal;
|
|
133
|
-
// Feature eligible posts
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const
|
|
178
|
+
// Feature eligible posts, retaining the other posts in their original
|
|
179
|
+
// order. A gallery is a horizontal strip, so it may gather image posts
|
|
180
|
+
// from the whole page (up to twelve); conversations and threads stay
|
|
181
|
+
// near their position so the vertical feed keeps its sense of time.
|
|
182
|
+
const group = remaining.slice(0, 12);
|
|
183
|
+
const images = remaining.filter(row => row.item.post.media?.some(media => media.type === 'image')).slice(0, GALLERY_MAX_ITEMS);
|
|
184
|
+
const conversations = group.filter(row => row.item.kind === 'social' ? (row.item.post.replyCount ?? 0) > 0 : (row.item.post.reply_count ?? 0) > 0).slice(0, 6);
|
|
185
|
+
const quotes = group.filter(row => quotesAnother(row.item)).slice(0, 6);
|
|
139
186
|
const video = preferred === 'video' && first.item.post.media?.some(media => media.type === 'video');
|
|
140
|
-
const visual = preferred === 'visual' && images.length >= 2;
|
|
187
|
+
const visual = preferred === 'visual' && !galleryMinted && images.length >= 2;
|
|
188
|
+
if (visual)
|
|
189
|
+
galleryMinted = true;
|
|
141
190
|
const discussion = preferred === 'conversation' && conversations.length >= 2;
|
|
142
|
-
const
|
|
143
|
-
|
|
191
|
+
const thread = preferred === 'thread' && quotes.length >= 2;
|
|
192
|
+
// A reading card features the post itself, never a neighbour: the first row must be the long one.
|
|
193
|
+
const longread = preferred === 'longread' && textLength(first.item) >= LONGREAD_MIN_CHARS;
|
|
194
|
+
const presentation = video ? 'video' : visual ? 'visual' : discussion ? 'conversation' : thread ? 'thread' : longread ? 'longread' : 'posts';
|
|
195
|
+
const selected = visual ? images : discussion ? conversations : thread ? quotes : [first];
|
|
144
196
|
const items = selected.map(row => row.item);
|
|
145
|
-
|
|
146
|
-
|
|
197
|
+
const type = video ? 'DiscoveryVideo' : visual ? 'DiscoveryGallery' : discussion ? 'DiscoveryConversation' : thread ? 'DiscoveryThread' : longread ? 'DiscoveryLongRead' : 'DiscoveryPosts';
|
|
198
|
+
const theme = presentation !== 'posts' && first.layout.theme && first.layout.theme !== 'mixed' ? first.layout.theme : undefined;
|
|
199
|
+
modules.push({ id: `discovery-${page}-${index}-${items[0].key}`, presentation, source: presentation === 'posts' ? 'fallback' : 'jev', items, ...(theme ? { theme } : {}),
|
|
200
|
+
spec: { root: 'module', elements: { module: { type, props: { itemKeys: items.map(item => item.key) }, children: [] } } } });
|
|
147
201
|
const selectedKeys = new Set(items.map(item => item.key));
|
|
148
202
|
remaining = remaining.filter(row => !selectedKeys.has(row.item.key));
|
|
149
203
|
}
|
|
204
|
+
// One recommendation module per page with posts, rotating kind, placed after
|
|
205
|
+
// the opening modules so the page still leads with content.
|
|
206
|
+
const recommendation = modules.length ? await this.recommendation(page) : null;
|
|
207
|
+
if (recommendation)
|
|
208
|
+
modules.splice(Math.min(2, modules.length), 0, recommendation);
|
|
150
209
|
return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
|
|
151
210
|
failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial) };
|
|
152
211
|
}
|
|
212
|
+
async recommendation(page) {
|
|
213
|
+
const kinds = RECOMMENDATION_KINDS.filter(kind => typeof this.recommend?.[kind] === 'function');
|
|
214
|
+
const kind = kinds[page % Math.max(1, kinds.length)];
|
|
215
|
+
if (!kind)
|
|
216
|
+
return null;
|
|
217
|
+
// A viewer who keeps hiding a kind stops seeing it, through the same bandit as layouts.
|
|
218
|
+
if (this.learning && this.learning.choose(kind, page) === 'posts')
|
|
219
|
+
return null;
|
|
220
|
+
let candidates;
|
|
221
|
+
let cancel = () => { };
|
|
222
|
+
try {
|
|
223
|
+
candidates = await Promise.race([this.recommend[kind](), new Promise((_resolve, reject) => {
|
|
224
|
+
const timer = setTimeout(() => reject(new Error('Recommendation source timed out')), 3000);
|
|
225
|
+
cancel = () => clearTimeout(timer);
|
|
226
|
+
})]);
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
cancel();
|
|
233
|
+
}
|
|
234
|
+
if (!Array.isArray(candidates))
|
|
235
|
+
return null;
|
|
236
|
+
const items = [];
|
|
237
|
+
for (const candidate of candidates) {
|
|
238
|
+
const item = recommendationItem(kind, candidate);
|
|
239
|
+
if (!item || this.seen.has(item.key) || items.some(existing => existing.key === item.key))
|
|
240
|
+
continue;
|
|
241
|
+
items.push(item);
|
|
242
|
+
if (items.length >= RECOMMENDATION_MAX_ITEMS)
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
if (items.length < 2)
|
|
246
|
+
return null;
|
|
247
|
+
for (const item of items)
|
|
248
|
+
this.seen.add(item.key);
|
|
249
|
+
const type = kind === 'groups' ? 'DiscoveryGroups' : kind === 'people' ? 'DiscoveryPeople' : 'DiscoveryTags';
|
|
250
|
+
return { id: `discovery-${page}-recommend-${kind}`, presentation: kind, source: 'fallback', items,
|
|
251
|
+
spec: { root: 'module', elements: { module: { type, props: { itemKeys: items.map(item => item.key) }, children: [] } } } };
|
|
252
|
+
}
|
|
153
253
|
}
|
|
154
254
|
export class DiscoveryResource {
|
|
155
255
|
posts;
|
|
@@ -159,6 +259,6 @@ export class DiscoveryResource {
|
|
|
159
259
|
this.protocols = protocols;
|
|
160
260
|
}
|
|
161
261
|
createFeed(options) {
|
|
162
|
-
return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning);
|
|
262
|
+
return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend);
|
|
163
263
|
}
|
|
164
264
|
}
|
|
@@ -989,6 +989,24 @@ export declare class ProtocolsResource {
|
|
|
989
989
|
post: string;
|
|
990
990
|
readRef?: string;
|
|
991
991
|
}): Promise<SingleResponse<SocialConversation>>;
|
|
992
|
+
/** Public, app-moderated discovery without account authority. Enrichment uses cached judgments only. */
|
|
993
|
+
getSharedTrends(projectId: string, params: {
|
|
994
|
+
protocol: string;
|
|
995
|
+
limit?: number;
|
|
996
|
+
cursor?: string;
|
|
997
|
+
includePreviews?: boolean;
|
|
998
|
+
category?: string;
|
|
999
|
+
discovery?: boolean;
|
|
1000
|
+
}): Promise<SingleResponse<SocialTrends>>;
|
|
1001
|
+
/** Open a provider-listed topic through the same public-only app boundary. */
|
|
1002
|
+
getSharedTrendFeed(projectId: string, params: {
|
|
1003
|
+
protocol: string;
|
|
1004
|
+
topic: string;
|
|
1005
|
+
limit?: number;
|
|
1006
|
+
cursor?: string;
|
|
1007
|
+
category?: string;
|
|
1008
|
+
discovery?: boolean;
|
|
1009
|
+
}): Promise<SingleResponse<SocialTrendFeed>>;
|
|
992
1010
|
publicNetworks(): Promise<SingleResponse<SocialNetwork[]>>;
|
|
993
1011
|
/** Query the live public network. It does not import users or posts. */
|
|
994
1012
|
searchPublicPosts(params: SearchPublicPostsParams): Promise<SingleResponse<SocialPostSearch>>;
|
|
@@ -70,6 +70,16 @@ export class ProtocolsResource {
|
|
|
70
70
|
protocol: params.protocol, post: params.post, readRef: params.readRef,
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
|
+
/** Public, app-moderated discovery without account authority. Enrichment uses cached judgments only. */
|
|
74
|
+
getSharedTrends(projectId, params) {
|
|
75
|
+
return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/trends`, { protocol: params.protocol, limit: params.limit, cursor: params.cursor,
|
|
76
|
+
includePreviews: params.includePreviews, category: params.category, discovery: params.discovery });
|
|
77
|
+
}
|
|
78
|
+
/** Open a provider-listed topic through the same public-only app boundary. */
|
|
79
|
+
getSharedTrendFeed(projectId, params) {
|
|
80
|
+
return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/trend-feed`, { protocol: params.protocol, topic: params.topic,
|
|
81
|
+
limit: params.limit, cursor: params.cursor, category: params.category, discovery: params.discovery });
|
|
82
|
+
}
|
|
73
83
|
publicNetworks() {
|
|
74
84
|
return this.client.get('/protocols/social/networks');
|
|
75
85
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -5,10 +5,22 @@ export interface PaginationParams {
|
|
|
5
5
|
cursor?: string;
|
|
6
6
|
[key: string]: unknown;
|
|
7
7
|
}
|
|
8
|
+
/** One judgment per consecutive window of the response; `start`/`end` index the response order. */
|
|
9
|
+
export interface DiscoveryLayoutWindow {
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
presentation: 'posts' | 'visual' | 'conversation' | 'video' | 'thread' | 'longread';
|
|
13
|
+
source: 'jev' | 'fallback';
|
|
14
|
+
/** Browse-category id (or `mixed`) the window's posts share; lets a module be titled after its subject. */
|
|
15
|
+
theme?: string;
|
|
16
|
+
}
|
|
8
17
|
export interface DiscoveryLayout {
|
|
9
18
|
version: 'discovery-layout-v1';
|
|
10
|
-
|
|
19
|
+
/** The first featured window's presentation; readers that understand `windows` use those instead. */
|
|
20
|
+
presentation: 'posts' | 'visual' | 'conversation' | 'video' | 'thread' | 'longread';
|
|
11
21
|
source: 'jev' | 'fallback';
|
|
22
|
+
theme?: string;
|
|
23
|
+
windows?: DiscoveryLayoutWindow[];
|
|
12
24
|
}
|
|
13
25
|
export interface PaginationMeta {
|
|
14
26
|
discovery?: DiscoveryLayout;
|
package/llm.md
CHANGED
|
@@ -637,6 +637,7 @@ if (subscription.active) {
|
|
|
637
637
|
- 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, 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 and exact `wireText`; connection is not publication approval. `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.
|
|
638
638
|
- `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 }); 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. 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`.
|
|
639
639
|
- 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.
|
|
640
|
+
- Anonymous discovery: `const visitor = new Recursiv({ anonymous: true }); 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.
|
|
640
641
|
- Public trends: require `public_trends` from `publicNetworks()`, then `getPublicTrends({ protocol, limit?, cursor? })`; 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.
|
|
641
642
|
- `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.
|
|
642
643
|
- 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`.
|