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,459 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { execFile, spawnSync } from "node:child_process";
5
+ import { documentText } from "./files.mjs";
6
+ import { windows } from "./media.mjs";
7
+ import { decodeWav, encodeWav, resample } from "./speech.mjs";
8
+
9
+ // What is in a file, as the parts the search model reads: a PDF's pages, a document's passages, a picture, a
10
+ // recording's 30-second windows, a video's frames and its soundtrack. Each part says where it is in its file (a page,
11
+ // a line, a moment), so a match points there. Only tools every Mac has are needed (PDFKit through osascript, textutil,
12
+ // sips, afconvert, Quick Look); elsewhere poppler and ffmpeg are used when installed, and a file needing a missing tool
13
+ // is skipped with the reason. Every tool runs as its own process without holding up the daemon.
14
+
15
+ const RATE = 16_000;
16
+ const MAX_PAGES = 300;
17
+ const MAX_PASSAGES = 300;
18
+ const PASSAGE_CHARS = 1_500;
19
+ // A page with less text than this is read as a picture (a scan, a slide).
20
+ const SCANNED_BELOW = 40;
21
+ const MAX_FRAMES = 120;
22
+ const FRAME_EVERY_S = 5;
23
+
24
+ const KINDS = {
25
+ image: /\.(?:png|jpe?g|heic|heif|webp|gif|tiff?|bmp)$/i,
26
+ audio: /\.(?:wav|mp3|m4a|aac|caf|aiff?|flac|ogg|opus)$/i,
27
+ video: /\.(?:mp4|mov|m4v|webm|mkv|avi)$/i,
28
+ pdf: /\.pdf$/i,
29
+ doc: /\.(?:txt|md|markdown|rtf|html?|docx|pptx|odt|doc)$/i,
30
+ };
31
+ const PLAIN = /\.(?:txt|md|markdown)$/i;
32
+ const MODEL_READS = /\.(?:png|jpe?g)$/i;
33
+
34
+ /** The kind of file a name says it is: "image", "audio", "video", "pdf", "doc", or null for one not read. */
35
+ export function fileKind(name) {
36
+ for (const [kind, pattern] of Object.entries(KINDS))
37
+ if (pattern.test(name)) return kind;
38
+ return null;
39
+ }
40
+
41
+ const has = (tool) =>
42
+ spawnSync(process.platform === "win32" ? "where" : "which", [tool], {
43
+ encoding: "utf8",
44
+ }).status === 0;
45
+ const tools = {};
46
+ const available = (tool) => (tools[tool] ??= has(tool));
47
+
48
+ const scratch = () => fs.mkdtempSync(path.join(os.tmpdir(), "harness-read-"));
49
+
50
+ /** Runs a tool to its end: { ok, stdout }. A tool that is missing or fails is ok: false. */
51
+ const tool = (command, args, { timeout = 300_000 } = {}) =>
52
+ new Promise((resolve) =>
53
+ execFile(
54
+ command,
55
+ args,
56
+ { encoding: "utf8", maxBuffer: 256 * 2 ** 20, timeout },
57
+ (error, stdout) => resolve({ ok: !error, stdout: stdout ?? "", error })
58
+ )
59
+ );
60
+
61
+ // PDFKit through JavaScript for Automation: every page's text, and a page drawn as a picture.
62
+ const PDF_PAGES = `
63
+ ObjC.import("PDFKit");
64
+ function run(argv) {
65
+ const url = $.NSURL.fileURLWithPath(argv[0]);
66
+ const doc = $.PDFDocument.alloc.initWithURL(url);
67
+ if (!doc) return "null";
68
+ const pages = [];
69
+ const count = Math.min(doc.pageCount, Number(argv[1]));
70
+ for (let i = 0; i < count; i++)
71
+ pages.push(ObjC.unwrap(doc.pageAtIndex(i).string) || "");
72
+ return JSON.stringify({ pages, total: doc.pageCount });
73
+ }`;
74
+ const PDF_DRAW = `
75
+ ObjC.import("PDFKit");
76
+ ObjC.import("AppKit");
77
+ function run(argv) {
78
+ const url = $.NSURL.fileURLWithPath(argv[0]);
79
+ const page = $.PDFDocument.alloc.initWithURL(url).pageAtIndex(Number(argv[1]));
80
+ const size = $.NSMakeSize(1024, 1024);
81
+ const image = page.thumbnailOfSizeForBox(size, $.kPDFDisplayBoxMediaBox);
82
+ const bitmap = $.NSBitmapImageRep.imageRepWithData(image.TIFFRepresentation);
83
+ const png = bitmap.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $());
84
+ png.writeToFileAtomically(argv[2], true);
85
+ return "ok";
86
+ }`;
87
+ const jxa = (script, args) =>
88
+ tool("osascript", ["-l", "JavaScript", "-e", script, ...args], {
89
+ timeout: 120_000,
90
+ });
91
+
92
+ async function pdfPages(file) {
93
+ if (process.platform === "darwin") {
94
+ const read = await jxa(PDF_PAGES, [file, String(MAX_PAGES)]);
95
+ const parsed = read.ok ? JSON.parse(read.stdout || "null") : null;
96
+ if (!parsed) throw new Error("PDFKit could not open it");
97
+ return parsed.pages;
98
+ }
99
+ if (!available("pdftotext"))
100
+ throw new Error("reading PDFs here needs pdftotext (poppler-utils)");
101
+ const read = await tool("pdftotext", [
102
+ "-l",
103
+ String(MAX_PAGES),
104
+ "-layout",
105
+ file,
106
+ "-",
107
+ ]);
108
+ if (!read.ok) throw new Error("pdftotext could not read it");
109
+ return read.stdout.split("\f").slice(0, -1);
110
+ }
111
+
112
+ /** A PDF page drawn as a PNG, for a page with no text of its own. */
113
+ async function pdfPicture(file, page) {
114
+ const dir = scratch();
115
+ try {
116
+ if (process.platform === "darwin") {
117
+ const out = path.join(dir, "page.png");
118
+ await jxa(PDF_DRAW, [file, String(page - 1), out]);
119
+ return fs.existsSync(out) ? fs.readFileSync(out) : null;
120
+ }
121
+ if (!available("pdftoppm")) return null;
122
+ const prefix = path.join(dir, "page");
123
+ await tool("pdftoppm", [
124
+ "-png",
125
+ "-r",
126
+ "100",
127
+ "-f",
128
+ String(page),
129
+ "-l",
130
+ String(page),
131
+ "-singlefile",
132
+ file,
133
+ prefix,
134
+ ]);
135
+ return fs.existsSync(`${prefix}.png`)
136
+ ? fs.readFileSync(`${prefix}.png`)
137
+ : null;
138
+ } finally {
139
+ fs.rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ }
142
+
143
+ /** The words of a document as plain text, or null when this computer cannot read its format. */
144
+ async function docText(file) {
145
+ if (PLAIN.test(file)) return fs.readFileSync(file, "utf8");
146
+ if (/\.html?$/i.test(file))
147
+ return fs
148
+ .readFileSync(file, "utf8")
149
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
150
+ .replace(/<br\s*\/?>|<\/(p|div|li|h\d|tr)>/gi, "\n")
151
+ .replace(/<[^>]+>/g, " ")
152
+ .replace(/&nbsp;/g, " ");
153
+ if (process.platform === "darwin") {
154
+ const read = await tool("textutil", ["-convert", "txt", "-stdout", file]);
155
+ if (read.ok) return read.stdout;
156
+ }
157
+ const office = {
158
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
159
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
160
+ }[path.extname(file).slice(1).toLowerCase()];
161
+ return office ? documentText(file, office) : null;
162
+ }
163
+
164
+ /** Text cut into passages of whole paragraphs, each with the line it starts on. */
165
+ export function passages(text, size = PASSAGE_CHARS) {
166
+ const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
167
+ const out = [];
168
+ let current = [];
169
+ let length = 0;
170
+ let start = 1;
171
+ const flush = (nextLine) => {
172
+ const body = current.join("\n").trim();
173
+ if (body) out.push({ line: start, text: body });
174
+ current = [];
175
+ length = 0;
176
+ start = nextLine;
177
+ };
178
+ lines.forEach((line, index) => {
179
+ const full = length + line.length > size;
180
+ const paragraphEnd = !line.trim() && length > size * 0.6;
181
+ if (current.length && (full || paragraphEnd)) flush(index + 1);
182
+ current.push(line.slice(0, size));
183
+ length += line.length + 1;
184
+ });
185
+ flush(lines.length + 1);
186
+ return out.slice(0, MAX_PASSAGES);
187
+ }
188
+
189
+ /** Text that is mostly numbers (an export, a table) is found by its exact words only. */
190
+ const mostlyNumbers = (text) => {
191
+ const digits = (text.match(/\d/g) ?? []).length;
192
+ const letters = (text.match(/\p{L}/gu) ?? []).length;
193
+ return digits > letters;
194
+ };
195
+
196
+ const textPart = (text, at) => ({
197
+ modality: "text",
198
+ text,
199
+ embed: !mostlyNumbers(text),
200
+ ...at,
201
+ });
202
+
203
+ /** A picture the search model reads (PNG or JPEG), converted when it is another format. */
204
+ export async function pictureBytes(file) {
205
+ if (MODEL_READS.test(file)) return fs.promises.readFile(file);
206
+ const dir = scratch();
207
+ const out = path.join(dir, "picture.jpg");
208
+ try {
209
+ if (process.platform === "darwin")
210
+ await tool("sips", [
211
+ "-s",
212
+ "format",
213
+ "jpeg",
214
+ "-Z",
215
+ "1536",
216
+ file,
217
+ "--out",
218
+ out,
219
+ ]);
220
+ else if (available("ffmpeg"))
221
+ await tool("ffmpeg", ["-v", "error", "-y", "-i", file, out]);
222
+ if (!fs.existsSync(out))
223
+ throw new Error("this computer cannot convert that picture");
224
+ return fs.readFileSync(out);
225
+ } finally {
226
+ fs.rmSync(dir, { recursive: true, force: true });
227
+ }
228
+ }
229
+
230
+ async function duration(file) {
231
+ if (!available("ffprobe")) return null;
232
+ const read = await tool("ffprobe", [
233
+ "-v",
234
+ "error",
235
+ "-show_entries",
236
+ "format=duration",
237
+ "-of",
238
+ "csv=p=0",
239
+ file,
240
+ ]);
241
+ const seconds = Number(read.stdout.trim());
242
+ return Number.isFinite(seconds) ? seconds : null;
243
+ }
244
+
245
+ /** A video's frames, one every few seconds (more apart in a long one): [{ atMs, data }]. */
246
+ async function videoFrames(file, seconds) {
247
+ const dir = scratch();
248
+ try {
249
+ if (!available("ffmpeg")) {
250
+ // Without ffmpeg a Mac still has Quick Look's poster frame.
251
+ if (process.platform !== "darwin") return [];
252
+ await tool("qlmanage", ["-t", "-s", "768", "-o", dir, file]);
253
+ const poster = fs.readdirSync(dir).find((name) => name.endsWith(".png"));
254
+ return poster
255
+ ? [{ atMs: 0, data: fs.readFileSync(path.join(dir, poster)) }]
256
+ : [];
257
+ }
258
+ const every = Math.max(FRAME_EVERY_S, (seconds ?? 0) / MAX_FRAMES);
259
+ await tool("ffmpeg", [
260
+ "-v",
261
+ "error",
262
+ "-i",
263
+ file,
264
+ "-vf",
265
+ `fps=1/${every},scale='min(768,iw)':-2`,
266
+ "-frames:v",
267
+ String(MAX_FRAMES),
268
+ "-q:v",
269
+ "4",
270
+ path.join(dir, "f%04d.jpg"),
271
+ ]);
272
+ return fs
273
+ .readdirSync(dir)
274
+ .filter((name) => name.endsWith(".jpg"))
275
+ .sort()
276
+ .map((name, index) => ({
277
+ atMs: Math.round(index * every * 1000),
278
+ data: fs.readFileSync(path.join(dir, name)),
279
+ }));
280
+ } finally {
281
+ fs.rmSync(dir, { recursive: true, force: true });
282
+ }
283
+ }
284
+
285
+ /** A recording as 16 kHz mono samples: WAV read here; anything else converted by the OS's own tool or ffmpeg. */
286
+ async function recording(file) {
287
+ if (/\.wav$/i.test(file)) {
288
+ const { sampleRate, samples } = decodeWav(await fs.promises.readFile(file));
289
+ return resample(samples, sampleRate, RATE);
290
+ }
291
+ const dir = scratch();
292
+ const out = path.join(dir, "audio.wav");
293
+ try {
294
+ const converted =
295
+ process.platform === "darwin"
296
+ ? await tool("afconvert", [
297
+ "-f",
298
+ "WAVE",
299
+ "-d",
300
+ `LEI16@${RATE}`,
301
+ "-c",
302
+ "1",
303
+ file,
304
+ out,
305
+ ])
306
+ : await tool("ffmpeg", [
307
+ "-v",
308
+ "error",
309
+ "-y",
310
+ "-i",
311
+ file,
312
+ "-ac",
313
+ "1",
314
+ "-ar",
315
+ String(RATE),
316
+ out,
317
+ ]);
318
+ if (!converted.ok || !fs.existsSync(out))
319
+ throw new Error(
320
+ converted.error?.code === "ENOENT"
321
+ ? "no audio converter on this computer (ffmpeg)"
322
+ : `could not read ${path.basename(file)}`
323
+ );
324
+ const { sampleRate, samples } = decodeWav(await fs.promises.readFile(out));
325
+ return resample(samples, sampleRate, RATE);
326
+ } finally {
327
+ fs.rmSync(dir, { recursive: true, force: true });
328
+ }
329
+ }
330
+
331
+ const soundParts = async (file) =>
332
+ windows(await recording(file)).map((window) => ({
333
+ modality: "audio",
334
+ data: encodeWav(window.samples, RATE),
335
+ loc: window.startMs,
336
+ locEnd: window.endMs,
337
+ embed: true,
338
+ }));
339
+
340
+ /**
341
+ * A file's parts: [{ modality: "text" | "image" | "audio", text?, data?, loc, locEnd?, embed }]. loc is a page (PDF),
342
+ * a line (document) or milliseconds (recording, video); embed is false for text found by its words alone. Throws
343
+ * with the reason when this computer cannot read the file.
344
+ */
345
+ export async function readParts(file, kind = fileKind(file)) {
346
+ if (kind === "image")
347
+ return [
348
+ {
349
+ modality: "image",
350
+ data: await pictureBytes(file),
351
+ loc: 0,
352
+ embed: true,
353
+ },
354
+ ];
355
+ if (kind === "audio") return soundParts(file);
356
+ if (kind === "video") {
357
+ let sound = [];
358
+ try {
359
+ sound = await soundParts(file);
360
+ } catch {}
361
+ const seconds = sound.length
362
+ ? sound.at(-1).locEnd / 1000
363
+ : await duration(file);
364
+ const frames = (await videoFrames(file, seconds)).map((frame) => ({
365
+ modality: "image",
366
+ data: frame.data,
367
+ loc: frame.atMs,
368
+ embed: true,
369
+ }));
370
+ if (!sound.length && !frames.length)
371
+ throw new Error("this computer cannot read that video (needs ffmpeg)");
372
+ return [...frames, ...sound];
373
+ }
374
+ if (kind === "pdf") {
375
+ const parts = [];
376
+ for (const [index, text] of (await pdfPages(file)).entries()) {
377
+ const page = index + 1;
378
+ if (text.trim().length >= SCANNED_BELOW) {
379
+ parts.push(textPart(text.trim().slice(0, 6_000), { loc: page }));
380
+ continue;
381
+ }
382
+ const picture = await pdfPicture(file, page);
383
+ if (picture)
384
+ parts.push({
385
+ modality: "image",
386
+ data: picture,
387
+ loc: page,
388
+ embed: true,
389
+ });
390
+ }
391
+ return parts;
392
+ }
393
+ if (kind === "doc") {
394
+ const text = await docText(file);
395
+ if (text === null) throw new Error("this computer cannot read that format");
396
+ return passages(text).map((passage) =>
397
+ textPart(passage.text, { loc: passage.line })
398
+ );
399
+ }
400
+ throw new Error("not a kind of file the relay reads");
401
+ }
402
+
403
+ /** A video's frame at a moment, as a JPEG, or null. */
404
+ async function videoFrameAt(file, atMs) {
405
+ if (!available("ffmpeg"))
406
+ return atMs === 0 ? ((await videoFrames(file, 0))[0]?.data ?? null) : null;
407
+ const dir = scratch();
408
+ const out = path.join(dir, "frame.jpg");
409
+ try {
410
+ await tool("ffmpeg", [
411
+ "-v",
412
+ "error",
413
+ "-ss",
414
+ String(atMs / 1000),
415
+ "-i",
416
+ file,
417
+ "-frames:v",
418
+ "1",
419
+ "-vf",
420
+ "scale='min(1280,iw)':-2",
421
+ out,
422
+ ]);
423
+ return fs.existsSync(out) ? fs.readFileSync(out) : null;
424
+ } finally {
425
+ fs.rmSync(dir, { recursive: true, force: true });
426
+ }
427
+ }
428
+
429
+ /** One part again, read fresh from the file for sharing: { modality, text? | data? | samples } or null. */
430
+ export async function readPart(file, { kind, modality, loc }) {
431
+ if (kind === "image")
432
+ return { modality: "image", data: await pictureBytes(file) };
433
+ if (kind === "video" && modality === "image") {
434
+ const data = await videoFrameAt(file, loc);
435
+ return data ? { modality: "image", data } : null;
436
+ }
437
+ if (kind === "pdf") {
438
+ const pages = await pdfPages(file);
439
+ const text = pages[loc - 1]?.trim() ?? "";
440
+ if (text.length >= SCANNED_BELOW) return { modality: "text", text };
441
+ const picture = await pdfPicture(file, loc);
442
+ return picture ? { modality: "image", data: picture } : null;
443
+ }
444
+ if (kind === "doc") {
445
+ const text = (await docText(file)) ?? "";
446
+ const found = passages(text).find((p) => p.line === loc);
447
+ return found ? { modality: "text", text: found.text } : null;
448
+ }
449
+ if (kind === "audio" || kind === "video") {
450
+ const samples = await recording(file);
451
+ const start = Math.round((loc / 1000) * RATE);
452
+ return {
453
+ modality: "audio",
454
+ samples: samples.subarray(start, start + 30 * RATE),
455
+ sampleRate: RATE,
456
+ };
457
+ }
458
+ return null;
459
+ }
package/lib/runner.mjs CHANGED
@@ -154,8 +154,18 @@ export function agentInvocation(task) {
154
154
  ...(task.resumeSession ? ["resume", task.resumeSession] : []),
155
155
  "--json",
156
156
  ...modelFlags(task),
157
+ // A review still talks to the thread: with approvals off, Codex refused every Harness tool as unapproved, so
158
+ // it could not read the room or post what it found (October 14, the chop keeper review). Harness's own tools
159
+ // are approved for it; the files stay read-only.
157
160
  ...(task.readOnly
158
- ? ["-c", 'sandbox_mode="read-only"', "-c", 'approval_policy="never"']
161
+ ? [
162
+ "-c",
163
+ 'sandbox_mode="read-only"',
164
+ "-c",
165
+ 'approval_policy="never"',
166
+ "-c",
167
+ 'mcp_servers.harness.default_tools_approval_mode="approve"',
168
+ ]
159
169
  : ["--dangerously-bypass-approvals-and-sandbox"]),
160
170
  "--skip-git-repo-check",
161
171
  "-",
package/lib/service.mjs CHANGED
@@ -5,6 +5,7 @@ import { spawn } from "node:child_process";
5
5
  import { resolveCommand, run } from "./commands.mjs";
6
6
  import { ensureDir } from "./files.mjs";
7
7
  import { paths } from "./paths.mjs";
8
+ import { appProgram } from "./desktop.mjs";
8
9
 
9
10
  const LABEL = "ai.tryharness.daemon";
10
11
 
@@ -183,6 +184,17 @@ function retireLegacyBridge() {
183
184
  }
184
185
  }
185
186
 
187
+ /** Whether the Harness app's launcher is here on a Mac but the daemon does not start through it yet. */
188
+ export function launcherMissing(platform = process.platform) {
189
+ if (platform !== "darwin" || !appProgram("HarnessDaemon", { platform }))
190
+ return false;
191
+ try {
192
+ return !fs.readFileSync(servicePath(), "utf8").includes("HarnessDaemon");
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
186
198
  export function installService({
187
199
  runtimeRoot,
188
200
  nodePath = process.execPath,
@@ -197,6 +209,14 @@ export function installService({
197
209
  return installWindowsTask({ runtimeRoot, nodePath, execute });
198
210
  if (platform === "darwin") {
199
211
  const plist = servicePath();
212
+ // Through the Harness app's launcher when the app is here, so macOS asks for the person's folders as Harness.
213
+ const launcher = appProgram("HarnessDaemon", { platform });
214
+ const program = [
215
+ ...(launcher ? [launcher] : []),
216
+ nodePath,
217
+ entry,
218
+ "daemon",
219
+ ];
200
220
  ensureDir(path.dirname(plist));
201
221
  // MallocLargeCache=0: macOS otherwise keeps the large blocks ONNX Runtime frees (a screenshot's OCR held 1.6 GB after
202
222
  // it finished; 255 MB with it off, image-redaction eval).
@@ -206,7 +226,7 @@ export function installService({
206
226
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
207
227
  <plist version="1.0"><dict>
208
228
  <key>Label</key><string>${LABEL}</string>
209
- <key>ProgramArguments</key><array><string>${xml(nodePath)}</string><string>${xml(entry)}</string><string>daemon</string></array>
229
+ <key>ProgramArguments</key><array>${program.map((arg) => `<string>${xml(arg)}</string>`).join("")}</array>
210
230
  <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(envPath)}</string><key>MallocLargeCache</key><string>0</string></dict>
211
231
  <key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>5</integer>
212
232
  <key>StandardOutPath</key><string>${xml(paths.log)}</string><key>StandardErrorPath</key><string>${xml(paths.log)}</string>
package/lib/snippets.mjs CHANGED
@@ -4,10 +4,12 @@ import { randomUUID } from "node:crypto";
4
4
  import { nameOf } from "./members.mjs";
5
5
  import { repositoryOf } from "./worktrees.mjs";
6
6
 
7
- // Code for an agent that does not run on this computer (Grok Bot, Muse) leaves it only with the person's OK. The agent
8
- // says what it needs to see (request_snippet); the relay finds it in the thread's folder with its local search, takes
9
- // the lines around it, masks any secret in them, and holds them. The person shares or keeps them (the buddy, or
10
- // `tryharness board snippet <id> share|keep`); shared lines are posted in the thread for that agent, kept ones never leave.
7
+ // Code or a file for an agent that does not run on this computer (Grok Bot, Muse) leaves it only with the person's OK.
8
+ // The agent says what it needs to see (request_snippet); the relay finds it with its local search, in the thread's
9
+ // folder (code) or in the folders the person added to the library (files), masks any secret in it, and holds it. The
10
+ // person shares or keeps it (the buddy, or `tryharness board snippet <id> share|keep`); what is shared is posted in the
11
+ // thread for that agent, what is kept never leaves. A file's part goes as its words (a page, a passage), a picture with
12
+ // keys and personal details covered, or what a recording's minute says.
11
13
 
12
14
  const BEFORE = 5;
13
15
  const LINES = 40;
@@ -16,6 +18,7 @@ export function createSnippets({
16
18
  store,
17
19
  search,
18
20
  scrub,
21
+ files = null,
19
22
  wake = () => {},
20
23
  notify = () => {},
21
24
  log = () => {},
@@ -35,9 +38,12 @@ export function createSnippets({
35
38
  };
36
39
 
37
40
  /** A cloud agent's request: { ok, snippet } held for the person, or why there is nothing to hold. */
38
- const request = async ({ threadId, agent, about }) => {
41
+ const request = async ({ threadId, agent, about, where = null }) => {
39
42
  const thread = store.getTask(threadId);
40
43
  const folder = thread?.kind === "goal" ? thread.goal.workspace : null;
44
+ // Without a say, a thread with a folder means its code, one without means the person's files.
45
+ if ((where ?? (folder ? "code" : "files")) === "files")
46
+ return requestFile({ threadId, agent, about });
41
47
  if (!folder)
42
48
  return {
43
49
  ok: false,
@@ -100,6 +106,107 @@ export function createSnippets({
100
106
  };
101
107
  };
102
108
 
109
+ /** The part of a file the agent asked about, prepared to leave: { text } or { picture }, and what was hidden. */
110
+ const prepare = async (content) => {
111
+ if (content.modality === "text") return scrub(content.text);
112
+ if (content.modality === "audio") {
113
+ const heard = await files.transcribe(content);
114
+ return heard
115
+ ? scrub(heard)
116
+ : { error: "nothing could be heard in that minute" };
117
+ }
118
+ const covered = await files.cover(content.data);
119
+ if (covered.refused) return { error: covered.refused };
120
+ return {
121
+ picture: files.keep(covered.bytes),
122
+ mime: covered.mime,
123
+ masked: covered.masked?.length ?? 0,
124
+ };
125
+ };
126
+
127
+ const requestFile = async ({ threadId, agent, about }) => {
128
+ if (!files)
129
+ return { ok: false, error: "the person's files are not searchable here" };
130
+ const words = String(about || "")
131
+ .trim()
132
+ .slice(0, 500);
133
+ if (!words) return { ok: false, error: "say what you need to see" };
134
+ const [hit] = await files.search(words);
135
+ if (!hit)
136
+ return {
137
+ ok: false,
138
+ error: "nothing in the folders the person added matched that",
139
+ };
140
+ let ready = null;
141
+ try {
142
+ const content = await files.content(hit.file, hit.vec);
143
+ ready = content ? await prepare(content) : null;
144
+ } catch (error) {
145
+ ready = { error: error.message };
146
+ }
147
+ if (!ready || ready.error)
148
+ return {
149
+ ok: false,
150
+ error: ready?.error || `${hit.name} could not be read`,
151
+ };
152
+ const label = hit.place ? `${hit.place} of ${hit.name}` : hit.name;
153
+ const snippet = {
154
+ id: randomUUID(),
155
+ thread: threadId,
156
+ agent,
157
+ about: words,
158
+ source: "files",
159
+ label,
160
+ path: hit.file,
161
+ text:
162
+ ready.text ??
163
+ `A picture: ${label}, with keys and personal details covered.`,
164
+ picture: ready.picture ?? null,
165
+ mime: ready.mime ?? null,
166
+ masked: ready.masked,
167
+ state: "waiting",
168
+ at: new Date().toISOString(),
169
+ };
170
+ save(snippet);
171
+ store.roomPost({
172
+ goalId: threadId,
173
+ author: "harness",
174
+ kind: "note",
175
+ text: `${nameOf(agent)} asks to see ${label} from your files ("${words.slice(0, 120)}"). It stays on this computer until you share it.`,
176
+ });
177
+ notify(
178
+ `${nameOf(agent)} asks to see ${label}. Share or keep it in Harness.`,
179
+ snippet
180
+ );
181
+ return {
182
+ ok: true,
183
+ snippet: { id: snippet.id, label, state: "waiting" },
184
+ note: "The person decides whether to share it; you are told in the thread.",
185
+ };
186
+ };
187
+
188
+ /** What the thread is told when the person shares a waiting snippet. */
189
+ const sharedText = (snippet) => {
190
+ const hidden = snippet.masked
191
+ ? ` (${snippet.masked} secret${snippet.masked === 1 ? "" : "s"} in it stay masked)`
192
+ : "";
193
+ if (snippet.source !== "files")
194
+ return `@${snippet.agent} the person shared lines ${snippet.from} to ${snippet.to} of ${snippet.path}${hidden}:\n\n\`\`\`\n${snippet.text}\n\`\`\``;
195
+ if (snippet.picture) {
196
+ const file = files.attach({
197
+ threadId: snippet.thread,
198
+ sha256: snippet.picture,
199
+ mime: snippet.mime,
200
+ name: `${snippet.label}.${snippet.mime === "image/jpeg" ? "jpg" : "png"}`,
201
+ });
202
+ return `@${snippet.agent} the person shared ${snippet.label}, with keys and personal details covered: file ${file.id} (get_file opens it).`;
203
+ }
204
+ return `@${snippet.agent} the person shared ${snippet.label}${hidden}:\n\n${snippet.text
205
+ .split("\n")
206
+ .map((line) => `> ${line}`)
207
+ .join("\n")}`;
208
+ };
209
+
103
210
  /** The person shares or keeps a waiting snippet. */
104
211
  const decide = (id, share) => {
105
212
  const snippet = read(id);
@@ -118,7 +225,7 @@ export function createSnippets({
118
225
  author: "harness",
119
226
  kind: "post",
120
227
  mentions: [snippet.agent],
121
- text: `@${snippet.agent} the person shared lines ${snippet.from} to ${snippet.to} of ${snippet.path}${snippet.masked ? ` (${snippet.masked} secret${snippet.masked === 1 ? "" : "s"} in them stay masked)` : ""}:\n\n\`\`\`\n${snippet.text}\n\`\`\``,
228
+ text: sharedText(snippet),
122
229
  });
123
230
  wake(snippet.thread, post, [snippet.agent]);
124
231
  } else
@@ -126,7 +233,7 @@ export function createSnippets({
126
233
  goalId: snippet.thread,
127
234
  author: "harness",
128
235
  kind: "note",
129
- text: `The person kept lines ${snippet.from} to ${snippet.to} of ${snippet.path} on this computer.`,
236
+ text: `The person kept ${snippet.label ?? `lines ${snippet.from} to ${snippet.to} of ${snippet.path}`} on this computer.`,
130
237
  });
131
238
  log(`snippet ${id}: ${done.state}`);
132
239
  return { ok: true, snippet: { id, state: done.state } };