@recursiv/sdk 0.7.83 → 0.7.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +86 -2
- package/dist/assistant/index.d.ts +11 -3
- package/dist/assistant/index.js +11 -3
- package/dist/assistant/render.d.ts +25 -2
- package/dist/assistant/render.js +197 -16
- package/dist/assistant/subjectBundle.js +20 -3
- package/dist/assistant/turnCaps.d.ts +3 -0
- package/dist/assistant/turnCaps.js +141 -0
- package/dist/assistant/types.d.ts +223 -7
- package/dist/assistant/types.js +15 -0
- package/dist/index.d.ts +12 -6
- package/dist/index.js +8 -2
- package/dist/privacy/facts.generated.d.ts +79 -0
- package/dist/privacy/facts.generated.js +93 -0
- package/dist/privacy/index.d.ts +48 -0
- package/dist/privacy/index.js +42 -0
- package/dist/resources/discovery-merge.d.ts +17 -1
- package/dist/resources/discovery-merge.js +84 -4
- package/dist/resources/discovery-progressive.d.ts +4 -3
- package/dist/resources/discovery-progressive.js +11 -8
- package/dist/resources/discovery.d.ts +9 -1
- package/dist/resources/discovery.js +62 -15
- package/dist/resources/feeds.d.ts +15 -6
- package/dist/resources/feeds.js +2 -1
- package/dist/resources/integrations.d.ts +3 -0
- package/dist/resources/privacy.d.ts +13 -0
- package/dist/resources/privacy.js +14 -0
- package/dist/resources/protocols.d.ts +99 -11
- package/dist/resources/protocols.js +45 -0
- package/dist/resources/sealed.d.ts +127 -7
- package/dist/resources/sealed.js +33 -6
- package/dist/resources/settings.d.ts +3 -1
- package/dist/resources/settings.js +3 -1
- package/dist/resources/signals.d.ts +129 -6
- package/dist/resources/signals.js +58 -2
- package/dist/social-protocol-ids.d.ts +28 -0
- package/dist/social-protocol-ids.js +30 -0
- package/dist/types.d.ts +45 -0
- package/llm.md +17 -6
- package/package.json +6 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export declare const PRIVACY_REGISTRY_VERSION: 0;
|
|
2
|
+
export declare const PRIVACY_FACTS: readonly [{
|
|
3
|
+
readonly id: "agents.tool_mode.chat_only";
|
|
4
|
+
readonly status: "planned";
|
|
5
|
+
readonly enforcement: "server";
|
|
6
|
+
readonly capability: "web_tools";
|
|
7
|
+
readonly step: "S5";
|
|
8
|
+
readonly says: "An agent in chat_only tool mode is given no tools, so it opens no web page.";
|
|
9
|
+
}, {
|
|
10
|
+
readonly id: "agents.turn_stamp";
|
|
11
|
+
readonly status: "enforced";
|
|
12
|
+
readonly enforcement: "server";
|
|
13
|
+
readonly capability: null;
|
|
14
|
+
readonly step: "S7";
|
|
15
|
+
readonly says: "Every reply a model writes in an agent chat through the platform's agent turn records, in metadata.ai, the model, its maker and the route, a model swapped in and whether for usage limits or after a failure, and the tools it called.";
|
|
16
|
+
}, {
|
|
17
|
+
readonly id: "agents.personal_context_sections";
|
|
18
|
+
readonly status: "planned";
|
|
19
|
+
readonly enforcement: "server";
|
|
20
|
+
readonly capability: "activity";
|
|
21
|
+
readonly step: "S26";
|
|
22
|
+
readonly says: "A personal agent's turn carries no follows, upvotes, groups or views of the member unless the member turned that part of their activity on.";
|
|
23
|
+
}, {
|
|
24
|
+
readonly id: "sealed.attest_before_send";
|
|
25
|
+
readonly status: "enforced";
|
|
26
|
+
readonly enforcement: "device";
|
|
27
|
+
readonly capability: "chat_tier";
|
|
28
|
+
readonly step: "S28";
|
|
29
|
+
readonly says: "The phone seals a Sealed request only to the key of a router it checked against a fresh challenge of its own: the router's report, signed by its AMD hardware, must answer that challenge with debug mode off and firmware at or above a floor, and its software must be a release Tinfoil signed and listed in a public log. If any check fails there is no key to seal to, so nothing is sealed or sent. How long a checked router is used before it is checked again is up to the app.";
|
|
30
|
+
}, {
|
|
31
|
+
readonly id: "sealed.attest_second_signer";
|
|
32
|
+
readonly status: "planned";
|
|
33
|
+
readonly enforcement: "device";
|
|
34
|
+
readonly capability: "chat_tier";
|
|
35
|
+
readonly step: "S28";
|
|
36
|
+
readonly says: "The phone also requires Recursiv's own signature over the router's release, so a release Tinfoil signed alone is refused.";
|
|
37
|
+
}, {
|
|
38
|
+
readonly id: "sealed.no_standard_fallback";
|
|
39
|
+
readonly status: "enforced";
|
|
40
|
+
readonly enforcement: "client";
|
|
41
|
+
readonly capability: "chat_tier";
|
|
42
|
+
readonly step: "S28";
|
|
43
|
+
readonly says: "The SDK sends a Sealed request only to the Sealed route, marked Sealed, and only once: a refusal, an error or an answer that is not sealed goes back to the app, and the SDK never sends the request again, to Sealed or to any other route.";
|
|
44
|
+
}, {
|
|
45
|
+
readonly id: "sealed.gateway_opaque";
|
|
46
|
+
readonly status: "enforced";
|
|
47
|
+
readonly enforcement: "server";
|
|
48
|
+
readonly capability: "chat_tier";
|
|
49
|
+
readonly step: "S28";
|
|
50
|
+
readonly says: "The Sealed gateway passes each Sealed request and answer on as bytes it does not read: it never parses, stores or logs a request or answer body. It refuses a request that carries no EHBP encapsulated key, the mark of a sealed body, and a success answer that carries no well-formed EHBP response nonce, the mark of a sealed answer; it cannot tell from the bytes themselves whether a body is sealed.";
|
|
51
|
+
}, {
|
|
52
|
+
readonly id: "sealed.dest_tinfoil_only";
|
|
53
|
+
readonly status: "enforced";
|
|
54
|
+
readonly enforcement: "server";
|
|
55
|
+
readonly capability: "chat_tier";
|
|
56
|
+
readonly step: "S28";
|
|
57
|
+
readonly says: "The Sealed gateway sends Sealed requests, and asks for attestation documents, only to the router origins its configuration lists, named exactly, and it lists only https origins of Tinfoil router hosts (inference.tinfoil.sh or router-N.tinfoil.sh). It refuses any other server a client names and follows no redirect.";
|
|
58
|
+
}, {
|
|
59
|
+
readonly id: "sealed.no_server_history";
|
|
60
|
+
readonly status: "enforced";
|
|
61
|
+
readonly enforcement: "server";
|
|
62
|
+
readonly capability: "sealed_history";
|
|
63
|
+
readonly step: "S29";
|
|
64
|
+
readonly says: "The platform keeps no Sealed chat history: no Sealed route writes a conversation or message row, and the gateway stores no request or answer body.";
|
|
65
|
+
}, {
|
|
66
|
+
readonly id: "sealed.backup_excluded";
|
|
67
|
+
readonly status: "planned";
|
|
68
|
+
readonly enforcement: "device";
|
|
69
|
+
readonly capability: "sealed_history";
|
|
70
|
+
readonly step: "S29";
|
|
71
|
+
readonly says: "Sealed chats saved on a phone are left out of the phone's backups.";
|
|
72
|
+
}, {
|
|
73
|
+
readonly id: "sealed.no_push";
|
|
74
|
+
readonly status: "enforced";
|
|
75
|
+
readonly enforcement: "server";
|
|
76
|
+
readonly capability: "chat_tier";
|
|
77
|
+
readonly step: "S29";
|
|
78
|
+
readonly says: "No Sealed route sends a push notification or writes a notification: a Sealed answer goes back only on the request that asked for it.";
|
|
79
|
+
}];
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Generated by scripts/privacy-claims.mjs from docs/privacy-claims.json. Do not edit: CI refuses a hand edit.
|
|
2
|
+
// Change the registry, then run: node scripts/privacy-claims.mjs --write
|
|
3
|
+
export const PRIVACY_REGISTRY_VERSION = 0;
|
|
4
|
+
export const PRIVACY_FACTS = [
|
|
5
|
+
{
|
|
6
|
+
id: "agents.tool_mode.chat_only",
|
|
7
|
+
status: "planned",
|
|
8
|
+
enforcement: "server",
|
|
9
|
+
capability: "web_tools",
|
|
10
|
+
step: "S5",
|
|
11
|
+
says: "An agent in chat_only tool mode is given no tools, so it opens no web page.",
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: "agents.turn_stamp",
|
|
15
|
+
status: "enforced",
|
|
16
|
+
enforcement: "server",
|
|
17
|
+
capability: null,
|
|
18
|
+
step: "S7",
|
|
19
|
+
says: "Every reply a model writes in an agent chat through the platform's agent turn records, in metadata.ai, the model, its maker and the route, a model swapped in and whether for usage limits or after a failure, and the tools it called.",
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: "agents.personal_context_sections",
|
|
23
|
+
status: "planned",
|
|
24
|
+
enforcement: "server",
|
|
25
|
+
capability: "activity",
|
|
26
|
+
step: "S26",
|
|
27
|
+
says: "A personal agent's turn carries no follows, upvotes, groups or views of the member unless the member turned that part of their activity on.",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "sealed.attest_before_send",
|
|
31
|
+
status: "enforced",
|
|
32
|
+
enforcement: "device",
|
|
33
|
+
capability: "chat_tier",
|
|
34
|
+
step: "S28",
|
|
35
|
+
says: "The phone seals a Sealed request only to the key of a router it checked against a fresh challenge of its own: the router's report, signed by its AMD hardware, must answer that challenge with debug mode off and firmware at or above a floor, and its software must be a release Tinfoil signed and listed in a public log. If any check fails there is no key to seal to, so nothing is sealed or sent. How long a checked router is used before it is checked again is up to the app.",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: "sealed.attest_second_signer",
|
|
39
|
+
status: "planned",
|
|
40
|
+
enforcement: "device",
|
|
41
|
+
capability: "chat_tier",
|
|
42
|
+
step: "S28",
|
|
43
|
+
says: "The phone also requires Recursiv's own signature over the router's release, so a release Tinfoil signed alone is refused.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "sealed.no_standard_fallback",
|
|
47
|
+
status: "enforced",
|
|
48
|
+
enforcement: "client",
|
|
49
|
+
capability: "chat_tier",
|
|
50
|
+
step: "S28",
|
|
51
|
+
says: "The SDK sends a Sealed request only to the Sealed route, marked Sealed, and only once: a refusal, an error or an answer that is not sealed goes back to the app, and the SDK never sends the request again, to Sealed or to any other route.",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "sealed.gateway_opaque",
|
|
55
|
+
status: "enforced",
|
|
56
|
+
enforcement: "server",
|
|
57
|
+
capability: "chat_tier",
|
|
58
|
+
step: "S28",
|
|
59
|
+
says: "The Sealed gateway passes each Sealed request and answer on as bytes it does not read: it never parses, stores or logs a request or answer body. It refuses a request that carries no EHBP encapsulated key, the mark of a sealed body, and a success answer that carries no well-formed EHBP response nonce, the mark of a sealed answer; it cannot tell from the bytes themselves whether a body is sealed.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "sealed.dest_tinfoil_only",
|
|
63
|
+
status: "enforced",
|
|
64
|
+
enforcement: "server",
|
|
65
|
+
capability: "chat_tier",
|
|
66
|
+
step: "S28",
|
|
67
|
+
says: "The Sealed gateway sends Sealed requests, and asks for attestation documents, only to the router origins its configuration lists, named exactly, and it lists only https origins of Tinfoil router hosts (inference.tinfoil.sh or router-N.tinfoil.sh). It refuses any other server a client names and follows no redirect.",
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "sealed.no_server_history",
|
|
71
|
+
status: "enforced",
|
|
72
|
+
enforcement: "server",
|
|
73
|
+
capability: "sealed_history",
|
|
74
|
+
step: "S29",
|
|
75
|
+
says: "The platform keeps no Sealed chat history: no Sealed route writes a conversation or message row, and the gateway stores no request or answer body.",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: "sealed.backup_excluded",
|
|
79
|
+
status: "planned",
|
|
80
|
+
enforcement: "device",
|
|
81
|
+
capability: "sealed_history",
|
|
82
|
+
step: "S29",
|
|
83
|
+
says: "Sealed chats saved on a phone are left out of the phone's backups.",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
id: "sealed.no_push",
|
|
87
|
+
status: "enforced",
|
|
88
|
+
enforcement: "server",
|
|
89
|
+
capability: "chat_tier",
|
|
90
|
+
step: "S29",
|
|
91
|
+
says: "No Sealed route sends a push notification or writes a notification: a Sealed answer goes back only on the request that asked for it.",
|
|
92
|
+
},
|
|
93
|
+
];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@recursiv/sdk/privacy`: the platform privacy facts, generated from the claims registry (docs/privacy-claims.json
|
|
3
|
+
* in recursivlabs/recursiv). Each fact says one thing the platform does with member data and whether this SDK release
|
|
4
|
+
* marks it enforced, which it does only once every test that proves it is real and runs in CI.
|
|
5
|
+
*
|
|
6
|
+
* An app shows a privacy sentence only when the facts it rests on are live: enforced here AND listed by the API it
|
|
7
|
+
* talks to (`sdk.privacy.capabilities()`), so neither a newer SDK nor a newer API can make a claim on its own. A fact
|
|
8
|
+
* held on the phone (`enforcement: 'device'`) needs a third check, the app's own: neither this SDK nor the API can see
|
|
9
|
+
* which native module the phone runs, so the app names the device facts its installed module carries (`deviceFacts`).
|
|
10
|
+
*
|
|
11
|
+
* The types live here, not in the SDK's shared types, so this entry point stands alone.
|
|
12
|
+
*/
|
|
13
|
+
import { PRIVACY_FACTS, PRIVACY_REGISTRY_VERSION } from './facts.generated.js';
|
|
14
|
+
export { PRIVACY_FACTS, PRIVACY_REGISTRY_VERSION };
|
|
15
|
+
export type PrivacyFact = (typeof PRIVACY_FACTS)[number];
|
|
16
|
+
export type PrivacyFactId = PrivacyFact['id'];
|
|
17
|
+
/** `planned`: the platform does not yet prove it. `enforced`: every test that proves it is real and runs in CI. */
|
|
18
|
+
export type PrivacyFactStatus = 'planned' | 'enforced';
|
|
19
|
+
/** Where a fact holds: on the server, on the phone (the native module), in the SDK, or in both server and client. */
|
|
20
|
+
export type PrivacyFactEnforcement = 'server' | 'device' | 'client' | 'server_and_client';
|
|
21
|
+
/** GET /privacy/capabilities: the facts the deployed API's build marks enforced, and the commit it runs. */
|
|
22
|
+
export interface PrivacyCapabilities {
|
|
23
|
+
/** The commit the API was built from, or `unknown`. */
|
|
24
|
+
api_commit: string;
|
|
25
|
+
/** Fact ids, sorted. An id this SDK release does not know is never live here. */
|
|
26
|
+
facts: string[];
|
|
27
|
+
}
|
|
28
|
+
/** The fact with this id, or undefined for an id this SDK release does not know. */
|
|
29
|
+
export declare function privacyFact(id: string): PrivacyFact | undefined;
|
|
30
|
+
/** Whether this SDK release marks the fact enforced. An unknown id is not. */
|
|
31
|
+
export declare function isFactEnforced(id: string): boolean;
|
|
32
|
+
export interface LiveFactOptions {
|
|
33
|
+
/**
|
|
34
|
+
* The device facts (`enforcement: 'device'`) the app's installed native module carries, as the app knows from the
|
|
35
|
+
* module revision it ships (for Sealed, @recursiv/e2ee-react-native). A device fact is live only when it is named
|
|
36
|
+
* here too. Left out, no device fact is live. A fact says what the module does, not what the app does with it: for
|
|
37
|
+
* `sealed.attest_before_send` the app chooses how long a checked router is reused, so copy that says the router is
|
|
38
|
+
* checked before every send needs the app to check before every send.
|
|
39
|
+
*/
|
|
40
|
+
deviceFacts?: Iterable<string>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Enforced in this SDK release AND listed by the deployed API, and for a device fact also named in `deviceFacts`.
|
|
44
|
+
* Missing capabilities mean nothing is live.
|
|
45
|
+
*/
|
|
46
|
+
export declare function isFactLive(id: string, capabilities: PrivacyCapabilities | null | undefined, options?: LiveFactOptions): boolean;
|
|
47
|
+
/** Every fact that is live for this API (and, for device facts, this app's native module), in registry order. */
|
|
48
|
+
export declare function liveFacts(capabilities: PrivacyCapabilities | null | undefined, options?: LiveFactOptions): PrivacyFactId[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@recursiv/sdk/privacy`: the platform privacy facts, generated from the claims registry (docs/privacy-claims.json
|
|
3
|
+
* in recursivlabs/recursiv). Each fact says one thing the platform does with member data and whether this SDK release
|
|
4
|
+
* marks it enforced, which it does only once every test that proves it is real and runs in CI.
|
|
5
|
+
*
|
|
6
|
+
* An app shows a privacy sentence only when the facts it rests on are live: enforced here AND listed by the API it
|
|
7
|
+
* talks to (`sdk.privacy.capabilities()`), so neither a newer SDK nor a newer API can make a claim on its own. A fact
|
|
8
|
+
* held on the phone (`enforcement: 'device'`) needs a third check, the app's own: neither this SDK nor the API can see
|
|
9
|
+
* which native module the phone runs, so the app names the device facts its installed module carries (`deviceFacts`).
|
|
10
|
+
*
|
|
11
|
+
* The types live here, not in the SDK's shared types, so this entry point stands alone.
|
|
12
|
+
*/
|
|
13
|
+
import { PRIVACY_FACTS, PRIVACY_REGISTRY_VERSION } from './facts.generated.js';
|
|
14
|
+
export { PRIVACY_FACTS, PRIVACY_REGISTRY_VERSION };
|
|
15
|
+
const BY_ID = new Map(PRIVACY_FACTS.map((fact) => [fact.id, fact]));
|
|
16
|
+
/** The fact with this id, or undefined for an id this SDK release does not know. */
|
|
17
|
+
export function privacyFact(id) {
|
|
18
|
+
return BY_ID.get(id);
|
|
19
|
+
}
|
|
20
|
+
/** Whether this SDK release marks the fact enforced. An unknown id is not. */
|
|
21
|
+
export function isFactEnforced(id) {
|
|
22
|
+
return BY_ID.get(id)?.status === 'enforced';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Enforced in this SDK release AND listed by the deployed API, and for a device fact also named in `deviceFacts`.
|
|
26
|
+
* Missing capabilities mean nothing is live.
|
|
27
|
+
*/
|
|
28
|
+
export function isFactLive(id, capabilities, options = {}) {
|
|
29
|
+
const fact = BY_ID.get(id);
|
|
30
|
+
if (!fact || fact.status !== 'enforced')
|
|
31
|
+
return false;
|
|
32
|
+
if (!Array.isArray(capabilities?.facts) || !capabilities.facts.includes(id))
|
|
33
|
+
return false;
|
|
34
|
+
if (fact.enforcement === 'device')
|
|
35
|
+
return new Set(options.deviceFacts ?? []).has(id);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
/** Every fact that is live for this API (and, for device facts, this app's native module), in registry order. */
|
|
39
|
+
export function liveFacts(capabilities, options = {}) {
|
|
40
|
+
const deviceFacts = [...(options.deviceFacts ?? [])];
|
|
41
|
+
return PRIVACY_FACTS.filter((fact) => isFactLive(fact.id, capabilities, { deviceFacts })).map((fact) => fact.id);
|
|
42
|
+
}
|
|
@@ -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: {
|