@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
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `buildSubjectBundle`: what an assistant may read about a post, built from data the caller already holds. The
|
|
3
|
+
* server, a Sealed phone (no request at all) and an app's own ask all build it the same way, so the rules below hold
|
|
4
|
+
* wherever the ask starts:
|
|
5
|
+
*
|
|
6
|
+
* - posts and replies whose authors turned asks off are left out, and 'sealed_only' authors are read only in Sealed;
|
|
7
|
+
* - authors the asker blocked or muted, and authors who blocked the asker, are left out;
|
|
8
|
+
* - known-minor authors are left out unless the asker is that author;
|
|
9
|
+
* - posts under a child-safety hold, and posts a moderator turned the assistant off for, are left out, whoever asks;
|
|
10
|
+
* - a post the server's per-viewer verdict (`assist_readable`) says is not readable is left out, whoever asks;
|
|
11
|
+
* - pictures go as alt text and the stored caption only: never bytes, never an address (held media not even that). A
|
|
12
|
+
* picture keeps its plain id, and Minds' last child-safety check time (`checked_at`) when its input says `held` is
|
|
13
|
+
* exactly `false` (2.1: "checked within the last 24 hours and not held"), so a renderer given the caller's image
|
|
14
|
+
* cache may attach one on the asked-about post, parent or quote (never a reply's) as a picture while that check is
|
|
15
|
+
* under 24 hours old (renderBundleForModel);
|
|
16
|
+
* - every string loses what a reader cannot see (stripHiddenText) and is cut to its limit, and the bundle has a total
|
|
17
|
+
* that the asked-about post is cut to fit as well.
|
|
18
|
+
*
|
|
19
|
+
* Every safety flag fails closed: any value but `false`, `null` or absent counts as set, and an id list that cannot be
|
|
20
|
+
* read throws. When the asked-about post itself is left out the result has no bundle: make no model call. The
|
|
21
|
+
* exclusions are counts by reason, never ids or handles, and the reasons the asker may see fold blocks, minors, holds
|
|
22
|
+
* and suppression into one ('unavailable'); the detail is in `audit`, for the server's log only. Pure: no clock, no
|
|
23
|
+
* randomness, no network.
|
|
24
|
+
*/
|
|
25
|
+
import { findHandles, findUrls, mapUrls, stripHiddenText } from './hiddenText.js';
|
|
26
|
+
/** The default (and highest) limits. A caller may lower them, never raise them. */
|
|
27
|
+
export const SUBJECT_BUNDLE_LIMITS = Object.freeze({
|
|
28
|
+
max_replies: 20,
|
|
29
|
+
max_media: 8,
|
|
30
|
+
post_chars: 2000,
|
|
31
|
+
context_chars: 1000,
|
|
32
|
+
reply_chars: 500,
|
|
33
|
+
title_chars: 200,
|
|
34
|
+
name_chars: 64,
|
|
35
|
+
alt_text_chars: 300,
|
|
36
|
+
caption_chars: 600,
|
|
37
|
+
link_title_chars: 200,
|
|
38
|
+
link_description_chars: 300,
|
|
39
|
+
total_chars: 12_000,
|
|
40
|
+
});
|
|
41
|
+
/** Ids are opaque, but plain: nothing that could close a label or start a line. */
|
|
42
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
43
|
+
/** A plain handle, or a federated one with its server. Anything else is rendered as no handle, never mangled. */
|
|
44
|
+
const HANDLE_PATTERN = /^[A-Za-z0-9_](?:[A-Za-z0-9_.-]{0,62}[A-Za-z0-9_])?(?:@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+)?$/;
|
|
45
|
+
const ISO_TIME = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2})?)(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/i;
|
|
46
|
+
const WEB_ADDRESS = /^https?:\/\/[^\s<>"'`]+$/i;
|
|
47
|
+
const MAX_URL_LENGTH = 2048;
|
|
48
|
+
/** The host and path of a web address, without the URL global (not every phone runtime has a whole one). */
|
|
49
|
+
const URL_PARTS = /^https?:\/\/(?:[^/?#@]*@)?(\[[^\]]*\]|[^/?#:]*)(?::\d*)?([^?#]*)/i;
|
|
50
|
+
const PICTURE_FILE = /\.(?:png|jpe?g|jfif|gif|webp|avif|heic|heif|bmp|svg|ico|tiff?)$/i;
|
|
51
|
+
/** Hosts that serve members' pictures and clips: Minds' CDN, R2 and Bunny. */
|
|
52
|
+
const MEDIA_HOST = /(?:^|\.)(?:cdn\.minds\.com|r2\.dev|r2\.cloudflarestorage\.com|b-cdn\.net|bunnycdn\.com|cdn\.recursiv\.io)$/i;
|
|
53
|
+
/** Paths that serve them on any host: Minds' /fs/v1/thumbnail and kin, and /api/v2/media/proxy and kin. */
|
|
54
|
+
const MEDIA_PATH = /^\/(?:fs\/v\d+\/|api\/v\d+\/media\/)/i;
|
|
55
|
+
/** What a picture or clip address in a member's text becomes. */
|
|
56
|
+
export const PICTURE_LINK_LABEL = '[link to a picture or clip]';
|
|
57
|
+
const ELLIPSIS = '\u2026';
|
|
58
|
+
/** Characters as code points: what every limit counts, so an emoji is one character and a cut never splits one. */
|
|
59
|
+
export function textLength(text) {
|
|
60
|
+
let n = 0;
|
|
61
|
+
for (const _point of text)
|
|
62
|
+
n += 1;
|
|
63
|
+
return n;
|
|
64
|
+
}
|
|
65
|
+
function cut(text, limit) {
|
|
66
|
+
if (!text)
|
|
67
|
+
return { value: null, truncated: false };
|
|
68
|
+
if (text.length <= limit)
|
|
69
|
+
return { value: text, truncated: false };
|
|
70
|
+
// Count code points, so a cut never splits a surrogate pair.
|
|
71
|
+
const points = Array.from(text);
|
|
72
|
+
if (points.length <= limit)
|
|
73
|
+
return { value: text, truncated: false };
|
|
74
|
+
if (limit <= 0)
|
|
75
|
+
return { value: null, truncated: true };
|
|
76
|
+
return { value: `${points.slice(0, Math.max(0, limit - 1)).join('').trimEnd()}${ELLIPSIS}`, truncated: true };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A picture's or clip's address, which a model must never be handed: one ending in a picture file type, or on a host
|
|
80
|
+
* or path that serves members' media (Minds' own picture addresses have no file type).
|
|
81
|
+
*/
|
|
82
|
+
export function isPictureAddress(url) {
|
|
83
|
+
const parts = URL_PARTS.exec(url);
|
|
84
|
+
if (!parts)
|
|
85
|
+
return false;
|
|
86
|
+
const host = parts[1].toLowerCase().replace(/\.$/, '');
|
|
87
|
+
let path = parts[2];
|
|
88
|
+
try {
|
|
89
|
+
path = decodeURIComponent(path);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// A path that does not decode is read as written.
|
|
93
|
+
}
|
|
94
|
+
return MEDIA_HOST.test(host) || MEDIA_PATH.test(path) || PICTURE_FILE.test(path.replace(/\/+$/, ''));
|
|
95
|
+
}
|
|
96
|
+
/** Text a member wrote as a reader sees it, with each picture address replaced by a label. */
|
|
97
|
+
function visible(value) {
|
|
98
|
+
return mapUrls(stripHiddenText(value), (url) => (isPictureAddress(url) ? PICTURE_LINK_LABEL : url));
|
|
99
|
+
}
|
|
100
|
+
/** One line of visible text: hidden text gone, spacing collapsed, cut to `limit`. */
|
|
101
|
+
function line(value, limit) {
|
|
102
|
+
if (typeof value !== 'string')
|
|
103
|
+
return { value: null, truncated: false };
|
|
104
|
+
return cut(visible(value).replace(/\s+/g, ' ').trim(), limit);
|
|
105
|
+
}
|
|
106
|
+
/** Visible text that keeps its paragraphs. */
|
|
107
|
+
function paragraphs(value, limit) {
|
|
108
|
+
if (typeof value !== 'string')
|
|
109
|
+
return { value: null, truncated: false };
|
|
110
|
+
const text = visible(value)
|
|
111
|
+
.replace(/[^\S\n]+/g, ' ')
|
|
112
|
+
.replace(/ ?\n ?/g, '\n')
|
|
113
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
114
|
+
.trim();
|
|
115
|
+
return cut(text, limit);
|
|
116
|
+
}
|
|
117
|
+
export function plainId(value) {
|
|
118
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
|
|
119
|
+
return String(value);
|
|
120
|
+
return typeof value === 'string' && ID_PATTERN.test(value) ? value : null;
|
|
121
|
+
}
|
|
122
|
+
export function plainHandle(value) {
|
|
123
|
+
if (typeof value !== 'string')
|
|
124
|
+
return null;
|
|
125
|
+
const handle = value.trim().replace(/^@/, '');
|
|
126
|
+
return HANDLE_PATTERN.test(handle) ? handle : null;
|
|
127
|
+
}
|
|
128
|
+
export function isoTime(value) {
|
|
129
|
+
if (typeof value !== 'string')
|
|
130
|
+
return null;
|
|
131
|
+
const match = ISO_TIME.exec(value.trim());
|
|
132
|
+
if (!match)
|
|
133
|
+
return null;
|
|
134
|
+
const fraction = match[3] ? match[3].slice(0, 4) : '';
|
|
135
|
+
const zone = match[4].length === 5 ? `${match[4].slice(0, 3)}:${match[4].slice(3)}` : match[4];
|
|
136
|
+
const time = Date.parse(`${match[1]}T${match[2]}${fraction}${zone.toUpperCase()}`);
|
|
137
|
+
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
138
|
+
}
|
|
139
|
+
export function webAddress(value) {
|
|
140
|
+
if (typeof value !== 'string')
|
|
141
|
+
return null;
|
|
142
|
+
const url = stripHiddenText(value).trim();
|
|
143
|
+
if (!url || url.length > MAX_URL_LENGTH || !WEB_ADDRESS.test(url))
|
|
144
|
+
return null;
|
|
145
|
+
// A link card that points at a picture would hand the model a picture's address.
|
|
146
|
+
return isPictureAddress(url) ? null : url;
|
|
147
|
+
}
|
|
148
|
+
export function count(value) {
|
|
149
|
+
return typeof value === 'number' && Number.isSafeInteger(value) ? value : undefined;
|
|
150
|
+
}
|
|
151
|
+
function counts(value) {
|
|
152
|
+
if (!value || typeof value !== 'object')
|
|
153
|
+
return null;
|
|
154
|
+
const out = {};
|
|
155
|
+
for (const key of ['votes', 'upvotes', 'downvotes', 'replies', 'reposts']) {
|
|
156
|
+
const n = count(value[key]);
|
|
157
|
+
if (n === undefined)
|
|
158
|
+
continue;
|
|
159
|
+
if (key !== 'votes' && n < 0)
|
|
160
|
+
continue;
|
|
161
|
+
out[key] = n;
|
|
162
|
+
}
|
|
163
|
+
return Object.keys(out).length ? out : null;
|
|
164
|
+
}
|
|
165
|
+
function mediaType(value) {
|
|
166
|
+
const type = typeof value === 'string' ? value.toLowerCase() : '';
|
|
167
|
+
if (type === 'image' || type === 'photo' || type === 'gif' || type.startsWith('image/'))
|
|
168
|
+
return 'image';
|
|
169
|
+
if (type === 'video' || type === 'clip' || type.startsWith('video/'))
|
|
170
|
+
return 'video';
|
|
171
|
+
if (type === 'audio' || type === 'sound' || type.startsWith('audio/'))
|
|
172
|
+
return 'audio';
|
|
173
|
+
return 'other';
|
|
174
|
+
}
|
|
175
|
+
/** The author's choice as one of the three, failing closed on a value this SDK does not know. */
|
|
176
|
+
export function resolveAskChoice(value) {
|
|
177
|
+
if (value === undefined || value === null || value === true || value === 'all')
|
|
178
|
+
return 'all';
|
|
179
|
+
if (value === 'sealed_only')
|
|
180
|
+
return 'sealed_only';
|
|
181
|
+
return 'off';
|
|
182
|
+
}
|
|
183
|
+
/** The server's verdict, or null when it gave none; a value this SDK does not know means 'none'. */
|
|
184
|
+
function resolveReadable(value) {
|
|
185
|
+
if (value === undefined || value === null)
|
|
186
|
+
return null;
|
|
187
|
+
return value === 'all' || value === 'sealed_only' ? value : 'none';
|
|
188
|
+
}
|
|
189
|
+
/** A safety flag, failing closed: anything but false, null or absent (a record, a time, 1, 't') counts as set. */
|
|
190
|
+
function isSet(value) {
|
|
191
|
+
return value !== undefined && value !== null && value !== false;
|
|
192
|
+
}
|
|
193
|
+
/** An id list. One that is present but cannot be read throws: a block list must never count as empty by mistake. */
|
|
194
|
+
function toSet(values, name) {
|
|
195
|
+
const out = new Set();
|
|
196
|
+
if (values === undefined || values === null)
|
|
197
|
+
return out;
|
|
198
|
+
if (typeof values !== 'object' || typeof values[Symbol.iterator] !== 'function') {
|
|
199
|
+
throw new TypeError(`buildSubjectBundle: asker.${name} must be an array or a set of ids`);
|
|
200
|
+
}
|
|
201
|
+
for (const value of values) {
|
|
202
|
+
if (typeof value === 'string') {
|
|
203
|
+
if (value)
|
|
204
|
+
out.add(value);
|
|
205
|
+
}
|
|
206
|
+
else if (typeof value === 'number' && Number.isFinite(value)) {
|
|
207
|
+
out.add(String(value));
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
throw new TypeError(`buildSubjectBundle: asker.${name} holds an entry that is not an id`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
function resolveLimits(overrides) {
|
|
216
|
+
const limits = { ...SUBJECT_BUNDLE_LIMITS };
|
|
217
|
+
if (!overrides)
|
|
218
|
+
return limits;
|
|
219
|
+
for (const key of Object.keys(limits)) {
|
|
220
|
+
const value = overrides[key];
|
|
221
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
222
|
+
limits[key] = Math.max(0, Math.min(SUBJECT_BUNDLE_LIMITS[key], Math.floor(value)));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return limits;
|
|
226
|
+
}
|
|
227
|
+
/** Why `post` may not be read by this asker, or null when it may. Order matters only for which reason is counted. */
|
|
228
|
+
function ruleOut(post, rules) {
|
|
229
|
+
if (!post || typeof post !== 'object' || plainId(post.id) === null)
|
|
230
|
+
return 'invalid';
|
|
231
|
+
if (isSet(post.child_safety_hold))
|
|
232
|
+
return 'child_safety_hold';
|
|
233
|
+
if (isSet(post.assistant_suppressed))
|
|
234
|
+
return 'moderator_suppressed';
|
|
235
|
+
// The server's verdict is for this viewer already, the author included.
|
|
236
|
+
const verdict = resolveReadable(post.assist_readable);
|
|
237
|
+
if (verdict === 'none')
|
|
238
|
+
return 'not_readable';
|
|
239
|
+
if (verdict === 'sealed_only' && rules.mode !== 'sealed')
|
|
240
|
+
return 'author_sealed_only';
|
|
241
|
+
const authorId = post.author && typeof post.author.id === 'string' ? post.author.id : '';
|
|
242
|
+
if (!authorId)
|
|
243
|
+
return 'unknown_author';
|
|
244
|
+
// The asker's own posts: their blocks, age and ask choice are theirs to waive.
|
|
245
|
+
if (authorId === rules.askerId)
|
|
246
|
+
return null;
|
|
247
|
+
if (rules.walls.has(authorId))
|
|
248
|
+
return 'blocked_or_muted';
|
|
249
|
+
if (isSet(post.author?.known_minor))
|
|
250
|
+
return 'known_minor_author';
|
|
251
|
+
const choice = resolveAskChoice(post.ai_ask_allowed);
|
|
252
|
+
if (choice === 'off')
|
|
253
|
+
return 'author_opted_out';
|
|
254
|
+
if (choice === 'sealed_only' && rules.mode !== 'sealed')
|
|
255
|
+
return 'author_sealed_only';
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const UNAVAILABLE = new Set([
|
|
259
|
+
'child_safety_hold', 'moderator_suppressed', 'blocked_or_muted', 'known_minor_author', 'media_held', 'not_readable',
|
|
260
|
+
]);
|
|
261
|
+
/** A reason as the asker may be told it: blocks, minors, holds, suppression and a 'none' verdict all read the same. */
|
|
262
|
+
function askerReason(reason) {
|
|
263
|
+
return UNAVAILABLE.has(reason) ? 'unavailable' : reason;
|
|
264
|
+
}
|
|
265
|
+
class Exclusions {
|
|
266
|
+
counts = new Map();
|
|
267
|
+
add(role, reason, n = 1) {
|
|
268
|
+
if (n <= 0)
|
|
269
|
+
return;
|
|
270
|
+
const key = `${role}:${reason}`;
|
|
271
|
+
const entry = this.counts.get(key);
|
|
272
|
+
if (entry)
|
|
273
|
+
entry.count += n;
|
|
274
|
+
else
|
|
275
|
+
this.counts.set(key, { role, reason, count: n });
|
|
276
|
+
}
|
|
277
|
+
/** The detail, for the server's log. */
|
|
278
|
+
detailed() {
|
|
279
|
+
return [...this.counts.values()].map((entry) => ({ ...entry }));
|
|
280
|
+
}
|
|
281
|
+
/** What the asker may be told: the same counts, with the reasons behind 'unavailable' merged. */
|
|
282
|
+
forAsker() {
|
|
283
|
+
const merged = new Map();
|
|
284
|
+
for (const entry of this.counts.values()) {
|
|
285
|
+
const reason = askerReason(entry.reason);
|
|
286
|
+
const key = `${entry.role}:${reason}`;
|
|
287
|
+
const found = merged.get(key);
|
|
288
|
+
if (found)
|
|
289
|
+
found.count += entry.count;
|
|
290
|
+
else
|
|
291
|
+
merged.set(key, { role: entry.role, reason, count: entry.count });
|
|
292
|
+
}
|
|
293
|
+
return [...merged.values()];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function result(bundle, refused, excluded) {
|
|
297
|
+
return {
|
|
298
|
+
bundle,
|
|
299
|
+
refused: refused ? askerReason(refused) : null,
|
|
300
|
+
excluded: excluded.forAsker(),
|
|
301
|
+
audit: { refused, excluded: excluded.detailed() },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const TEXT_LIMIT = {
|
|
305
|
+
subject: 'post_chars',
|
|
306
|
+
parent: 'context_chars',
|
|
307
|
+
quoted: 'context_chars',
|
|
308
|
+
reply: 'reply_chars',
|
|
309
|
+
};
|
|
310
|
+
/**
|
|
311
|
+
* The item for `post`. Each string is cut to its field limit and, for the asked-about post, to what is left of
|
|
312
|
+
* `budget` (the total limit), in this order: words, title, display name, alt text, stored caption, link card. Other
|
|
313
|
+
* items pass no budget: one that does not fit the total is left out whole.
|
|
314
|
+
*/
|
|
315
|
+
function buildItem(post, role, limits, excluded, budget = Number.POSITIVE_INFINITY) {
|
|
316
|
+
let truncated = false;
|
|
317
|
+
let left = budget;
|
|
318
|
+
/** A field limit, lowered to what is left of the budget. */
|
|
319
|
+
const room = (limit) => Math.min(limit, Math.max(0, left));
|
|
320
|
+
const take = (clean) => {
|
|
321
|
+
truncated ||= clean.truncated;
|
|
322
|
+
if (clean.value)
|
|
323
|
+
left -= textLength(clean.value);
|
|
324
|
+
return clean.value;
|
|
325
|
+
};
|
|
326
|
+
const text = take(paragraphs(post.content, room(limits[TEXT_LIMIT[role]]))) ?? '';
|
|
327
|
+
const title = take(line(post.title, room(limits.title_chars)));
|
|
328
|
+
const name = take(line(post.author?.name, room(limits.name_chars)));
|
|
329
|
+
const media = [];
|
|
330
|
+
let held = 0;
|
|
331
|
+
let overLimit = 0;
|
|
332
|
+
for (const item of Array.isArray(post.media) ? post.media : []) {
|
|
333
|
+
if (!item || typeof item !== 'object')
|
|
334
|
+
continue;
|
|
335
|
+
if (isSet(item.held)) {
|
|
336
|
+
held += 1;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (media.length >= limits.max_media) {
|
|
340
|
+
overLimit += 1;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const entry = { type: mediaType(item.type), alt_text: take(line(item.alt_text, room(limits.alt_text_chars))) };
|
|
344
|
+
if (entry.type === 'image') {
|
|
345
|
+
// What a renderer needs to attach the picture itself: never an address, and a check time only if it is one.
|
|
346
|
+
const id = plainId(item.id);
|
|
347
|
+
if (id !== null)
|
|
348
|
+
entry.id = id;
|
|
349
|
+
// 2.1: a picture goes only if the payload marks it checked and not held. A phone never sees why media is held, so
|
|
350
|
+
// an absent `held` is not "not held": only an explicit `false` keeps the check time, and with it the picture.
|
|
351
|
+
const checkedAt = item.held === false ? isoTime(item.checked_at) : null;
|
|
352
|
+
if (checkedAt !== null)
|
|
353
|
+
entry.checked_at = checkedAt;
|
|
354
|
+
}
|
|
355
|
+
media.push(entry);
|
|
356
|
+
}
|
|
357
|
+
excluded.add('media', 'media_held', held);
|
|
358
|
+
excluded.add('media', 'media_limit', overLimit);
|
|
359
|
+
// The stored caption describes the post's picture: when any of its media is held, it may describe that one. A client
|
|
360
|
+
// never sees held media, so for another member's payload the server leaves this caption out itself.
|
|
361
|
+
let aiCaption = null;
|
|
362
|
+
if (held > 0) {
|
|
363
|
+
if (typeof post.ai_caption === 'string' && post.ai_caption.trim())
|
|
364
|
+
excluded.add('caption', 'media_held');
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
aiCaption = take(line(post.ai_caption, room(limits.caption_chars)));
|
|
368
|
+
}
|
|
369
|
+
let linkCard = null;
|
|
370
|
+
if (post.link_card && typeof post.link_card === 'object') {
|
|
371
|
+
const linkTitle = take(line(post.link_card.title, room(limits.link_title_chars)));
|
|
372
|
+
const description = take(line(post.link_card.description, room(limits.link_description_chars)));
|
|
373
|
+
// An address is never cut: one that does not fit what is left is left out.
|
|
374
|
+
let url = webAddress(post.link_card.url);
|
|
375
|
+
if (url && textLength(url) > left) {
|
|
376
|
+
url = null;
|
|
377
|
+
truncated = true;
|
|
378
|
+
}
|
|
379
|
+
if (url)
|
|
380
|
+
left -= textLength(url);
|
|
381
|
+
if (url || linkTitle || description)
|
|
382
|
+
linkCard = { url, title: linkTitle, description };
|
|
383
|
+
}
|
|
384
|
+
return {
|
|
385
|
+
role,
|
|
386
|
+
id: plainId(post.id),
|
|
387
|
+
author: {
|
|
388
|
+
id: post.author.id,
|
|
389
|
+
username: plainHandle(post.author?.username),
|
|
390
|
+
name,
|
|
391
|
+
},
|
|
392
|
+
created_at: isoTime(post.created_at),
|
|
393
|
+
title,
|
|
394
|
+
text,
|
|
395
|
+
media,
|
|
396
|
+
ai_caption: aiCaption,
|
|
397
|
+
link_card: linkCard,
|
|
398
|
+
counts: counts(post.counts),
|
|
399
|
+
truncated,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/** Characters of member-written text in an item: what the total limit counts. */
|
|
403
|
+
export function itemChars(item) {
|
|
404
|
+
const strings = [item.text, item.title, item.author.name, item.ai_caption, ...item.media.map((media) => media.alt_text)];
|
|
405
|
+
if (item.link_card)
|
|
406
|
+
strings.push(item.link_card.url, item.link_card.title, item.link_card.description);
|
|
407
|
+
return strings.reduce((n, text) => n + (text ? textLength(text) : 0), 0);
|
|
408
|
+
}
|
|
409
|
+
function hasWords(item) {
|
|
410
|
+
return Boolean(item.text || item.title || item.ai_caption || item.link_card || item.media.some((media) => media.alt_text));
|
|
411
|
+
}
|
|
412
|
+
/** The member-written strings of an item, where an address or a handle may appear. */
|
|
413
|
+
export function itemTexts(item) {
|
|
414
|
+
const texts = [item.text, item.title, item.author.name, item.ai_caption];
|
|
415
|
+
for (const media of item.media)
|
|
416
|
+
texts.push(media.alt_text);
|
|
417
|
+
if (item.link_card)
|
|
418
|
+
texts.push(item.link_card.title, item.link_card.description);
|
|
419
|
+
return texts.filter((text) => typeof text === 'string' && text.length > 0);
|
|
420
|
+
}
|
|
421
|
+
/** Every id, handle and web address the items hold, in order, each once. Never a picture or media address. */
|
|
422
|
+
export function referencesOf(items) {
|
|
423
|
+
const ids = new Set();
|
|
424
|
+
const handles = new Set();
|
|
425
|
+
const urls = new Set();
|
|
426
|
+
const addUrl = (url) => {
|
|
427
|
+
if (!isPictureAddress(url))
|
|
428
|
+
urls.add(url);
|
|
429
|
+
};
|
|
430
|
+
for (const item of items) {
|
|
431
|
+
ids.add(item.id);
|
|
432
|
+
if (item.author.username)
|
|
433
|
+
handles.add(item.author.username);
|
|
434
|
+
if (item.link_card?.url)
|
|
435
|
+
addUrl(item.link_card.url);
|
|
436
|
+
for (const text of itemTexts(item)) {
|
|
437
|
+
for (const handle of findHandles(text))
|
|
438
|
+
handles.add(handle);
|
|
439
|
+
for (const url of findUrls(text))
|
|
440
|
+
addUrl(url);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return { ids: [...ids], handles: [...handles], urls: [...urls] };
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* What an assistant may read about `input.post`, with its parent, quoted post and replies, for this asker in this
|
|
447
|
+
* mode. See the module comment for the rules.
|
|
448
|
+
*/
|
|
449
|
+
export function buildSubjectBundle(input) {
|
|
450
|
+
if (!input || typeof input !== 'object')
|
|
451
|
+
throw new TypeError('buildSubjectBundle needs an input object');
|
|
452
|
+
const asker = input.asker;
|
|
453
|
+
if (!asker || typeof asker.id !== 'string' || !asker.id)
|
|
454
|
+
throw new TypeError('buildSubjectBundle needs asker.id');
|
|
455
|
+
if (!input.post || typeof input.post !== 'object')
|
|
456
|
+
throw new TypeError('buildSubjectBundle needs the post');
|
|
457
|
+
const mode = input.mode === 'sealed' ? 'sealed' : 'standard';
|
|
458
|
+
const rules = {
|
|
459
|
+
mode,
|
|
460
|
+
askerId: asker.id,
|
|
461
|
+
walls: new Set([
|
|
462
|
+
...toSet(asker.blocked_ids, 'blocked_ids'),
|
|
463
|
+
...toSet(asker.muted_ids, 'muted_ids'),
|
|
464
|
+
...toSet(asker.blocked_by_ids, 'blocked_by_ids'),
|
|
465
|
+
]),
|
|
466
|
+
};
|
|
467
|
+
const limits = resolveLimits(input.limits);
|
|
468
|
+
const excluded = new Exclusions();
|
|
469
|
+
const refused = ruleOut(input.post, rules);
|
|
470
|
+
if (refused) {
|
|
471
|
+
excluded.add('subject', refused);
|
|
472
|
+
return result(null, refused, excluded);
|
|
473
|
+
}
|
|
474
|
+
const seen = new Set();
|
|
475
|
+
// The asked-about post is always kept, cut to fit the total; what follows it is added only while it fits.
|
|
476
|
+
const subject = buildItem(input.post, 'subject', limits, excluded, limits.total_chars);
|
|
477
|
+
seen.add(subject.id);
|
|
478
|
+
let chars = itemChars(subject);
|
|
479
|
+
/** The item for `post` in `role`, or null with the reason counted. */
|
|
480
|
+
const admit = (post, role) => {
|
|
481
|
+
const reason = ruleOut(post, rules);
|
|
482
|
+
if (reason) {
|
|
483
|
+
excluded.add(role, reason);
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
const id = plainId(post.id);
|
|
487
|
+
if (seen.has(id)) {
|
|
488
|
+
excluded.add(role, 'duplicate');
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
const item = buildItem(post, role, limits, excluded);
|
|
492
|
+
if (!hasWords(item)) {
|
|
493
|
+
excluded.add(role, 'empty');
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
const size = itemChars(item);
|
|
497
|
+
if (chars + size > limits.total_chars) {
|
|
498
|
+
excluded.add(role, 'size_limit');
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
seen.add(id);
|
|
502
|
+
chars += size;
|
|
503
|
+
return item;
|
|
504
|
+
};
|
|
505
|
+
const parent = input.parent ? admit(input.parent, 'parent') : null;
|
|
506
|
+
const quoted = input.quoted ? admit(input.quoted, 'quoted') : null;
|
|
507
|
+
const replies = [];
|
|
508
|
+
for (const reply of Array.isArray(input.replies) ? input.replies : []) {
|
|
509
|
+
// Rules first, so a left-out reply never takes one of the kept places.
|
|
510
|
+
const reason = ruleOut(reply, rules);
|
|
511
|
+
if (reason) {
|
|
512
|
+
excluded.add('reply', reason);
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (replies.length >= limits.max_replies) {
|
|
516
|
+
excluded.add('reply', 'reply_limit');
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
const item = admit(reply, 'reply');
|
|
520
|
+
if (item)
|
|
521
|
+
replies.push(item);
|
|
522
|
+
}
|
|
523
|
+
const items = [subject, ...(parent ? [parent] : []), ...(quoted ? [quoted] : []), ...replies];
|
|
524
|
+
const bundle = {
|
|
525
|
+
version: 1,
|
|
526
|
+
mode,
|
|
527
|
+
subject,
|
|
528
|
+
parent,
|
|
529
|
+
quoted,
|
|
530
|
+
replies,
|
|
531
|
+
references: referencesOf(items),
|
|
532
|
+
chars,
|
|
533
|
+
};
|
|
534
|
+
return result(bundle, null, excluded);
|
|
535
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sealedTurnCaps`: what one Sealed message may carry (sealed-full-scope-v1 3.2 rule 5, 4.2, 4.3 and 2.6). A message is
|
|
3
|
+
* dangerous when it holds three things at once: something about the member, words or pictures from someone else, and
|
|
4
|
+
* a way to send data out. Web search is a way out (page opens and search words both reach Exa), and nothing screens a
|
|
5
|
+
* post before the model reads it. So:
|
|
6
|
+
*
|
|
7
|
+
* - a message that searches never carries memory, About you or a picture from the phone. Those are never dropped to
|
|
8
|
+
* make room for search: search is what turns off ('search.off_mix');
|
|
9
|
+
* - a message with someone else's public post in it, or earlier in its chat, doesn't search until Tinfoil limits page
|
|
10
|
+
* opens (ask 3, `page_opens_limited`, default false) ('search.off_subject');
|
|
11
|
+
* - members-only posts and picked chat messages never search ('search.off_members'), nor do encrypted-chat messages
|
|
12
|
+
* ('search.e2ee'): their words would reach a search company without their authors agreeing;
|
|
13
|
+
* - a message that searches carries only the question (and, after ask 3, the public post it asks about), never the
|
|
14
|
+
* chat's earlier messages or George's earlier answers, before and after ask 3 (S-D9) ('search.question_only');
|
|
15
|
+
* - a Both message never searches ('both.no_search') and never carries a picture from the phone ('both.no_photo'),
|
|
16
|
+
* because its Standard half is an ordinary Minds turn. For the same reason a Both message with encrypted-chat
|
|
17
|
+
* messages throws: an encrypted-chat ask is Sealed only (G21), and Both would send the messages to Standard;
|
|
18
|
+
* - the saved facts go only when Sealed memory is on and on for this chat: the header chip in the member's own chat,
|
|
19
|
+
* and only the member's "Use what George remembers" tick in an ask or chat carrying someone else's content (George
|
|
20
|
+
* plan 4.7; the chip alone never counts there); never in a temporary chat or with encrypted-chat messages. About
|
|
21
|
+
* you, when "Use these" is on, follows the same rules except that it doesn't need memory on;
|
|
22
|
+
* - an encrypted-chat ask carries no picture from the phone (G21: one message of words);
|
|
23
|
+
* - temporary chats neither read nor write memory, and a suggestion in an answer to a message that may hold anyone
|
|
24
|
+
* else's words (a post, chat messages, search results, a picture from the phone, which may be a screenshot, or
|
|
25
|
+
* earlier answers in a chat that searched or carried a phone picture) is never offered for saving (4.2).
|
|
26
|
+
*
|
|
27
|
+
* What the caps cannot see for themselves (someone else's content, how many pictures from the phone this message
|
|
28
|
+
* carries, a temporary chat, an earlier search, an earlier phone picture, earlier messages in the chat) are required
|
|
29
|
+
* inputs: one left out throws rather than reading as "none", so a caller that forgot one never sends more than it
|
|
30
|
+
* should or loses a line the member is owed. More than 3 pictures from the phone throws too: a message carries at most
|
|
31
|
+
* 3 pictures in all (2.2), and the composer caps them.
|
|
32
|
+
*
|
|
33
|
+
* Pure: no clock, randomness or network. The same input gives the same caps on every phone and server.
|
|
34
|
+
*/
|
|
35
|
+
import { BUNDLE_PICTURE_LIMITS } from './render.js';
|
|
36
|
+
import { SEALED_CONTENT_KINDS } from './types.js';
|
|
37
|
+
/** A choice that sends something counts only when it is exactly true. */
|
|
38
|
+
const isOn = (value) => value === true;
|
|
39
|
+
/** A restriction counts for anything but false, null or absent, so a value it cannot read restricts. */
|
|
40
|
+
const isSet = (value) => value !== undefined && value !== null && value !== false;
|
|
41
|
+
const KINDS = new Set(SEALED_CONTENT_KINDS);
|
|
42
|
+
/** A required restriction: absent or null throws, so a caller that forgot it never reads as unrestricted. */
|
|
43
|
+
function restriction(value, name, pass) {
|
|
44
|
+
if (value === undefined || value === null)
|
|
45
|
+
throw new TypeError(`sealedTurnCaps: ${name} is required (pass ${pass} for none)`);
|
|
46
|
+
return isSet(value);
|
|
47
|
+
}
|
|
48
|
+
function contentKinds(value) {
|
|
49
|
+
const out = new Set();
|
|
50
|
+
if (value === undefined || value === null) {
|
|
51
|
+
throw new TypeError("sealedTurnCaps: others_content is required (pass [] for the member's own words only)");
|
|
52
|
+
}
|
|
53
|
+
if (!Array.isArray(value))
|
|
54
|
+
throw new TypeError('sealedTurnCaps: others_content must be a list of content kinds');
|
|
55
|
+
for (const kind of value) {
|
|
56
|
+
if (!KINDS.has(kind))
|
|
57
|
+
throw new TypeError(`sealedTurnCaps: unknown content kind ${JSON.stringify(kind) ?? String(kind)}`);
|
|
58
|
+
out.add(kind);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** At most 3 pictures in one message, from the phone and from posts together (sealed-full-scope-v1 2.2). */
|
|
63
|
+
const MAX_PICTURES = BUNDLE_PICTURE_LIMITS.max_pictures;
|
|
64
|
+
function pictureCount(value) {
|
|
65
|
+
// Required: left out, a message with a picture would search beside it, or drop it with no 'search.off_mix' line, and
|
|
66
|
+
// could offer a suggestion from a screenshot.
|
|
67
|
+
if (value === undefined || value === null)
|
|
68
|
+
throw new TypeError('sealedTurnCaps: phone_pictures is required (pass 0 for none)');
|
|
69
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0 || value > MAX_PICTURES) {
|
|
70
|
+
throw new TypeError(`sealedTurnCaps: phone_pictures must be a whole number from 0 to ${MAX_PICTURES}: the composer caps them`);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
/** What `input`, one Sealed message, may carry. See the module comment for the rules. */
|
|
75
|
+
export function sealedTurnCaps(input) {
|
|
76
|
+
if (!input || typeof input !== 'object')
|
|
77
|
+
throw new TypeError('sealedTurnCaps needs an input object');
|
|
78
|
+
if (input.mode !== 'sealed' && input.mode !== 'both') {
|
|
79
|
+
throw new TypeError("sealedTurnCaps: mode must be 'sealed' or 'both'");
|
|
80
|
+
}
|
|
81
|
+
const both = input.mode === 'both';
|
|
82
|
+
const kinds = contentKinds(input.others_content);
|
|
83
|
+
const others = kinds.size > 0;
|
|
84
|
+
const encrypted = kinds.has('encrypted_chat');
|
|
85
|
+
// G21: an encrypted-chat ask is Sealed only. A Both message's Standard half is an ordinary Minds turn (2.6), so it
|
|
86
|
+
// would put the decrypted messages on Minds and the AI company. No surface offers this, so it is a caller's mistake.
|
|
87
|
+
if (both && encrypted) {
|
|
88
|
+
throw new TypeError("sealedTurnCaps: an encrypted-chat ask is Sealed only (G21): mode 'both' would send it to Standard");
|
|
89
|
+
}
|
|
90
|
+
const membersOnly = kinds.has('members_only_post') || kinds.has('chat_messages');
|
|
91
|
+
const temporary = restriction(input.temporary, 'temporary', 'false');
|
|
92
|
+
const searchedEarlier = restriction(input.searched_earlier, 'searched_earlier', 'false');
|
|
93
|
+
// Decides the 'search.question_only' line (S-D9: the member is told the chat so far was left out), so it is required
|
|
94
|
+
// like the restrictions: left out, a searching message would drop the chat so far without saying so.
|
|
95
|
+
const earlierMessages = restriction(input.earlier_messages, 'earlier_messages', 'false');
|
|
96
|
+
const picturesEarlier = restriction(input.phone_pictures_earlier, 'phone_pictures_earlier', 'false');
|
|
97
|
+
const pictures = pictureCount(input.phone_pictures);
|
|
98
|
+
// The memory message (4.3): never in a temporary chat or with encrypted-chat messages. In the member's own chat the
|
|
99
|
+
// chip turns it on; with someone else's content only the "Use what George remembers" tick does, never the chip.
|
|
100
|
+
const inChat = others ? isOn(input.memory_tick) : isOn(input.memory_in_chat);
|
|
101
|
+
const memory = isOn(input.memory) && inChat && !temporary && !encrypted;
|
|
102
|
+
const aboutYou = isOn(input.about_you) && !temporary && !encrypted && (!others || isOn(input.memory_tick));
|
|
103
|
+
// G21: an encrypted-chat ask is one message of words.
|
|
104
|
+
const phonePictures = pictures > 0 && !both && !encrypted;
|
|
105
|
+
let search = false;
|
|
106
|
+
let searchOff = null;
|
|
107
|
+
if (isOn(input.search)) {
|
|
108
|
+
if (both)
|
|
109
|
+
searchOff = 'both.no_search';
|
|
110
|
+
else if (encrypted)
|
|
111
|
+
searchOff = 'search.e2ee';
|
|
112
|
+
else if (membersOnly)
|
|
113
|
+
searchOff = 'search.off_members';
|
|
114
|
+
else if (others && !isOn(input.page_opens_limited))
|
|
115
|
+
searchOff = 'search.off_subject';
|
|
116
|
+
// Memory, About you and pictures stay; search is what turns off.
|
|
117
|
+
else if (memory || aboutYou || phonePictures)
|
|
118
|
+
searchOff = 'search.off_mix';
|
|
119
|
+
else
|
|
120
|
+
search = true;
|
|
121
|
+
}
|
|
122
|
+
const reasons = [];
|
|
123
|
+
if (searchOff)
|
|
124
|
+
reasons.push(searchOff);
|
|
125
|
+
if (search && earlierMessages)
|
|
126
|
+
reasons.push('search.question_only');
|
|
127
|
+
if (pictures > 0 && both)
|
|
128
|
+
reasons.push('both.no_photo');
|
|
129
|
+
return {
|
|
130
|
+
search,
|
|
131
|
+
history: !search,
|
|
132
|
+
memory,
|
|
133
|
+
about_you: aboutYou,
|
|
134
|
+
phone_pictures: phonePictures,
|
|
135
|
+
// Search results are someone else's words too, and so is an earlier answer built from them, which this message
|
|
136
|
+
// carries in its history: a page could plant a "fact" (4.2, gate M3). A picture from the phone may be a screenshot
|
|
137
|
+
// or a meme with someone else's words in it, and an earlier answer about one stays in the history (2.2).
|
|
138
|
+
suggest_memory: memory && !others && !search && !searchedEarlier && !phonePictures && !picturesEarlier,
|
|
139
|
+
reasons,
|
|
140
|
+
};
|
|
141
|
+
}
|