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/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
|
-
//
|
|
2734
|
-
// search
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4309
|
-
|
|
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:
|
|
4432
|
+
error:
|
|
4433
|
+
"searching the person's files runs only for agents on this computer",
|
|
4314
4434
|
};
|
|
4315
|
-
if (!localReady(["search"
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
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:
|
|
4819
|
-
id,
|
|
4820
|
-
|
|
4821
|
-
|
|
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
|
-
|
|
6463
|
-
|
|
6464
|
-
|
|
6465
|
-
(done) =>
|
|
6616
|
+
if (request.command === "local.add") {
|
|
6617
|
+
const read = () =>
|
|
6618
|
+
contextEngine
|
|
6619
|
+
.refreshFiles()
|
|
6620
|
+
.then((done) =>
|
|
6466
6621
|
log(
|
|
6467
|
-
`
|
|
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:
|
|
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
|
-
|
|
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 (
|