@hatk/hatk 0.0.1-alpha.11 → 0.0.1-alpha.111

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.
Files changed (224) hide show
  1. package/dist/adapter.d.ts +19 -0
  2. package/dist/adapter.d.ts.map +1 -0
  3. package/dist/adapter.js +113 -0
  4. package/dist/backfill.d.ts +36 -2
  5. package/dist/backfill.d.ts.map +1 -1
  6. package/dist/backfill.js +153 -37
  7. package/dist/car.js +1 -1
  8. package/dist/classifiers.d.ts +228 -0
  9. package/dist/classifiers.d.ts.map +1 -0
  10. package/dist/classifiers.js +688 -0
  11. package/dist/cli.js +248 -1043
  12. package/dist/config.d.ts +179 -1
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js +78 -8
  15. package/dist/database/adapter-factory.d.ts +6 -0
  16. package/dist/database/adapter-factory.d.ts.map +1 -0
  17. package/dist/database/adapter-factory.js +20 -0
  18. package/dist/database/adapters/duckdb-search.d.ts +12 -0
  19. package/dist/database/adapters/duckdb-search.d.ts.map +1 -0
  20. package/dist/database/adapters/duckdb-search.js +27 -0
  21. package/dist/database/adapters/duckdb.d.ts +25 -0
  22. package/dist/database/adapters/duckdb.d.ts.map +1 -0
  23. package/dist/database/adapters/duckdb.js +161 -0
  24. package/dist/database/adapters/sqlite-search.d.ts +16 -0
  25. package/dist/database/adapters/sqlite-search.d.ts.map +1 -0
  26. package/dist/database/adapters/sqlite-search.js +76 -0
  27. package/dist/database/adapters/sqlite.d.ts +18 -0
  28. package/dist/database/adapters/sqlite.d.ts.map +1 -0
  29. package/dist/database/adapters/sqlite.js +88 -0
  30. package/dist/database/db.d.ts +290 -0
  31. package/dist/database/db.d.ts.map +1 -0
  32. package/dist/database/db.js +2073 -0
  33. package/dist/database/dialect.d.ts +45 -0
  34. package/dist/database/dialect.d.ts.map +1 -0
  35. package/dist/database/dialect.js +72 -0
  36. package/dist/{fts.d.ts → database/fts.d.ts} +7 -0
  37. package/dist/database/fts.d.ts.map +1 -0
  38. package/dist/{fts.js → database/fts.js} +122 -34
  39. package/dist/database/index.d.ts +7 -0
  40. package/dist/database/index.d.ts.map +1 -0
  41. package/dist/database/index.js +6 -0
  42. package/dist/database/ports.d.ts +50 -0
  43. package/dist/database/ports.d.ts.map +1 -0
  44. package/dist/database/ports.js +1 -0
  45. package/dist/database/schema.d.ts +91 -0
  46. package/dist/database/schema.d.ts.map +1 -0
  47. package/dist/{schema.js → database/schema.js} +153 -54
  48. package/dist/dev-entry.d.ts +8 -0
  49. package/dist/dev-entry.d.ts.map +1 -0
  50. package/dist/dev-entry.js +131 -0
  51. package/dist/feeds.d.ts +26 -8
  52. package/dist/feeds.d.ts.map +1 -1
  53. package/dist/feeds.js +72 -16
  54. package/dist/hooks.d.ts +102 -0
  55. package/dist/hooks.d.ts.map +1 -0
  56. package/dist/hooks.js +186 -0
  57. package/dist/hydrate.d.ts +55 -6
  58. package/dist/hydrate.d.ts.map +1 -1
  59. package/dist/hydrate.js +21 -20
  60. package/dist/indexer.d.ts +194 -3
  61. package/dist/indexer.d.ts.map +1 -1
  62. package/dist/indexer.js +627 -92
  63. package/dist/jetstream.d.ts +118 -0
  64. package/dist/jetstream.d.ts.map +1 -0
  65. package/dist/jetstream.js +238 -0
  66. package/dist/labels.d.ts +36 -0
  67. package/dist/labels.d.ts.map +1 -1
  68. package/dist/labels.js +71 -6
  69. package/dist/lex-types.d.ts +2 -0
  70. package/dist/lex-types.d.ts.map +1 -1
  71. package/dist/lexicon-resolve.d.ts.map +1 -1
  72. package/dist/lexicon-resolve.js +27 -112
  73. package/dist/lexicons/com/atproto/label/defs.json +75 -0
  74. package/dist/lexicons/com/atproto/moderation/defs.json +30 -0
  75. package/dist/lexicons/com/atproto/repo/strongRef.json +21 -0
  76. package/dist/lexicons/dev/hatk/applyWrites.json +87 -0
  77. package/dist/lexicons/dev/hatk/createRecord.json +40 -0
  78. package/dist/lexicons/dev/hatk/createReport.json +48 -0
  79. package/dist/lexicons/dev/hatk/deleteRecord.json +25 -0
  80. package/dist/lexicons/dev/hatk/describeCollections.json +41 -0
  81. package/dist/lexicons/dev/hatk/describeFeeds.json +29 -0
  82. package/dist/lexicons/dev/hatk/describeLabels.json +45 -0
  83. package/dist/lexicons/dev/hatk/getFeed.json +30 -0
  84. package/dist/lexicons/dev/hatk/getPreferences.json +19 -0
  85. package/dist/lexicons/dev/hatk/getRecord.json +26 -0
  86. package/dist/lexicons/dev/hatk/getRecords.json +32 -0
  87. package/dist/lexicons/dev/hatk/putPreference.json +28 -0
  88. package/dist/lexicons/dev/hatk/putRecord.json +41 -0
  89. package/dist/lexicons/dev/hatk/searchRecords.json +32 -0
  90. package/dist/lexicons/dev/hatk/uploadBlob.json +23 -0
  91. package/dist/logger.d.ts +29 -0
  92. package/dist/logger.d.ts.map +1 -1
  93. package/dist/logger.js +29 -0
  94. package/dist/main.js +212 -75
  95. package/dist/mst.d.ts +15 -0
  96. package/dist/mst.d.ts.map +1 -1
  97. package/dist/mst.js +13 -0
  98. package/dist/oauth/db.d.ts +27 -1
  99. package/dist/oauth/db.d.ts.map +1 -1
  100. package/dist/oauth/db.js +80 -19
  101. package/dist/oauth/discovery.d.ts +10 -0
  102. package/dist/oauth/discovery.d.ts.map +1 -1
  103. package/dist/oauth/discovery.js +54 -2
  104. package/dist/oauth/server.d.ts +131 -1
  105. package/dist/oauth/server.d.ts.map +1 -1
  106. package/dist/oauth/server.js +504 -39
  107. package/dist/oauth/service-describe.d.ts +42 -0
  108. package/dist/oauth/service-describe.d.ts.map +1 -0
  109. package/dist/oauth/service-describe.js +108 -0
  110. package/dist/oauth/session.d.ts +32 -0
  111. package/dist/oauth/session.d.ts.map +1 -0
  112. package/dist/oauth/session.js +131 -0
  113. package/dist/opengraph.d.ts +10 -0
  114. package/dist/opengraph.d.ts.map +1 -1
  115. package/dist/opengraph.js +94 -42
  116. package/dist/pds-proxy.d.ts +92 -0
  117. package/dist/pds-proxy.d.ts.map +1 -0
  118. package/dist/pds-proxy.js +396 -0
  119. package/dist/private-collections.d.ts +13 -0
  120. package/dist/private-collections.d.ts.map +1 -0
  121. package/dist/private-collections.js +27 -0
  122. package/dist/push.d.ts +54 -0
  123. package/dist/push.d.ts.map +1 -0
  124. package/dist/push.js +367 -0
  125. package/dist/renderer.d.ts +27 -0
  126. package/dist/renderer.d.ts.map +1 -0
  127. package/dist/renderer.js +46 -0
  128. package/dist/resolve-hatk.d.ts +6 -0
  129. package/dist/resolve-hatk.d.ts.map +1 -0
  130. package/dist/resolve-hatk.js +20 -0
  131. package/dist/response.d.ts +16 -0
  132. package/dist/response.d.ts.map +1 -0
  133. package/dist/response.js +69 -0
  134. package/dist/scanner.d.ts +22 -0
  135. package/dist/scanner.d.ts.map +1 -0
  136. package/dist/scanner.js +92 -0
  137. package/dist/seed.d.ts +19 -0
  138. package/dist/seed.d.ts.map +1 -1
  139. package/dist/seed.js +43 -4
  140. package/dist/server-init.d.ts +8 -0
  141. package/dist/server-init.d.ts.map +1 -0
  142. package/dist/server-init.js +75 -0
  143. package/dist/server.d.ts +46 -3
  144. package/dist/server.d.ts.map +1 -1
  145. package/dist/server.js +1137 -649
  146. package/dist/setup.d.ts +28 -1
  147. package/dist/setup.d.ts.map +1 -1
  148. package/dist/setup.js +50 -3
  149. package/dist/spaces/blob.d.ts +112 -0
  150. package/dist/spaces/blob.d.ts.map +1 -0
  151. package/dist/spaces/blob.js +460 -0
  152. package/dist/spaces/credential.d.ts +82 -0
  153. package/dist/spaces/credential.d.ts.map +1 -0
  154. package/dist/spaces/credential.js +220 -0
  155. package/dist/spaces/engine.d.ts +97 -0
  156. package/dist/spaces/engine.d.ts.map +1 -0
  157. package/dist/spaces/engine.js +563 -0
  158. package/dist/spaces/guard.d.ts +41 -0
  159. package/dist/spaces/guard.d.ts.map +1 -0
  160. package/dist/spaces/guard.js +79 -0
  161. package/dist/spaces/identity.d.ts +37 -0
  162. package/dist/spaces/identity.d.ts.map +1 -0
  163. package/dist/spaces/identity.js +93 -0
  164. package/dist/spaces/index.d.ts +56 -0
  165. package/dist/spaces/index.d.ts.map +1 -0
  166. package/dist/spaces/index.js +134 -0
  167. package/dist/spaces/notify.d.ts +60 -0
  168. package/dist/spaces/notify.d.ts.map +1 -0
  169. package/dist/spaces/notify.js +128 -0
  170. package/dist/spaces/records.d.ts +37 -0
  171. package/dist/spaces/records.d.ts.map +1 -0
  172. package/dist/spaces/records.js +63 -0
  173. package/dist/spaces/signature.d.ts +36 -0
  174. package/dist/spaces/signature.d.ts.map +1 -0
  175. package/dist/spaces/signature.js +83 -0
  176. package/dist/spaces/store.d.ts +51 -0
  177. package/dist/spaces/store.d.ts.map +1 -0
  178. package/dist/spaces/store.js +99 -0
  179. package/dist/spaces/uri.d.ts +60 -0
  180. package/dist/spaces/uri.d.ts.map +1 -0
  181. package/dist/spaces/uri.js +97 -0
  182. package/dist/spaces/verify.d.ts +46 -0
  183. package/dist/spaces/verify.d.ts.map +1 -0
  184. package/dist/spaces/verify.js +94 -0
  185. package/dist/spaces/viewer.d.ts +51 -0
  186. package/dist/spaces/viewer.d.ts.map +1 -0
  187. package/dist/spaces/viewer.js +167 -0
  188. package/dist/spaces/visibility.d.ts +57 -0
  189. package/dist/spaces/visibility.d.ts.map +1 -0
  190. package/dist/spaces/visibility.js +81 -0
  191. package/dist/templates/feed.tpl +18 -0
  192. package/dist/templates/hook.tpl +5 -0
  193. package/dist/templates/label.tpl +15 -0
  194. package/dist/templates/og.tpl +17 -0
  195. package/dist/templates/seed.tpl +11 -0
  196. package/dist/templates/setup.tpl +5 -0
  197. package/dist/templates/test-feed.tpl +19 -0
  198. package/dist/templates/test-xrpc.tpl +19 -0
  199. package/dist/templates/xrpc.tpl +53 -0
  200. package/dist/test.d.ts +3 -1
  201. package/dist/test.d.ts.map +1 -1
  202. package/dist/test.js +60 -36
  203. package/dist/views.js +1 -1
  204. package/dist/vite-plugin.d.ts +1 -1
  205. package/dist/vite-plugin.d.ts.map +1 -1
  206. package/dist/vite-plugin.js +254 -66
  207. package/dist/xrpc.d.ts +143 -12
  208. package/dist/xrpc.d.ts.map +1 -1
  209. package/dist/xrpc.js +282 -55
  210. package/package.json +29 -9
  211. package/public/admin-auth.js +271 -306
  212. package/public/admin.html +652 -61
  213. package/dist/db.d.ts +0 -135
  214. package/dist/db.d.ts.map +0 -1
  215. package/dist/db.js +0 -1365
  216. package/dist/fts.d.ts.map +0 -1
  217. package/dist/oauth/hooks.d.ts +0 -10
  218. package/dist/oauth/hooks.d.ts.map +0 -1
  219. package/dist/oauth/hooks.js +0 -40
  220. package/dist/schema.d.ts +0 -51
  221. package/dist/schema.d.ts.map +0 -1
  222. package/dist/test-browser.d.ts +0 -14
  223. package/dist/test-browser.d.ts.map +0 -1
  224. package/dist/test-browser.js +0 -26
@@ -0,0 +1,688 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ /**
10
+ * Classifier system for scoring accounts and records with Clef, Cloudflare's
11
+ * decision model on Workers AI.
12
+ *
13
+ * A classifier is the moderation counterpart to a label rule: where a label rule
14
+ * decides from the record alone, a classifier asks a model a set of narrow typed
15
+ * questions and stores the probabilities. Nothing acts on a score — crossing a
16
+ * threshold files a report into the same `_reports` queue a user report lands in,
17
+ * so a person still makes every enforcement decision.
18
+ *
19
+ * Place classifier modules in the `classifiers/` directory.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * // classifiers/impersonation.ts
24
+ * import { defineClassifier } from '$hatk'
25
+ *
26
+ * export default defineClassifier({
27
+ * subject: 'account',
28
+ * label: 'impersonation',
29
+ * threshold: 0.7,
30
+ * questions: {
31
+ * passing_as: {
32
+ * type: 'noul',
33
+ * instructions: 'Is this account trying to be mistaken for a famous person?',
34
+ * },
35
+ * },
36
+ * async buildState({ subject, db }) {
37
+ * const [p] = await db.query(`SELECT description FROM profile WHERE did = $1`, [subject.did])
38
+ * return { handle: subject.handle, bio: p?.description }
39
+ * },
40
+ * })
41
+ * ```
42
+ */
43
+ import { resolve } from 'node:path';
44
+ import { readdirSync } from 'node:fs';
45
+ import { createHash } from 'node:crypto';
46
+ import { querySQL, runSQL, getSchema, listSchemas, getCursor, setCursor, upsertClassification, getClassificationFingerprints, getClassificationFingerprint, findOpenReport, insertReport, } from "./database/db.js";
47
+ import { log, emit } from "./logger.js";
48
+ export function defineClassifier(module) {
49
+ return { __type: 'classifiers', ...module };
50
+ }
51
+ const classifiers = [];
52
+ /** Discover and load classifier modules from the `classifiers/` directory. */
53
+ export async function initClassifiers(classifiersDir) {
54
+ let files;
55
+ try {
56
+ files = readdirSync(classifiersDir)
57
+ .filter((f) => (f.endsWith('.ts') || f.endsWith('.js')) && !f.startsWith('_'))
58
+ .sort();
59
+ }
60
+ catch {
61
+ return;
62
+ }
63
+ for (const file of files) {
64
+ const name = file.replace(/\.(ts|js)$/, '');
65
+ const scriptPath = resolve(classifiersDir, file);
66
+ const mod = await import(__rewriteRelativeImportExtension(/* @vite-ignore */ `${scriptPath}?t=${Date.now()}`));
67
+ const handler = mod.default;
68
+ if (!handler?.buildState || !(handler.questions || handler.classify)) {
69
+ log(`[classifiers] skipped ${name}: needs buildState plus questions or classify`);
70
+ continue;
71
+ }
72
+ classifiers.push({ name, ...handler });
73
+ log(`[classifiers] discovered: ${name} (${handler.subject})`);
74
+ }
75
+ }
76
+ /**
77
+ * Register a classifier directly, bypassing directory discovery.
78
+ *
79
+ * Mirrors {@link registerLabelModule}: tests and embedders that build their own
80
+ * module graph have no `classifiers/` directory to read.
81
+ */
82
+ export function registerClassifier(name, module) {
83
+ classifiers.push({ name, ...module });
84
+ }
85
+ /** Clear all registered classifiers (for hot-reload). */
86
+ export function clearClassifiers() {
87
+ classifiers.length = 0;
88
+ }
89
+ /** Names of all loaded classifiers. */
90
+ export function listClassifiers() {
91
+ return classifiers.map((c) => c.name);
92
+ }
93
+ export function getClassifier(name) {
94
+ return classifiers.find((c) => c.name === name);
95
+ }
96
+ let clef = null;
97
+ export function configureClef(config) {
98
+ clef = config;
99
+ }
100
+ export function isClefConfigured() {
101
+ return clef !== null;
102
+ }
103
+ /** Clef's ceiling on images per request. */
104
+ const MAX_IMAGES = 4;
105
+ /**
106
+ * The longest side an image is sent at. Clef counts its context on the encoded
107
+ * bytes: a 1000px feed thumbnail of 200KB or more overflows the 65k window,
108
+ * while at 512px every image measured fit in about a thousand tokens.
109
+ */
110
+ const IMAGE_EDGE = 512;
111
+ let sharpModule;
112
+ function loadSharp() {
113
+ sharpModule ??= import('sharp')
114
+ .then((m) => m.default)
115
+ .catch(() => {
116
+ emit('classifiers', 'resize_unavailable', { reason: 'sharp is not installed' });
117
+ return null;
118
+ });
119
+ return sharpModule;
120
+ }
121
+ /**
122
+ * Fetch an image and embed it as the base64 data URI Clef requires, scaled
123
+ * down to {@link IMAGE_EDGE}. Without sharp the original is sent, and an image
124
+ * too large for the context window fails that one subject.
125
+ */
126
+ async function embedImage(url, signal) {
127
+ const res = await fetch(url, { signal });
128
+ if (!res.ok)
129
+ throw new Error(`image ${res.status}: ${url}`);
130
+ const type = (res.headers.get('content-type') ?? '').split(';')[0].trim();
131
+ if (!/^image\/(png|jpeg|webp)$/.test(type))
132
+ throw new Error(`image ${url} is ${type || 'untyped'}, not PNG, JPEG or WebP`);
133
+ const source = Buffer.from(await res.arrayBuffer());
134
+ const sharp = await loadSharp();
135
+ if (!sharp)
136
+ return `data:${type};base64,${source.toString('base64')}`;
137
+ const scaled = await sharp(source)
138
+ .rotate()
139
+ .resize({ width: IMAGE_EDGE, height: IMAGE_EDGE, fit: 'inside', withoutEnlargement: true })
140
+ .jpeg({ quality: 80 })
141
+ .toBuffer();
142
+ return `data:image/jpeg;base64,${scaled.toString('base64')}`;
143
+ }
144
+ const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
145
+ /** A response that a retry would only repeat: a rejected request, or the last attempt. */
146
+ class FatalClefError extends Error {
147
+ }
148
+ /**
149
+ * Ask Clef one request's worth of questions about one state.
150
+ *
151
+ * Retries on 429 and 5xx, honoring `retry-after` when the response carries one.
152
+ * The caller's concurrency is what keeps us under the rate limit; this only
153
+ * recovers the requests that still slip past it.
154
+ */
155
+ export async function askClef(state, questions, opts = {}) {
156
+ if (!clef)
157
+ throw new Error('Clef is not configured — set `clef` in hatk.config.ts');
158
+ const attempts = opts.attempts ?? 4;
159
+ const images = opts.images?.length ? opts.images : undefined;
160
+ const body = JSON.stringify({ state, model: clef.model, questions, ...(images ? { images } : {}) });
161
+ let lastError = null;
162
+ for (let attempt = 0; attempt < attempts; attempt++) {
163
+ try {
164
+ const res = await fetch(clef.endpoint, {
165
+ method: 'POST',
166
+ headers: { authorization: `Bearer ${clef.apiToken}`, 'content-type': 'application/json' },
167
+ body,
168
+ signal: opts.signal,
169
+ });
170
+ if (!res.ok) {
171
+ if (!RETRYABLE.has(res.status) || attempt === attempts - 1) {
172
+ throw new FatalClefError(`Clef ${res.status}: ${(await res.text()).slice(0, 200)}`);
173
+ }
174
+ const retryAfter = Number(res.headers.get('retry-after'));
175
+ const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500;
176
+ await new Promise((r) => setTimeout(r, waitMs));
177
+ continue;
178
+ }
179
+ // Workers AI wraps the model's output in `{ result, success, errors }`.
180
+ const json = (await res.json());
181
+ if (json.success === false)
182
+ throw new FatalClefError(`Clef: ${JSON.stringify(json.errors).slice(0, 200)}`);
183
+ const out = json.result ?? json;
184
+ const answers = {};
185
+ for (const [key, a] of Object.entries(out.answers ?? {})) {
186
+ answers[key] = normalizeAnswer(a);
187
+ }
188
+ return { answers, model: out.model ?? clef.model, inputTokens: out.usage?.input_tokens ?? 0 };
189
+ }
190
+ catch (err) {
191
+ if (err?.name === 'AbortError' || err instanceof FatalClefError)
192
+ throw err;
193
+ lastError = err;
194
+ if (attempt === attempts - 1)
195
+ break;
196
+ await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
197
+ }
198
+ }
199
+ throw lastError ?? new Error('Clef request failed');
200
+ }
201
+ /**
202
+ * Collapse any answer type to one 0–1 number so a mixed question set can share a
203
+ * threshold. A Noul is already a probability. A Choice reports the probability
204
+ * of the option it picked, and a Score its position across the levels — both
205
+ * keep the original answer alongside, because the number alone loses which
206
+ * option was chosen.
207
+ */
208
+ function normalizeAnswer(a) {
209
+ if (a?.type === 'noul')
210
+ return { score: a.noul ?? 0, type: 'noul' };
211
+ if (a?.type === 'choice') {
212
+ const choice = a.choice;
213
+ return { score: a.probabilities?.[choice] ?? 0, type: 'choice', choice, confidence: a.confidence };
214
+ }
215
+ if (a?.type === 'score') {
216
+ const levels = a.probabilities ? Object.keys(a.probabilities).length : 0;
217
+ const raw = typeof a.score === 'number' ? a.score : 0;
218
+ return {
219
+ score: levels > 1 ? raw / (levels - 1) : raw,
220
+ type: 'score',
221
+ choice: String(a.score),
222
+ confidence: a.confidence,
223
+ };
224
+ }
225
+ return { score: 0, type: a?.type ?? 'unknown' };
226
+ }
227
+ function thresholdFor(c, signal) {
228
+ if (c.threshold == null)
229
+ return null;
230
+ if (typeof c.threshold === 'number')
231
+ return c.threshold;
232
+ return c.threshold[signal] ?? null;
233
+ }
234
+ function labelFor(c, signal, score) {
235
+ if (typeof c.label === 'function')
236
+ return c.label(signal, score);
237
+ return c.label ?? 'spam';
238
+ }
239
+ const dbCtx = {
240
+ query: (sql, params) => querySQL(sql, params),
241
+ run: (sql, ...params) => runSQL(sql, params).then(() => undefined),
242
+ };
243
+ const TID_CHARS = '234567abcdefghijklmnopqrstuvwxyz';
244
+ /** When a TID record key was minted, or null if the key is not a TID. */
245
+ export function tidTime(rkey) {
246
+ if (rkey.length !== 13)
247
+ return null;
248
+ let n = 0n;
249
+ for (const ch of rkey) {
250
+ const i = TID_CHARS.indexOf(ch);
251
+ if (i < 0)
252
+ return null;
253
+ n = n * 32n + BigInt(i);
254
+ }
255
+ return new Date(Number(n >> 10n) / 1000);
256
+ }
257
+ /**
258
+ * Whether a row is new since `since`: written into its repo after then, not
259
+ * merely indexed after then.
260
+ *
261
+ * Index time alone is not enough. A repo backfilled again rewrites every row
262
+ * with a fresh `indexed_at`, so old content would read as new. `createdAt` is no
263
+ * better: an import of an old archive carries the photos' original dates. The
264
+ * record key is minted when the record is written, so for a TID key that is the
265
+ * answer; a key that is not a TID (`self`) falls back to the index time the
266
+ * caller has already filtered on.
267
+ */
268
+ function isNewSince(uri, since) {
269
+ const t = tidTime(uri.slice(uri.lastIndexOf('/') + 1));
270
+ return t === null || t.toISOString() >= since;
271
+ }
272
+ /**
273
+ * Rows of a table indexed at or after `since`, public ones only.
274
+ *
275
+ * The query names `indexed_at` alone. Given `space IS NULL` as well, SQLite
276
+ * picks the space index, which matches nearly every row, and walks the whole
277
+ * table; on a few million Bluesky posts that is seconds of a blocked process.
278
+ * So the space filter is applied here instead.
279
+ */
280
+ async function indexedSince(tableName, columns, since) {
281
+ const rows = (await querySQL(`SELECT ${columns}, space FROM ${tableName} WHERE indexed_at >= $1`, [since]));
282
+ return rows.filter((r) => r.space == null);
283
+ }
284
+ /** Active DIDs with any public record new since `since`, in any collection. */
285
+ async function activeSince(since) {
286
+ const dids = new Set();
287
+ for (const schema of listSchemas()) {
288
+ for (const r of await indexedSince(schema.tableName, 'uri, did', since)) {
289
+ if (isNewSince(r.uri, since))
290
+ dids.add(r.did);
291
+ }
292
+ }
293
+ return dids;
294
+ }
295
+ /**
296
+ * Every subject a classifier applies to. With `since`, only what is new after
297
+ * it: records written after then, and accounts that wrote one.
298
+ */
299
+ async function enumerateSubjects(c, since) {
300
+ if (c.subject === 'account') {
301
+ const rows = (await querySQL(`SELECT did, handle FROM _repos WHERE status = 'active'`));
302
+ const recent = since ? await activeSince(since) : null;
303
+ return rows.filter((r) => !recent || recent.has(r.did)).map((r) => ({ uri: r.did, did: r.did, handle: r.handle }));
304
+ }
305
+ const out = [];
306
+ let active;
307
+ for (const collection of c.collections ?? []) {
308
+ const schema = getSchema(collection);
309
+ if (!schema)
310
+ continue;
311
+ // The same population as an account scan: active repos only, so content an
312
+ // administrator already took down is not filed again. Space rows are left
313
+ // out entirely; they are members' permissioned data, not public posts.
314
+ let rows;
315
+ if (since) {
316
+ active ??= new Set((await querySQL(`SELECT did FROM _repos WHERE status = 'active'`)).map((r) => r.did));
317
+ rows = (await indexedSince(schema.tableName, '*', since)).filter((r) => active.has(r.did));
318
+ }
319
+ else {
320
+ rows = (await querySQL(`SELECT t.* FROM ${schema.tableName} t
321
+ JOIN _repos r ON r.did = t.did AND r.status = 'active'
322
+ WHERE t.space IS NULL`));
323
+ }
324
+ for (const row of rows) {
325
+ if (since && !isNewSince(row.uri, since))
326
+ continue;
327
+ out.push({ uri: row.uri, did: row.did, collection, value: rowValue(schema, row) });
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+ /** A table row back in the record's own field names, JSON columns parsed. */
333
+ function rowValue(schema, row) {
334
+ const value = {};
335
+ for (const col of schema.columns) {
336
+ let v = row[col.name];
337
+ if (v === null || v === undefined)
338
+ continue;
339
+ if (col.isJson && typeof v === 'string') {
340
+ try {
341
+ v = JSON.parse(v);
342
+ }
343
+ catch { }
344
+ }
345
+ value[col.originalName] = v;
346
+ }
347
+ return value;
348
+ }
349
+ /**
350
+ * The questions are part of what produced a score, so a fingerprint covers them
351
+ * as well as the state: rewording a classifier's criteria, or changing which
352
+ * images it sends, invalidates every score written under the old version.
353
+ */
354
+ function classifierHash(c) {
355
+ return createHash('sha256')
356
+ .update(JSON.stringify(c.questions ?? c.classify?.toString() ?? ''))
357
+ .update(c.images?.toString() ?? '')
358
+ .digest('hex')
359
+ .slice(0, 16);
360
+ }
361
+ /**
362
+ * Score one subject: build its state, skip it if that state was already scored
363
+ * (`known` is the stored fingerprint), otherwise ask the model, store the
364
+ * result, and file a report if a signal crosses its threshold. Throws on a
365
+ * failure, for the caller to count or retry.
366
+ */
367
+ async function scoreSubject(c, subject, hash, known, signal) {
368
+ const state = await c.buildState({ db: dbCtx, subject });
369
+ if (!state)
370
+ return { outcome: 'skipped' };
371
+ const fingerprint = createHash('sha256').update(hash).update(JSON.stringify(state)).digest('hex').slice(0, 32);
372
+ if (known === fingerprint)
373
+ return { outcome: 'skipped' };
374
+ let answers;
375
+ let model;
376
+ let inputTokens = 0;
377
+ if (c.classify) {
378
+ const raw = await c.classify(state, { db: dbCtx, subject });
379
+ answers = Object.fromEntries(Object.entries(raw).map(([k, v]) => [k, typeof v === 'number' ? { score: v, type: 'local' } : v]));
380
+ model = `local:${c.name}`;
381
+ }
382
+ else {
383
+ const urls = c.images ? (await c.images(state, { db: dbCtx, subject })).slice(0, MAX_IMAGES) : [];
384
+ const images = await Promise.all(urls.map((u) => embedImage(u, signal)));
385
+ const res = await askClef(state, c.questions, { images, signal });
386
+ answers = res.answers;
387
+ model = res.model;
388
+ inputTokens = res.inputTokens;
389
+ }
390
+ let topSignal = null;
391
+ let topScore = 0;
392
+ for (const [name, sig] of Object.entries(answers)) {
393
+ if (sig.score > topScore) {
394
+ topScore = sig.score;
395
+ topSignal = name;
396
+ }
397
+ }
398
+ await upsertClassification({
399
+ subjectUri: subject.uri,
400
+ subjectDid: subject.did,
401
+ classifier: c.name,
402
+ signals: answers,
403
+ state,
404
+ topSignal,
405
+ topScore,
406
+ fingerprint,
407
+ model,
408
+ });
409
+ // File on the highest-scoring signal that crosses its own threshold — a
410
+ // second report for the same subject would only split the review.
411
+ let crossing = null;
412
+ for (const [name, sig] of Object.entries(answers)) {
413
+ const t = thresholdFor(c, name);
414
+ if (t != null && sig.score >= t && (!crossing || sig.score > crossing.score)) {
415
+ crossing = { signal: name, score: sig.score };
416
+ }
417
+ }
418
+ let filed = false;
419
+ if (crossing) {
420
+ const label = labelFor(c, crossing.signal, crossing.score);
421
+ if (!(await findOpenReport(subject.uri, label))) {
422
+ await insertReport({
423
+ subjectUri: subject.uri,
424
+ subjectDid: subject.did,
425
+ label,
426
+ reason: `${c.name}: ${crossing.signal} ${crossing.score.toFixed(2)}`,
427
+ reportedBy: `system:${c.name}`,
428
+ });
429
+ filed = true;
430
+ }
431
+ }
432
+ return { outcome: 'scored', filed, inputTokens };
433
+ }
434
+ let current = null;
435
+ /** Progress of the scan in flight, or the last one to finish. */
436
+ export function getScanProgress() {
437
+ return current;
438
+ }
439
+ /**
440
+ * Score every subject a classifier applies to, storing results and filing a
441
+ * report for each crossing signal.
442
+ *
443
+ * Subjects whose state is unchanged since the last scan are skipped unless
444
+ * `force` is set, so a repeat scan costs only what actually moved.
445
+ */
446
+ export async function runScan(opts = {}) {
447
+ if (current?.running)
448
+ throw new Error('A scan is already running');
449
+ const selected = opts.classifier ? classifiers.filter((c) => c.name === opts.classifier) : classifiers;
450
+ if (!selected.length)
451
+ throw new Error(opts.classifier ? `No classifier named ${opts.classifier}` : 'No classifiers');
452
+ // Only the classifiers that actually ask Clef need it configured. A
453
+ // host-local classifier runs with no API token at all.
454
+ if (selected.some((c) => !c.classify) && !isClefConfigured())
455
+ throw new Error('Clef is not configured');
456
+ const progress = {
457
+ scanned: 0,
458
+ skipped: 0,
459
+ scored: 0,
460
+ filed: 0,
461
+ errors: 0,
462
+ inputTokens: 0,
463
+ running: true,
464
+ startedAt: new Date().toISOString(),
465
+ };
466
+ current = progress;
467
+ try {
468
+ for (const c of selected) {
469
+ const subjects = await enumerateSubjects(c, opts.since);
470
+ const seen = opts.force ? new Map() : await getClassificationFingerprints(c.name);
471
+ const hash = classifierHash(c);
472
+ const queue = opts.limit ? subjects.slice(0, opts.limit) : subjects;
473
+ const concurrency = opts.concurrency ?? 8;
474
+ let cursor = 0;
475
+ const worker = async () => {
476
+ while (cursor < queue.length) {
477
+ if (opts.signal?.aborted)
478
+ return;
479
+ const subject = queue[cursor++];
480
+ progress.scanned++;
481
+ try {
482
+ const res = await scoreSubject(c, subject, hash, seen.get(subject.uri), opts.signal);
483
+ if (res.outcome === 'skipped') {
484
+ progress.skipped++;
485
+ continue;
486
+ }
487
+ progress.scored++;
488
+ progress.inputTokens += res.inputTokens;
489
+ if (res.filed)
490
+ progress.filed++;
491
+ }
492
+ catch (err) {
493
+ if (err?.name === 'AbortError')
494
+ return;
495
+ progress.errors++;
496
+ emit('classifiers', 'scan_error', { classifier: c.name, subject: subject.uri, error: err.message });
497
+ }
498
+ }
499
+ };
500
+ await Promise.all(Array.from({ length: Math.min(concurrency, queue.length) }, worker));
501
+ }
502
+ }
503
+ catch (err) {
504
+ progress.error = err.message;
505
+ throw err;
506
+ }
507
+ finally {
508
+ progress.running = false;
509
+ progress.finishedAt = new Date().toISOString();
510
+ // A scheduled pass that found nothing new is the common case, every few
511
+ // minutes; only a pass that did something is worth a line.
512
+ if (!opts.since || progress.scored || progress.errors)
513
+ log(`[classifiers] scan: ${progress.scored} scored, ${progress.skipped} skipped, ` +
514
+ `${progress.filed} filed, ${progress.errors} errors, ${progress.inputTokens} tokens`);
515
+ }
516
+ return progress;
517
+ }
518
+ // ── Scoring on index ───────────────────────────────────────────────────────
519
+ /** Where scoring's starting point is kept, so a restart keeps it. */
520
+ const SINCE_CURSOR = 'classifiers:since';
521
+ /**
522
+ * How long an account waits after its last write before it is scored. An import
523
+ * writes hundreds of photos in a row, and an account's state counts them, so
524
+ * scoring on every write would ask the model once per photo; waiting for the
525
+ * account to settle asks once.
526
+ */
527
+ const ACCOUNT_SETTLE_MS = 60_000;
528
+ const LIVE_CONCURRENCY = 4;
529
+ /** How long after boot the catch-up pass waits. */
530
+ let CATCH_UP_DELAY_MS = 60_000;
531
+ /** For tests: run the boot catch-up after `ms` rather than a minute. */
532
+ export function _setCatchUpDelayForTests(ms) {
533
+ CATCH_UP_DELAY_MS = ms;
534
+ }
535
+ const MAX_ATTEMPTS = 5;
536
+ let since = null;
537
+ const pending = new Map();
538
+ const settling = new Map();
539
+ let inFlight = 0;
540
+ /**
541
+ * Score new content as it is indexed, from the moment this was first switched
542
+ * on. What existed before then is never scored here — a pass over the existing
543
+ * library is a deliberate, manual scan from /admin.
544
+ *
545
+ * Starts with one catch-up pass over everything new since that moment, which
546
+ * picks up what a restart dropped from the queue; anything already scored is
547
+ * skipped on its fingerprint before it costs a model call.
548
+ */
549
+ export async function startScoringOnIndex() {
550
+ since = await getCursor(SINCE_CURSOR);
551
+ if (!since) {
552
+ since = new Date().toISOString();
553
+ await setCursor(SINCE_CURSOR, since);
554
+ }
555
+ log(`[classifiers] scoring on index, for content new since ${since}`);
556
+ // Held back from boot: the catch-up reads the database, and a server still
557
+ // starting up should not wait on that.
558
+ const from = since;
559
+ const catchUp = setTimeout(() => {
560
+ if (since !== from || current?.running)
561
+ return;
562
+ runScan({ since: from }).catch((err) => emit('classifiers', 'catch_up_error', { error: err.message }));
563
+ }, CATCH_UP_DELAY_MS);
564
+ catchUp.unref?.();
565
+ }
566
+ export function stopScoringOnIndex() {
567
+ since = null;
568
+ pending.clear();
569
+ for (const t of settling.values())
570
+ clearTimeout(t);
571
+ settling.clear();
572
+ }
573
+ /**
574
+ * Called by the indexer with each batch it applied. Queues every new record for
575
+ * the record classifiers that cover its collection, and its author for the
576
+ * account classifiers once the author has settled. Returns immediately: the
577
+ * indexer never waits on a model.
578
+ */
579
+ export function noteIndexed(items) {
580
+ if (!since || !classifiers.length)
581
+ return;
582
+ for (const item of items) {
583
+ if (item.action !== 'create' || !isNewSince(item.uri, since))
584
+ continue;
585
+ for (const c of classifiers) {
586
+ if (c.subject === 'record' && c.collections?.includes(item.collection)) {
587
+ enqueue({ classifier: c, collection: item.collection, uri: item.uri, did: item.authorDid, attempts: 0 });
588
+ }
589
+ }
590
+ if (!classifiers.some((c) => c.subject === 'account'))
591
+ continue;
592
+ clearTimeout(settling.get(item.authorDid));
593
+ const did = item.authorDid;
594
+ const timer = setTimeout(() => {
595
+ settling.delete(did);
596
+ for (const c of classifiers) {
597
+ if (c.subject === 'account')
598
+ enqueue({ classifier: c, uri: did, did, attempts: 0 });
599
+ }
600
+ }, ACCOUNT_SETTLE_MS);
601
+ timer.unref?.();
602
+ settling.set(did, timer);
603
+ }
604
+ }
605
+ /**
606
+ * Called by the indexer when a repo backfill lands. Its rows were written
607
+ * without passing through {@link noteIndexed} — a new account's first posts
608
+ * arrive this way — so queue whatever in it is new.
609
+ */
610
+ export function noteBackfilled(did) {
611
+ if (!since || !classifiers.length)
612
+ return;
613
+ const from = since;
614
+ void (async () => {
615
+ const items = [];
616
+ // Every collection, not only the record classifiers': a write anywhere is
617
+ // what queues the account for the account classifiers.
618
+ for (const schema of listSchemas()) {
619
+ const collection = schema.collection;
620
+ const rows = (await querySQL(`SELECT uri FROM ${schema.tableName} WHERE did = $1 AND space IS NULL`, [did]));
621
+ for (const r of rows)
622
+ if (isNewSince(r.uri, from))
623
+ items.push({ action: 'create', collection, uri: r.uri, authorDid: did });
624
+ }
625
+ noteIndexed(items);
626
+ })().catch((err) => emit('classifiers', 'backfill_queue_error', { did, error: err.message }));
627
+ }
628
+ function enqueue(item) {
629
+ pending.set(`${item.classifier.name}|${item.uri}`, item);
630
+ pump();
631
+ }
632
+ function pump() {
633
+ while (inFlight < LIVE_CONCURRENCY && pending.size) {
634
+ const [key, item] = pending.entries().next().value;
635
+ pending.delete(key);
636
+ inFlight++;
637
+ scorePending(item)
638
+ .catch((err) => {
639
+ item.attempts++;
640
+ if (item.attempts >= MAX_ATTEMPTS || !since) {
641
+ emit('classifiers', 'live_dropped', {
642
+ classifier: item.classifier.name,
643
+ subject: item.uri,
644
+ error: err.message,
645
+ });
646
+ return;
647
+ }
648
+ const retry = setTimeout(() => enqueue(item), 2 ** item.attempts * 5_000);
649
+ retry.unref?.();
650
+ })
651
+ .finally(() => {
652
+ inFlight--;
653
+ pump();
654
+ });
655
+ }
656
+ }
657
+ /** Read the subject as it stands now, and score it if it is still in scope. */
658
+ async function scorePending(item) {
659
+ const c = item.classifier;
660
+ let subject;
661
+ if (item.collection) {
662
+ const schema = getSchema(item.collection);
663
+ if (!schema)
664
+ return;
665
+ const [row] = (await querySQL(`SELECT t.* FROM ${schema.tableName} t
666
+ JOIN _repos r ON r.did = t.did AND r.status = 'active'
667
+ WHERE t.uri = $1 AND t.space IS NULL`, [item.uri]));
668
+ // Deleted, taken down, or in a space since it was queued.
669
+ if (!row)
670
+ return;
671
+ subject = { uri: row.uri, did: row.did, collection: item.collection, value: rowValue(schema, row) };
672
+ }
673
+ else {
674
+ const [repo] = (await querySQL(`SELECT did, handle FROM _repos WHERE did = $1 AND status = 'active'`, [
675
+ item.did,
676
+ ]));
677
+ if (!repo)
678
+ return;
679
+ subject = { uri: repo.did, did: repo.did, handle: repo.handle };
680
+ }
681
+ const res = await scoreSubject(c, subject, classifierHash(c), await getClassificationFingerprint(c.name, subject.uri));
682
+ if (res.outcome === 'scored' && res.filed)
683
+ log(`[classifiers] ${c.name} filed a report on ${subject.uri}`);
684
+ }
685
+ /** Items waiting to be scored, for tests and the admin view. */
686
+ export function pendingCount() {
687
+ return pending.size + inFlight;
688
+ }