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/lib/daemon.mjs CHANGED
@@ -228,6 +228,14 @@ function daemonAnswers(socketPath) {
228
228
 
229
229
  export async function startDaemon() {
230
230
  ensureDir(paths.home);
231
+ // Started by the Harness app's launcher (HarnessDaemon.swift): if it goes, this goes too, so the daemon launchd starts
232
+ // next is the only one. The agents this daemon starts do not inherit the mark.
233
+ const launcher = Number(process.env.HARNESS_LAUNCHER_PID);
234
+ delete process.env.HARNESS_LAUNCHER_PID;
235
+ if (launcher)
236
+ setInterval(() => {
237
+ if (process.ppid !== launcher) process.exit(0);
238
+ }, 5_000).unref();
231
239
  const config = readJson(paths.config, {});
232
240
  if (!config.origin || !config.token)
233
241
  throw new Error("Harness is not paired. Run: npx tryharness");
@@ -2730,15 +2738,18 @@ export async function startDaemon() {
2730
2738
  // needed, a context pack for each local agent's brief, and the overnight learner. The firewall (firewall.mjs) reads what
2731
2739
  // agents hand each other and scrubs what leaves for a hosted agent. Both need the core local models; without them
2732
2740
  // briefs and handoffs go as they did before.
2733
- // Folders of recordings and screenshots the person added (tryharness local add), searched by the media part of the
2734
- // search model; it downloads (555 MB) the first time a folder is added.
2735
- const mediaFolders = () => {
2736
- const listed = readJson(paths.config, {}).localFolders;
2737
- return Array.isArray(listed)
2738
- ? listed.filter(
2739
- (folder) => typeof folder === "string" && fs.existsSync(folder)
2740
- )
2741
- : [];
2741
+ // The folders searched by what is in their files (library.mjs): where people keep their own files, unless the person
2742
+ // turned search off (tryharness local off), and any they added (tryharness local add). Documents and PDFs need only
2743
+ // the search model; pictures, recordings and video wait for its image and audio part, which comes down on the charger.
2744
+ const OWN_FOLDERS = ["Desktop", "Documents", "Downloads"];
2745
+ const libraryFolders = () => {
2746
+ const config = readJson(paths.config, {});
2747
+ if (config.libraryOff === true) return [];
2748
+ const added = Array.isArray(config.localFolders) ? config.localFolders : [];
2749
+ const own = OWN_FOLDERS.map((name) => path.join(os.homedir(), name));
2750
+ return [...new Set([...own, ...added])].filter(
2751
+ (folder) => typeof folder === "string" && fs.existsSync(folder)
2752
+ );
2742
2753
  };
2743
2754
  let mediaDownload = null;
2744
2755
  const fetchMedia = () => {
@@ -2758,13 +2769,85 @@ export async function startDaemon() {
2758
2769
  });
2759
2770
  return mediaDownload;
2760
2771
  };
2772
+ const ocr = createOcr({ inference });
2773
+ // A library hit as an agent on this computer reads it: a path it can open, and where in the file.
2774
+ const localHit = ({ vec, file, thread, ...hit }) => {
2775
+ const stored = thread ? store.file(file) : null;
2776
+ return {
2777
+ ...hit,
2778
+ path: stored ? namedPath(stored) : file,
2779
+ ...(thread ? { thread, fileId: file } : {}),
2780
+ };
2781
+ };
2782
+ // The thread's own files closest to a question, beside its written context. A caller through Harness finds only the
2783
+ // files shared with cloud agents, by id (get_file opens them); an agent here gets the path too.
2784
+ const threadFileHits = async (task, query, hostedCaller) => {
2785
+ if (!localReady(["search"]) || !query.trim()) return [];
2786
+ const thread = roomOf(task)?.id ?? task.id;
2787
+ const readable = (fileId) => {
2788
+ const file = store.file(fileId);
2789
+ return Boolean(file) && (!hostedCaller || file.cloud);
2790
+ };
2791
+ try {
2792
+ const hits = await contextEngine.searchFiles(query, {
2793
+ threads: [thread],
2794
+ allow: readable,
2795
+ limit: 5,
2796
+ });
2797
+ return hits.map((hit) =>
2798
+ hostedCaller
2799
+ ? {
2800
+ fileId: hit.file,
2801
+ name: hit.name,
2802
+ place: hit.place,
2803
+ // A picture's text could name what its covered copy hides: get_file opens the covered picture.
2804
+ excerpt: hit.modality === "image" ? null : hit.excerpt,
2805
+ score: hit.score,
2806
+ }
2807
+ : localHit(hit)
2808
+ );
2809
+ } catch (error) {
2810
+ log(`thread files search: ${error.message}`);
2811
+ return [];
2812
+ }
2813
+ };
2814
+ // Every file the threads hold, for the library: read from its stored copy under its own name.
2815
+ const libraryThreadFiles = () =>
2816
+ store.presentFiles().map((file) => ({
2817
+ id: file.id,
2818
+ thread: roomOf(store.getTask(file.task_id))?.id ?? file.task_id,
2819
+ name: file.name,
2820
+ sha256: file.sha256,
2821
+ path: namedPath(file),
2822
+ }));
2823
+ // A thread file is read soon after it arrives; several arriving together are read in one pass.
2824
+ let filesSoon = null;
2825
+ const readFilesSoon = () => {
2826
+ clearTimeout(filesSoon);
2827
+ filesSoon = setTimeout(() => {
2828
+ if (localReady(["search"]))
2829
+ void contextEngine.refreshFiles().catch(() => {});
2830
+ }, 2_000);
2831
+ filesSoon.unref?.();
2832
+ };
2761
2833
  const contextEngine = createContextEngine({
2762
2834
  store,
2763
2835
  inference,
2764
2836
  home: localRoot,
2765
2837
  known: () => folders.known(),
2766
- mediaFolders,
2838
+ libraryFolders,
2839
+ threadFiles: () => libraryThreadFiles(),
2767
2840
  mediaReady: () => localReady(["search", "media"]),
2841
+ // Pictures and sound waiting: the image and audio part comes down on the charger, then they are read.
2842
+ wantMedia: () => {
2843
+ if (!inference.power.onCharger() || inference.power.paused()) return;
2844
+ void fetchMedia()
2845
+ .then(() => contextEngine.refreshFiles())
2846
+ .catch(() => {});
2847
+ },
2848
+ ocr,
2849
+ mask: async (text) =>
2850
+ (await firewall.scrub(text, { lane: "interactive" })).text,
2768
2851
  log,
2769
2852
  notify: (text) => {
2770
2853
  if (notificationsOn()) notifyDesktop(text);
@@ -2779,7 +2862,6 @@ export async function startDaemon() {
2779
2862
  // original's hash, so another read costs nothing, and the task says what was hidden from whom. Until both models are
2780
2863
  // here no image leaves: the read is refused and they come down now. An image that cannot be checked (not PNG or
2781
2864
  // JPEG, animated, broken) never leaves.
2782
- const ocr = createOcr({ inference });
2783
2865
  const REDACT_MODELS = ["ocr", "credentials"];
2784
2866
  let redactModels = null;
2785
2867
  const redacting = new Map();
@@ -3275,6 +3357,38 @@ export async function startDaemon() {
3275
3357
  error: "the relay's local search is not downloaded yet",
3276
3358
  }),
3277
3359
  scrub: (text) => firewall.scrub(text),
3360
+ files: {
3361
+ search: (words) =>
3362
+ localReady(["search"])
3363
+ ? contextEngine.searchFiles(words, { folders: true, limit: 1 })
3364
+ : Promise.resolve([]),
3365
+ content: (file, vec) => contextEngine.fileContent(file, vec),
3366
+ transcribe: async (audio) =>
3367
+ (await speech.transcribe(audio, { lane: "handoff" })).text || null,
3368
+ cover: (bytes) =>
3369
+ redactImage({ bytes, mime: "image/png" }, { ocr, firewall }),
3370
+ keep: keepBlob,
3371
+ attach: ({ threadId, sha256, name, mime }) => {
3372
+ const file = store.addFile({
3373
+ taskId: threadId,
3374
+ name: fileName(name),
3375
+ mime,
3376
+ bytes: fs.statSync(blobPath(sha256)).size,
3377
+ sha256,
3378
+ author: "harness",
3379
+ cloud: true,
3380
+ });
3381
+ store.addContribution({
3382
+ taskId: threadId,
3383
+ kind: "file",
3384
+ author: "harness",
3385
+ text: `harness attached ${fileLine(file)}`,
3386
+ source: `file:${file.id}`,
3387
+ });
3388
+ readFilesSoon();
3389
+ return file;
3390
+ },
3391
+ },
3278
3392
  wake: (goalId, post, names) => wakeFor(goalId, post, "harness", names),
3279
3393
  notify: (text) => {
3280
3394
  localEvent("snippet", text);
@@ -4292,6 +4406,7 @@ export async function startDaemon() {
4292
4406
  threadId: thread.id,
4293
4407
  agent,
4294
4408
  about: request.about,
4409
+ where: ["code", "files"].includes(request.in) ? request.in : null,
4295
4410
  }),
4296
4411
  };
4297
4412
  }
@@ -4305,26 +4420,39 @@ export async function startDaemon() {
4305
4420
  ? { ok: true, snippets: snippets.list({ all: request.all === true }) }
4306
4421
  : snippets.decide(String(request.id || ""), request.share === true);
4307
4422
  }
4308
- // Recordings and screenshots in folders the person added, for agents on this computer only.
4309
- if (request.command === "board.media.search") {
4423
+ // The person's files and a thread's files, by what is in them, for agents on this computer only (library.mjs).
4424
+ // board.media.search is the name the tool had before documents and thread files were added.
4425
+ if (
4426
+ request.command === "board.files.search" ||
4427
+ request.command === "board.media.search"
4428
+ ) {
4310
4429
  if (request.hostedCaller)
4311
4430
  return {
4312
4431
  ok: false,
4313
- error: "searching recordings runs only for agents on this computer",
4432
+ error:
4433
+ "searching the person's files runs only for agents on this computer",
4314
4434
  };
4315
- if (!localReady(["search", "media"]) || !mediaFolders().length)
4435
+ if (!localReady(["search"]))
4436
+ return { ok: false, error: "the local search model is not ready yet" };
4437
+ const thread = request.thread
4438
+ ? store.getTask(String(request.thread))
4439
+ : null;
4440
+ const threadId = thread ? (roomOf(thread)?.id ?? thread.id) : null;
4441
+ if (!libraryFolders().length && !threadId)
4316
4442
  return {
4317
4443
  ok: false,
4318
4444
  error:
4319
- "no folders of recordings or screenshots are added yet: the person adds one with npx tryharness local add <folder>",
4445
+ "no folders are added yet (the person adds one with npx tryharness local add <folder>); give a thread to search its files",
4320
4446
  };
4321
4447
  return {
4322
4448
  ok: true,
4323
4449
  pending: contextEngine
4324
- .searchMedia(String(request.query || "").slice(0, 2_000), {
4450
+ .searchFiles(String(request.query || "").slice(0, 2_000), {
4451
+ folders: true,
4452
+ threads: threadId ? [threadId] : [],
4325
4453
  limit: Math.min(20, Number(request.limit) || 8),
4326
4454
  })
4327
- .then((hits) => ({ ok: true, hits })),
4455
+ .then((hits) => ({ ok: true, hits: hits.map(localHit) })),
4328
4456
  };
4329
4457
  }
4330
4458
  // What the person saw in the tabs they share with Harness, asked of their own Harness window sealed (phones.mjs ask),
@@ -4812,14 +4940,22 @@ export async function startDaemon() {
4812
4940
  ? { ok: true, entry, context: store.contextCount(id) }
4813
4941
  : { ok: false, error: "nothing to add" };
4814
4942
  } else if (request.command === "board.context.search") {
4815
- if (!store.getTask(id)) return { ok: false, error: "task not found" };
4943
+ const task = store.getTask(id);
4944
+ if (!task) return { ok: false, error: "task not found" };
4945
+ const query = String(request.query || "");
4946
+ const k = Math.max(
4947
+ 1,
4948
+ Math.min(20, Number(request.limit ?? request.k) || 8)
4949
+ );
4816
4950
  return {
4817
4951
  ok: true,
4818
- pending: searchContext(
4819
- id,
4820
- String(request.query || ""),
4821
- Math.max(1, Math.min(20, Number(request.limit) || 8))
4822
- ),
4952
+ pending: Promise.all([
4953
+ searchContext(id, query, k),
4954
+ threadFileHits(task, query, request.hostedCaller === true),
4955
+ ]).then(([found, files]) => ({
4956
+ ...found,
4957
+ ...(files.length ? { files } : {}),
4958
+ })),
4823
4959
  };
4824
4960
  } else if (request.command === "board.thread.read") {
4825
4961
  const thread = store.getThread(id);
@@ -5078,6 +5214,7 @@ export async function startDaemon() {
5078
5214
  source: `file:${file.id}`,
5079
5215
  });
5080
5216
  notifyBoard(store.getTask(place));
5217
+ readFilesSoon();
5081
5218
  return { ok: true, file: fileView(file, request.hostedCaller) };
5082
5219
  } else if (request.command === "board.file.share") {
5083
5220
  // Whether the agents on Harness's side may read a file: the person's call, either way, on any file. A call through
@@ -5528,6 +5665,11 @@ export async function startDaemon() {
5528
5665
  ? await contextEngine.pack({
5529
5666
  brief: request,
5530
5667
  folder: task.run_workspace,
5668
+ thread: wake
5669
+ ? task.parent_id
5670
+ : room?.kind === "goal"
5671
+ ? room.id
5672
+ : null,
5531
5673
  })
5532
5674
  : null;
5533
5675
  const entry =
@@ -6298,6 +6440,7 @@ export async function startDaemon() {
6298
6440
  if (
6299
6441
  command === "board.code.search" ||
6300
6442
  command === "board.media.search" ||
6443
+ command === "board.files.search" ||
6301
6444
  command === "board.screens.compare" ||
6302
6445
  command === "board.video.watch" ||
6303
6446
  command === "board.audio.listen" ||
@@ -6428,6 +6571,18 @@ export async function startDaemon() {
6428
6571
  .catch((error) => log(`context engine: ${error.message}`));
6429
6572
  reply({ ok: true, started: true });
6430
6573
  }
6574
+ // Search over the person's folders, on (the default) or off; off forgets what was read from them.
6575
+ else if (
6576
+ request.command === "local.on" ||
6577
+ request.command === "local.off"
6578
+ ) {
6579
+ writeJsonAtomic(paths.config, {
6580
+ ...readJson(paths.config, {}),
6581
+ libraryOff: request.command === "local.off",
6582
+ });
6583
+ reply({ ok: true, folders: libraryFolders() });
6584
+ setImmediate(() => void contextEngine.refreshFiles().catch(() => {}));
6585
+ }
6431
6586
  // A folder of recordings and screenshots the person adds or takes away (no picker: they name it). Adding one starts
6432
6587
  // the media part's download, then reads the folder on the background lane.
6433
6588
  else if (
@@ -6458,19 +6613,23 @@ export async function startDaemon() {
6458
6613
  localFolders:
6459
6614
  request.command === "local.add" ? [...listed, folder] : listed,
6460
6615
  });
6461
- if (request.command === "local.add")
6462
- void fetchMedia()
6463
- .then(() => contextEngine.refreshMedia())
6464
- .then(
6465
- (done) =>
6616
+ if (request.command === "local.add") {
6617
+ const read = () =>
6618
+ contextEngine
6619
+ .refreshFiles()
6620
+ .then((done) =>
6466
6621
  log(
6467
- `media: ${done.added} of ${done.files} files read in the added folders`
6468
- ),
6469
- () => {}
6470
- );
6622
+ `library: ${done.added} of ${done.files} files read in the added folders`
6623
+ )
6624
+ );
6625
+ void read()
6626
+ .then(() => fetchMedia())
6627
+ .then(read)
6628
+ .catch(() => {});
6629
+ }
6471
6630
  reply({
6472
6631
  ok: true,
6473
- folders: mediaFolders(),
6632
+ folders: libraryFolders(),
6474
6633
  downloading:
6475
6634
  request.command === "local.add" && !localReady(["media"]),
6476
6635
  });
package/lib/desktop.mjs CHANGED
@@ -165,6 +165,26 @@ export function desktopTarget({
165
165
  return path.join(paths.home, "desktop", file);
166
166
  }
167
167
 
168
+ /** A program inside the installed Harness app on a Mac (its helpers), or null where there is none. */
169
+ export function appProgram(
170
+ name,
171
+ {
172
+ env = process.env,
173
+ platform = process.platform,
174
+ homeDir = os.homedir(),
175
+ } = {}
176
+ ) {
177
+ if (platform !== "darwin") return null;
178
+ const app = desktopTarget({
179
+ source: { app: "Harness.app" },
180
+ env,
181
+ platform,
182
+ homeDir,
183
+ });
184
+ const file = path.join(app, "Contents", "MacOS", name);
185
+ return fs.existsSync(file) ? file : null;
186
+ }
187
+
168
188
  function executables(target, platform) {
169
189
  if (platform === "darwin") {
170
190
  const dir = path.join(target, "Contents", "MacOS");
@@ -184,7 +204,10 @@ export function quitDesktop({
184
204
  execute("taskkill", ["/F", "/IM", path.basename(target)]);
185
205
  return;
186
206
  }
187
- const exes = executables(target, platform);
207
+ // The daemon's launcher keeps running through an update: its next start is the new one.
208
+ const exes = executables(target, platform).filter(
209
+ (exe) => path.basename(exe) !== "HarnessDaemon"
210
+ );
188
211
  for (const exe of exes) execute("pkill", ["-f", exe]);
189
212
  // Opening it again before the old one has gone fails on macOS (LaunchServices error -600).
190
213
  for (