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.
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 +5248 -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 +382 -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/context.mjs CHANGED
@@ -1,9 +1,26 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
- import { openAtlas, treeFiles, addFiles, embedMissing, searchFiles, ATLAS_VERSION } from "./atlas.mjs";
4
+ import {
5
+ openAtlas,
6
+ treeFiles,
7
+ addFiles,
8
+ embedMissing,
9
+ searchFiles,
10
+ ATLAS_VERSION,
11
+ } from "./atlas.mjs";
5
12
  import { readTrail, needs, trailFromTranscript } from "./trails.mjs";
6
- import { featureRows, swarmPrior, baselineScore, train, modelScore, recallAt, loadModel, saveModel, codeFiles } from "./learn.mjs";
13
+ import {
14
+ featureRows,
15
+ swarmPrior,
16
+ baselineScore,
17
+ train,
18
+ modelScore,
19
+ recallAt,
20
+ loadModel,
21
+ saveModel,
22
+ codeFiles,
23
+ } from "./learn.mjs";
7
24
  import { repositoryOf } from "./worktrees.mjs";
8
25
  import { createMediaIndex } from "./media.mjs";
9
26
 
@@ -28,48 +45,127 @@ const MEDIA_FLOOR = 0.7;
28
45
  const MEDIA_IN_PACK = 2;
29
46
  const MIN_TRAINING_RUNS = 30;
30
47
 
31
- const git = (cwd, args) => { const out = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 1 << 28 }); return out.status === 0 ? out.stdout : ""; };
32
- const commitBefore = (root, at) => git(root, ["rev-list", "-1", `--before=${new Date(at).toISOString()}`, "HEAD"]).trim() || null;
48
+ const git = (cwd, args) => {
49
+ const out = spawnSync("git", args, {
50
+ cwd,
51
+ encoding: "utf8",
52
+ maxBuffer: 1 << 28,
53
+ });
54
+ return out.status === 0 ? out.stdout : "";
55
+ };
56
+ const commitBefore = (root, at) =>
57
+ git(root, [
58
+ "rev-list",
59
+ "-1",
60
+ `--before=${new Date(at).toISOString()}`,
61
+ "HEAD",
62
+ ]).trim() || null;
33
63
  // Files changed in the two weeks before a commit, by how often; kept per commit, since a commit's history is fixed.
34
64
  const recentByCommit = new Map();
35
65
  export function recentChanges(root, commit = "HEAD") {
36
66
  const sha = git(root, ["rev-parse", commit]).trim();
37
67
  if (recentByCommit.has(sha)) return recentByCommit.get(sha);
38
68
  const counts = new Map();
39
- const since = new Date((Number(git(root, ["show", "-s", "--format=%ct", sha]).trim()) - 14 * 86_400) * 1000).toISOString();
40
- for (const file of git(root, ["log", `--since=${since}`, "--name-only", "--format=", sha]).split("\n")) if (file) counts.set(file, (counts.get(file) ?? 0) + 1);
69
+ const since = new Date(
70
+ (Number(git(root, ["show", "-s", "--format=%ct", sha]).trim()) -
71
+ 14 * 86_400) *
72
+ 1000
73
+ ).toISOString();
74
+ for (const file of git(root, [
75
+ "log",
76
+ `--since=${since}`,
77
+ "--name-only",
78
+ "--format=",
79
+ sha,
80
+ ]).split("\n"))
81
+ if (file) counts.set(file, (counts.get(file) ?? 0) + 1);
41
82
  if (recentByCommit.size > 500) recentByCommit.clear();
42
83
  recentByCommit.set(sha, counts);
43
84
  return counts;
44
85
  }
45
86
  const days = (ms) => Math.round(ms / 86_400_000);
46
- const AGENT_NAMES = { "claude-code": "Claude Code", codex: "Codex", pi: "Pi", opencode: "OpenCode" };
87
+ const AGENT_NAMES = {
88
+ "claude-code": "Claude Code",
89
+ codex: "Codex",
90
+ pi: "Pi",
91
+ opencode: "OpenCode",
92
+ };
47
93
 
48
94
  /**
49
95
  * Ranks a snapshot's files for a request with every signal the engine has: [{ path, best, score, x }] best first.
50
96
  * history: earlier runs in this repository ({ at, edited, read }); model: the learned ranker or null for the default.
51
97
  */
52
- export async function rankFiles({ atlas, inference, query, scope, history = [], at = Date.now(), recent = new Map(), model = null, vectors = null, lane = "handoff", wordLimit }) {
53
- const candidates = await searchFiles(atlas, inference, query, scope, { vectors, lane, wordLimit });
98
+ export async function rankFiles({
99
+ atlas,
100
+ inference,
101
+ query,
102
+ scope,
103
+ history = [],
104
+ at = Date.now(),
105
+ recent = new Map(),
106
+ model = null,
107
+ vectors = null,
108
+ lane = "handoff",
109
+ wordLimit,
110
+ }) {
111
+ const candidates = await searchFiles(atlas, inference, query, scope, {
112
+ vectors,
113
+ lane,
114
+ wordLimit,
115
+ });
54
116
  const topWords = Math.max(...candidates.map((c) => c.lexical), 0) || 1;
55
117
  const hybrid = (c) => c.dense + 0.25 * (c.lexical / topWords) + 0.2 * c.named;
56
118
  const byHybrid = [...candidates].sort((a, b) => hybrid(b) - hybrid(a));
57
119
  const prior = swarmPrior(history, at);
58
120
  // No cross-encoder pass: on the owner's own sessions it moved recall by under one point (context-recall v3).
59
- const byDense = [...candidates].sort((a, b) => b.dense - a.dense).slice(0, POOL).map((c) => c.path);
60
- const byWords = [...candidates].sort((a, b) => b.lexical - a.lexical).slice(0, POOL).map((c) => c.path);
61
- const pool = new Set([...byDense, ...byWords, ...byHybrid.slice(0, POOL).map((c) => c.path), ...candidates.filter((c) => c.named > 0).map((c) => c.path),
62
- ...[...prior.edits.entries()].sort((a, b) => b[1] - a[1]).slice(0, POOL).map(([file]) => file)]);
121
+ const byDense = [...candidates]
122
+ .sort((a, b) => b.dense - a.dense)
123
+ .slice(0, POOL)
124
+ .map((c) => c.path);
125
+ const byWords = [...candidates]
126
+ .sort((a, b) => b.lexical - a.lexical)
127
+ .slice(0, POOL)
128
+ .map((c) => c.path);
129
+ const pool = new Set([
130
+ ...byDense,
131
+ ...byWords,
132
+ ...byHybrid.slice(0, POOL).map((c) => c.path),
133
+ ...candidates.filter((c) => c.named > 0).map((c) => c.path),
134
+ ...[...prior.edits.entries()]
135
+ .sort((a, b) => b[1] - a[1])
136
+ .slice(0, POOL)
137
+ .map(([file]) => file),
138
+ ]);
63
139
  const best = new Map(candidates.map((c) => [c.path, c.best]));
64
- const scored = featureRows(candidates.filter((c) => pool.has(c.path)), { prior, recent })
65
- .map((row) => ({ ...row, best: best.get(row.path), score: model ? modelScore(model, row.x) : baselineScore(row.x) }));
140
+ const scored = featureRows(
141
+ candidates.filter((c) => pool.has(c.path)),
142
+ { prior, recent }
143
+ ).map((row) => ({
144
+ ...row,
145
+ best: best.get(row.path),
146
+ score: model ? modelScore(model, row.x) : baselineScore(row.x),
147
+ }));
66
148
  return scored.sort((a, b) => b.score - a.score);
67
149
  }
68
150
 
69
- export function createContextEngine({ store, inference, home, known, mediaFolders = () => [], mediaReady = () => false, log = () => {}, notify = () => {} }) {
151
+ export function createContextEngine({
152
+ store,
153
+ inference,
154
+ home,
155
+ known,
156
+ mediaFolders = () => [],
157
+ mediaReady = () => false,
158
+ log = () => {},
159
+ notify = () => {},
160
+ }) {
70
161
  const atlas = openAtlas(path.join(home, "atlas.db"));
71
162
  // Recordings and screenshots in folders the person added (media.mjs), once the image and audio part is here.
72
- const media = createMediaIndex({ atlas, inference, folders: mediaFolders, log });
163
+ const media = createMediaIndex({
164
+ atlas,
165
+ inference,
166
+ folders: mediaFolders,
167
+ log,
168
+ });
73
169
  const modelPath = path.join(home, "ranker.json");
74
170
  let model = loadModel(modelPath);
75
171
  let refreshing = null;
@@ -79,8 +175,12 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
79
175
  /** The vectors for a scope's pieces, read from the atlas once and kept until packs stop for a while. */
80
176
  const vectorsFor = (scope) => {
81
177
  const wanted = new Set();
82
- for (const row of scope) for (const hash of atlas.blob(row.blob) ?? []) if (!cached.has(hash)) wanted.add(hash);
83
- if (wanted.size) for (const [hash, vector] of atlas.vectors(wanted, ATLAS_VERSION)) cached.set(hash, vector);
178
+ for (const row of scope)
179
+ for (const hash of atlas.blob(row.blob) ?? [])
180
+ if (!cached.has(hash)) wanted.add(hash);
181
+ if (wanted.size)
182
+ for (const [hash, vector] of atlas.vectors(wanted, ATLAS_VERSION))
183
+ cached.set(hash, vector);
84
184
  clearTimeout(dropCache);
85
185
  dropCache = setTimeout(() => cached.clear(), VECTORS_IDLE_MS);
86
186
  dropCache.unref?.();
@@ -91,12 +191,25 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
91
191
  const placeOf = (folder) => {
92
192
  const repo = repositoryOf(folder);
93
193
  if (!repo) return null;
94
- const common = git(repo.root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]).trim();
95
- const root = common && path.basename(common) === ".git" ? path.dirname(common) : repo.root;
194
+ const common = git(repo.root, [
195
+ "rev-parse",
196
+ "--path-format=absolute",
197
+ "--git-common-dir",
198
+ ]).trim();
199
+ const root =
200
+ common && path.basename(common) === ".git"
201
+ ? path.dirname(common)
202
+ : repo.root;
96
203
  return { root, top: repo.root };
97
204
  };
98
205
 
99
- const roots = () => [...new Set(known().map((place) => placeOf(place.path)?.root).filter(Boolean))];
206
+ const roots = () => [
207
+ ...new Set(
208
+ known()
209
+ .map((place) => placeOf(place.path)?.root)
210
+ .filter(Boolean)
211
+ ),
212
+ ];
100
213
 
101
214
  /** Brings one checkout's files into the atlas; returns its files. Pieces are embedded later on the background lane. */
102
215
  const indexTree = (top, commit = null) => {
@@ -108,7 +221,9 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
108
221
  /** Records every coding session that ran in a known repository: what it was asked and what it needed. */
109
222
  const recordRuns = async () => {
110
223
  const rootList = roots();
111
- const since = new Date(Date.now() - HISTORY_DAYS * 86_400_000).toISOString();
224
+ const since = new Date(
225
+ Date.now() - HISTORY_DAYS * 86_400_000
226
+ ).toISOString();
112
227
  const trees = new Map();
113
228
  let added = 0;
114
229
  for (const session of store.sessionSources({ since })) {
@@ -116,16 +231,50 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
116
231
  if (!place || !rootList.includes(place.root)) continue;
117
232
  // A session whose source file the agent has since deleted is read from the relay's own copy, once.
118
233
  let mtime = 0;
119
- try { mtime = fs.statSync(session.source).mtimeMs; } catch {}
234
+ try {
235
+ mtime = fs.statSync(session.source).mtimeMs;
236
+ } catch {}
120
237
  if (atlas.runSeen(session.agent, session.session_id) === mtime) continue;
121
- const trail = mtime ? await readTrail(session.agent, session.source)
122
- : trailFromTranscript(session.agent, store.sessionTranscript(session.agent, session.session_id), { cwd: session.cwd, startedAt: Date.parse(session.started_at) || null });
238
+ const trail = mtime
239
+ ? await readTrail(session.agent, session.source)
240
+ : trailFromTranscript(
241
+ session.agent,
242
+ store.sessionTranscript(session.agent, session.session_id),
243
+ {
244
+ cwd: session.cwd,
245
+ startedAt: Date.parse(session.started_at) || null,
246
+ }
247
+ );
123
248
  if (!trail?.brief || trail.startedAt === null) continue;
124
249
  const commit = commitBefore(place.root, trail.startedAt);
125
250
  if (!commit) continue;
126
- if (!trees.has(commit)) trees.set(commit, new Set(treeFiles(place.root, commit).map((row) => row.path)));
127
- const need = needs({ ...trail, cwd: trail.cwd && placeOf(trail.cwd)?.top === place.top ? path.join(place.root, path.relative(place.top, trail.cwd)) : trail.cwd }, place.root, trees.get(commit));
128
- atlas.putRun({ agent: session.agent, sessionId: session.session_id, root: place.root, at: trail.startedAt, brief: trail.brief, edited: need.edited, read: need.read, before: need.beforeFirstEdit, sourceMtime: mtime });
251
+ if (!trees.has(commit))
252
+ trees.set(
253
+ commit,
254
+ new Set(treeFiles(place.root, commit).map((row) => row.path))
255
+ );
256
+ const need = needs(
257
+ {
258
+ ...trail,
259
+ cwd:
260
+ trail.cwd && placeOf(trail.cwd)?.top === place.top
261
+ ? path.join(place.root, path.relative(place.top, trail.cwd))
262
+ : trail.cwd,
263
+ },
264
+ place.root,
265
+ trees.get(commit)
266
+ );
267
+ atlas.putRun({
268
+ agent: session.agent,
269
+ sessionId: session.session_id,
270
+ root: place.root,
271
+ at: trail.startedAt,
272
+ brief: trail.brief,
273
+ edited: need.edited,
274
+ read: need.read,
275
+ before: need.beforeFirstEdit,
276
+ sourceMtime: mtime,
277
+ });
129
278
  added += 1;
130
279
  }
131
280
  return added;
@@ -135,8 +284,17 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
135
284
  for (;;) {
136
285
  const rows = atlas.runsToEmbed(ATLAS_VERSION, 16);
137
286
  if (!rows.length) return;
138
- const vectors = await inference.embed(rows.map((row) => ({ kind: "document", title: "none", text: row.brief })), { lane });
139
- rows.forEach((row, i) => atlas.putRunVector(row.agent, row.session_id, ATLAS_VERSION, vectors[i]));
287
+ const vectors = await inference.embed(
288
+ rows.map((row) => ({
289
+ kind: "document",
290
+ title: "none",
291
+ text: row.brief,
292
+ })),
293
+ { lane }
294
+ );
295
+ rows.forEach((row, i) =>
296
+ atlas.putRunVector(row.agent, row.session_id, ATLAS_VERSION, vectors[i])
297
+ );
140
298
  }
141
299
  };
142
300
 
@@ -147,7 +305,8 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
147
305
  // A first pass over a repository waits for the charger; small catch-ups run on battery too unless it is low.
148
306
  if (power.paused()) return { skipped: "power" };
149
307
  const first = !atlas.roots().length;
150
- if (first && !power.onCharger()) return { skipped: "waiting for the charger" };
308
+ if (first && !power.onCharger())
309
+ return { skipped: "waiting for the charger" };
151
310
  let files = 0;
152
311
  for (const root of roots()) {
153
312
  const rows = indexTree(root);
@@ -155,15 +314,30 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
155
314
  files += rows.length;
156
315
  }
157
316
  const runs = await recordRuns();
158
- const pieces = await embedMissing(atlas, inference, { lane: "background" });
317
+ const pieces = await embedMissing(atlas, inference, {
318
+ lane: "background",
319
+ });
159
320
  await embedRuns("background");
160
- const recorded = mediaReady() && mediaFolders().length && power.onCharger() ? await media.refresh({ lane: "background" }) : null;
321
+ const recorded =
322
+ mediaReady() && mediaFolders().length && power.onCharger()
323
+ ? await media.refresh({ lane: "background" })
324
+ : null;
161
325
  return { files, runs, pieces, ...(recorded ? { media: recorded } : {}) };
162
- })().catch((error) => { log(`context engine: ${error.message}`); return { error: error.message }; }).finally(() => { refreshing = null; });
326
+ })()
327
+ .catch((error) => {
328
+ log(`context engine: ${error.message}`);
329
+ return { error: error.message };
330
+ })
331
+ .finally(() => {
332
+ refreshing = null;
333
+ });
163
334
  return refreshing;
164
335
  };
165
336
 
166
- const historyFor = (root) => atlas.runs(root).map((run) => ({ at: run.at, edited: run.edited, read: run.read }));
337
+ const historyFor = (root) =>
338
+ atlas
339
+ .runs(root)
340
+ .map((run) => ({ at: run.at, edited: run.edited, read: run.read }));
167
341
 
168
342
  /**
169
343
  * The context pack for a brief going to a local agent working in folder: { files, earlier, text }. Files are the most
@@ -177,10 +351,21 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
177
351
  // A few changed pieces are embedded now; a repository the relay has not indexed yet is searched by words and names
178
352
  // this time while the background pass embeds it.
179
353
  const missing = atlas.missing(ATLAS_VERSION);
180
- if (missing && missing <= 400) await embedMissing(atlas, inference, { lane });
354
+ if (missing && missing <= 400)
355
+ await embedMissing(atlas, inference, { lane });
181
356
  else if (missing) void refresh();
182
357
  const history = historyFor(place.root);
183
- const ranked = await rankFiles({ atlas, inference, query: String(brief).slice(0, 4_000), scope, history, recent: recentChanges(place.top), model, lane, vectors: vectorsFor(scope) });
358
+ const ranked = await rankFiles({
359
+ atlas,
360
+ inference,
361
+ query: String(brief).slice(0, 4_000),
362
+ scope,
363
+ history,
364
+ recent: recentChanges(place.top),
365
+ model,
366
+ lane,
367
+ vectors: vectorsFor(scope),
368
+ });
184
369
  const prior = swarmPrior(history, Date.now());
185
370
  const files = ranked.slice(0, PACK_FILES).map((row) => {
186
371
  const piece = row.best ? atlas.piece(row.best) : null;
@@ -188,30 +373,86 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
188
373
  if (row.x.named) why.push("named in the request");
189
374
  if (row.x.lexical > 0.5) why.push("shares the request's words");
190
375
  if (prior.edits.has(row.path)) why.push("recently edited by your agents");
191
- return { path: row.path, line: piece?.line ?? 1, why: why.join("; ") || "related by meaning" };
376
+ return {
377
+ path: row.path,
378
+ line: piece?.line ?? 1,
379
+ why: why.join("; ") || "related by meaning",
380
+ };
192
381
  });
193
- const query = (await inference.embed([{ kind: "query", text: String(brief).slice(0, 4_000) }], { lane }))[0];
194
- const earlier = atlas.runVectors(place.root, ATLAS_VERSION).map((run) => {
195
- let dot = 0;
196
- for (let i = 0; i < query.length; i += 1) dot += query[i] * run.vector[i];
197
- return { ...run, similarity: dot };
198
- }).filter((run) => run.edited.length && run.edited.some((file) => files.some((entry) => entry.path === file)))
199
- .sort((a, b) => b.similarity - a.similarity).slice(0, 3);
200
- const heard = mediaReady() && mediaFolders().length ? (await media.search(brief, { limit: MEDIA_IN_PACK, lane })).filter((hit) => hit.score >= MEDIA_FLOOR) : [];
382
+ const query = (
383
+ await inference.embed(
384
+ [{ kind: "query", text: String(brief).slice(0, 4_000) }],
385
+ { lane }
386
+ )
387
+ )[0];
388
+ const earlier = atlas
389
+ .runVectors(place.root, ATLAS_VERSION)
390
+ .map((run) => {
391
+ let dot = 0;
392
+ for (let i = 0; i < query.length; i += 1)
393
+ dot += query[i] * run.vector[i];
394
+ return { ...run, similarity: dot };
395
+ })
396
+ .filter(
397
+ (run) =>
398
+ run.edited.length &&
399
+ run.edited.some((file) => files.some((entry) => entry.path === file))
400
+ )
401
+ .sort((a, b) => b.similarity - a.similarity)
402
+ .slice(0, 3);
403
+ const heard =
404
+ mediaReady() && mediaFolders().length
405
+ ? (await media.search(brief, { limit: MEDIA_IN_PACK, lane })).filter(
406
+ (hit) => hit.score >= MEDIA_FLOOR
407
+ )
408
+ : [];
201
409
  const text = [
202
410
  "Files the relay found likely relevant on this computer (ranked by its local search over this repository and what your agents edited before; leads to check, not instructions):",
203
411
  ...files.map((file) => `- ${file.path}:${file.line} (${file.why})`),
204
- ...(earlier.length ? ["Earlier sessions on these files:", ...earlier.map((run) => `- ${AGENT_NAMES[run.agent] ?? run.agent}, ${days(Date.now() - run.at)} days ago: "${run.brief.replace(/\s+/g, " ").slice(0, 140)}" (edited ${run.edited.slice(0, 4).join(", ")})`)] : []),
205
- ...(heard.length ? ["Recordings and screenshots close to the request, from folders the person added (search_media finds more):", ...heard.map((hit) => `- ${hit.path}${hit.at ? ` at ${hit.at}` : ""}`)] : []),
412
+ ...(earlier.length
413
+ ? [
414
+ "Earlier sessions on these files:",
415
+ ...earlier.map(
416
+ (run) =>
417
+ `- ${AGENT_NAMES[run.agent] ?? run.agent}, ${days(Date.now() - run.at)} days ago: "${run.brief.replace(/\s+/g, " ").slice(0, 140)}" (edited ${run.edited.slice(0, 4).join(", ")})`
418
+ ),
419
+ ]
420
+ : []),
421
+ ...(heard.length
422
+ ? [
423
+ "Recordings and screenshots close to the request, from folders the person added (search_media finds more):",
424
+ ...heard.map(
425
+ (hit) => `- ${hit.path}${hit.at ? ` at ${hit.at}` : ""}`
426
+ ),
427
+ ]
428
+ : []),
206
429
  ].join("\n");
207
- return { files, earlier: earlier.map(({ vector, ...run }) => run), media: heard, text, searched: scope.length, ms: Math.round(performance.now() - started), model: model ? "learned" : "default" };
430
+ return {
431
+ files,
432
+ earlier: earlier.map(({ vector, ...run }) => run),
433
+ media: heard,
434
+ text,
435
+ searched: scope.length,
436
+ ms: Math.round(performance.now() - started),
437
+ model: model ? "learned" : "default",
438
+ };
208
439
  };
209
440
 
210
441
  /** Search for the agents' tool: files and earlier sessions in a repository, best first. */
211
442
  const search = async ({ query, folder, limit = 10 }) => {
212
443
  const result = await pack({ brief: query, folder, lane: "interactive" });
213
- if (!result) return { ok: false, error: "that folder is not in a git repository the relay indexes" };
214
- return { ok: true, files: result.files.slice(0, limit), earlier: result.earlier, model: result.model, ms: result.ms };
444
+ if (!result)
445
+ return {
446
+ ok: false,
447
+ error: "that folder is not in a git repository the relay indexes",
448
+ };
449
+ return {
450
+ ok: true,
451
+ files: result.files.slice(0, limit),
452
+ earlier: result.earlier,
453
+ model: result.model,
454
+ ms: result.ms,
455
+ };
215
456
  };
216
457
 
217
458
  /**
@@ -220,56 +461,126 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
220
461
  */
221
462
  const learn = async ({ force = false } = {}) => {
222
463
  const last = Number(atlas.meta("learned_at") || 0);
223
- if (!force && (Date.now() - last < LEARN_EVERY_MS || !inference.power.onCharger() || inference.power.paused())) return null;
464
+ if (
465
+ !force &&
466
+ (Date.now() - last < LEARN_EVERY_MS ||
467
+ !inference.power.onCharger() ||
468
+ inference.power.paused())
469
+ )
470
+ return null;
224
471
  await refresh();
225
472
  const report = { at: new Date().toISOString(), repositories: [] };
226
473
  const rows = [];
227
474
  for (const root of roots()) {
228
- const runs = atlas.runs(root, Date.now() - HISTORY_DAYS * 86_400_000).filter((run) => run.edited.length);
475
+ const runs = atlas
476
+ .runs(root, Date.now() - HISTORY_DAYS * 86_400_000)
477
+ .filter((run) => run.edited.length);
229
478
  if (!runs.length) continue;
230
479
  const snapshots = new Map();
231
480
  for (const run of runs) {
232
481
  const commit = commitBefore(root, run.at);
233
482
  if (!commit) continue;
234
- if (!snapshots.has(commit)) snapshots.set(commit, indexTree(root, commit));
483
+ if (!snapshots.has(commit))
484
+ snapshots.set(commit, indexTree(root, commit));
235
485
  rows.push({ root, run, commit });
236
486
  }
237
- report.repositories.push({ root, runs: runs.length, snapshots: snapshots.size });
487
+ report.repositories.push({
488
+ root,
489
+ runs: runs.length,
490
+ snapshots: snapshots.size,
491
+ });
238
492
  }
239
493
  await embedMissing(atlas, inference, { lane: "background" });
240
494
  rows.sort((a, b) => a.run.at - b.run.at);
241
- if (rows.length < MIN_TRAINING_RUNS) { atlas.meta("learned_at", Date.now()); return { ...report, kept: false, why: `only ${rows.length} runs so far` }; }
495
+ if (rows.length < MIN_TRAINING_RUNS) {
496
+ atlas.meta("learned_at", Date.now());
497
+ return { ...report, kept: false, why: `only ${rows.length} runs so far` };
498
+ }
242
499
  const featured = [];
243
500
  for (const { root, run, commit } of rows) {
244
501
  const history = historyFor(root).filter((other) => other.at < run.at);
245
- const ranked = await rankFiles({ atlas, inference, query: run.brief, scope: treeFiles(root, commit), history, at: run.at, recent: recentChanges(root, commit), model: null, lane: "background" });
502
+ const ranked = await rankFiles({
503
+ atlas,
504
+ inference,
505
+ query: run.brief,
506
+ scope: treeFiles(root, commit),
507
+ history,
508
+ at: run.at,
509
+ recent: recentChanges(root, commit),
510
+ model: null,
511
+ lane: "background",
512
+ });
246
513
  featured.push({ run, ranked });
247
514
  }
248
515
  const cut = Math.floor(featured.length * (1 - HELD_OUT_SHARE));
249
- const label = (run, row) => (codeFiles(run.edited).includes(row.path) ? 1 : 0);
250
- const trainRows = featured.slice(0, cut).flatMap(({ run, ranked }) => ranked.map((row) => ({ x: row.x, y: label(run, row) })));
516
+ const label = (run, row) =>
517
+ codeFiles(run.edited).includes(row.path) ? 1 : 0;
518
+ const trainRows = featured
519
+ .slice(0, cut)
520
+ .flatMap(({ run, ranked }) =>
521
+ ranked.map((row) => ({ x: row.x, y: label(run, row) }))
522
+ );
251
523
  const candidate = train(trainRows);
252
- const heldOut = featured.slice(cut).filter(({ run }) => codeFiles(run.edited).length);
253
- const recall = (score) => heldOut.reduce((sum, { run, ranked }) => sum + recallAt([...ranked].sort((a, b) => score(b.x) - score(a.x)).map((row) => row.path), codeFiles(run.edited), 10), 0) / heldOut.length;
254
- const result = { default: recall(baselineScore), current: model ? recall((x) => modelScore(model, x)) : null, candidate: recall((x) => modelScore(candidate, x)) };
524
+ const heldOut = featured
525
+ .slice(cut)
526
+ .filter(({ run }) => codeFiles(run.edited).length);
527
+ const recall = (score) =>
528
+ heldOut.reduce(
529
+ (sum, { run, ranked }) =>
530
+ sum +
531
+ recallAt(
532
+ [...ranked]
533
+ .sort((a, b) => score(b.x) - score(a.x))
534
+ .map((row) => row.path),
535
+ codeFiles(run.edited),
536
+ 10
537
+ ),
538
+ 0
539
+ ) / heldOut.length;
540
+ const result = {
541
+ default: recall(baselineScore),
542
+ current: model ? recall((x) => modelScore(model, x)) : null,
543
+ candidate: recall((x) => modelScore(candidate, x)),
544
+ };
255
545
  const bar = Math.max(result.default, result.current ?? 0);
256
546
  const kept = result.candidate > bar;
257
547
  if (kept) {
258
548
  // Retrained on every run before it ships; the held-out score is the one reported.
259
- model = { ...train(featured.flatMap(({ run, ranked }) => ranked.map((row) => ({ x: row.x, y: label(run, row) })))), trainedAt: report.at, heldOut: result, runs: featured.length };
549
+ model = {
550
+ ...train(
551
+ featured.flatMap(({ run, ranked }) =>
552
+ ranked.map((row) => ({ x: row.x, y: label(run, row) }))
553
+ )
554
+ ),
555
+ trainedAt: report.at,
556
+ heldOut: result,
557
+ runs: featured.length,
558
+ };
260
559
  saveModel(modelPath, model);
261
560
  }
262
561
  atlas.meta("learned_at", Date.now());
263
- const full = { ...report, runs: featured.length, heldOutRuns: heldOut.length, recallAt10: result, kept };
562
+ const full = {
563
+ ...report,
564
+ runs: featured.length,
565
+ heldOutRuns: heldOut.length,
566
+ recallAt10: result,
567
+ kept,
568
+ };
264
569
  atlas.meta("last_learning", JSON.stringify(full));
265
- if (kept) notify(`Learned from ${featured.length} runs overnight: the files your agents went on to change are now in their briefs' top ten ${Math.round(result.candidate * 100)}% of the time (was ${Math.round(bar * 100)}%).`);
570
+ if (kept)
571
+ notify(
572
+ `Learned from ${featured.length} runs overnight: the files your agents went on to change are now in their briefs' top ten ${Math.round(result.candidate * 100)}% of the time (was ${Math.round(bar * 100)}%).`
573
+ );
266
574
  return full;
267
575
  };
268
576
 
269
577
  return {
270
578
  atlas,
271
579
  /** A folder's files as the atlas holds them now ([{ path, blob }]), indexed first; [] outside a repository. */
272
- scope: (folder) => { const place = folder && placeOf(folder); return place ? indexTree(place.top) : []; },
580
+ scope: (folder) => {
581
+ const place = folder && placeOf(folder);
582
+ return place ? indexTree(place.top) : [];
583
+ },
273
584
  pack,
274
585
  search,
275
586
  /** Recordings' minutes and screenshots closest to a question, for an agent's search_media. */
@@ -277,14 +588,41 @@ export function createContextEngine({ store, inference, home, known, mediaFolder
277
588
  refreshMedia: () => media.refresh({ lane: "background" }),
278
589
  refresh,
279
590
  learn,
280
- report: () => { try { return JSON.parse(atlas.meta("last_learning") || "null"); } catch { return null; } },
281
- status: () => ({ roots: atlas.roots(), pieces: { waiting: atlas.missing(ATLAS_VERSION) }, media: media.status(), model: model ? { trainedAt: model.trainedAt, heldOut: model.heldOut, runs: model.runs } : null, device: inference.device() }),
591
+ report: () => {
592
+ try {
593
+ return JSON.parse(atlas.meta("last_learning") || "null");
594
+ } catch {
595
+ return null;
596
+ }
597
+ },
598
+ status: () => ({
599
+ roots: atlas.roots(),
600
+ pieces: { waiting: atlas.missing(ATLAS_VERSION) },
601
+ media: media.status(),
602
+ model: model
603
+ ? {
604
+ trainedAt: model.trainedAt,
605
+ heldOut: model.heldOut,
606
+ runs: model.runs,
607
+ }
608
+ : null,
609
+ device: inference.device(),
610
+ }),
282
611
  start() {
283
- const tick = () => { void refresh().then(() => learn()).catch((error) => log(`context engine: ${error.message}`)); };
612
+ const tick = () => {
613
+ void refresh()
614
+ .then(() => learn())
615
+ .catch((error) => log(`context engine: ${error.message}`));
616
+ };
284
617
  setTimeout(tick, 60_000).unref?.();
285
618
  timer = setInterval(tick, REFRESH_MS);
286
619
  timer.unref?.();
287
620
  },
288
- stop() { clearInterval(timer); clearTimeout(dropCache); cached.clear(); atlas.close(); },
621
+ stop() {
622
+ clearInterval(timer);
623
+ clearTimeout(dropCache);
624
+ cached.clear();
625
+ atlas.close();
626
+ },
289
627
  };
290
628
  }
@@ -19,8 +19,11 @@ export function daemonRequest(message, timeoutMs = 5_000) {
19
19
  buffer += chunk;
20
20
  const newline = buffer.indexOf("\n");
21
21
  if (newline < 0) return;
22
- try { finish(null, JSON.parse(buffer.slice(0, newline))); }
23
- catch { finish(new Error("daemon returned invalid JSON")); }
22
+ try {
23
+ finish(null, JSON.parse(buffer.slice(0, newline)));
24
+ } catch {
25
+ finish(new Error("daemon returned invalid JSON"));
26
+ }
24
27
  });
25
28
  socket.on("timeout", () => finish(new Error("daemon request timed out")));
26
29
  socket.on("error", (error) => finish(error));