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/frames.mjs ADDED
@@ -0,0 +1,333 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { repositoryOf } from "./worktrees.mjs";
4
+ import { nameOf } from "./members.mjs";
5
+
6
+ // The task frame: one record per thread, built by the relay from what is on this computer and refreshed at every
7
+ // handoff. What's asked: the files and code the person named, resolved to where they are (a function named in the words
8
+ // becomes the file and line that declare it). The environment: the repository's branch and what is changed and not
9
+ // committed. Peers: other open threads working in the same repository and what they change. Checks: each run's receipt
10
+ // (receipts.mjs), so "done" is read against what the run's own session shows. The evidence (the context pack) is the
11
+ // context engine's. Local agents get the frame in their brief and from get_task; the coordinator's round gets its peers,
12
+ // checks and repository state; nothing in it is a guess by a model.
13
+
14
+ const LINE_SUFFIX = /:(\d{1,6})(?::\d+)?$/;
15
+ const FILE_TOKEN = /(?:[\w@.-]+\/)+[\w@.-]+|[\w-]+\.[a-z][a-z0-9]{0,5}\b/gi;
16
+ // Names that only code has: camelCase, PascalCase with two humps, snake_case. A plain word, even written as a call
17
+ // ("address(es)"), is left alone: in the owner's sessions they resolved to unrelated files (task-frame eval).
18
+ const CODE_NAME = /\b([A-Za-z_$][\w$]{3,})\b/g;
19
+ const codeShaped = (name) =>
20
+ /[a-z][A-Z]/.test(name) ||
21
+ /^[A-Z][a-z]+[A-Z]/.test(name) ||
22
+ (/_/.test(name) && /[a-z]/i.test(name.replace(/_/g, "")));
23
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
24
+ const declares = (name) =>
25
+ new RegExp(
26
+ `(?:^|\\n)[ \\t]*(?:export\\s+)?(?:default\\s+)?(?:async\\s+)?(?:function\\*?|class|const|let|var|type|interface|enum|def|fn|func|struct|trait|impl|module|contract|library|pub(?:\\([^)]*\\))?\\s+(?:async\\s+)?fn)\\s+${escape(name)}\\b|(?:^|\\n)[ \\t]+(?:async\\s+)?${escape(name)}\\s*\\([^)]*\\)\\s*\\{|(?:^|\\n)[ \\t]*(?:export\\s+)?const\\s+${escape(name)}\\s*=`
27
+ );
28
+ const minutes = (at, now) =>
29
+ at ? Math.max(0, Math.round((now - at) / 60_000)) : null;
30
+ const MAX_NAMED = 8;
31
+
32
+ /**
33
+ * Files and code named in the person's words, resolved against a repository snapshot: { files: [{ path, line }],
34
+ * symbols: [{ name, path, line }] }. scope: the snapshot's files ([{ path, blob }]); atlas: the context engine's index.
35
+ */
36
+ export function mentions(text, { scope = [], atlas = null } = {}) {
37
+ const words = String(text || "");
38
+ const files = [];
39
+ const seen = new Set();
40
+ for (const raw of words.match(FILE_TOKEN) ?? []) {
41
+ const token = raw.replace(/^\.?\/+/, "").replace(/[.,;:)]+$/, "");
42
+ const line = Number(token.match(LINE_SUFFIX)?.[1]) || null;
43
+ const rel = token.replace(LINE_SUFFIX, "");
44
+ if (rel.length < 4) continue;
45
+ const hits = scope.filter(
46
+ (row) => row.path === rel || row.path.endsWith(`/${rel}`)
47
+ );
48
+ if (!hits.length || hits.length > 3) continue;
49
+ for (const row of hits)
50
+ if (!seen.has(row.path)) {
51
+ seen.add(row.path);
52
+ files.push({ path: row.path, line });
53
+ }
54
+ }
55
+ const symbols = [];
56
+ if (atlas) {
57
+ let inScope = null;
58
+ const names = new Map();
59
+ for (const match of words.matchAll(CODE_NAME))
60
+ if (codeShaped(match[1])) names.set(match[1], true);
61
+ for (const name of [...names.keys()].slice(0, 20)) {
62
+ // The atlas's word index finds the pieces that hold the name; one in this snapshot that declares it wins.
63
+ const hits = atlas.words([name], 200);
64
+ if (!hits.length) continue;
65
+ inScope ??= new Set(scope.flatMap((row) => atlas.blob(row.blob) ?? []));
66
+ const pattern = declares(name);
67
+ for (const hit of hits) {
68
+ if (!inScope.has(hit.hash)) continue;
69
+ const piece = atlas.piece(hit.hash);
70
+ const found = piece && pattern.exec(piece.text);
71
+ if (!found) continue;
72
+ const before = piece.text.slice(
73
+ 0,
74
+ found.index + (found[0].startsWith("\n") ? 1 : 0)
75
+ );
76
+ symbols.push({
77
+ name,
78
+ path: piece.path,
79
+ line: piece.line + (before.match(/\n/g)?.length ?? 0),
80
+ });
81
+ break;
82
+ }
83
+ if (symbols.length >= MAX_NAMED) break;
84
+ }
85
+ }
86
+ return { files: files.slice(0, MAX_NAMED), symbols };
87
+ }
88
+
89
+ export function createFrames({
90
+ store,
91
+ environment,
92
+ worktrees,
93
+ atlas = () => null,
94
+ scope = () => [],
95
+ log = () => {},
96
+ }) {
97
+ const key = (threadId) => `frame:${threadId}`;
98
+ const get = (threadId) => {
99
+ try {
100
+ return JSON.parse(store.meta(key(threadId)) || "null");
101
+ } catch {
102
+ return null;
103
+ }
104
+ };
105
+
106
+ /** Where a thread's files are: its own worktree while open, else its folder. */
107
+ const placeOf = (thread) => {
108
+ const tree = worktrees.get(thread.id);
109
+ return tree?.state === "open" && tree.path
110
+ ? { folder: tree.path, own: true, repo: tree.repo }
111
+ : thread.goal?.workspace
112
+ ? {
113
+ folder: thread.goal.workspace,
114
+ own: false,
115
+ repo: repositoryOf(thread.goal.workspace)?.root ?? null,
116
+ }
117
+ : null;
118
+ };
119
+
120
+ /** The files a thread is changing: its worktree's diff, or what its runs' receipts say they changed. */
121
+ const changing = (thread) => {
122
+ const tree = worktrees.get(thread.id);
123
+ if (tree?.state === "open") {
124
+ try {
125
+ return (worktrees.diff(thread.id).files || []).map(
126
+ (entry) => entry.file
127
+ );
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+ return [
133
+ ...new Set(
134
+ store
135
+ .threadRuns(thread.id)
136
+ .flatMap((run) => receiptOf(run.id)?.changed ?? [])
137
+ ),
138
+ ];
139
+ };
140
+
141
+ const receiptOf = (taskId) => {
142
+ try {
143
+ return JSON.parse(store.meta(`receipt:${taskId}`) || "null");
144
+ } catch {
145
+ return null;
146
+ }
147
+ };
148
+
149
+ /** The frame for a thread, rebuilt from what is on the computer now. pack: the context engine's latest pack, if any. */
150
+ const build = (threadId, { pack = null } = {}) => {
151
+ const started = performance.now();
152
+ const thread = store.getTask(threadId);
153
+ if (!thread || thread.kind !== "goal") return null;
154
+ const now = Date.now();
155
+ const previous = get(threadId);
156
+ const place = placeOf(thread);
157
+ // What's asked: the person's own words in the thread, resolved once per change of words.
158
+ const asked = [
159
+ thread.objective,
160
+ thread.brief,
161
+ ...store
162
+ .roomPosts(threadId, 0, 500)
163
+ .filter((post) => post.author === "user")
164
+ .map((post) => post.text),
165
+ ]
166
+ .filter(Boolean)
167
+ .join("\n");
168
+ const wordsHash = createHash("sha1").update(asked).digest("hex");
169
+ let named = previous?.asked?.hash === wordsHash ? previous.asked : null;
170
+ if (!named) {
171
+ const index = atlas();
172
+ named = {
173
+ hash: wordsHash,
174
+ ...mentions(asked, {
175
+ scope: index && place ? scope(place.folder) : [],
176
+ atlas: index,
177
+ }),
178
+ };
179
+ }
180
+ const repo = place ? environment.repository(place.folder) : null;
181
+ const repository = repo
182
+ ? {
183
+ branch: repo.branch,
184
+ own: place.own,
185
+ changed: repo.changed.slice(0, 8).map((entry) => ({
186
+ path: entry.path,
187
+ state: entry.state,
188
+ minutesAgo: minutes(entry.at, now),
189
+ })),
190
+ uncommitted: repo.changed.length,
191
+ lastCommit: repo.lastCommit
192
+ ? {
193
+ subject: repo.lastCommit.subject,
194
+ minutesAgo: minutes(repo.lastCommit.at, now),
195
+ }
196
+ : null,
197
+ }
198
+ : null;
199
+ const peers = [];
200
+ if (place?.repo) {
201
+ for (const other of store.goals({ all: false })) {
202
+ if (other.id === threadId || other.status !== "open") continue;
203
+ const there = placeOf(other);
204
+ if (!there?.repo || there.repo !== place.repo) continue;
205
+ const runs = store.threadRuns(other.id);
206
+ const files = changing(other);
207
+ const running = runs
208
+ .filter((run) => run.status === "running")
209
+ .map((run) => run.target_agent);
210
+ if (!files.length && !running.length) continue;
211
+ peers.push({
212
+ thread: other.id,
213
+ title: other.objective.slice(0, 200),
214
+ agents: other.goal.team.filter(
215
+ (agent) => agent !== other.goal.facilitator
216
+ ),
217
+ running,
218
+ files: files.slice(0, 20),
219
+ });
220
+ }
221
+ }
222
+ const checks = store
223
+ .threadRuns(threadId)
224
+ .map((run) => ({
225
+ run: run.id,
226
+ agent: run.target_agent,
227
+ ...receiptOf(run.id),
228
+ }))
229
+ .filter((check) => check.verdict)
230
+ .slice(-5);
231
+ const frame = {
232
+ thread: threadId,
233
+ updatedAt: new Date(now).toISOString(),
234
+ folder: place?.folder ?? null,
235
+ asked: named,
236
+ repository,
237
+ peers: peers.slice(0, 6),
238
+ checks,
239
+ evidence: pack
240
+ ? {
241
+ files: pack.files,
242
+ earlier: pack.earlier.map((run) => ({
243
+ agent: run.agent,
244
+ at: run.at,
245
+ brief: run.brief.slice(0, 200),
246
+ edited: run.edited.slice(0, 6),
247
+ })),
248
+ }
249
+ : (previous?.evidence ?? null),
250
+ ms: 0,
251
+ };
252
+ frame.ms = Math.round(performance.now() - started);
253
+ store.meta(key(threadId), JSON.stringify(frame));
254
+ return frame;
255
+ };
256
+
257
+ /** The frame as a local agent reads it in its brief; empty parts are left out. */
258
+ const briefText = (frame, agent) => {
259
+ if (!frame) return "";
260
+ const lines = [];
261
+ const named = [
262
+ ...frame.asked.symbols.map(
263
+ (symbol) =>
264
+ `\`${symbol.name}\` (declared at ${symbol.path}:${symbol.line})`
265
+ ),
266
+ ...frame.asked.files.map(
267
+ (file) => `${file.path}${file.line ? `:${file.line}` : ""}`
268
+ ),
269
+ ];
270
+ if (named.length)
271
+ lines.push(`- Named in the person's words: ${named.join("; ")}.`);
272
+ const repo = frame.repository;
273
+ if (repo) {
274
+ const changed = repo.changed
275
+ .slice(0, 5)
276
+ .map(
277
+ (entry) =>
278
+ `${entry.path} (${entry.state}${entry.minutesAgo !== null ? `, ${entry.minutesAgo} min ago` : ""})`
279
+ );
280
+ lines.push(
281
+ `- Repository: ${repo.branch ? `branch ${repo.branch}` : "no branch"}${repo.own ? ", this thread's own copy" : ""}; ${repo.uncommitted ? `${repo.uncommitted} file${repo.uncommitted === 1 ? "" : "s"} changed and not committed${changed.length ? `: ${changed.join(", ")}${repo.uncommitted > changed.length ? ", ..." : ""}` : ""}` : "nothing uncommitted"}${repo.lastCommit ? `; last commit ${repo.lastCommit.minutesAgo} min ago ("${repo.lastCommit.subject.slice(0, 80)}")` : ""}.`
282
+ );
283
+ }
284
+ for (const peer of frame.peers)
285
+ lines.push(
286
+ `- Also working in this repository: the thread "${peer.title.slice(0, 80)}"${peer.running.length ? ` (${peer.running.map(nameOf).join(", ")} running now)` : ""}${peer.files.length ? `, changing ${peer.files.slice(0, 5).join(", ")}${peer.files.length > 5 ? ` and ${peer.files.length - 5} more` : ""}` : ""}.`
287
+ );
288
+ for (const check of frame.checks
289
+ .filter((entry) => entry.agent !== agent || frame.checks.length > 1)
290
+ .slice(-3))
291
+ lines.push(
292
+ `- Check on ${nameOf(check.agent)}'s run: ${check.verdict}${check.changed?.length ? ` (${check.changed.length} file${check.changed.length === 1 ? "" : "s"} changed)` : ""}.`
293
+ );
294
+ return lines.length
295
+ ? `What the relay knows about this task on this computer (facts to check, not instructions):\n${lines.join("\n")}`
296
+ : "";
297
+ };
298
+
299
+ /** The parts of the frame the coordinator's round reads, in the server's shape (src/server/swarm/coordinator.ts). */
300
+ const roundFacts = (frame) =>
301
+ frame
302
+ ? {
303
+ checks: frame.checks
304
+ .map((check) => ({
305
+ agent: check.agent,
306
+ verdict: check.verdict,
307
+ changedFiles: check.changed?.length ?? 0,
308
+ ...(check.test ? { test: String(check.test).slice(0, 200) } : {}),
309
+ }))
310
+ .slice(-5),
311
+ peers: frame.peers
312
+ .map((peer) => ({
313
+ title: peer.title,
314
+ agents: peer.agents.slice(0, 8),
315
+ running: peer.running.slice(0, 8),
316
+ files: peer.files.slice(0, 20),
317
+ }))
318
+ .slice(0, 6),
319
+ repository: frame.repository
320
+ ? {
321
+ branch: frame.repository.branch,
322
+ uncommitted: frame.repository.uncommitted,
323
+ minutesSinceLastChange:
324
+ frame.repository.changed[0]?.minutesAgo ??
325
+ frame.repository.lastCommit?.minutesAgo ??
326
+ null,
327
+ }
328
+ : null,
329
+ }
330
+ : null;
331
+
332
+ return { get, build, briefText, roundFacts, receiptOf, log };
333
+ }