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.
- package/README.md +48 -8
- package/assets/opencode-plugin.js +48 -10
- package/assets/pi-extension.ts +106 -26
- package/cli.mjs +1281 -289
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +604 -0
- package/lib/bip39-english.txt +2048 -0
- package/lib/cards.mjs +177 -39
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +628 -0
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +281 -56
- package/lib/daemon.mjs +5590 -838
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +81 -0
- package/lib/environment.mjs +180 -0
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +288 -0
- package/lib/folders.mjs +223 -57
- package/lib/frames.mjs +333 -0
- package/lib/inference.mjs +663 -0
- package/lib/learn.mjs +182 -0
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +571 -0
- package/lib/mcp-stdio.mjs +912 -103
- package/lib/media.mjs +228 -0
- package/lib/members.json +1 -1
- package/lib/members.mjs +35 -9
- package/lib/models.mjs +342 -80
- package/lib/network.mjs +201 -42
- package/lib/notify.mjs +49 -12
- package/lib/nudge.mjs +190 -32
- package/lib/ocr.mjs +380 -0
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +77 -0
- package/lib/phones.mjs +398 -51
- package/lib/places.mjs +566 -102
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +82 -0
- package/lib/redact.mjs +730 -0
- package/lib/runner.mjs +347 -55
- package/lib/runtime-install.mjs +59 -17
- package/lib/screens.mjs +212 -0
- package/lib/sealed.mjs +160 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +173 -35
- package/lib/snippets.mjs +140 -0
- package/lib/speech.mjs +993 -0
- package/lib/store.mjs +1678 -301
- package/lib/swarm.mjs +598 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +401 -0
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +362 -0
- package/lib/worktrees.mjs +186 -38
- package/package.json +6 -4
- 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
|
+
}
|