tryharness 0.7.23 → 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/assets/opencode-plugin.js +48 -11
- package/assets/pi-extension.ts +105 -26
- package/cli.mjs +1238 -322
- package/lib/activity.mjs +197 -40
- package/lib/adapters.mjs +337 -54
- package/lib/agentnode.mjs +175 -35
- package/lib/atlas.mjs +383 -83
- package/lib/cards.mjs +174 -40
- package/lib/claudemem.mjs +74 -18
- package/lib/commands.mjs +13 -5
- package/lib/context.mjs +409 -71
- package/lib/control-client.mjs +5 -2
- package/lib/coordinator.mjs +274 -57
- package/lib/daemon.mjs +5248 -1024
- package/lib/desktop.mjs +149 -35
- package/lib/digest.mjs +54 -13
- package/lib/environment.mjs +103 -21
- package/lib/files.mjs +246 -13
- package/lib/firewall.mjs +169 -35
- package/lib/folders.mjs +179 -36
- package/lib/frames.mjs +214 -40
- package/lib/inference.mjs +463 -126
- package/lib/learn.mjs +78 -16
- package/lib/live.mjs +286 -61
- package/lib/localmodels.mjs +424 -83
- package/lib/mcp-stdio.mjs +868 -151
- package/lib/media.mjs +138 -28
- 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 +184 -33
- package/lib/ocr.mjs +223 -60
- package/lib/onboard.mjs +321 -70
- package/lib/parsers.mjs +132 -41
- package/lib/paths.mjs +33 -10
- package/lib/personal.mjs +51 -13
- package/lib/phones.mjs +382 -60
- package/lib/places.mjs +555 -107
- package/lib/provenance.mjs +19 -4
- package/lib/receipts.mjs +51 -11
- package/lib/redact.mjs +512 -116
- package/lib/runner.mjs +345 -55
- package/lib/runtime-install.mjs +55 -14
- package/lib/screens.mjs +154 -38
- package/lib/sealed.mjs +157 -28
- package/lib/secrets.mjs +18 -5
- package/lib/service.mjs +170 -34
- package/lib/snippets.mjs +94 -18
- package/lib/speech.mjs +688 -175
- package/lib/store.mjs +1654 -307
- package/lib/swarm.mjs +596 -117
- package/lib/t3.mjs +400 -79
- package/lib/trails.mjs +285 -61
- package/lib/ui.mjs +95 -23
- package/lib/vectors.mjs +5 -2
- package/lib/wakes.mjs +284 -68
- package/lib/watches.mjs +247 -48
- package/lib/worktrees.mjs +186 -38
- package/package.json +3 -3
package/lib/folders.mjs
CHANGED
|
@@ -20,10 +20,22 @@ const OFFERED = 60;
|
|
|
20
20
|
const LISTED = 200;
|
|
21
21
|
const PICK_TTL_MS = 10 * 60_000;
|
|
22
22
|
|
|
23
|
-
const realDir = (dir) => {
|
|
24
|
-
|
|
23
|
+
const realDir = (dir) => {
|
|
24
|
+
try {
|
|
25
|
+
return fs.realpathSync(dir);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
export const isDir = (dir) =>
|
|
31
|
+
typeof dir === "string" &&
|
|
32
|
+
path.isAbsolute(dir) &&
|
|
33
|
+
fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
25
34
|
|
|
26
|
-
const within = (dir, root) => {
|
|
35
|
+
const within = (dir, root) => {
|
|
36
|
+
const rel = root ? path.relative(root, dir) : "..";
|
|
37
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
38
|
+
};
|
|
27
39
|
|
|
28
40
|
/** A folder Harness may offer or pick by itself: not the home folder or above it, not hidden, not Harness's own, not gone. */
|
|
29
41
|
function offerable(dir) {
|
|
@@ -32,11 +44,19 @@ function offerable(dir) {
|
|
|
32
44
|
const home = realDir(os.homedir()) ?? os.homedir();
|
|
33
45
|
const rel = path.relative(home, real);
|
|
34
46
|
if (rel === "" || path.parse(real).root === real) return false;
|
|
35
|
-
if (!rel.startsWith("..") && !path.isAbsolute(rel))
|
|
47
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel))
|
|
48
|
+
return !rel.split(path.sep).some((part) => part.startsWith("."));
|
|
36
49
|
return !real.split(path.sep).some((part) => part.startsWith("."));
|
|
37
50
|
}
|
|
38
51
|
|
|
39
|
-
const git = (cwd, args) => {
|
|
52
|
+
const git = (cwd, args) => {
|
|
53
|
+
const found = spawnSync("git", args, {
|
|
54
|
+
cwd,
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
timeout: 2_000,
|
|
57
|
+
});
|
|
58
|
+
return found.status === 0 ? (found.stdout || "").trim() : "";
|
|
59
|
+
};
|
|
40
60
|
|
|
41
61
|
// The repository a folder is in, as its main checkout (for a worktree's or a subfolder's sessions), else the folder itself.
|
|
42
62
|
const ROOT_TTL_MS = 10 * 60_000;
|
|
@@ -44,8 +64,16 @@ const roots = new Map();
|
|
|
44
64
|
function repositoryOf(dir) {
|
|
45
65
|
const cached = roots.get(dir);
|
|
46
66
|
if (cached && cached.at > Date.now() - ROOT_TTL_MS) return cached.root;
|
|
47
|
-
const [top, common] = git(dir, [
|
|
48
|
-
|
|
67
|
+
const [top, common] = git(dir, [
|
|
68
|
+
"rev-parse",
|
|
69
|
+
"--path-format=absolute",
|
|
70
|
+
"--show-toplevel",
|
|
71
|
+
"--git-common-dir",
|
|
72
|
+
]).split("\n");
|
|
73
|
+
const root =
|
|
74
|
+
(top && common
|
|
75
|
+
? realDir(path.basename(common) === ".git" ? path.dirname(common) : top)
|
|
76
|
+
: null) ?? dir;
|
|
49
77
|
roots.set(dir, { root, at: Date.now() });
|
|
50
78
|
return root;
|
|
51
79
|
}
|
|
@@ -57,11 +85,29 @@ function repositoryOf(dir) {
|
|
|
57
85
|
*/
|
|
58
86
|
function facts(place) {
|
|
59
87
|
// A remote's address without any credentials in it.
|
|
60
|
-
const remote = git(place.path, ["remote", "get-url", "origin"]).replace(
|
|
61
|
-
|
|
88
|
+
const remote = git(place.path, ["remote", "get-url", "origin"]).replace(
|
|
89
|
+
/\/\/[^@/]+@/,
|
|
90
|
+
"//"
|
|
91
|
+
);
|
|
92
|
+
return {
|
|
93
|
+
path: place.path,
|
|
94
|
+
name: place.name,
|
|
95
|
+
repository: remote || null,
|
|
96
|
+
agents: place.agents.map(nameOf),
|
|
97
|
+
lastWorked: place.lastWorked,
|
|
98
|
+
titles: place.titles,
|
|
99
|
+
};
|
|
62
100
|
}
|
|
63
101
|
|
|
64
|
-
export function createFolders({
|
|
102
|
+
export function createFolders({
|
|
103
|
+
store,
|
|
104
|
+
askHarness,
|
|
105
|
+
canAsk,
|
|
106
|
+
privacy,
|
|
107
|
+
log = () => {},
|
|
108
|
+
lastChanged = () => null,
|
|
109
|
+
rank = async (_message, places) => places,
|
|
110
|
+
}) {
|
|
65
111
|
/** The folders the person works in, most recently worked in first: { path, name, t3, agents, lastWorked, titles }. */
|
|
66
112
|
const known = () => {
|
|
67
113
|
const byPath = new Map();
|
|
@@ -69,18 +115,42 @@ export function createFolders({ store, askHarness, canAsk, privacy, log = () =>
|
|
|
69
115
|
const real = realDir(dir);
|
|
70
116
|
const where = real && repositoryOf(real);
|
|
71
117
|
if (!where || !offerable(where)) return;
|
|
72
|
-
const place = byPath.get(where) ?? {
|
|
73
|
-
|
|
74
|
-
|
|
118
|
+
const place = byPath.get(where) ?? {
|
|
119
|
+
path: where,
|
|
120
|
+
name: path.basename(where),
|
|
121
|
+
t3: false,
|
|
122
|
+
agents: [],
|
|
123
|
+
lastWorked: null,
|
|
124
|
+
titles: [],
|
|
125
|
+
};
|
|
126
|
+
if (agent && CODING.has(agent) && !place.agents.includes(agent))
|
|
127
|
+
place.agents.push(agent);
|
|
128
|
+
if (at && (!place.lastWorked || at > place.lastWorked))
|
|
129
|
+
place.lastWorked = at;
|
|
75
130
|
if (title && !place.titles.includes(title)) place.titles.push(title);
|
|
76
131
|
byPath.set(where, place);
|
|
77
132
|
};
|
|
78
|
-
for (const row of store.sessionPlaces())
|
|
79
|
-
|
|
133
|
+
for (const row of store.sessionPlaces())
|
|
134
|
+
add(row.cwd, row.agent, row.at, row.title);
|
|
135
|
+
for (const thread of store.goals({ all: true }))
|
|
136
|
+
if (thread.goal?.workspace)
|
|
137
|
+
for (const agent of thread.goal.team)
|
|
138
|
+
add(
|
|
139
|
+
thread.goal.workspace,
|
|
140
|
+
agent,
|
|
141
|
+
thread.updated_at || thread.created_at
|
|
142
|
+
);
|
|
80
143
|
const projects = t3Projects();
|
|
81
144
|
for (const dir of projects) add(dir, null, null);
|
|
82
|
-
for (const place of byPath.values())
|
|
83
|
-
|
|
145
|
+
for (const place of byPath.values())
|
|
146
|
+
place.t3 = projects.includes(place.path);
|
|
147
|
+
return [...byPath.values()]
|
|
148
|
+
.sort(
|
|
149
|
+
(a, b) =>
|
|
150
|
+
(b.lastWorked ?? "").localeCompare(a.lastWorked ?? "") ||
|
|
151
|
+
a.name.localeCompare(b.name)
|
|
152
|
+
)
|
|
153
|
+
.slice(0, OFFERED);
|
|
84
154
|
};
|
|
85
155
|
|
|
86
156
|
/**
|
|
@@ -89,20 +159,48 @@ export function createFolders({ store, askHarness, canAsk, privacy, log = () =>
|
|
|
89
159
|
*/
|
|
90
160
|
const browse = (typed) => {
|
|
91
161
|
const raw = String(typed ?? "").trim() || "~";
|
|
92
|
-
const expanded =
|
|
93
|
-
|
|
162
|
+
const expanded =
|
|
163
|
+
raw === "~"
|
|
164
|
+
? os.homedir()
|
|
165
|
+
: raw.startsWith("~/")
|
|
166
|
+
? path.join(os.homedir(), raw.slice(2))
|
|
167
|
+
: raw;
|
|
168
|
+
if (!path.isAbsolute(expanded))
|
|
169
|
+
return { ok: false, error: "type a full path, or start with ~" };
|
|
94
170
|
// "~" or a path ending in "/" lists that folder; anything else lists the folders beside it that start with its name.
|
|
95
171
|
const inside = raw === "~" || raw.endsWith("/");
|
|
96
172
|
const dir = path.resolve(inside ? expanded : path.dirname(expanded));
|
|
97
173
|
const start = inside ? "" : path.basename(expanded).toLowerCase();
|
|
98
|
-
if (!isDir(dir))
|
|
174
|
+
if (!isDir(dir))
|
|
175
|
+
return { ok: false, error: `${dir} is not a folder on this computer` };
|
|
99
176
|
let entries = [];
|
|
100
|
-
try {
|
|
177
|
+
try {
|
|
178
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
error: `${dir} can't be read: ${error.code || error.message}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
101
185
|
const folders = entries
|
|
102
|
-
.filter(
|
|
186
|
+
.filter(
|
|
187
|
+
(entry) =>
|
|
188
|
+
(entry.isDirectory() ||
|
|
189
|
+
(entry.isSymbolicLink() && isDir(path.join(dir, entry.name)))) &&
|
|
190
|
+
entry.name.toLowerCase().startsWith(start) &&
|
|
191
|
+
(start.startsWith(".") || !entry.name.startsWith("."))
|
|
192
|
+
)
|
|
103
193
|
.map((entry) => ({ path: path.join(dir, entry.name), name: entry.name }))
|
|
104
|
-
.sort((a, b) =>
|
|
105
|
-
|
|
194
|
+
.sort((a, b) =>
|
|
195
|
+
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
196
|
+
)
|
|
197
|
+
.slice(0, LISTED);
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
dir,
|
|
201
|
+
parent: path.dirname(dir) === dir ? null : path.dirname(dir),
|
|
202
|
+
folders,
|
|
203
|
+
};
|
|
106
204
|
};
|
|
107
205
|
|
|
108
206
|
// A pick is asked once per message: the composer shows it, and the thread it starts uses it.
|
|
@@ -113,26 +211,51 @@ export function createFolders({ store, askHarness, canAsk, privacy, log = () =>
|
|
|
113
211
|
if (cached && cached.at > Date.now() - PICK_TTL_MS) return cached.pending;
|
|
114
212
|
// How recently each folder's files changed, where Harness takes it (a daemon newer than the server would be refused).
|
|
115
213
|
const changed = lastChanged();
|
|
116
|
-
const ask = (offered) =>
|
|
214
|
+
const ask = (offered) =>
|
|
215
|
+
askHarness(
|
|
216
|
+
"pick_folder",
|
|
217
|
+
{
|
|
218
|
+
message,
|
|
219
|
+
folders: offered.map((place) =>
|
|
220
|
+
changed
|
|
221
|
+
? { ...facts(place), lastChanged: changed(place.path) }
|
|
222
|
+
: facts(place)
|
|
223
|
+
),
|
|
224
|
+
},
|
|
225
|
+
null
|
|
226
|
+
);
|
|
117
227
|
const pending = (async () => {
|
|
118
228
|
let reply = await ask(places);
|
|
119
229
|
// Over Jev's limit: asked again about the most recently worked in half, halving until it fits. On the owner's
|
|
120
230
|
// sessions that half held the folder 99.5% of the time; the search model's closest half, 95.6% (eval folder-shortlist).
|
|
121
231
|
if (reply?.ok && reply.tooLarge) {
|
|
122
232
|
const ranked = await rank(message, places);
|
|
123
|
-
for (
|
|
124
|
-
|
|
233
|
+
for (
|
|
234
|
+
let n = Math.ceil(places.length / 2);
|
|
235
|
+
reply?.ok && reply.tooLarge && n >= 2;
|
|
236
|
+
n = Math.floor(n / 2)
|
|
237
|
+
) {
|
|
238
|
+
log(
|
|
239
|
+
`folder pick: ${places.length} folders are over Jev's limit; asking about the closest ${n}`
|
|
240
|
+
);
|
|
125
241
|
reply = await ask(ranked.slice(0, n));
|
|
126
242
|
}
|
|
127
243
|
}
|
|
128
|
-
if (!reply?.ok)
|
|
129
|
-
|
|
244
|
+
if (!reply?.ok)
|
|
245
|
+
throw new Error(reply?.error || "Harness did not pick a folder");
|
|
246
|
+
const folder = places.some((place) => place.path === reply.folder)
|
|
247
|
+
? reply.folder
|
|
248
|
+
: null;
|
|
130
249
|
// Jev's answers, so a wrong pick can be explained (not the message).
|
|
131
|
-
log(
|
|
250
|
+
log(
|
|
251
|
+
`folder pick: ${folder ?? "none"}${reply.tooLarge ? " (over Jev's limit)" : ""} ${JSON.stringify(reply.judgments ?? null)}`
|
|
252
|
+
);
|
|
132
253
|
return folder;
|
|
133
254
|
})();
|
|
134
255
|
picks.set(key, { at: Date.now(), pending });
|
|
135
|
-
for (const [old, entry] of picks)
|
|
256
|
+
for (const [old, entry] of picks)
|
|
257
|
+
if (entry.at <= Date.now() - PICK_TTL_MS || picks.size > 50)
|
|
258
|
+
picks.delete(old);
|
|
136
259
|
pending.catch(() => picks.delete(key));
|
|
137
260
|
return pending;
|
|
138
261
|
};
|
|
@@ -145,17 +268,37 @@ export function createFolders({ store, askHarness, canAsk, privacy, log = () =>
|
|
|
145
268
|
const suggest = async (text, agents) => {
|
|
146
269
|
const places = known();
|
|
147
270
|
const coding = agents.filter((agent) => CODING.has(agent));
|
|
148
|
-
const last =
|
|
149
|
-
|
|
271
|
+
const last =
|
|
272
|
+
places
|
|
273
|
+
.filter((place) => place.lastWorked)
|
|
274
|
+
.find(
|
|
275
|
+
(place) =>
|
|
276
|
+
!coding.length ||
|
|
277
|
+
place.agents.some((agent) => coding.includes(agent))
|
|
278
|
+
)?.path ?? null;
|
|
279
|
+
const message = String(text)
|
|
280
|
+
.replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "")
|
|
281
|
+
.trim();
|
|
150
282
|
if (message && places.length && canAsk() && !privacy()) {
|
|
151
283
|
try {
|
|
152
284
|
const folder = await pickFromMessage(message, places);
|
|
153
|
-
if (folder)
|
|
154
|
-
|
|
285
|
+
if (folder)
|
|
286
|
+
return {
|
|
287
|
+
folder,
|
|
288
|
+
by: "message",
|
|
289
|
+
lastWorked: folder === last ? null : last,
|
|
290
|
+
};
|
|
291
|
+
} catch (error) {
|
|
292
|
+
log(`folder pick: ${error.message}`);
|
|
293
|
+
}
|
|
155
294
|
}
|
|
156
295
|
return { folder: last, by: last ? "last" : null, lastWorked: null };
|
|
157
296
|
};
|
|
158
297
|
|
|
159
298
|
// Offered to the composer without the session titles, which only Jev reads.
|
|
160
|
-
return {
|
|
299
|
+
return {
|
|
300
|
+
known: () => known().map(({ titles, ...place }) => place),
|
|
301
|
+
browse,
|
|
302
|
+
suggest,
|
|
303
|
+
};
|
|
161
304
|
}
|
package/lib/frames.mjs
CHANGED
|
@@ -16,10 +16,17 @@ const FILE_TOKEN = /(?:[\w@.-]+\/)+[\w@.-]+|[\w-]+\.[a-z][a-z0-9]{0,5}\b/gi;
|
|
|
16
16
|
// Names that only code has: camelCase, PascalCase with two humps, snake_case. A plain word, even written as a call
|
|
17
17
|
// ("address(es)"), is left alone: in the owner's sessions they resolved to unrelated files (task-frame eval).
|
|
18
18
|
const CODE_NAME = /\b([A-Za-z_$][\w$]{3,})\b/g;
|
|
19
|
-
const codeShaped = (name) =>
|
|
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, "")));
|
|
20
23
|
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
21
|
-
const declares = (name) =>
|
|
22
|
-
|
|
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;
|
|
23
30
|
const MAX_NAMED = 8;
|
|
24
31
|
|
|
25
32
|
/**
|
|
@@ -35,15 +42,22 @@ export function mentions(text, { scope = [], atlas = null } = {}) {
|
|
|
35
42
|
const line = Number(token.match(LINE_SUFFIX)?.[1]) || null;
|
|
36
43
|
const rel = token.replace(LINE_SUFFIX, "");
|
|
37
44
|
if (rel.length < 4) continue;
|
|
38
|
-
const hits = scope.filter(
|
|
45
|
+
const hits = scope.filter(
|
|
46
|
+
(row) => row.path === rel || row.path.endsWith(`/${rel}`)
|
|
47
|
+
);
|
|
39
48
|
if (!hits.length || hits.length > 3) continue;
|
|
40
|
-
for (const row of hits)
|
|
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
|
+
}
|
|
41
54
|
}
|
|
42
55
|
const symbols = [];
|
|
43
56
|
if (atlas) {
|
|
44
57
|
let inScope = null;
|
|
45
58
|
const names = new Map();
|
|
46
|
-
for (const match of words.matchAll(CODE_NAME))
|
|
59
|
+
for (const match of words.matchAll(CODE_NAME))
|
|
60
|
+
if (codeShaped(match[1])) names.set(match[1], true);
|
|
47
61
|
for (const name of [...names.keys()].slice(0, 20)) {
|
|
48
62
|
// The atlas's word index finds the pieces that hold the name; one in this snapshot that declares it wins.
|
|
49
63
|
const hits = atlas.words([name], 200);
|
|
@@ -55,8 +69,15 @@ export function mentions(text, { scope = [], atlas = null } = {}) {
|
|
|
55
69
|
const piece = atlas.piece(hit.hash);
|
|
56
70
|
const found = piece && pattern.exec(piece.text);
|
|
57
71
|
if (!found) continue;
|
|
58
|
-
const before = piece.text.slice(
|
|
59
|
-
|
|
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
|
+
});
|
|
60
81
|
break;
|
|
61
82
|
}
|
|
62
83
|
if (symbols.length >= MAX_NAMED) break;
|
|
@@ -65,24 +86,65 @@ export function mentions(text, { scope = [], atlas = null } = {}) {
|
|
|
65
86
|
return { files: files.slice(0, MAX_NAMED), symbols };
|
|
66
87
|
}
|
|
67
88
|
|
|
68
|
-
export function createFrames({
|
|
89
|
+
export function createFrames({
|
|
90
|
+
store,
|
|
91
|
+
environment,
|
|
92
|
+
worktrees,
|
|
93
|
+
atlas = () => null,
|
|
94
|
+
scope = () => [],
|
|
95
|
+
log = () => {},
|
|
96
|
+
}) {
|
|
69
97
|
const key = (threadId) => `frame:${threadId}`;
|
|
70
|
-
const get = (threadId) => {
|
|
98
|
+
const get = (threadId) => {
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(store.meta(key(threadId)) || "null");
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
71
105
|
|
|
72
106
|
/** Where a thread's files are: its own worktree while open, else its folder. */
|
|
73
107
|
const placeOf = (thread) => {
|
|
74
108
|
const tree = worktrees.get(thread.id);
|
|
75
|
-
return tree?.state === "open" && tree.path
|
|
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;
|
|
76
118
|
};
|
|
77
119
|
|
|
78
120
|
/** The files a thread is changing: its worktree's diff, or what its runs' receipts say they changed. */
|
|
79
121
|
const changing = (thread) => {
|
|
80
122
|
const tree = worktrees.get(thread.id);
|
|
81
|
-
if (tree?.state === "open") {
|
|
82
|
-
|
|
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
|
+
];
|
|
83
139
|
};
|
|
84
140
|
|
|
85
|
-
const receiptOf = (taskId) => {
|
|
141
|
+
const receiptOf = (taskId) => {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(store.meta(`receipt:${taskId}`) || "null");
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
86
148
|
|
|
87
149
|
/** The frame for a thread, rebuilt from what is on the computer now. pack: the context engine's latest pack, if any. */
|
|
88
150
|
const build = (threadId, { pack = null } = {}) => {
|
|
@@ -93,20 +155,47 @@ export function createFrames({ store, environment, worktrees, atlas = () => null
|
|
|
93
155
|
const previous = get(threadId);
|
|
94
156
|
const place = placeOf(thread);
|
|
95
157
|
// What's asked: the person's own words in the thread, resolved once per change of words.
|
|
96
|
-
const asked = [
|
|
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");
|
|
97
168
|
const wordsHash = createHash("sha1").update(asked).digest("hex");
|
|
98
169
|
let named = previous?.asked?.hash === wordsHash ? previous.asked : null;
|
|
99
170
|
if (!named) {
|
|
100
171
|
const index = atlas();
|
|
101
|
-
named = {
|
|
172
|
+
named = {
|
|
173
|
+
hash: wordsHash,
|
|
174
|
+
...mentions(asked, {
|
|
175
|
+
scope: index && place ? scope(place.folder) : [],
|
|
176
|
+
atlas: index,
|
|
177
|
+
}),
|
|
178
|
+
};
|
|
102
179
|
}
|
|
103
180
|
const repo = place ? environment.repository(place.folder) : null;
|
|
104
|
-
const repository = repo
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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;
|
|
110
199
|
const peers = [];
|
|
111
200
|
if (place?.repo) {
|
|
112
201
|
for (const other of store.goals({ all: false })) {
|
|
@@ -115,16 +204,49 @@ export function createFrames({ store, environment, worktrees, atlas = () => null
|
|
|
115
204
|
if (!there?.repo || there.repo !== place.repo) continue;
|
|
116
205
|
const runs = store.threadRuns(other.id);
|
|
117
206
|
const files = changing(other);
|
|
118
|
-
const running = runs
|
|
207
|
+
const running = runs
|
|
208
|
+
.filter((run) => run.status === "running")
|
|
209
|
+
.map((run) => run.target_agent);
|
|
119
210
|
if (!files.length && !running.length) continue;
|
|
120
|
-
peers.push({
|
|
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
|
+
});
|
|
121
220
|
}
|
|
122
221
|
}
|
|
123
|
-
const checks = store
|
|
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);
|
|
124
231
|
const frame = {
|
|
125
|
-
thread: threadId,
|
|
126
|
-
|
|
127
|
-
|
|
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),
|
|
128
250
|
ms: 0,
|
|
129
251
|
};
|
|
130
252
|
frame.ms = Math.round(performance.now() - started);
|
|
@@ -136,24 +258,76 @@ export function createFrames({ store, environment, worktrees, atlas = () => null
|
|
|
136
258
|
const briefText = (frame, agent) => {
|
|
137
259
|
if (!frame) return "";
|
|
138
260
|
const lines = [];
|
|
139
|
-
const named = [
|
|
140
|
-
|
|
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("; ")}.`);
|
|
141
272
|
const repo = frame.repository;
|
|
142
273
|
if (repo) {
|
|
143
|
-
const changed = repo.changed
|
|
144
|
-
|
|
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
|
+
);
|
|
145
283
|
}
|
|
146
|
-
for (const peer of frame.peers)
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
: "";
|
|
149
297
|
};
|
|
150
298
|
|
|
151
299
|
/** The parts of the frame the coordinator's round reads, in the server's shape (src/server/swarm/coordinator.ts). */
|
|
152
|
-
const roundFacts = (frame) =>
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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;
|
|
157
331
|
|
|
158
332
|
return { get, build, briefText, roundFacts, receiptOf, log };
|
|
159
333
|
}
|