tryharness 0.7.28 → 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.
- package/README.md +5 -4
- package/cli.mjs +32 -7
- package/lib/atlas.mjs +155 -31
- package/lib/context.mjs +44 -32
- package/lib/daemon.mjs +193 -34
- package/lib/desktop.mjs +24 -1
- package/lib/library.mjs +693 -0
- package/lib/mcp-stdio.mjs +12 -6
- package/lib/readers.mjs +459 -0
- package/lib/service.mjs +21 -1
- package/lib/snippets.mjs +114 -7
- package/lib/store.mjs +6 -0
- package/package.json +1 -1
package/lib/mcp-stdio.mjs
CHANGED
|
@@ -454,15 +454,20 @@ const LOCAL_TOOLS = [
|
|
|
454
454
|
},
|
|
455
455
|
},
|
|
456
456
|
{
|
|
457
|
-
name: "
|
|
457
|
+
name: "search_files",
|
|
458
458
|
description:
|
|
459
|
-
"Find the
|
|
459
|
+
"Find what is in the person's files and a thread's files, by what it is about or its exact words: PDFs (the page), documents (the line), screenshots and pictures, recordings and videos (the moment). Searches the folders the person added to the relay and, given a thread, the files attached there. You get each file's path, the place in it, a short excerpt and how it matched. Runs on this computer; nothing leaves it.",
|
|
460
460
|
inputSchema: {
|
|
461
461
|
type: "object",
|
|
462
462
|
properties: {
|
|
463
463
|
query: {
|
|
464
464
|
type: "string",
|
|
465
|
-
description:
|
|
465
|
+
description:
|
|
466
|
+
"what it is about, or the exact words, code or name in it",
|
|
467
|
+
},
|
|
468
|
+
thread: {
|
|
469
|
+
type: "string",
|
|
470
|
+
description: "a thread (goal) id, to search its files too",
|
|
466
471
|
},
|
|
467
472
|
},
|
|
468
473
|
required: ["query"],
|
|
@@ -654,9 +659,10 @@ const BOARD_CALLS = {
|
|
|
654
659
|
from: args.from,
|
|
655
660
|
to: args.to,
|
|
656
661
|
}),
|
|
657
|
-
|
|
658
|
-
command: "board.
|
|
662
|
+
search_files: (args) => ({
|
|
663
|
+
command: "board.files.search",
|
|
659
664
|
query: args.query,
|
|
665
|
+
thread: args.thread,
|
|
660
666
|
}),
|
|
661
667
|
search_screen: (args) => ({
|
|
662
668
|
command: "board.screen.search",
|
|
@@ -934,7 +940,7 @@ const SEARCHES = [
|
|
|
934
940
|
"search_room",
|
|
935
941
|
"search_swarm",
|
|
936
942
|
"search_code",
|
|
937
|
-
"
|
|
943
|
+
"search_files",
|
|
938
944
|
"search_screen",
|
|
939
945
|
"compare_screens",
|
|
940
946
|
];
|
package/lib/readers.mjs
ADDED
|
@@ -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(/ /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/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
|
|
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>
|