tryharness 0.7.26 → 0.7.29

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.
@@ -0,0 +1,693 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { ATLAS_VERSION } from "./atlas.mjs";
5
+ import { fileKind, readParts, readPart } from "./readers.mjs";
6
+ import { clock } from "./media.mjs";
7
+ import { encodeWav } from "./speech.mjs";
8
+
9
+ // The library: what is in the person's files and in the files their threads hold, found by what it is about and by
10
+ // its exact words. Folders the person added (`tryharness local add`) are read on the background lane: PDFs page by
11
+ // page, documents in passages, pictures, recordings in 30-second windows, videos as frames and soundtrack. A thread's
12
+ // files are read as they arrive. Everything stays in the atlas on this computer.
13
+ //
14
+ // Ranking follows DigUp (github.com/ARahim3/DigUp, MIT), which measured it on its own evals: every part is scored
15
+ // against the question, and a part counts by meaning only when it stands out from all the others for that question
16
+ // (its score in standard deviations, after each kind of part is centred on its own average). Words count beside
17
+ // meaning, so a code or a name is found as written. Standing out is what lets a search say "nothing here".
18
+
19
+ const MAX_FILES = 5_000;
20
+ // A folder is walked this far to find its newest files.
21
+ const MAX_WALK = 50_000;
22
+ const MAX_DEPTH = 8;
23
+ const MAX_BYTES = { image: 20e6, pdf: 200e6, doc: 20e6 };
24
+ // A part counts by meaning from this many standard deviations above the rest, for a search an agent asked for.
25
+ const Z_GATE = 2.0;
26
+ // A brief nobody asked for takes a file only when it stands out much further, and close to the best one: among 268
27
+ // files, right answers stood 3.9 to 8.2 above the rest and the best answer to a task about something never written 3.9
28
+ // at most, while an unrelated README in second place trailed the right file by more than a point (eval file-search).
29
+ const BRIEF_Z = 4.0;
30
+ const BRIEF_NEAR_BEST = 1.0;
31
+ // Below this many parts the spread says little, and a fixed similarity stands in (eval media-search: right answers
32
+ // 0.68 and up, never-said questions 0.675 at most).
33
+ const MIN_SCALE = 200;
34
+ const FLOOR = { image: 0.7, audio: 0.7, text: 0.7 };
35
+ const SMALL_GROUP = 20;
36
+ const WORD_WEIGHT = 2.0;
37
+ const VECTORS_IDLE_MS = 5 * 60_000;
38
+ // A change in a searched folder is read once the folder has been quiet this long (a download writes for a while).
39
+ const CHANGE_QUIET_MS = 5_000;
40
+
41
+ const SKIP_DIR =
42
+ /^(?:\..*|node_modules|Library|\.Trash|__pycache__|venv|site-packages|.*\.(?:app|photoslibrary|noindex|bundle|framework))$/i;
43
+ const CODE_MARKERS = [
44
+ ".git",
45
+ "package.json",
46
+ "Cargo.toml",
47
+ "go.mod",
48
+ "pyproject.toml",
49
+ ];
50
+ const SECRET_NAME =
51
+ /^(?:id_(?:rsa|dsa|ecdsa|ed25519)\w*|\.env.*|wallet\.dat)$|\.(?:pem|key|p12|pfx|kdbx|keychain(?:-db)?|gpg|asc|ovpn|ppk|jks|keystore)$|(?:^|[-_. ])(?:credentials?|secrets?|passwords?|recovery[-_ ]codes?|seed[-_ ]?phrase)(?:[-_. ]|$)/i;
52
+ const SCREENSHOT = /screen ?shot|cleanshot|capture|^scr[-_ ]?\d/i;
53
+ const MACHINE_NAME =
54
+ /^(?:[a-z]+[-_]?)?\d{3,}$|^[0-9a-f]{8}-[0-9a-f]{4}-|^[0-9a-f]{16,}$/i;
55
+ const DATASET_FILES = 200;
56
+
57
+ const STOP = new Set(
58
+ "a an and are as at be by can did do does find for from had has have how i in is it its me my of on or our show that the their them they this to was we were what when where which who why with you your".split(
59
+ " "
60
+ )
61
+ );
62
+ // Words that say what kind of file to look for, not what is in it.
63
+ const KIND_WORDS = {
64
+ image: [
65
+ "screenshot",
66
+ "screenshots",
67
+ "picture",
68
+ "pictures",
69
+ "photo",
70
+ "photos",
71
+ "image",
72
+ "images",
73
+ ],
74
+ audio: [
75
+ "recording",
76
+ "recordings",
77
+ "call",
78
+ "voice",
79
+ "audio",
80
+ "podcast",
81
+ "memo",
82
+ ],
83
+ video: ["video", "videos", "clip", "clips", "movie"],
84
+ pdf: ["pdf", "pdfs"],
85
+ doc: ["document", "documents", "doc", "docs", "notes", "note"],
86
+ };
87
+
88
+ /** Why a file is never read, or null. Keys and password files never; files that only live in iCloud are not downloaded. */
89
+ export function skipReason(file, stat) {
90
+ const name = path.basename(file);
91
+ if (SECRET_NAME.test(name)) return "looks like a key or password file";
92
+ if (name.endsWith(".icloud")) return "only in iCloud";
93
+ // A file the cloud took back keeps its size but no blocks on the disk; reading it would download it.
94
+ if (stat.size > 0 && stat.blocks === 0) return "only in the cloud";
95
+ const kind = fileKind(name);
96
+ if (!kind) return "not a kind of file the relay reads";
97
+ if (stat.size > (MAX_BYTES[kind] ?? Infinity)) return "too large";
98
+ return null;
99
+ }
100
+
101
+ /** Search words: lowercased, no stopwords or file-kind words. */
102
+ export function terms(text) {
103
+ const kindWords = new Set(Object.values(KIND_WORDS).flat());
104
+ const words =
105
+ String(text)
106
+ .toLowerCase()
107
+ .match(/[\p{L}\p{N}_-]+/gu) ?? [];
108
+ return [...new Set(words)]
109
+ .filter(
110
+ (word) => word.length > 1 && !STOP.has(word) && !kindWords.has(word)
111
+ )
112
+ .slice(0, 12);
113
+ }
114
+
115
+ /** Codes and IDs ("INV-20931", "K7Q2LM") mean nothing to an embedding: a query made only of them leans on words. */
116
+ export const isLookup = (words) =>
117
+ words.length > 0 &&
118
+ words.every(
119
+ (word) => /\d/.test(word) && (/\p{L}/u.test(word) || word.length >= 4)
120
+ );
121
+
122
+ const hintedKinds = (text) => {
123
+ const words = new Set(
124
+ String(text)
125
+ .toLowerCase()
126
+ .match(/\p{L}+/gu) ?? []
127
+ );
128
+ return new Set(
129
+ Object.entries(KIND_WORDS)
130
+ .filter(([, list]) => list.some((word) => words.has(word)))
131
+ .map(([kind]) => kind)
132
+ );
133
+ };
134
+
135
+ const phrase = (word) => `"${word.replaceAll('"', '""')}"`;
136
+
137
+ /**
138
+ * Meaning scores on one scale for this query (DigUp's Searcher.meaning): each part's similarity minus the average of
139
+ * its modality, in standard deviations of all of them together.
140
+ */
141
+ export function standOut(scores, modalities) {
142
+ const sums = new Map();
143
+ for (let i = 0; i < scores.length; i += 1) {
144
+ const entry = sums.get(modalities[i]) ?? { sum: 0, count: 0 };
145
+ entry.sum += scores[i];
146
+ entry.count += 1;
147
+ sums.set(modalities[i], entry);
148
+ }
149
+ let total = 0;
150
+ for (const entry of sums.values()) total += entry.sum;
151
+ const overall = total / Math.max(scores.length, 1);
152
+ const meanOf = (modality) => {
153
+ const entry = sums.get(modality);
154
+ return entry.count >= SMALL_GROUP ? entry.sum / entry.count : overall;
155
+ };
156
+ const centred = scores.map((score, i) => score - meanOf(modalities[i]));
157
+ const mean = centred.reduce((a, b) => a + b, 0) / Math.max(centred.length, 1);
158
+ const variance =
159
+ centred.reduce((sum, value) => sum + (value - mean) ** 2, 0) /
160
+ Math.max(centred.length, 1);
161
+ const deviation = Math.max(Math.sqrt(variance), 0.02);
162
+ return centred.map((value) => (value - mean) / deviation);
163
+ }
164
+
165
+ /** Where in its file a part is, in words: "page 3", "line 40", "1:20". */
166
+ export function placeOf(part) {
167
+ if (part.kind === "pdf") return `page ${part.loc}`;
168
+ if (part.kind === "doc") return `line ${part.loc}`;
169
+ if (part.kind === "audio" || part.kind === "video") return clock(part.loc);
170
+ return null;
171
+ }
172
+
173
+ /**
174
+ * The newest files under a folder the library reads (up to max), and what was passed over and why. Asynchronous: the
175
+ * first read of a folder macOS protects waits for the person's answer to its prompt, and must not hold up the daemon.
176
+ */
177
+ export async function crawl(
178
+ folder,
179
+ { max = MAX_FILES, walkAtMost = MAX_WALK } = {}
180
+ ) {
181
+ const files = [];
182
+ const skipped = [];
183
+ const walk = async (dir, depth, top) => {
184
+ if (depth > MAX_DEPTH || files.length >= walkAtMost) return;
185
+ let entries = [];
186
+ try {
187
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
188
+ } catch (error) {
189
+ // macOS keeps Desktop, Documents and Downloads from a program the person has not allowed.
190
+ if (top && (error.code === "EPERM" || error.code === "EACCES"))
191
+ skipped.push({
192
+ path: dir,
193
+ reason: "Harness is not allowed to read it",
194
+ });
195
+ return;
196
+ }
197
+ // A code project is the code index's (atlas.mjs): here only its screenshots are read.
198
+ const project =
199
+ !top && entries.some((entry) => CODE_MARKERS.includes(entry.name));
200
+ const found = [];
201
+ for (const entry of entries) {
202
+ const full = path.join(dir, entry.name);
203
+ if (entry.isDirectory()) {
204
+ if (!SKIP_DIR.test(entry.name)) await walk(full, depth + 1, false);
205
+ continue;
206
+ }
207
+ if (!entry.isFile()) continue;
208
+ if (project && !SCREENSHOT.test(entry.name)) continue;
209
+ let stat;
210
+ try {
211
+ stat = await fs.promises.stat(full);
212
+ } catch {
213
+ continue;
214
+ }
215
+ const reason = skipReason(full, stat);
216
+ if (reason) {
217
+ if (reason !== "not a kind of file the relay reads")
218
+ skipped.push({ path: full, reason });
219
+ continue;
220
+ }
221
+ found.push({
222
+ path: full,
223
+ kind: fileKind(entry.name),
224
+ mtime: stat.mtimeMs,
225
+ size: stat.size,
226
+ });
227
+ }
228
+ // Hundreds of machine-named files of one kind are training data, not the person's own: passed over and said.
229
+ const machineMade = found.filter((file) =>
230
+ MACHINE_NAME.test(path.parse(file.path).name)
231
+ );
232
+ if (!top && machineMade.length >= DATASET_FILES) {
233
+ skipped.push({ path: dir, reason: "looks like a dataset" });
234
+ return;
235
+ }
236
+ files.push(...found);
237
+ };
238
+ await walk(folder, 0, true);
239
+ const newest = files.sort((a, b) => b.mtime - a.mtime).slice(0, max);
240
+ return { files: newest, skipped };
241
+ }
242
+
243
+ const partKey = (contentKey, kind, part) =>
244
+ createHash("sha1")
245
+ .update(
246
+ // Recordings and pictures keep the keys the media index gave them, so their vectors carry over.
247
+ kind === "audio" || kind === "image"
248
+ ? `${contentKey}\0${part.loc}`
249
+ : `${contentKey}\0${part.modality}\0${part.loc}`
250
+ )
251
+ .digest("hex");
252
+
253
+ export function createLibrary({
254
+ atlas,
255
+ inference,
256
+ folders = () => [],
257
+ threadFiles = () => [],
258
+ mediaReady = () => false,
259
+ wantMedia = () => {},
260
+ ocr = null,
261
+ mask = async (text) => text,
262
+ log = () => {},
263
+ version = ATLAS_VERSION,
264
+ }) {
265
+ const store = atlas.library;
266
+ let cache = null;
267
+ let dropCache = null;
268
+ let refreshing = null;
269
+ let skipped = [];
270
+
271
+ const vectors = () => {
272
+ cache ??= store.vectors(version);
273
+ clearTimeout(dropCache);
274
+ dropCache = setTimeout(() => (cache = null), VECTORS_IDLE_MS);
275
+ dropCache.unref?.();
276
+ return cache;
277
+ };
278
+
279
+ /** The words a picture is found by: its text, read here (screenshots and other PNGs). */
280
+ const wordsIn = async (file, kind, part, lane) => {
281
+ if (part.modality === "text") return part.text;
282
+ const screenshotLike =
283
+ SCREENSHOT.test(path.basename(file)) || /\.png$/i.test(file);
284
+ if (!ocr || kind !== "image" || !screenshotLike) return "";
285
+ try {
286
+ const read = await ocr.read(part.data, { lane });
287
+ return read.lines.map((line) => line.text).join("\n");
288
+ } catch {
289
+ return "";
290
+ }
291
+ };
292
+
293
+ /** Reads one file into the library: its parts and their words now, their vectors as the model gets to them. */
294
+ const add = async (
295
+ { file, readFrom, source, name, kind, mtime, contentKey },
296
+ lane
297
+ ) => {
298
+ const parts = await readParts(readFrom, kind);
299
+ const rows = [];
300
+ for (const part of parts)
301
+ rows.push({
302
+ file,
303
+ vec: partKey(contentKey, kind, part),
304
+ source,
305
+ name,
306
+ kind,
307
+ modality: part.modality,
308
+ loc: part.loc,
309
+ locEnd: part.locEnd,
310
+ mtime,
311
+ embed: part.embed,
312
+ text: await wordsIn(readFrom, kind, part, lane),
313
+ });
314
+ atlas.batch(() => {
315
+ store.drop(file);
316
+ store.put(rows);
317
+ });
318
+ if (mediaReady()) await embedMedia(parts, rows, lane);
319
+ cache = null;
320
+ return parts;
321
+ };
322
+
323
+ /** Embeds a file's pictures and sound from the bytes just read. */
324
+ const embedMedia = async (parts, rows, lane) => {
325
+ for (const [i, part] of parts.entries()) {
326
+ if (part.modality === "text") continue;
327
+ const data = part.data ?? encodeWav(part.samples, part.sampleRate);
328
+ const [vector] = await inference.embedMedia(
329
+ [{ title: rows[i].name, data }],
330
+ { lane }
331
+ );
332
+ atlas.putVector(rows[i].vec, version, vector);
333
+ }
334
+ };
335
+
336
+ /** Embeds what is waiting: text parts always, pictures and sound once the image and audio part is here. */
337
+ const embedWaiting = async (lane) => {
338
+ let done = 0;
339
+ for (;;) {
340
+ const texts = store.toEmbed(version, ["text"], 16);
341
+ if (!texts.length) break;
342
+ const embedded = await inference.embed(
343
+ texts.map((part) => ({
344
+ kind: "document",
345
+ title: part.name,
346
+ text: part.text,
347
+ })),
348
+ { lane }
349
+ );
350
+ atlas.batch(() =>
351
+ texts.forEach((part, i) =>
352
+ atlas.putVector(part.vec, version, embedded[i])
353
+ )
354
+ );
355
+ done += texts.length;
356
+ }
357
+ // Pictures and sound read before the image and audio part came down: each file read again once.
358
+ const tried = new Set();
359
+ while (mediaReady()) {
360
+ const [waiting] = store.toEmbed(version, ["image", "audio"], 1);
361
+ if (!waiting || tried.has(waiting.file)) break;
362
+ tried.add(waiting.file);
363
+ const from = sourceOf(waiting);
364
+ try {
365
+ await add({ ...fileOf(waiting), readFrom: from }, lane);
366
+ done += 1;
367
+ } catch (error) {
368
+ log(`library: ${waiting.name}: ${error.message}`);
369
+ store.drop(waiting.file);
370
+ }
371
+ }
372
+ if (done) cache = null;
373
+ return done;
374
+ };
375
+
376
+ // Where a part's file is read from now: a folder file's own path, a thread file's stored copy.
377
+ const threadPaths = new Map();
378
+ const sourceOf = (part) =>
379
+ part.source === "folders" ? part.file : threadPaths.get(part.file);
380
+ const contentKeys = new Map();
381
+ const fileOf = (part) => ({
382
+ file: part.file,
383
+ source: part.source,
384
+ name: part.name,
385
+ kind: part.kind,
386
+ mtime: part.mtime,
387
+ contentKey:
388
+ part.source === "folders"
389
+ ? `${part.file}\0${part.mtime}`
390
+ : contentKeys.get(part.file),
391
+ });
392
+
393
+ // A file that could not be read is remembered by its name, so it is not tried again until it changes.
394
+ const unreadable = (file, source) =>
395
+ store.put([
396
+ {
397
+ file: file.path,
398
+ vec: "unreadable",
399
+ source,
400
+ name: file.name ?? path.basename(file.path),
401
+ kind: file.kind ?? fileKind(file.name ?? file.path),
402
+ modality: "none",
403
+ loc: 0,
404
+ mtime: file.mtime,
405
+ embed: false,
406
+ },
407
+ ]);
408
+
409
+ // Each searched folder is watched, so a new or changed file is read seconds after it lands.
410
+ const watchers = new Map();
411
+ let quiet = null;
412
+ const changed = (_, name) => {
413
+ const parts = String(name ?? "").split(path.sep);
414
+ if (parts.some((part) => SKIP_DIR.test(part))) return;
415
+ clearTimeout(quiet);
416
+ quiet = setTimeout(() => {
417
+ const running = refreshing ?? Promise.resolve();
418
+ void running.then(() => refresh()).catch(() => {});
419
+ }, CHANGE_QUIET_MS);
420
+ quiet.unref?.();
421
+ };
422
+ const watch = (wanted) => {
423
+ for (const [folder, watcher] of watchers)
424
+ if (!wanted.includes(folder)) {
425
+ watcher.close();
426
+ watchers.delete(folder);
427
+ }
428
+ for (const folder of wanted) {
429
+ if (watchers.has(folder)) continue;
430
+ try {
431
+ const watcher = fs.watch(
432
+ folder,
433
+ { recursive: true, persistent: false },
434
+ changed
435
+ );
436
+ watcher.on("error", () => {
437
+ watcher.close();
438
+ watchers.delete(folder);
439
+ });
440
+ watchers.set(folder, watcher);
441
+ } catch {}
442
+ }
443
+ };
444
+
445
+ /** Brings the library in step with the searched folders and the threads' files, then embeds what waits. */
446
+ const refresh = ({ lane = "background" } = {}) =>
447
+ (refreshing ??= (async () => {
448
+ let added = 0;
449
+ const seenSkips = [];
450
+ // The person's folders.
451
+ const searched = folders();
452
+ const listed = [];
453
+ for (const folder of searched) {
454
+ const found = await crawl(folder);
455
+ seenSkips.push(...found.skipped);
456
+ listed.push(...found.files);
457
+ }
458
+ // Watched once walked, so the first look into a folder macOS protects is the walk's.
459
+ watch(searched);
460
+ const known = store.files("folders");
461
+ const present = new Set(listed.map((file) => file.path));
462
+ for (const file of known.keys()) if (!present.has(file)) store.drop(file);
463
+ for (const file of listed) {
464
+ if (known.get(file.path) === file.mtime) continue;
465
+ try {
466
+ await add(
467
+ {
468
+ file: file.path,
469
+ readFrom: file.path,
470
+ source: "folders",
471
+ name: path.basename(file.path),
472
+ kind: file.kind,
473
+ mtime: file.mtime,
474
+ contentKey: `${file.path}\0${file.mtime}`,
475
+ },
476
+ lane
477
+ );
478
+ added += 1;
479
+ } catch (error) {
480
+ seenSkips.push({ path: file.path, reason: error.message });
481
+ log(`library: ${path.basename(file.path)}: ${error.message}`);
482
+ unreadable(file, "folders");
483
+ }
484
+ }
485
+ // The threads' files: stored once and never changed, so each is read once.
486
+ const threadList = threadFiles().filter((file) => fileKind(file.name));
487
+ threadPaths.clear();
488
+ for (const file of threadList) {
489
+ threadPaths.set(file.id, file.path);
490
+ contentKeys.set(file.id, file.sha256);
491
+ }
492
+ const keep = new Set(threadList.map((file) => file.id));
493
+ for (const source of store.sources())
494
+ if (source !== "folders")
495
+ for (const file of store.files(source).keys())
496
+ if (!keep.has(file)) store.drop(file);
497
+ for (const file of threadList) {
498
+ if (store.files(file.thread).has(file.id)) continue;
499
+ try {
500
+ await add(
501
+ {
502
+ file: file.id,
503
+ readFrom: file.path,
504
+ source: file.thread,
505
+ name: file.name,
506
+ kind: fileKind(file.name),
507
+ mtime: 0,
508
+ contentKey: file.sha256,
509
+ },
510
+ lane
511
+ );
512
+ added += 1;
513
+ } catch (error) {
514
+ log(`library: thread file ${file.name}: ${error.message}`);
515
+ unreadable({ ...file, path: file.id, mtime: 0 }, file.thread);
516
+ }
517
+ }
518
+ skipped = seenSkips;
519
+ const embedded = await embedWaiting(lane);
520
+ // Pictures and sound wait for the image and audio part of the search model.
521
+ if (!mediaReady() && store.toEmbed(version, ["image", "audio"], 1).length)
522
+ wantMedia();
523
+ return {
524
+ files: listed.length + threadList.length,
525
+ added,
526
+ embedded,
527
+ skipped: skipped.length,
528
+ };
529
+ })().finally(() => {
530
+ refreshing = null;
531
+ }));
532
+
533
+ /**
534
+ * The files closest to a question, best first, one hit per file at its best part. scope: folders (the person's added
535
+ * folders: agents on this computer only) and threads (thread ids whose files to search); allow(file) drops thread
536
+ * files a caller may not read. brief: only hits that stand out, for a brief nobody asked for.
537
+ */
538
+ const search = async (
539
+ query,
540
+ {
541
+ folders: inFolders = false,
542
+ threads = [],
543
+ allow = () => true,
544
+ limit = 8,
545
+ lane = "interactive",
546
+ brief = false,
547
+ } = {}
548
+ ) => {
549
+ const text = String(query).slice(0, 4_000);
550
+ const inScope = (part) =>
551
+ part.source === "folders"
552
+ ? inFolders
553
+ : threads.includes(part.source) && allow(part.file);
554
+ const words = terms(text);
555
+ const lookup = isLookup(words);
556
+
557
+ // Meaning: every part scored, so the spread of scores says what stands out for this question.
558
+ const rows = vectors();
559
+ const best = new Map();
560
+ if (rows.length) {
561
+ const [q] = await inference.embed([{ kind: "query", text }], { lane });
562
+ const scores = rows.map((row) => {
563
+ let dot = 0;
564
+ for (let i = 0; i < q.length; i += 1) dot += q[i] * row.vector[i];
565
+ return dot;
566
+ });
567
+ const z = standOut(
568
+ scores,
569
+ rows.map((row) => row.modality)
570
+ );
571
+ const scaled = rows.length >= MIN_SCALE;
572
+ const gate = brief ? BRIEF_Z : Z_GATE;
573
+ rows.forEach((row, i) => {
574
+ if (!inScope(row)) return;
575
+ const strong = scaled ? z[i] >= gate : scores[i] >= FLOOR[row.modality];
576
+ const held = best.get(row.file);
577
+ if (!held || z[i] > held.z)
578
+ best.set(row.file, { part: row, z: z[i], cosine: scores[i], strong });
579
+ });
580
+ }
581
+
582
+ // Words: a file holding at least half the question's words qualifies, a code or name matched as written. A brief's
583
+ // request is long, so there only the codes it names count, and one found is enough.
584
+ const counted = brief
585
+ ? words.filter((word) => word.length >= 5 && isLookup([word]))
586
+ : words;
587
+ const needed = brief ? 1 / Math.max(counted.length, 1) : 0.5;
588
+ const coverage = new Map();
589
+ const wordPart = new Map();
590
+ for (const word of counted) {
591
+ for (const [file, vecs] of store.holding(phrase(word))) {
592
+ coverage.set(file, (coverage.get(file) ?? 0) + 1 / counted.length);
593
+ if (vecs[0] && !wordPart.has(file)) wordPart.set(file, vecs[0]);
594
+ }
595
+ }
596
+
597
+ const [meaningWeight, wordsWeight] = lookup
598
+ ? [0.5, WORD_WEIGHT * 2]
599
+ : [1, WORD_WEIGHT];
600
+ const hinted = hintedKinds(text);
601
+ const hits = [];
602
+ for (const file of new Set([...best.keys(), ...coverage.keys()])) {
603
+ const meaning = best.get(file);
604
+ const share = coverage.get(file) ?? 0;
605
+ const byWords = share >= needed - 1e-9;
606
+ if (!meaning?.strong && !byWords) continue;
607
+ // Point at the part that stands out by meaning, else the one the words are in, else the file's first.
608
+ const vec = meaning?.strong
609
+ ? meaning.part.vec
610
+ : (wordPart.get(file) ?? meaning?.part.vec);
611
+ const part = vec ? store.part(file, vec) : store.first(file);
612
+ if (!part || !inScope(part)) continue;
613
+ let score =
614
+ meaningWeight * Math.max(meaning?.z ?? 0, 0) +
615
+ wordsWeight * share ** 1.5;
616
+ if (hinted.has(part.kind)) score += 1;
617
+ hits.push({ part, score, meaning, byWords });
618
+ }
619
+ // A brief's files stand close to the best one; a file found by a code it names always counts.
620
+ const bestZ = Math.max(...hits.map((hit) => hit.meaning?.z ?? -Infinity));
621
+ const kept = hits.filter(
622
+ (hit) =>
623
+ !brief ||
624
+ hit.byWords ||
625
+ !rows.length ||
626
+ rows.length < MIN_SCALE ||
627
+ hit.meaning.z >= bestZ - BRIEF_NEAR_BEST
628
+ );
629
+ const top = kept.sort((a, b) => b.score - a.score).slice(0, limit);
630
+ // An excerpt goes into an agent's context: a key in it is masked first.
631
+ const excerpts = await Promise.all(
632
+ top.map(({ part }) => {
633
+ const excerpt = excerptOf(part.text, words);
634
+ return excerpt ? mask(excerpt) : null;
635
+ })
636
+ );
637
+ return top.map(({ part, score, meaning, byWords }, i) => ({
638
+ file: part.file,
639
+ thread: part.source === "folders" ? null : part.source,
640
+ name: part.name,
641
+ kind: part.kind,
642
+ modality: part.modality,
643
+ loc: part.loc,
644
+ place: placeOf(part),
645
+ excerpt: excerpts[i],
646
+ score: Math.round(score * 100) / 100,
647
+ standsOut: meaning?.strong ? Math.round(meaning.z * 10) / 10 : null,
648
+ similarity: meaning ? Math.round(meaning.cosine * 1000) / 1000 : null,
649
+ byWords,
650
+ vec: part.vec,
651
+ }));
652
+ };
653
+
654
+ /** One part's content, read fresh from its file for sharing: { modality, text? | data? | samples } or null. */
655
+ const content = async (file, vec) => {
656
+ const part = store.part(file, vec);
657
+ const from = part && sourceOf(part);
658
+ return from ? { part, ...(await readPart(from, part)) } : null;
659
+ };
660
+
661
+ return {
662
+ refresh,
663
+ search,
664
+ content,
665
+ stop() {
666
+ clearTimeout(quiet);
667
+ clearTimeout(dropCache);
668
+ for (const watcher of watchers.values()) watcher.close();
669
+ watchers.clear();
670
+ },
671
+ status: () => ({
672
+ folders: folders(),
673
+ files: store.count(),
674
+ folderFiles: store.files("folders").size,
675
+ waiting: store.waiting(version),
676
+ skipped: skipped.slice(0, 50),
677
+ }),
678
+ };
679
+ }
680
+
681
+ const excerptOf = (text, words, size = 300) => {
682
+ const body = String(text || "")
683
+ .replace(/\s+/g, " ")
684
+ .trim();
685
+ if (!body) return null;
686
+ const lower = body.toLowerCase();
687
+ const at = words
688
+ .map((word) => lower.indexOf(word))
689
+ .filter((index) => index >= 0)
690
+ .sort((a, b) => a - b)[0];
691
+ const start = at === undefined ? 0 : Math.max(0, at - 80);
692
+ return `${start ? "…" : ""}${body.slice(start, start + size)}${start + size < body.length ? "…" : ""}`;
693
+ };