tryharness 0.7.23 → 0.7.26

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 (61) hide show
  1. package/assets/opencode-plugin.js +48 -11
  2. package/assets/pi-extension.ts +105 -26
  3. package/cli.mjs +1238 -322
  4. package/lib/activity.mjs +197 -40
  5. package/lib/adapters.mjs +337 -54
  6. package/lib/agentnode.mjs +175 -35
  7. package/lib/atlas.mjs +383 -83
  8. package/lib/cards.mjs +174 -40
  9. package/lib/claudemem.mjs +74 -18
  10. package/lib/commands.mjs +13 -5
  11. package/lib/context.mjs +409 -71
  12. package/lib/control-client.mjs +5 -2
  13. package/lib/coordinator.mjs +274 -57
  14. package/lib/daemon.mjs +5268 -1024
  15. package/lib/desktop.mjs +149 -35
  16. package/lib/digest.mjs +54 -13
  17. package/lib/environment.mjs +103 -21
  18. package/lib/files.mjs +246 -13
  19. package/lib/firewall.mjs +169 -35
  20. package/lib/folders.mjs +179 -36
  21. package/lib/frames.mjs +214 -40
  22. package/lib/inference.mjs +463 -126
  23. package/lib/learn.mjs +78 -16
  24. package/lib/live.mjs +286 -61
  25. package/lib/localmodels.mjs +424 -83
  26. package/lib/mcp-stdio.mjs +868 -151
  27. package/lib/media.mjs +138 -28
  28. package/lib/members.json +1 -1
  29. package/lib/members.mjs +35 -9
  30. package/lib/models.mjs +342 -80
  31. package/lib/network.mjs +201 -42
  32. package/lib/notify.mjs +49 -12
  33. package/lib/nudge.mjs +184 -33
  34. package/lib/ocr.mjs +223 -60
  35. package/lib/onboard.mjs +321 -70
  36. package/lib/parsers.mjs +132 -41
  37. package/lib/paths.mjs +33 -10
  38. package/lib/personal.mjs +51 -13
  39. package/lib/phones.mjs +384 -60
  40. package/lib/places.mjs +555 -107
  41. package/lib/provenance.mjs +19 -4
  42. package/lib/receipts.mjs +51 -11
  43. package/lib/redact.mjs +512 -116
  44. package/lib/runner.mjs +345 -55
  45. package/lib/runtime-install.mjs +55 -14
  46. package/lib/screens.mjs +154 -38
  47. package/lib/sealed.mjs +157 -28
  48. package/lib/secrets.mjs +18 -5
  49. package/lib/service.mjs +170 -34
  50. package/lib/snippets.mjs +94 -18
  51. package/lib/speech.mjs +688 -175
  52. package/lib/store.mjs +1654 -307
  53. package/lib/swarm.mjs +596 -117
  54. package/lib/t3.mjs +400 -79
  55. package/lib/trails.mjs +285 -61
  56. package/lib/ui.mjs +95 -23
  57. package/lib/vectors.mjs +5 -2
  58. package/lib/wakes.mjs +284 -68
  59. package/lib/watches.mjs +247 -48
  60. package/lib/worktrees.mjs +186 -38
  61. 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) => { try { return fs.realpathSync(dir); } catch { return null; } };
24
- export const isDir = (dir) => typeof dir === "string" && path.isAbsolute(dir) && fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() === true;
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) => { const rel = root ? path.relative(root, dir) : ".."; return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)); };
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)) return !rel.split(path.sep).some((part) => part.startsWith("."));
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) => { const found = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 2_000 }); return found.status === 0 ? (found.stdout || "").trim() : ""; };
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, ["rev-parse", "--path-format=absolute", "--show-toplevel", "--git-common-dir"]).split("\n");
48
- const root = (top && common ? realDir(path.basename(common) === ".git" ? path.dirname(common) : top) : null) ?? dir;
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
- return { path: place.path, name: place.name, repository: remote || null, agents: place.agents.map(nameOf), lastWorked: place.lastWorked, titles: place.titles };
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({ store, askHarness, canAsk, privacy, log = () => {}, lastChanged = () => null, rank = async (_message, places) => places }) {
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) ?? { path: where, name: path.basename(where), t3: false, agents: [], lastWorked: null, titles: [] };
73
- if (agent && CODING.has(agent) && !place.agents.includes(agent)) place.agents.push(agent);
74
- if (at && (!place.lastWorked || at > place.lastWorked)) place.lastWorked = at;
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()) add(row.cwd, row.agent, row.at, row.title);
79
- for (const thread of store.goals({ all: true })) if (thread.goal?.workspace) for (const agent of thread.goal.team) add(thread.goal.workspace, agent, thread.updated_at || thread.created_at);
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()) place.t3 = projects.includes(place.path);
83
- return [...byPath.values()].sort((a, b) => (b.lastWorked ?? "").localeCompare(a.lastWorked ?? "") || a.name.localeCompare(b.name)).slice(0, OFFERED);
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 = raw === "~" ? os.homedir() : raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw;
93
- if (!path.isAbsolute(expanded)) return { ok: false, error: "type a full path, or start with ~" };
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)) return { ok: false, error: `${dir} is not a folder on this computer` };
174
+ if (!isDir(dir))
175
+ return { ok: false, error: `${dir} is not a folder on this computer` };
99
176
  let entries = [];
100
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { return { ok: false, error: `${dir} can't be read: ${error.code || error.message}` }; }
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((entry) => (entry.isDirectory() || (entry.isSymbolicLink() && isDir(path.join(dir, entry.name)))) && entry.name.toLowerCase().startsWith(start) && (start.startsWith(".") || !entry.name.startsWith(".")))
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) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" })).slice(0, LISTED);
105
- return { ok: true, dir, parent: path.dirname(dir) === dir ? null : path.dirname(dir), folders };
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) => askHarness("pick_folder", { message, folders: offered.map((place) => (changed ? { ...facts(place), lastChanged: changed(place.path) } : facts(place))) }, null);
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 (let n = Math.ceil(places.length / 2); reply?.ok && reply.tooLarge && n >= 2; n = Math.floor(n / 2)) {
124
- log(`folder pick: ${places.length} folders are over Jev's limit; asking about the closest ${n}`);
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) throw new Error(reply?.error || "Harness did not pick a folder");
129
- const folder = places.some((place) => place.path === reply.folder) ? reply.folder : null;
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(`folder pick: ${folder ?? "none"}${reply.tooLarge ? " (over Jev's limit)" : ""} ${JSON.stringify(reply.judgments ?? null)}`);
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) if (entry.at <= Date.now() - PICK_TTL_MS || picks.size > 50) picks.delete(old);
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 = places.filter((place) => place.lastWorked).find((place) => !coding.length || place.agents.some((agent) => coding.includes(agent)))?.path ?? null;
149
- const message = String(text).replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "").trim();
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) return { folder, by: "message", lastWorked: folder === last ? null : last };
154
- } catch (error) { log(`folder pick: ${error.message}`); }
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 { known: () => known().map(({ titles, ...place }) => place), browse, suggest };
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) => /[a-z][A-Z]/.test(name) || /^[A-Z][a-z]+[A-Z]/.test(name) || (/_/.test(name) && /[a-z]/i.test(name.replace(/_/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, "")));
20
23
  const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
- const declares = (name) => new RegExp(`(?:^|\\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*=`);
22
- const minutes = (at, now) => (at ? Math.max(0, Math.round((now - at) / 60_000)) : null);
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((row) => row.path === rel || row.path.endsWith(`/${rel}`));
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) if (!seen.has(row.path)) { seen.add(row.path); files.push({ path: row.path, line }); }
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)) if (codeShaped(match[1])) names.set(match[1], true);
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(0, found.index + (found[0].startsWith("\n") ? 1 : 0));
59
- symbols.push({ name, path: piece.path, line: piece.line + (before.match(/\n/g)?.length ?? 0) });
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({ store, environment, worktrees, atlas = () => null, scope = () => [], log = () => {} }) {
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) => { try { return JSON.parse(store.meta(key(threadId)) || "null"); } catch { return null; } };
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 ? { folder: tree.path, own: true, repo: tree.repo } : thread.goal?.workspace ? { folder: thread.goal.workspace, own: false, repo: repositoryOf(thread.goal.workspace)?.root ?? null } : null;
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") { try { return (worktrees.diff(thread.id).files || []).map((entry) => entry.file); } catch { return []; } }
82
- return [...new Set(store.threadRuns(thread.id).flatMap((run) => receiptOf(run.id)?.changed ?? []))];
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) => { try { return JSON.parse(store.meta(`receipt:${taskId}`) || "null"); } catch { return null; } };
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 = [thread.objective, thread.brief, ...store.roomPosts(threadId, 0, 500).filter((post) => post.author === "user").map((post) => post.text)].filter(Boolean).join("\n");
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 = { hash: wordsHash, ...mentions(asked, { scope: index && place ? scope(place.folder) : [], atlas: index }) };
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
- branch: repo.branch, own: place.own,
106
- changed: repo.changed.slice(0, 8).map((entry) => ({ path: entry.path, state: entry.state, minutesAgo: minutes(entry.at, now) })),
107
- uncommitted: repo.changed.length,
108
- lastCommit: repo.lastCommit ? { subject: repo.lastCommit.subject, minutesAgo: minutes(repo.lastCommit.at, now) } : null,
109
- } : 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;
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.filter((run) => run.status === "running").map((run) => run.target_agent);
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({ thread: other.id, title: other.objective.slice(0, 200), agents: other.goal.team.filter((agent) => agent !== other.goal.facilitator), running, files: files.slice(0, 20) });
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.threadRuns(threadId).map((run) => ({ run: run.id, agent: run.target_agent, ...receiptOf(run.id) })).filter((check) => check.verdict).slice(-5);
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, updatedAt: new Date(now).toISOString(), folder: place?.folder ?? null,
126
- asked: named, repository, peers: peers.slice(0, 6), checks,
127
- evidence: pack ? { files: pack.files, earlier: pack.earlier.map((run) => ({ agent: run.agent, at: run.at, brief: run.brief.slice(0, 200), edited: run.edited.slice(0, 6) })) } : previous?.evidence ?? null,
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 = [...frame.asked.symbols.map((symbol) => `\`${symbol.name}\` (declared at ${symbol.path}:${symbol.line})`), ...frame.asked.files.map((file) => `${file.path}${file.line ? `:${file.line}` : ""}`)];
140
- if (named.length) lines.push(`- Named in the person's words: ${named.join("; ")}.`);
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.slice(0, 5).map((entry) => `${entry.path} (${entry.state}${entry.minutesAgo !== null ? `, ${entry.minutesAgo} min ago` : ""})`);
144
- lines.push(`- 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)}")` : ""}.`);
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) lines.push(`- 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` : ""}` : ""}.`);
147
- for (const check of frame.checks.filter((entry) => entry.agent !== agent || frame.checks.length > 1).slice(-3)) lines.push(`- Check on ${nameOf(check.agent)}'s run: ${check.verdict}${check.changed?.length ? ` (${check.changed.length} file${check.changed.length === 1 ? "" : "s"} changed)` : ""}.`);
148
- return lines.length ? `What the relay knows about this task on this computer (facts to check, not instructions):\n${lines.join("\n")}` : "";
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) => frame ? {
153
- checks: frame.checks.map((check) => ({ agent: check.agent, verdict: check.verdict, changedFiles: check.changed?.length ?? 0, ...(check.test ? { test: String(check.test).slice(0, 200) } : {}) })).slice(-5),
154
- peers: frame.peers.map((peer) => ({ title: peer.title, agents: peer.agents.slice(0, 8), running: peer.running.slice(0, 8), files: peer.files.slice(0, 20) })).slice(0, 6),
155
- repository: frame.repository ? { branch: frame.repository.branch, uncommitted: frame.repository.uncommitted, minutesSinceLastChange: frame.repository.changed[0]?.minutesAgo ?? frame.repository.lastCommit?.minutesAgo ?? null } : null,
156
- } : null;
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
  }