tryharness 0.7.22 → 0.7.25

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.
Files changed (64) hide show
  1. package/README.md +48 -8
  2. package/assets/opencode-plugin.js +48 -10
  3. package/assets/pi-extension.ts +106 -26
  4. package/cli.mjs +1281 -289
  5. package/lib/activity.mjs +197 -40
  6. package/lib/adapters.mjs +337 -54
  7. package/lib/agentnode.mjs +175 -35
  8. package/lib/atlas.mjs +604 -0
  9. package/lib/bip39-english.txt +2048 -0
  10. package/lib/cards.mjs +177 -39
  11. package/lib/claudemem.mjs +74 -18
  12. package/lib/commands.mjs +13 -5
  13. package/lib/context.mjs +628 -0
  14. package/lib/control-client.mjs +5 -2
  15. package/lib/coordinator.mjs +281 -56
  16. package/lib/daemon.mjs +5590 -838
  17. package/lib/desktop.mjs +149 -35
  18. package/lib/digest.mjs +81 -0
  19. package/lib/environment.mjs +180 -0
  20. package/lib/files.mjs +246 -13
  21. package/lib/firewall.mjs +288 -0
  22. package/lib/folders.mjs +223 -57
  23. package/lib/frames.mjs +333 -0
  24. package/lib/inference.mjs +663 -0
  25. package/lib/learn.mjs +182 -0
  26. package/lib/live.mjs +286 -61
  27. package/lib/localmodels.mjs +571 -0
  28. package/lib/mcp-stdio.mjs +912 -103
  29. package/lib/media.mjs +228 -0
  30. package/lib/members.json +1 -1
  31. package/lib/members.mjs +35 -9
  32. package/lib/models.mjs +342 -80
  33. package/lib/network.mjs +201 -42
  34. package/lib/notify.mjs +49 -12
  35. package/lib/nudge.mjs +190 -32
  36. package/lib/ocr.mjs +380 -0
  37. package/lib/onboard.mjs +321 -70
  38. package/lib/parsers.mjs +132 -41
  39. package/lib/paths.mjs +33 -10
  40. package/lib/personal.mjs +77 -0
  41. package/lib/phones.mjs +398 -51
  42. package/lib/places.mjs +566 -102
  43. package/lib/provenance.mjs +19 -4
  44. package/lib/receipts.mjs +82 -0
  45. package/lib/redact.mjs +730 -0
  46. package/lib/runner.mjs +347 -55
  47. package/lib/runtime-install.mjs +59 -17
  48. package/lib/screens.mjs +212 -0
  49. package/lib/sealed.mjs +160 -28
  50. package/lib/secrets.mjs +18 -5
  51. package/lib/service.mjs +173 -35
  52. package/lib/snippets.mjs +140 -0
  53. package/lib/speech.mjs +993 -0
  54. package/lib/store.mjs +1678 -301
  55. package/lib/swarm.mjs +598 -117
  56. package/lib/t3.mjs +400 -79
  57. package/lib/trails.mjs +401 -0
  58. package/lib/ui.mjs +95 -23
  59. package/lib/vectors.mjs +5 -2
  60. package/lib/wakes.mjs +284 -68
  61. package/lib/watches.mjs +362 -0
  62. package/lib/worktrees.mjs +186 -38
  63. package/package.json +6 -4
  64. package/lib/embedder.mjs +0 -156
package/lib/media.mjs ADDED
@@ -0,0 +1,228 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { createHash } from "node:crypto";
5
+ import { spawnSync } from "node:child_process";
6
+ import { ATLAS_VERSION } from "./atlas.mjs";
7
+ import { decodeWav, encodeWav, resample } from "./speech.mjs";
8
+
9
+ // Recordings and screenshots in folders the person adds (`tryharness local add <folder>`; nothing is read until they
10
+ // do), found by what they are about: the search model's image and audio part puts each 30-second window of a recording
11
+ // and each image in the same space as a typed question, so "the threshold we agreed on the call" finds the minute it was
12
+ // said. Windows overlap by 10 seconds so a sentence is never cut in two. Indexed on the background lane, first passes on
13
+ // the charger; everything stays in the context engine's atlas on this computer.
14
+
15
+ const WINDOW_MS = 30_000;
16
+ const STEP_MS = 20_000;
17
+ const RATE = 16_000;
18
+ const AUDIO = /\.(?:wav|mp3|m4a|aac|caf|aiff?|flac)$/i;
19
+ const IMAGE = /\.(?:png|jpe?g)$/i;
20
+ const SKIP_DIR = /^(?:\..*|node_modules|Library|\.Trash)$/;
21
+ const MAX_FILES = 5_000;
22
+ const MAX_IMAGE_BYTES = 20 * 2 ** 20;
23
+ const MAX_DEPTH = 6;
24
+
25
+ /** The media files under a folder, newest first. */
26
+ export function mediaFiles(folder, { max = MAX_FILES } = {}) {
27
+ const out = [];
28
+ const walk = (dir, depth) => {
29
+ if (depth > MAX_DEPTH || out.length >= max) return;
30
+ let entries = [];
31
+ try {
32
+ entries = fs.readdirSync(dir, { withFileTypes: true });
33
+ } catch {
34
+ return;
35
+ }
36
+ for (const entry of entries) {
37
+ if (out.length >= max) return;
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ if (!SKIP_DIR.test(entry.name)) walk(full, depth + 1);
41
+ continue;
42
+ }
43
+ if (
44
+ !entry.isFile() ||
45
+ !(AUDIO.test(entry.name) || IMAGE.test(entry.name))
46
+ )
47
+ continue;
48
+ try {
49
+ const stat = fs.statSync(full);
50
+ out.push({
51
+ path: full,
52
+ kind: AUDIO.test(entry.name) ? "audio" : "image",
53
+ mtime: stat.mtimeMs,
54
+ size: stat.size,
55
+ });
56
+ } catch {}
57
+ }
58
+ };
59
+ walk(folder, 0);
60
+ return out.sort((a, b) => b.mtime - a.mtime);
61
+ }
62
+
63
+ /** A recording as 16 kHz mono samples: WAV read here; anything else converted by the OS's own tool (afconvert) or ffmpeg. */
64
+ export function readRecording(file, { run = spawnSync } = {}) {
65
+ if (/\.wav$/i.test(file)) {
66
+ const { sampleRate, samples } = decodeWav(fs.readFileSync(file));
67
+ return resample(samples, sampleRate, RATE);
68
+ }
69
+ const out = path.join(
70
+ fs.mkdtempSync(path.join(os.tmpdir(), "harness-media-")),
71
+ "audio.wav"
72
+ );
73
+ try {
74
+ const converted =
75
+ process.platform === "darwin"
76
+ ? run(
77
+ "afconvert",
78
+ ["-f", "WAVE", "-d", `LEI16@${RATE}`, "-c", "1", file, out],
79
+ { encoding: "utf8" }
80
+ )
81
+ : run(
82
+ "ffmpeg",
83
+ [
84
+ "-v",
85
+ "error",
86
+ "-y",
87
+ "-i",
88
+ file,
89
+ "-ac",
90
+ "1",
91
+ "-ar",
92
+ String(RATE),
93
+ out,
94
+ ],
95
+ { encoding: "utf8" }
96
+ );
97
+ if (converted.status !== 0 || !fs.existsSync(out))
98
+ throw new Error(
99
+ converted.error?.code === "ENOENT"
100
+ ? "no audio converter on this computer (ffmpeg)"
101
+ : `could not read ${path.basename(file)}`
102
+ );
103
+ const { sampleRate, samples } = decodeWav(fs.readFileSync(out));
104
+ return resample(samples, sampleRate, RATE);
105
+ } finally {
106
+ fs.rmSync(path.dirname(out), { recursive: true, force: true });
107
+ }
108
+ }
109
+
110
+ /** A recording's windows: { startMs, endMs, samples }, 30 seconds each, a new one every 20. */
111
+ export function windows(
112
+ samples,
113
+ { windowMs = WINDOW_MS, stepMs = STEP_MS, rate = RATE } = {}
114
+ ) {
115
+ const size = Math.round((windowMs / 1000) * rate),
116
+ step = Math.round((stepMs / 1000) * rate);
117
+ const out = [];
118
+ for (let at = 0; at < samples.length; at += step) {
119
+ out.push({
120
+ startMs: Math.round((at / rate) * 1000),
121
+ endMs: Math.round((Math.min(samples.length, at + size) / rate) * 1000),
122
+ samples: samples.subarray(at, at + size),
123
+ });
124
+ if (at + size >= samples.length) break;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ export const clock = (ms) => {
130
+ const s = Math.floor(ms / 1000);
131
+ const h = Math.floor(s / 3600);
132
+ return `${h ? `${h}:` : ""}${String(Math.floor(s / 60) % 60).padStart(h ? 2 : 1, "0")}:${String(s % 60).padStart(2, "0")}`;
133
+ };
134
+
135
+ export function createMediaIndex({
136
+ atlas,
137
+ inference,
138
+ folders = () => [],
139
+ log = () => {},
140
+ version = ATLAS_VERSION,
141
+ }) {
142
+ let cache = null;
143
+ const vectors = () => (cache ??= atlas.mediaVectors(version));
144
+
145
+ /** Brings the added folders' media into the atlas; files gone or changed are dropped and read again. */
146
+ const refresh = async ({ lane = "background" } = {}) => {
147
+ const files = folders().flatMap((folder) => mediaFiles(folder));
148
+ const present = new Set(files.map((file) => file.path));
149
+ for (const gone of atlas.mediaPaths())
150
+ if (!present.has(gone)) atlas.dropMedia(gone);
151
+ let added = 0;
152
+ for (const file of files) {
153
+ if (atlas.mediaSeen(file.path) === file.mtime) continue;
154
+ try {
155
+ const parts =
156
+ file.kind === "image"
157
+ ? file.size <= MAX_IMAGE_BYTES
158
+ ? [{ startMs: 0, endMs: 0, data: fs.readFileSync(file.path) }]
159
+ : []
160
+ : windows(readRecording(file.path)).map((window) => ({
161
+ startMs: window.startMs,
162
+ endMs: window.endMs,
163
+ data: encodeWav(window.samples, RATE),
164
+ }));
165
+ const embedded = await inference.embedMedia(
166
+ parts.map((part) => ({
167
+ title: path.basename(file.path),
168
+ data: part.data,
169
+ })),
170
+ { lane }
171
+ );
172
+ atlas.dropMedia(file.path);
173
+ atlas.batch(() =>
174
+ parts.forEach((part, i) => {
175
+ const hash = createHash("sha1")
176
+ .update(`${file.path}\0${file.mtime}\0${part.startMs}`)
177
+ .digest("hex");
178
+ atlas.putMedia({
179
+ hash,
180
+ path: file.path,
181
+ kind: file.kind,
182
+ startMs: part.startMs,
183
+ endMs: part.endMs,
184
+ mtime: file.mtime,
185
+ });
186
+ atlas.putVector(hash, version, embedded[i]);
187
+ })
188
+ );
189
+ added += 1;
190
+ cache = null;
191
+ } catch (error) {
192
+ log(`media: ${path.basename(file.path)}: ${error.message}`);
193
+ }
194
+ }
195
+ return { files: files.length, added };
196
+ };
197
+
198
+ /** The recordings' windows and images closest to a question: [{ path, kind, startMs, endMs, at, score }], best first, one per file. */
199
+ const search = async (query, { limit = 5, lane = "interactive" } = {}) => {
200
+ const rows = vectors();
201
+ if (!rows.length) return [];
202
+ const [q] = await inference.embed(
203
+ [{ kind: "query", text: String(query).slice(0, 2_000) }],
204
+ { lane }
205
+ );
206
+ const best = new Map();
207
+ for (const row of rows) {
208
+ let dot = 0;
209
+ for (let i = 0; i < q.length; i += 1) dot += q[i] * row.vector[i];
210
+ if ((best.get(row.path)?.score ?? -1) < dot)
211
+ best.set(row.path, {
212
+ path: row.path,
213
+ kind: row.kind,
214
+ startMs: row.startMs,
215
+ endMs: row.endMs,
216
+ at: row.kind === "audio" ? clock(row.startMs) : null,
217
+ score: Math.round(dot * 1000) / 1000,
218
+ });
219
+ }
220
+ return [...best.values()].sort((a, b) => b.score - a.score).slice(0, limit);
221
+ };
222
+
223
+ return {
224
+ refresh,
225
+ search,
226
+ status: () => ({ folders: folders(), files: atlas.mediaPaths().length }),
227
+ };
228
+ }
package/lib/members.json CHANGED
@@ -20,7 +20,7 @@
20
20
  "about": "research on X and the web",
21
21
  "where": "in its own cloud", "limits": "Cannot see files on this computer unless one is attached with share_with_cloud, and cannot run anything here." },
22
22
  { "id": "bankr", "name": "Bankr", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
23
- "about": "the person's crypto wallet and trading, always under the person's own approval and limits",
23
+ "about": "holds the swarm's wallet, which is the person's own, and does anything onchain the work needs: buying, selling, swapping, sending or bridging tokens, or any other transaction, always under the person's own approval and limits",
24
24
  "where": "on Harness's server, through Bankr", "limits": "Cannot see files on this computer. Reading balances or a transaction needs no task: an agent on this computer reads them with the wallet tool in seconds." },
25
25
  { "id": "quotient", "name": "Quotient", "aliases": [], "road": "server", "home": "cloud", "runs": "server", "target": true, "wake": "run",
26
26
  "about": "prediction markets research: one search over the markets it tracks, with their odds and rules",
package/lib/members.mjs CHANGED
@@ -5,29 +5,41 @@ import { readFileSync } from "node:fs";
5
5
  // waiting (wake). The server reads the same file (src/shared/members.ts). A caller on this machine names itself
6
6
  // (--agent) and is taken at its word: anything running as the person could read a token kept for it, so none is
7
7
  // kept (owner, October 10). A call through Harness is never taken for a member on this machine (hostedCaller).
8
- const ROSTER = JSON.parse(readFileSync(new URL("./members.json", import.meta.url), "utf8"));
8
+ const ROSTER = JSON.parse(
9
+ readFileSync(new URL("./members.json", import.meta.url), "utf8")
10
+ );
9
11
 
10
12
  export const ROSTER_VERSION = ROSTER.version;
11
13
  export const MEMBERS = ROSTER.members;
12
14
  const byId = new Map(MEMBERS.map((entry) => [entry.id, entry]));
13
15
 
14
16
  export const member = (id) => byId.get(String(id)) ?? null;
15
- export const ids = (keep = () => true) => MEMBERS.filter(keep).map((entry) => entry.id);
17
+ export const ids = (keep = () => true) =>
18
+ MEMBERS.filter(keep).map((entry) => entry.id);
16
19
  /** Members others may hand work to, Harness included. */
17
20
  export const TARGETS = ids((entry) => entry.target);
18
21
  /** Coding agents the daemon runs on this machine. */
19
22
  export const MACHINE_RUNNERS = ids((entry) => entry.runs === "machine");
20
23
  /** Targets whose tasks live on Harness's server, not the machine (Harness itself excluded). */
21
- export const CLOUD_HOMED = ids((entry) => entry.target && entry.home === "cloud" && entry.id !== "harness");
24
+ export const CLOUD_HOMED = ids(
25
+ (entry) => entry.target && entry.home === "cloud" && entry.id !== "harness"
26
+ );
22
27
  /** Targets asked by a task rather than started here: they run themselves or on the server. */
23
- export const ASKED_BY_TASK = ids((entry) => entry.target && entry.runs !== "machine" && entry.id !== "harness");
28
+ export const ASKED_BY_TASK = ids(
29
+ (entry) => entry.target && entry.runs !== "machine" && entry.id !== "harness"
30
+ );
24
31
  export const nameOf = (id) => member(id)?.name ?? String(id);
25
32
  export const aboutOf = (id) => {
26
33
  const entry = member(id);
27
34
  return entry ? `${entry.name}: ${entry.about}` : String(id);
28
35
  };
29
36
 
30
- const plain = (value) => String(value || "").trim().toLowerCase().replace(/^@/, "").replace(/[\s_]+/g, " ");
37
+ const plain = (value) =>
38
+ String(value || "")
39
+ .trim()
40
+ .toLowerCase()
41
+ .replace(/^@/, "")
42
+ .replace(/[\s_]+/g, " ");
31
43
  /**
32
44
  * A name as people and agents write it, resolved to one member: an id, a display name or an alias, else the one
33
45
  * member whose name it starts. A start shared by several ("claude": Claude Code and the Claude app) comes back
@@ -37,11 +49,25 @@ const plain = (value) => String(value || "").trim().toLowerCase().replace(/^@/,
37
49
  export function resolveMember(name, { extra = [] } = {}) {
38
50
  const wanted = plain(name);
39
51
  if (!wanted) return { ok: false, candidates: [] };
40
- const exact = MEMBERS.find((entry) => entry.id === wanted || plain(entry.name) === wanted || entry.aliases.some((alias) => plain(alias) === wanted));
52
+ const exact = MEMBERS.find(
53
+ (entry) =>
54
+ entry.id === wanted ||
55
+ plain(entry.name) === wanted ||
56
+ entry.aliases.some((alias) => plain(alias) === wanted)
57
+ );
41
58
  if (exact) return { ok: true, id: exact.id };
42
59
  if (extra.map(String).includes(wanted)) return { ok: true, id: wanted };
43
60
  const words = wanted.split(" ");
44
- const candidates = MEMBERS.filter((entry) => [entry.id.replace(/-/g, " "), plain(entry.name), ...entry.aliases.map(plain)]
45
- .some((label) => words.every((word, index) => label.split(" ")[index]?.startsWith(word)))).map((entry) => entry.id);
46
- return candidates.length === 1 ? { ok: true, id: candidates[0] } : { ok: false, candidates };
61
+ const candidates = MEMBERS.filter((entry) =>
62
+ [
63
+ entry.id.replace(/-/g, " "),
64
+ plain(entry.name),
65
+ ...entry.aliases.map(plain),
66
+ ].some((label) =>
67
+ words.every((word, index) => label.split(" ")[index]?.startsWith(word))
68
+ )
69
+ ).map((entry) => entry.id);
70
+ return candidates.length === 1
71
+ ? { ok: true, id: candidates[0] }
72
+ : { ok: false, candidates };
47
73
  }