@recursiv/sdk 0.7.82 → 0.7.89
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 +128 -1
- package/dist/assistant/fixedCardData.d.ts +91 -0
- package/dist/assistant/fixedCardData.js +372 -0
- package/dist/assistant/fixedCards.d.ts +35 -0
- package/dist/assistant/fixedCards.js +904 -0
- package/dist/assistant/hiddenText.d.ts +24 -0
- package/dist/assistant/hiddenText.js +263 -0
- package/dist/assistant/index.d.ts +30 -0
- package/dist/assistant/index.js +30 -0
- package/dist/assistant/render.d.ts +31 -0
- package/dist/assistant/render.js +434 -0
- package/dist/assistant/subjectBundle.d.ts +30 -0
- package/dist/assistant/subjectBundle.js +535 -0
- package/dist/assistant/turnCaps.d.ts +3 -0
- package/dist/assistant/turnCaps.js +141 -0
- package/dist/assistant/types.d.ts +569 -0
- package/dist/assistant/types.js +26 -0
- package/dist/assistant-surfaces.d.ts +42 -0
- package/dist/assistant-surfaces.js +113 -0
- package/dist/index.d.ts +15 -6
- package/dist/index.js +8 -1
- 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/realtime.d.ts +10 -3
- package/dist/realtime.js +11 -2
- package/dist/resources/agents.d.ts +6 -1
- package/dist/resources/agents.js +12 -2
- 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/e2ee.d.ts +56 -0
- package/dist/resources/e2ee.js +21 -0
- 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/link-preview.d.ts +42 -1
- package/dist/resources/link-preview.js +36 -2
- package/dist/resources/network.d.ts +6 -0
- package/dist/resources/privacy.d.ts +13 -0
- package/dist/resources/privacy.js +14 -0
- package/dist/resources/project-brain.d.ts +8 -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/users.d.ts +11 -2
- package/dist/resources/users.js +1 -1
- package/dist/social-protocol-ids.d.ts +28 -0
- package/dist/social-protocol-ids.js +30 -0
- package/dist/types.d.ts +71 -0
- package/llm.md +45 -4
- package/package.json +11 -1
|
@@ -54,6 +54,20 @@ export declare function discoveryRowTitle(row: unknown): string | null;
|
|
|
54
54
|
export declare function discoveryLeadable(item: DiscoveryPostItem): boolean;
|
|
55
55
|
/** A post with a picture or a video of its own that the reader sees unblurred. */
|
|
56
56
|
export declare function discoveryShowsMedia(item: DiscoveryPostItem): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* What makes an item the same post as another on one screen: its key, and for an open-network post its network and id
|
|
59
|
+
* as the network spells it (a Nostr event id in any case) and each picture or clip file it carries (by address, and by
|
|
60
|
+
* content hash for a file named by one). Two notes that carry one clip are one post to a reader: a bot posting it
|
|
61
|
+
* again, a mirror account, the source index's copy beside the provider's. A Minds post is its key alone.
|
|
62
|
+
*/
|
|
63
|
+
export declare function discoveryItemIdentities(item: DiscoveryItem): string[];
|
|
64
|
+
/**
|
|
65
|
+
* The rows with each post shown once: a post item whose identity (discoveryItemIdentities) is in `shown`, or was on an
|
|
66
|
+
* earlier item of the rows, leaves its module, which keeps its spec in step; a module of posts left with none is dropped.
|
|
67
|
+
* A story keeps its headline when its posts go, and rows that are not modules pass as they are. `shown` gains the
|
|
68
|
+
* identities of every post kept.
|
|
69
|
+
*/
|
|
70
|
+
export declare function withoutRepeatedDiscoveryItems<T>(rows: readonly T[], shown?: Set<string>): T[];
|
|
57
71
|
/** A player found this video file (or HLS playlist) will not play: no Discover module promotes it, and it gets no video boost. */
|
|
58
72
|
export declare function reportDiscoveryVideoUnavailable(url: string): void;
|
|
59
73
|
/** Forget every report (a test seam, or a new viewer). */
|
|
@@ -151,7 +165,9 @@ export declare function weaveDiscoveryArrival<T extends {
|
|
|
151
165
|
* that are not posts (a story, a rail, a row of the client's own) take the places they would have had woven in one by
|
|
152
166
|
* one, and the posts fill the other places by engagement under the network mix (DiscoveryEngagementMerge). The first
|
|
153
167
|
* post at the top of the feed, or below the line where it turned (`floor`), leads what comes under it (takeLead): never
|
|
154
|
-
* a post with no picture or clip that nobody engaged with while the batch holds one with either.
|
|
168
|
+
* a post with no picture or clip that nobody engaged with while the batch holds one with either. A post the rows already
|
|
169
|
+
* show, or that arrives twice (the same open-network post, or another note with the same clip: discoveryItemIdentities),
|
|
170
|
+
* is shown once, where it was first.
|
|
155
171
|
*/
|
|
156
172
|
export declare function mergeDiscoveryArrival<T extends {
|
|
157
173
|
id: string;
|
|
@@ -130,6 +130,79 @@ export function discoveryLeadable(item) {
|
|
|
130
130
|
export function discoveryShowsMedia(item) {
|
|
131
131
|
return discoveryLeadable(item) && hasMedia(item.post.media);
|
|
132
132
|
}
|
|
133
|
+
// ── One post, shown once ────────────────────────────────────────────────
|
|
134
|
+
/** A file named by its SHA-256 (Blossom, nostr.build): the same bytes wherever they are served from. */
|
|
135
|
+
const CONTENT_HASH = /\/([0-9a-f]{64})(?:\.[a-z0-9]{1,8})?$/i;
|
|
136
|
+
/**
|
|
137
|
+
* What makes an item the same post as another on one screen: its key, and for an open-network post its network and id
|
|
138
|
+
* as the network spells it (a Nostr event id in any case) and each picture or clip file it carries (by address, and by
|
|
139
|
+
* content hash for a file named by one). Two notes that carry one clip are one post to a reader: a bot posting it
|
|
140
|
+
* again, a mirror account, the source index's copy beside the provider's. A Minds post is its key alone.
|
|
141
|
+
*/
|
|
142
|
+
export function discoveryItemIdentities(item) {
|
|
143
|
+
if (item.kind !== 'social')
|
|
144
|
+
return [item.key];
|
|
145
|
+
const post = item.post;
|
|
146
|
+
const identities = [item.key];
|
|
147
|
+
if (typeof post.protocol === 'string' && typeof post.nativeId === 'string' && post.nativeId) {
|
|
148
|
+
identities.push(`post:${post.protocol}:${post.protocol === 'nostr' ? post.nativeId.toLowerCase() : post.nativeId}`);
|
|
149
|
+
}
|
|
150
|
+
for (const entry of Array.isArray(post.media) ? post.media : []) {
|
|
151
|
+
const url = entry?.url;
|
|
152
|
+
if (typeof url !== 'string' || !/^https?:\/\//i.test(url))
|
|
153
|
+
continue;
|
|
154
|
+
const address = url.split('#')[0];
|
|
155
|
+
identities.push(`media:${address}`);
|
|
156
|
+
const hash = CONTENT_HASH.exec(address.split('?')[0])?.[1];
|
|
157
|
+
if (hash)
|
|
158
|
+
identities.push(`blob:${hash.toLowerCase()}`);
|
|
159
|
+
}
|
|
160
|
+
return identities;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The rows with each post shown once: a post item whose identity (discoveryItemIdentities) is in `shown`, or was on an
|
|
164
|
+
* earlier item of the rows, leaves its module, which keeps its spec in step; a module of posts left with none is dropped.
|
|
165
|
+
* A story keeps its headline when its posts go, and rows that are not modules pass as they are. `shown` gains the
|
|
166
|
+
* identities of every post kept.
|
|
167
|
+
*/
|
|
168
|
+
export function withoutRepeatedDiscoveryItems(rows, shown = new Set()) {
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const row of rows) {
|
|
171
|
+
if (!isModule(row)) {
|
|
172
|
+
out.push(row);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const items = row.items.filter(item => {
|
|
176
|
+
if (!isPostItem(item))
|
|
177
|
+
return true;
|
|
178
|
+
const identities = discoveryItemIdentities(item);
|
|
179
|
+
if (identities.some(identity => shown.has(identity)))
|
|
180
|
+
return false;
|
|
181
|
+
for (const identity of identities)
|
|
182
|
+
shown.add(identity);
|
|
183
|
+
return true;
|
|
184
|
+
});
|
|
185
|
+
if (items.length === row.items.length) {
|
|
186
|
+
out.push(row);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (!items.some(item => isPostItem(item) || item.kind === 'cluster'))
|
|
190
|
+
continue;
|
|
191
|
+
out.push(withItems(row, items));
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/** The identities of every post the rows show (discoveryItemIdentities). */
|
|
196
|
+
function shownIdentities(rows) {
|
|
197
|
+
const shown = new Set();
|
|
198
|
+
for (const row of rows)
|
|
199
|
+
if (isModule(row))
|
|
200
|
+
for (const item of row.items)
|
|
201
|
+
if (isPostItem(item))
|
|
202
|
+
for (const identity of discoveryItemIdentities(item))
|
|
203
|
+
shown.add(identity);
|
|
204
|
+
return shown;
|
|
205
|
+
}
|
|
133
206
|
// ── Videos that can play ────────────────────────────────────────────────
|
|
134
207
|
/** Video files (or playlists) a player found will not play this session: never promoted again. */
|
|
135
208
|
const UNAVAILABLE_VIDEOS = new Set();
|
|
@@ -332,9 +405,12 @@ export function poolDiscoveryGalleries(modules, now, keep) {
|
|
|
332
405
|
for (const galleries of themes.values()) {
|
|
333
406
|
const seen = new Set();
|
|
334
407
|
const cards = galleries.flatMap((gallery, from) => gallery.items.filter(isPostItem).flatMap((item, index) => {
|
|
335
|
-
|
|
408
|
+
// One card per post: a picture two galleries (or two notes) carry is pooled once (discoveryItemIdentities).
|
|
409
|
+
const identities = discoveryItemIdentities(item);
|
|
410
|
+
if (identities.some(identity => seen.has(identity)))
|
|
336
411
|
return [];
|
|
337
|
-
|
|
412
|
+
for (const identity of identities)
|
|
413
|
+
seen.add(identity);
|
|
338
414
|
return [{ item, gallery, network: discoveryPostNetwork(item), rank: rankDiscoveryItem(item, reference, now), order: index * galleries.length + from }];
|
|
339
415
|
})).sort((a, b) => a.rank.tier - b.rank.tier || b.rank.score - a.rank.score || a.order - b.order);
|
|
340
416
|
const counts = new Map();
|
|
@@ -492,12 +568,16 @@ export function weaveDiscoveryArrival(rows, arriving, reached, floor = 0) {
|
|
|
492
568
|
* that are not posts (a story, a rail, a row of the client's own) take the places they would have had woven in one by
|
|
493
569
|
* one, and the posts fill the other places by engagement under the network mix (DiscoveryEngagementMerge). The first
|
|
494
570
|
* post at the top of the feed, or below the line where it turned (`floor`), leads what comes under it (takeLead): never
|
|
495
|
-
* a post with no picture or clip that nobody engaged with while the batch holds one with either.
|
|
571
|
+
* a post with no picture or clip that nobody engaged with while the batch holds one with either. A post the rows already
|
|
572
|
+
* show, or that arrives twice (the same open-network post, or another note with the same clip: discoveryItemIdentities),
|
|
573
|
+
* is shown once, where it was first.
|
|
496
574
|
*/
|
|
497
575
|
export function mergeDiscoveryArrival(rows, arriving, reached, floor = 0, now = Date.now()) {
|
|
498
576
|
const at = arrivalPlace(rows, reached, floor);
|
|
499
577
|
const head = rows.slice(0, at);
|
|
500
|
-
|
|
578
|
+
// A post already in the rows, or twice in what arrives, is shown once: where it was first (withoutRepeatedDiscoveryItems).
|
|
579
|
+
const fresh = withoutRepeatedDiscoveryItems(arriving, shownIdentities(rows));
|
|
580
|
+
const below = at >= rows.length ? fresh : weave(fresh, rows.slice(at));
|
|
501
581
|
const posts = below.filter(isDiscoveryPostModule);
|
|
502
582
|
if (!posts.length)
|
|
503
583
|
return [...head, ...below];
|
|
@@ -77,9 +77,10 @@ export declare const FIRST_SCREEN_MS = 2500;
|
|
|
77
77
|
*/
|
|
78
78
|
export declare const VISITOR_FIRST_SCREEN_MS = 800;
|
|
79
79
|
/**
|
|
80
|
-
* A module without the items in `seen` (item keys
|
|
81
|
-
* the sessions dedupe within themselves, but across them a story's
|
|
82
|
-
*
|
|
80
|
+
* A module without the items in `seen` (item keys, or any identity of a post: discoveryItemIdentities). Posts already
|
|
81
|
+
* shown by another source's module are not shown again: the sessions dedupe within themselves, but across them a story's
|
|
82
|
+
* member posts can also arrive in that network's own page, and one clip in two notes. A module that loses items keeps
|
|
83
|
+
* its spec in step. A suggestion already shown leaves its rail, and a rail left
|
|
83
84
|
* with fewer than two is dropped; a story already shown is dropped whole, and keeps its headline and outlets when only
|
|
84
85
|
* its posts were shown; a post module with none left is dropped (null).
|
|
85
86
|
*/
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFalloverSteps } from './discovery-fallover.js';
|
|
2
2
|
import { composeFirstScreen } from './discovery-first-screen.js';
|
|
3
|
+
import { discoveryItemIdentities } from './discovery-merge.js';
|
|
3
4
|
/** How long a round keeps waiting for its slower sources once one of them has answered. */
|
|
4
5
|
export const DISCOVERY_STRAGGLER_MS = 3000;
|
|
5
6
|
/**
|
|
@@ -22,9 +23,10 @@ const isSuggestion = (item) => item.kind === 'group' || item.kind === 'person' |
|
|
|
22
23
|
/** A suggestion rail with fewer is not drawn: the session builds none with fewer either. */
|
|
23
24
|
const SUGGESTIONS_MIN = 2;
|
|
24
25
|
/**
|
|
25
|
-
* A module without the items in `seen` (item keys
|
|
26
|
-
* the sessions dedupe within themselves, but across them a story's
|
|
27
|
-
*
|
|
26
|
+
* A module without the items in `seen` (item keys, or any identity of a post: discoveryItemIdentities). Posts already
|
|
27
|
+
* shown by another source's module are not shown again: the sessions dedupe within themselves, but across them a story's
|
|
28
|
+
* member posts can also arrive in that network's own page, and one clip in two notes. A module that loses items keeps
|
|
29
|
+
* its spec in step. A suggestion already shown leaves its rail, and a rail left
|
|
28
30
|
* with fewer than two is dropped; a story already shown is dropped whole, and keeps its headline and outlets when only
|
|
29
31
|
* its posts were shown; a post module with none left is dropped (null).
|
|
30
32
|
*/
|
|
@@ -32,7 +34,7 @@ export function withoutSeenItems(module, seen) {
|
|
|
32
34
|
const lead = module.items[0];
|
|
33
35
|
if (lead?.kind === 'cluster' && seen.has(lead.key))
|
|
34
36
|
return null;
|
|
35
|
-
const items = module.items.filter(item => item.kind === 'cluster' || !seen.has(
|
|
37
|
+
const items = module.items.filter(item => item.kind === 'cluster' || !discoveryItemIdentities(item).some(identity => seen.has(identity)));
|
|
36
38
|
if (items.length === module.items.length)
|
|
37
39
|
return module;
|
|
38
40
|
if (module.items.some(isSuggestion)) {
|
|
@@ -46,14 +48,15 @@ export function withoutSeenItems(module, seen) {
|
|
|
46
48
|
module: { ...element, props: { ...element.props, itemKeys: items.map(item => item.key) } } } } };
|
|
47
49
|
}
|
|
48
50
|
/**
|
|
49
|
-
* Claims the posts a module shows, and only those. A story claims the members
|
|
50
|
-
* the Minds session reads a story's network members only through its member
|
|
51
|
-
* missing. A member the story does not carry still shows in its network's own page.
|
|
51
|
+
* Claims the posts a module shows, and only those, by every identity (discoveryItemIdentities). A story claims the members
|
|
52
|
+
* it carries, never its whole member list: the Minds session reads a story's network members only through its member
|
|
53
|
+
* reader, which may fail, time out or be missing. A member the story does not carry still shows in its network's own page.
|
|
52
54
|
*/
|
|
53
55
|
function remember(module, seen) {
|
|
54
56
|
for (const item of module.items)
|
|
55
57
|
if (isPost(item))
|
|
56
|
-
|
|
58
|
+
for (const identity of discoveryItemIdentities(item))
|
|
59
|
+
seen.add(identity);
|
|
57
60
|
}
|
|
58
61
|
/** The other networks a feed over every source reads, in the session's own order. */
|
|
59
62
|
export function discoveryRemoteSources(networks, source) {
|
|
@@ -506,7 +506,15 @@ export interface DiscoveryModule {
|
|
|
506
506
|
id: string;
|
|
507
507
|
presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind | DiscoveryRadarPresentation;
|
|
508
508
|
source: DiscoveryLayout['source'];
|
|
509
|
-
/**
|
|
509
|
+
/**
|
|
510
|
+
* Subject of the module (a browse-category id), for specific module copy ("Videos in Science"). A post module carries
|
|
511
|
+
* one only when its featured window was themed so AND most of its own posts were judged into that category (0.7.84):
|
|
512
|
+
* a window's theme is one judgment of up to six posts taken together, and the module features some of them (one clip,
|
|
513
|
+
* a conversation) or gathers pictures from the whole page, so an open-network clip with no judgment of its own was
|
|
514
|
+
* titled after its neighbours ("Videos in Technology" over a Nostr clip of birds). Today a post is judged into a
|
|
515
|
+
* category when a category feed served it (the read's category filter), so modules on a feed with no category carry
|
|
516
|
+
* none. A radar story carries its cluster's theme. Absent: title the module by its kind alone.
|
|
517
|
+
*/
|
|
510
518
|
theme?: string;
|
|
511
519
|
items: DiscoveryItem[];
|
|
512
520
|
spec: {
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { SOCIAL_PROTOCOL_IDS } from '../social-protocol-ids.js';
|
|
1
2
|
import { readFalloverSteps } from './discovery-fallover.js';
|
|
3
|
+
import { discoveryItemIdentities } from './discovery-merge.js';
|
|
2
4
|
import { createDiscoveryTopShare, createProgressiveFeed } from './discovery-progressive.js';
|
|
3
5
|
export * from './discovery-fallover.js';
|
|
4
6
|
export * from './discovery-first-screen.js';
|
|
@@ -142,7 +144,8 @@ function recommendationItem(kind, candidate) {
|
|
|
142
144
|
return { key: `rec:tag:${(nonEmpty(value.slug) ? value.slug : value.name).toLowerCase()}`, kind: 'tag', tag: value };
|
|
143
145
|
}
|
|
144
146
|
const RADAR_KINDS = new Set(['breaking', 'viral', 'story']);
|
|
145
|
-
|
|
147
|
+
/** A radar member's open network (SOCIAL_PROTOCOL_IDS, Farcaster included before the radar holds any); another kind is skipped. */
|
|
148
|
+
const RADAR_SOCIAL_KINDS = new Set(SOCIAL_PROTOCOL_IDS);
|
|
146
149
|
const httpUrl = (value) => typeof value === 'string' && value.length <= 4096 && /^https?:\/\//i.test(value);
|
|
147
150
|
const countOf = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
148
151
|
const dimension = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
@@ -271,7 +274,8 @@ export function pickRadarCluster(clusters, page, options = {}) {
|
|
|
271
274
|
}
|
|
272
275
|
return breaking[0] ?? unused[0];
|
|
273
276
|
}
|
|
274
|
-
|
|
277
|
+
/** A feed top's open-network item or lane (SOCIAL_PROTOCOL_IDS); a session keeps only the networks it reads (`readyTop`). */
|
|
278
|
+
const TOP_NETWORKS = new Set(SOCIAL_PROTOCOL_IDS);
|
|
275
279
|
const idString = (value) => typeof value === 'string' && value.length > 0 && value.length <= 4096;
|
|
276
280
|
const counted = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
277
281
|
/** The refs an app's seen set may hold for a post: its id, or '<protocol>/<nativeId>' (an ActivityPub id also without https://). */
|
|
@@ -339,6 +343,19 @@ function topItemSeenRefs(value) {
|
|
|
339
343
|
}
|
|
340
344
|
const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
|
|
341
345
|
const key = (protocol, id) => JSON.stringify([protocol, id]);
|
|
346
|
+
/** The judgment of a read made in `category`: its category filter's selection, when that is the category asked. */
|
|
347
|
+
const judgedInto = (category, filter) => (category && filter?.selected === category ? { judged: category } : {});
|
|
348
|
+
/** The judgment of a read's item at `index`. */
|
|
349
|
+
const judgmentAt = (read, index) => (read.judgments ? read.judgments[index] : read.judged);
|
|
350
|
+
/**
|
|
351
|
+
* A featured module's theme: its lead window's, only when most of the module's own posts were judged into it (see
|
|
352
|
+
* DiscoveryModule.theme). Never `mixed`.
|
|
353
|
+
*/
|
|
354
|
+
function judgedTheme(theme, judged) {
|
|
355
|
+
if (!theme || theme === 'mixed' || !judged.length)
|
|
356
|
+
return undefined;
|
|
357
|
+
return judged.filter(category => category === theme).length * 2 > judged.length ? theme : undefined;
|
|
358
|
+
}
|
|
342
359
|
const presentations = new Set(['posts', 'visual', 'conversation', 'video', 'thread', 'longread']);
|
|
343
360
|
/** A gallery strip holds up to twelve image posts gathered from the whole page. */
|
|
344
361
|
export const GALLERY_MAX_ITEMS = 12;
|
|
@@ -464,7 +481,7 @@ export class DiscoveryFeedSession {
|
|
|
464
481
|
? { key: key(row.post.protocol, row.post.nativeId), kind: 'social', post: row.post }
|
|
465
482
|
: { key: key('minds', row.id), kind: 'minds', post: row }));
|
|
466
483
|
const read = { layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial',
|
|
467
|
-
...(response.meta.compass_filter ? { compass: response.meta.compass_filter } : {}) };
|
|
484
|
+
...(response.meta.compass_filter ? { compass: response.meta.compass_filter } : {}), ...judgedInto(this.category, response.meta.category_filter) };
|
|
468
485
|
if (!this.filtering)
|
|
469
486
|
return { items, ...read };
|
|
470
487
|
head ??= read;
|
|
@@ -518,7 +535,7 @@ export class DiscoveryFeedSession {
|
|
|
518
535
|
const kept = data.items.flatMap((post, index) => this.metNetworkPost(post) ? [] : [{ post, index }]);
|
|
519
536
|
const layout = data.discovery ?? ordinary;
|
|
520
537
|
return { items: kept.map(({ post }) => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout, partial: data.categoryFilter?.status === 'partial',
|
|
521
|
-
...(kept.length !== data.items.length ? { layouts: kept.map(({ index }) => windowLayout(layout, index)) } : {}) };
|
|
538
|
+
...(kept.length !== data.items.length ? { layouts: kept.map(({ index }) => windowLayout(layout, index)) } : {}), ...judgedInto(this.category, data.categoryFilter) };
|
|
522
539
|
}
|
|
523
540
|
/** An open-network post in the member's seen set (options.seen: '<protocol>/<nativeId>', an ActivityPub id also without https://). */
|
|
524
541
|
metNetworkPost(post) {
|
|
@@ -607,7 +624,8 @@ export class DiscoveryFeedSession {
|
|
|
607
624
|
const items = (indexed ? data.items.filter(post => !this.seen.has(key(post.protocol, post.nativeId))) : data.items).filter(post => !this.metNetworkPost(post));
|
|
608
625
|
if (items.length) {
|
|
609
626
|
stream.done = !topics.length;
|
|
610
|
-
return { items: items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial
|
|
627
|
+
return { items: items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial,
|
|
628
|
+
...judgedInto(this.category, data.categoryFilter) };
|
|
611
629
|
}
|
|
612
630
|
}
|
|
613
631
|
while (topics[0] && passedOver() && !topics[0].cursor?.startsWith(INDEX_CURSOR))
|
|
@@ -680,17 +698,25 @@ export class DiscoveryFeedSession {
|
|
|
680
698
|
// Round-robin before grouping: the largest network cannot occupy every slot.
|
|
681
699
|
// Each item carries the judgment for its own window of its source response,
|
|
682
700
|
// so one page can feature several modules instead of one per source batch.
|
|
701
|
+
// A post is shown once by every identity (discoveryItemIdentities): a second note carrying a clip already placed is
|
|
702
|
+
// the same post to a reader, on this page or a later one.
|
|
683
703
|
const mixed = [];
|
|
684
704
|
for (let index = 0; index < Math.max(0, ...reads.map(read => read.items.length)); index++)
|
|
685
705
|
for (const read of reads) {
|
|
686
706
|
const item = read.items[index];
|
|
687
|
-
if (!item
|
|
707
|
+
if (!item)
|
|
708
|
+
continue;
|
|
709
|
+
const identities = discoveryItemIdentities(item);
|
|
710
|
+
if (identities.some(identity => this.seen.has(identity)))
|
|
688
711
|
continue;
|
|
689
|
-
|
|
690
|
-
|
|
712
|
+
for (const identity of identities)
|
|
713
|
+
this.seen.add(identity);
|
|
714
|
+
const judged = judgmentAt(read, index);
|
|
715
|
+
mixed.push({ item, layout: read.layouts?.[index] ?? windowLayout(read.layout, index), ...(judged ? { judged } : {}) });
|
|
691
716
|
}
|
|
692
|
-
// Deduplication memory is bounded independently of the UI's virtual window
|
|
693
|
-
|
|
717
|
+
// Deduplication memory is bounded independently of the UI's virtual window: an open-network post holds a few
|
|
718
|
+
// identities (its key, its id and its files), so the bound that held 4096 keys holds four times as many entries.
|
|
719
|
+
while (this.seen.size > 16_384)
|
|
694
720
|
this.seen.delete(this.seen.values().next().value);
|
|
695
721
|
const modules = topItems.map((item, index) => this.topModule(this.page, index, item));
|
|
696
722
|
const page = this.page++;
|
|
@@ -707,9 +733,22 @@ export class DiscoveryFeedSession {
|
|
|
707
733
|
const onPage = remaining.filter(row => members.has(row.item.key)).map(row => row.item);
|
|
708
734
|
const carried = new Set(onPage.map(item => item.key));
|
|
709
735
|
// Members this page did not carry come from the story's own reads; one an earlier page showed stays shown there.
|
|
710
|
-
|
|
736
|
+
// They are taken one at a time in the story's member order, each adding its identities as it is kept, so two
|
|
737
|
+
// members of one read that carry one clip (a mirror note on another server) show once: the one the story lists first.
|
|
738
|
+
const order = (item) => cluster.memberKeys.indexOf(item.key);
|
|
739
|
+
const read = [];
|
|
740
|
+
for (const item of [...await this.memberPosts(cluster)].sort((a, b) => order(a) - order(b))) {
|
|
741
|
+
if (carried.has(item.key))
|
|
742
|
+
continue;
|
|
743
|
+
const identities = discoveryItemIdentities(item);
|
|
744
|
+
if (identities.some(identity => this.seen.has(identity)))
|
|
745
|
+
continue;
|
|
746
|
+
for (const identity of identities)
|
|
747
|
+
this.seen.add(identity);
|
|
748
|
+
read.push(item);
|
|
749
|
+
}
|
|
711
750
|
// Under the story, posts keep the server's member order: the lead media member first, then by member score.
|
|
712
|
-
clusterItems = [...onPage, ...read].sort((a, b) =>
|
|
751
|
+
clusterItems = [...onPage, ...read].sort((a, b) => order(a) - order(b));
|
|
713
752
|
remaining = remaining.filter(row => !members.has(row.item.key));
|
|
714
753
|
for (const memberKey of cluster.memberKeys)
|
|
715
754
|
this.seen.add(memberKey);
|
|
@@ -742,7 +781,8 @@ export class DiscoveryFeedSession {
|
|
|
742
781
|
const selected = visual ? images : discussion ? conversations : thread ? quotes : [first];
|
|
743
782
|
const items = selected.map(row => row.item);
|
|
744
783
|
const type = video ? 'DiscoveryVideo' : visual ? 'DiscoveryGallery' : discussion ? 'DiscoveryConversation' : thread ? 'DiscoveryThread' : longread ? 'DiscoveryLongRead' : 'DiscoveryPosts';
|
|
745
|
-
|
|
784
|
+
// Titled after its lead window's theme only when most of its own posts were judged into it (DiscoveryModule.theme).
|
|
785
|
+
const theme = presentation !== 'posts' ? judgedTheme(first.layout.theme, selected.map(row => row.judged)) : undefined;
|
|
746
786
|
modules.push({ id: `discovery-${page}-${index}-${items[0].key}`, presentation, source: presentation === 'posts' ? 'fallback' : 'jev', items, ...(theme ? { theme } : {}),
|
|
747
787
|
spec: { root: 'module', elements: { module: { type, props: { itemKeys: items.map(item => item.key) }, children: [] } } } });
|
|
748
788
|
const selectedKeys = new Set(items.map(item => item.key));
|
|
@@ -848,7 +888,7 @@ export class DiscoveryFeedSession {
|
|
|
848
888
|
}
|
|
849
889
|
const lanes = new Map();
|
|
850
890
|
const networks = value.networks && typeof value.networks === 'object' ? value.networks : {};
|
|
851
|
-
for (const protocol of
|
|
891
|
+
for (const protocol of SOCIAL_PROTOCOL_IDS) {
|
|
852
892
|
const entries = networks[protocol];
|
|
853
893
|
if (!streams.has(protocol) || !Array.isArray(entries))
|
|
854
894
|
continue;
|
|
@@ -917,7 +957,9 @@ export class DiscoveryFeedSession {
|
|
|
917
957
|
const read = at >= 0 ? reads[at] : null;
|
|
918
958
|
if (read) {
|
|
919
959
|
const kept = read.previews ? [] : read.items;
|
|
920
|
-
|
|
960
|
+
// The lane's posts lead the read's; they carry no judgment of the read's category.
|
|
961
|
+
reads[at] = { ...read, items: [...lane, ...kept], layouts: [...lane.map(() => ordinary), ...kept.map((_item, index) => read.layouts?.[index] ?? windowLayout(read.layout, index))],
|
|
962
|
+
judgments: [...lane.map(() => undefined), ...kept.map((_item, index) => judgmentAt(read, index))] };
|
|
921
963
|
}
|
|
922
964
|
else if (this.streams.some(stream => stream.protocol === protocol))
|
|
923
965
|
reads.push({ items: lane, layout: ordinary, partial: false });
|
|
@@ -930,10 +972,15 @@ export class DiscoveryFeedSession {
|
|
|
930
972
|
const found = top.posts.get(row.key) ?? carried.get(row.key);
|
|
931
973
|
if (!found || this.seen.has(row.key))
|
|
932
974
|
continue;
|
|
975
|
+
const identities = discoveryItemIdentities(found);
|
|
976
|
+
if (identities.some(identity => this.seen.has(identity)))
|
|
977
|
+
continue;
|
|
933
978
|
const position = top.positions.get(row.ref) ?? placed.length;
|
|
934
979
|
placed.push({ ...found, top: { ref: row.ref, position, block: position < 10 ? 'top' : 'below' } });
|
|
935
980
|
this.seen.add(row.key);
|
|
936
981
|
this.topKeys.add(row.key);
|
|
982
|
+
for (const identity of identities)
|
|
983
|
+
this.seen.add(identity);
|
|
937
984
|
}
|
|
938
985
|
return placed;
|
|
939
986
|
}
|
package/dist/resources/e2ee.d.ts
CHANGED
|
@@ -68,6 +68,38 @@ export interface E2eeAccountReset {
|
|
|
68
68
|
* - `conflict` (409): the device name or key was used before; start the device again with fresh ones.
|
|
69
69
|
*/
|
|
70
70
|
export type E2eeResetErrorCode = 'confirmation_failed' | 'code_limit' | 'no_email_on_file' | 'email_delivery_unavailable' | 'reset_required' | 'conflict';
|
|
71
|
+
/**
|
|
72
|
+
* One account whose main device a reset of encrypted chat replaced (`peerChanges`): `at` is when the new main
|
|
73
|
+
* device was registered (the relay's clock). Look at its devices again (`devices({ user_id })`).
|
|
74
|
+
*/
|
|
75
|
+
export interface E2eePeerChange {
|
|
76
|
+
userId: string;
|
|
77
|
+
at: string;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The realtime `e2ee:peer` event (`r.realtime.onE2eePeer`), sent to each member of an encrypted conversation the
|
|
81
|
+
* account `userId` shares with them, once per such conversation, when that account reset encrypted chat
|
|
82
|
+
* (`reason: 'root_changed'`): its security code changed there. Only to sockets whose credential can read that
|
|
83
|
+
* conversation over the relay. A hint: look at the account's devices again. An app in the background catches up
|
|
84
|
+
* with `peerChanges` when it comes to the foreground.
|
|
85
|
+
*/
|
|
86
|
+
export interface E2eePeerRealtimeEvent {
|
|
87
|
+
userId: string;
|
|
88
|
+
reason: 'root_changed';
|
|
89
|
+
conversationId: string;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Codes a message that declares `trust_change` can be refused with in a direct chat whose other person reset
|
|
93
|
+
* encrypted chat, where `status().accountReset` is on (409 `ConflictError` for both):
|
|
94
|
+
* - `peer_not_joined`: no device of this account has added the other person's new device to the chat yet, so the
|
|
95
|
+
* message would be unreadable on it. `details: { userId }` names the person. Add the device, then send again.
|
|
96
|
+
* - `stale_epoch`: encrypted under a key generation before the chat's last key update. Sync, then encrypt it again.
|
|
97
|
+
*/
|
|
98
|
+
export type E2eePeerErrorCode = 'peer_not_joined' | 'stale_epoch';
|
|
99
|
+
/** The `details` of a `peer_not_joined` refusal. */
|
|
100
|
+
export interface E2eePeerNotJoinedDetails {
|
|
101
|
+
userId: string;
|
|
102
|
+
}
|
|
71
103
|
/** Opaque `recursiv-mls-v2-draft1` envelope with base64 MLS bytes. */
|
|
72
104
|
export interface E2eeEnvelope {
|
|
73
105
|
profile: 'recursiv-mls-v2-draft1';
|
|
@@ -367,6 +399,15 @@ export declare class E2eeResource {
|
|
|
367
399
|
* delete. A commit ignores the flag. The relay learns only that the event
|
|
368
400
|
* is quiet; the flag is not stored. A relay that predates it drops the
|
|
369
401
|
* key, so the event is simply not quiet there.
|
|
402
|
+
*
|
|
403
|
+
* `trust_change: true` says this device's engine can accept a contact's new
|
|
404
|
+
* main device after they reset encrypted chat. Where `status().accountReset`
|
|
405
|
+
* is on, such a message in a direct chat whose other person reset is refused
|
|
406
|
+
* 409 `peer_not_joined` (`details: { userId }`) until a device of this
|
|
407
|
+
* account added their new device, and 409 `stale_epoch` when encrypted under
|
|
408
|
+
* a key generation before the chat's last key update (E2eePeerErrorCode). A
|
|
409
|
+
* message without it is accepted as before. Never stored; relays without it
|
|
410
|
+
* ignore it.
|
|
370
411
|
*/
|
|
371
412
|
appendEvent(conversationId: string, input: {
|
|
372
413
|
device_id: string;
|
|
@@ -375,11 +416,26 @@ export declare class E2eeResource {
|
|
|
375
416
|
welcome_for_device_id?: string;
|
|
376
417
|
expected_commit_seq?: number;
|
|
377
418
|
silent?: boolean;
|
|
419
|
+
trust_change?: boolean;
|
|
378
420
|
}): Promise<SingleResponse<{
|
|
379
421
|
seq: number;
|
|
380
422
|
duplicate: boolean;
|
|
381
423
|
commitSeq: number;
|
|
382
424
|
}>>;
|
|
425
|
+
/**
|
|
426
|
+
* The accounts sharing an encrypted conversation with this account (a direct
|
|
427
|
+
* chat, or a group both accepted) whose main device a reset of encrypted chat
|
|
428
|
+
* replaced after `since` (ISO 8601; default and floor: 7 days ago), oldest
|
|
429
|
+
* first, at most 200 (ask again from the last `at` for more). Read it when the
|
|
430
|
+
* app comes to the foreground, then look at each account's devices again; the
|
|
431
|
+
* live form is `r.realtime.onE2eePeer`. Empty where `status().accountReset` is
|
|
432
|
+
* off. Relays without it answer 404 (`NotFoundError`): treat that as no changes.
|
|
433
|
+
*/
|
|
434
|
+
peerChanges(params?: {
|
|
435
|
+
since?: string;
|
|
436
|
+
}): Promise<{
|
|
437
|
+
data: E2eePeerChange[];
|
|
438
|
+
}>;
|
|
383
439
|
events(conversationId: string, params: {
|
|
384
440
|
device_id: string;
|
|
385
441
|
after_seq?: number;
|
package/dist/resources/e2ee.js
CHANGED
|
@@ -96,11 +96,32 @@ export class E2eeResource {
|
|
|
96
96
|
* delete. A commit ignores the flag. The relay learns only that the event
|
|
97
97
|
* is quiet; the flag is not stored. A relay that predates it drops the
|
|
98
98
|
* key, so the event is simply not quiet there.
|
|
99
|
+
*
|
|
100
|
+
* `trust_change: true` says this device's engine can accept a contact's new
|
|
101
|
+
* main device after they reset encrypted chat. Where `status().accountReset`
|
|
102
|
+
* is on, such a message in a direct chat whose other person reset is refused
|
|
103
|
+
* 409 `peer_not_joined` (`details: { userId }`) until a device of this
|
|
104
|
+
* account added their new device, and 409 `stale_epoch` when encrypted under
|
|
105
|
+
* a key generation before the chat's last key update (E2eePeerErrorCode). A
|
|
106
|
+
* message without it is accepted as before. Never stored; relays without it
|
|
107
|
+
* ignore it.
|
|
99
108
|
*/
|
|
100
109
|
appendEvent(conversationId, input) {
|
|
101
110
|
// Retries are safe: the server is idempotent on (conversation, device, event id).
|
|
102
111
|
return this.client.post(`/e2ee/conversations/${encodeURIComponent(conversationId)}/events`, input, { retry: true });
|
|
103
112
|
}
|
|
113
|
+
/**
|
|
114
|
+
* The accounts sharing an encrypted conversation with this account (a direct
|
|
115
|
+
* chat, or a group both accepted) whose main device a reset of encrypted chat
|
|
116
|
+
* replaced after `since` (ISO 8601; default and floor: 7 days ago), oldest
|
|
117
|
+
* first, at most 200 (ask again from the last `at` for more). Read it when the
|
|
118
|
+
* app comes to the foreground, then look at each account's devices again; the
|
|
119
|
+
* live form is `r.realtime.onE2eePeer`. Empty where `status().accountReset` is
|
|
120
|
+
* off. Relays without it answer 404 (`NotFoundError`): treat that as no changes.
|
|
121
|
+
*/
|
|
122
|
+
peerChanges(params) {
|
|
123
|
+
return this.client.get('/e2ee/peer-changes', params);
|
|
124
|
+
}
|
|
104
125
|
events(conversationId, params) {
|
|
105
126
|
return this.client.get(`/e2ee/conversations/${encodeURIComponent(conversationId)}/events`, params);
|
|
106
127
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
|
+
import { type SocialProtocolId } from '../social-protocol-ids.js';
|
|
2
3
|
import type { CompassFilterMeta, StoryOutlets } from '../types.js';
|
|
3
4
|
/**
|
|
4
5
|
* The top of a feed: what the member (or a visitor) is most likely to engage with, rotated by what they have seen.
|
|
@@ -8,9 +9,17 @@ import type { CompassFilterMeta, StoryOutlets } from '../types.js';
|
|
|
8
9
|
* Every tap target is in the app: a Minds post by id, an open-network post by `/social/post/<protocol>/<id>`, a story
|
|
9
10
|
* by id. Nothing here is a URL on another network.
|
|
10
11
|
*/
|
|
11
|
-
/**
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The open networks whose lanes a top may carry: every SOCIAL_PROTOCOL_IDS network (0.7.87: Farcaster before any top
|
|
14
|
+
* carries a Farcaster lane or item).
|
|
15
|
+
*/
|
|
16
|
+
export type FeedTopNetwork = SocialProtocolId;
|
|
13
17
|
export declare const FEED_TOP_NETWORKS: readonly FeedTopNetwork[];
|
|
18
|
+
/**
|
|
19
|
+
* A top's lanes, or their states, by network: Bluesky, ActivityPub and Nostr are always named; a network listed later
|
|
20
|
+
* (Farcaster) only once the API carries its lane, so read `networks.farcaster ?? []`.
|
|
21
|
+
*/
|
|
22
|
+
export type FeedTopNetworkMap<T> = Record<'bluesky' | 'activitypub' | 'nostr', T> & Partial<Record<FeedTopNetwork, T>>;
|
|
14
23
|
/** Where a tap on an item goes, always in the app. `path` is the app's own page of a network post, never its URL there. */
|
|
15
24
|
export type FeedTopTarget = {
|
|
16
25
|
kind: 'post';
|
|
@@ -103,7 +112,7 @@ export interface FeedStory {
|
|
|
103
112
|
* are politics. Hide politics (`compass: 'hide'`) leaves such a story out. Absent from a server older than 0.7.67.
|
|
104
113
|
*/
|
|
105
114
|
political?: boolean;
|
|
106
|
-
sources: Partial<Record<'outlet' | 'hackernews' |
|
|
115
|
+
sources: Partial<Record<'outlet' | 'hackernews' | SocialProtocolId | 'minds', number>>;
|
|
107
116
|
/** How many outlets reported it (StoryOutlets): the number every surface names for the story. Absent from a server older than 0.7.71. */
|
|
108
117
|
outlets?: StoryOutlets;
|
|
109
118
|
media: {
|
|
@@ -141,13 +150,13 @@ export interface FeedTopMeta {
|
|
|
141
150
|
/** 'safe' for a member on safe defaults, 'open' for one who turned them off. */
|
|
142
151
|
safety: 'safe' | 'open';
|
|
143
152
|
mindsSource: 'member' | 'anonymous' | 'none' | 'following';
|
|
144
|
-
networks:
|
|
153
|
+
networks: FeedTopNetworkMap<'ok' | 'unavailable'> | null;
|
|
145
154
|
compass_filter?: CompassFilterMeta;
|
|
146
155
|
}
|
|
147
156
|
/** GET /feeds/top. `networks` is null on a feed with no lanes (Following, a story); `lead` is null when the feed has no story. */
|
|
148
157
|
export interface FeedTopResponse {
|
|
149
158
|
items: FeedTopItem[];
|
|
150
|
-
networks:
|
|
159
|
+
networks: FeedTopNetworkMap<FeedTopNetworkEntry[]> | null;
|
|
151
160
|
lead: FeedStory | null;
|
|
152
161
|
meta: FeedTopMeta;
|
|
153
162
|
}
|
|
@@ -179,7 +188,7 @@ export interface PublicFeedTopMeta {
|
|
|
179
188
|
/** A visitor's top (`feeds.publicTop`): safe items only, the same for every visitor in one 15-minute bucket. */
|
|
180
189
|
export interface PublicFeedTopResponse {
|
|
181
190
|
items: FeedTopItem[];
|
|
182
|
-
networks:
|
|
191
|
+
networks: FeedTopNetworkMap<FeedTopNetworkEntry[]> | null;
|
|
183
192
|
lead: FeedStory | null;
|
|
184
193
|
meta: PublicFeedTopMeta;
|
|
185
194
|
}
|
package/dist/resources/feeds.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { NotFoundError, RecursivError } from '../errors.js';
|
|
2
|
-
|
|
2
|
+
import { SOCIAL_PROTOCOL_IDS } from '../social-protocol-ids.js';
|
|
3
|
+
export const FEED_TOP_NETWORKS = SOCIAL_PROTOCOL_IDS;
|
|
3
4
|
export class FeedsResource {
|
|
4
5
|
http;
|
|
5
6
|
constructor(http) {
|
|
@@ -90,6 +90,9 @@ export interface UpdateAgentIntegrationInput {
|
|
|
90
90
|
allowed_tools?: string[];
|
|
91
91
|
}
|
|
92
92
|
export interface PendingToolExecution {
|
|
93
|
+
/** First-party builder identity is supplied by the server, never inferred from tool name. */
|
|
94
|
+
builder_approval?: true;
|
|
95
|
+
details_withheld?: boolean;
|
|
93
96
|
id: string;
|
|
94
97
|
conversation_id: string;
|
|
95
98
|
agent: {
|