tryharness 0.7.22 → 0.7.23

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/lib/digest.mjs ADDED
@@ -0,0 +1,40 @@
1
+ import { nameOf } from "./members.mjs";
2
+
3
+ // "While you were away": what happened on the board since a moment, built by code from the board's own records (no
4
+ // model writes it, so every name, number and verdict in it is one the board holds). Threads with finished work and what
5
+ // each run's own session shows, questions waiting for the person, posts the guard held, watches that went off, and what
6
+ // the overnight learner kept. The buddy shows it when the person comes back.
7
+
8
+ const MAX_THREADS = 6;
9
+ const clip = (text, length) => { const flat = String(text || "").replace(/\s+/g, " ").trim(); return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat; };
10
+
11
+ /** { since, lines, threads, empty }: lines are plain sentences, threads their ids for opening them. */
12
+ export function digestSince(store, since, { alerts = [], learning = null, receiptOf = () => null } = {}) {
13
+ const lines = [];
14
+ const threads = [];
15
+ for (const thread of store.goals({ all: true })) {
16
+ if (thread.updated_at <= since) continue;
17
+ const posts = store.roomPosts(thread.id, 0, 500).filter((post) => post.created_at > since);
18
+ const results = posts.filter((post) => post.author !== "user" && post.author !== thread.goal.facilitator && (post.kind === "post" || post.kind === "result"));
19
+ const questions = posts.filter((post) => post.kind === "question");
20
+ const held = store.heldCount(thread.id, since);
21
+ const runs = store.threadRuns(thread.id).filter((run) => run.reported_at && run.reported_at > since);
22
+ if (!results.length && !questions.length && !runs.length) continue;
23
+ const parts = [];
24
+ const finished = runs.filter((run) => run.status === "done"), failed = runs.filter((run) => run.status === "failed");
25
+ if (finished.length) parts.push(`${[...new Set(finished.map((run) => nameOf(run.target_agent)))].join(" and ")} finished ${finished.length === 1 ? "a turn" : `${finished.length} turns`}`);
26
+ if (failed.length) parts.push(`${failed.length} failed`);
27
+ const checks = runs.map((run) => ({ agent: run.target_agent, ...receiptOf(run.id) })).filter((check) => check.verdict && check.verdict !== "no changes");
28
+ const untested = checks.filter((check) => check.verdict !== "tested");
29
+ if (checks.length) parts.push(untested.length ? `${untested.map((check) => `${nameOf(check.agent)}: ${check.verdict}`).join("; ")}` : "its changes were tested");
30
+ if (questions.length) parts.push(`a question waits for you: "${clip(questions.at(-1).text, 90)}"`);
31
+ if (held) parts.push(`${held} post${held === 1 ? "" : "s"} held because ${held === 1 ? "it reads" : "they read"} like orders to your agents`);
32
+ if (!finished.length && !failed.length && results.length) parts.unshift(`${[...new Set(results.map((post) => nameOf(post.author)))].join(" and ")} answered`);
33
+ lines.push(`"${clip(thread.objective, 60)}": ${parts.join("; ")}.`);
34
+ threads.push(thread.id);
35
+ if (threads.length >= MAX_THREADS) break;
36
+ }
37
+ for (const alert of alerts) lines.push(`Watch: ${alert.text}`);
38
+ if (learning?.kept && learning.at > since) lines.push(`Learned from ${learning.runs} runs: briefs now hold the files agents went on to change ${Math.round(learning.recallAt10.candidate * 100)}% of the time (was ${Math.round(Math.max(learning.recallAt10.default, learning.recallAt10.current ?? 0) * 100)}%).`);
39
+ return { since, lines, threads, empty: !lines.length };
40
+ }
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+
5
+ // What the relay can see of the person's computer without asking for any permission: which app is in front and how
6
+ // long since they last touched the keyboard or mouse (macOS: lsappinfo and the HID idle timer; Linux under X11 when
7
+ // xprintidle and xdotool are installed; nothing elsewhere), and the state of each repository the swarm works in (its
8
+ // branch, files changed and not committed, and how long ago). The front app and idle time stay on this computer: they
9
+ // time the buddy's digest and keep it quiet while the person is in a call. Window titles are never read.
10
+
11
+ const PERSON_TTL_MS = 15_000;
12
+ const REPO_TTL_MS = 10_000;
13
+ const CALL_APPS = /^(?:zoom\.us|FaceTime|Microsoft Teams|Webex|Slack Huddle|Discord|Around|Google Meet|Tuple|Pop)$/i;
14
+
15
+ const run = (command, args, options = {}) => {
16
+ try {
17
+ const out = spawnSync(command, args, { encoding: "utf8", timeout: 2_000, ...options });
18
+ return out.status === 0 ? out.stdout : null;
19
+ } catch { return null; }
20
+ };
21
+
22
+ /** The front app's name and seconds since the last input, or nulls where the OS can't say without a permission. */
23
+ export function readPerson({ platform = process.platform, exec = run, file = process.env.HARNESS_PRESENCE_FILE } = {}) {
24
+ // Tests stand in for the OS: a JSON file { app, idleSeconds }, read instead of it (tests/e2e/desktop-local.mts).
25
+ if (file) {
26
+ try {
27
+ const read = JSON.parse(fs.readFileSync(file, "utf8"));
28
+ return { app: typeof read.app === "string" ? read.app : null, idleSeconds: Number.isFinite(read.idleSeconds) ? read.idleSeconds : null };
29
+ } catch { return { app: null, idleSeconds: null }; }
30
+ }
31
+ if (platform === "darwin") {
32
+ const asn = exec("lsappinfo", ["front"])?.trim();
33
+ const app = asn ? exec("lsappinfo", ["info", "-only", "name", asn])?.match(/"LSDisplayName"\s*=\s*"([^"]*)"/)?.[1] ?? null : null;
34
+ const idle = exec("ioreg", ["-c", "IOHIDSystem", "-d", "4", "-r", "-k", "HIDIdleTime"])?.match(/"HIDIdleTime"\s*=\s*(\d+)/)?.[1];
35
+ return { app, idleSeconds: idle ? Math.round(Number(idle) / 1e9) : null };
36
+ }
37
+ if (platform === "linux" && process.env.DISPLAY) {
38
+ const idle = exec("xprintidle", []);
39
+ const pid = exec("xdotool", ["getactivewindow", "getwindowpid"])?.trim();
40
+ let app = null;
41
+ try { if (pid) app = fs.readFileSync(`/proc/${pid}/comm`, "utf8").trim(); } catch {}
42
+ return { app, idleSeconds: idle ? Math.round(Number(idle) / 1000) : null };
43
+ }
44
+ return { app: null, idleSeconds: null };
45
+ }
46
+
47
+ /** A repository's branch and the files changed in it and not committed, newest change first. */
48
+ export function readRepository(folder, { exec = run } = {}) {
49
+ const top = exec("git", ["rev-parse", "--show-toplevel"], { cwd: folder })?.trim();
50
+ if (!top) return null;
51
+ const branch = exec("git", ["branch", "--show-current"], { cwd: top })?.trim() || null;
52
+ const status = exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], { cwd: top }) ?? "";
53
+ const changed = [];
54
+ const entries = status.split("\0");
55
+ for (let i = 0; i < entries.length; i += 1) {
56
+ const entry = entries[i];
57
+ if (entry.length < 4) continue;
58
+ // A rename's old path follows it.
59
+ if (entry[0] === "R" || entry[0] === "C") i += 1;
60
+ const rel = entry.slice(3);
61
+ let mtime = null;
62
+ try { mtime = fs.statSync(path.join(top, rel)).mtimeMs; } catch {}
63
+ changed.push({ path: rel, state: entry.slice(0, 2).trim() === "??" ? "new" : entry[1] === "D" || entry[0] === "D" ? "deleted" : "changed", at: mtime });
64
+ }
65
+ changed.sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
66
+ const last = exec("git", ["log", "-1", "--format=%ct%x00%s"], { cwd: top })?.split("\0");
67
+ return { root: top, branch, changed, lastCommit: last?.[0] ? { at: Number(last[0]) * 1000, subject: String(last[1] || "").trim().slice(0, 200) } : null };
68
+ }
69
+
70
+ export function createEnvironment({ platform = process.platform, exec = run } = {}) {
71
+ let person = null;
72
+ const repos = new Map();
73
+ return {
74
+ /** { app, idleSeconds, inCall, at }: never sent off this computer. */
75
+ person() {
76
+ if (!person || Date.now() - person.at > (process.env.HARNESS_PRESENCE_FILE ? 0 : PERSON_TTL_MS)) {
77
+ const read = readPerson({ platform, exec });
78
+ person = { ...read, inCall: Boolean(read.app && CALL_APPS.test(read.app)), at: Date.now() };
79
+ }
80
+ return person;
81
+ },
82
+ repository(folder) {
83
+ const cached = repos.get(folder);
84
+ if (cached && Date.now() - cached.at < REPO_TTL_MS) return cached.value;
85
+ const value = folder && fs.existsSync(folder) ? readRepository(folder, { exec }) : null;
86
+ repos.set(folder, { at: Date.now(), value });
87
+ if (repos.size > 200) repos.delete(repos.keys().next().value);
88
+ return value;
89
+ },
90
+ /** Minutes since a file in folder last changed, from its uncommitted files or its last commit; null when unknown. */
91
+ lastChanged(folder, now = Date.now()) {
92
+ const repo = this.repository(folder);
93
+ if (!repo) return null;
94
+ const newest = Math.max(repo.changed[0]?.at ?? 0, repo.lastCommit?.at ?? 0);
95
+ return newest ? Math.max(0, Math.round((now - newest) / 60_000)) : null;
96
+ },
97
+ };
98
+ }
@@ -0,0 +1,154 @@
1
+ import { secretReason } from "./secrets.mjs";
2
+ import { personalSpans } from "./personal.mjs";
3
+
4
+ // The handoff firewall: two small local models on the text agents pass each other. Inbound, an injection guard reads
5
+ // what an agent, page, tool or file hands another agent and flags or holds words that try to give orders. Outbound, a
6
+ // credential model joins the room's patterns (secrets.mjs) and swaps any key or password in a brief bound for a hosted
7
+ // agent for a placeholder, restored when the answer comes back to this computer. Neither model decides anything about
8
+ // money or permissions: those stay code-enforced. Thresholds come from the handoff-firewall eval (measured on this
9
+ // computer's own threads); a guard that cannot run leaves the text as it was, with the patterns still applied.
10
+
11
+ const OVERLAP = 200;
12
+ // A guard's score is its logit margin (injection minus benign), the scale its card calibrates on; the maximum over
13
+ // windows stands for the whole text. Measured on this computer's own threads (handoff-firewall eval, October 14): small
14
+ // windows find a sentence planted in a long page, large ones almost never hold a harmless post. A hold (the person must
15
+ // release it) is Wolf Defender over 1,800-character windows at 2.5: no harmless post held, 90% of standalone attacks
16
+ // held. A flag (a label every reader sees) is a hold or Horizon over 300-character windows at 6.0: 3.4% of real
17
+ // harmless posts and pages flagged, 83% of planted and all standalone attacks caught (84% planted at 4.4% flagged with
18
+ // the thresholds chosen on one half of the attacks and checked on the other). Without Horizon, Wolf Defender flags alone
19
+ // over 400-character windows at 1.6 (3.9% flagged, 63% planted, 95% standalone).
20
+ const HOLD_WINDOW = 1_800;
21
+ const FLAG_WINDOW = 300;
22
+ const FALLBACK_WINDOW = 400;
23
+ export const GUARD_HOLD = 2.5;
24
+ export const GUARD_FLAG = 6.0;
25
+ export const FALLBACK_FLAG = 1.6;
26
+ // Lines worth asking the credential model about: an assignment, a header, or a long unbroken token.
27
+ const CANDIDATE_LINE = /[=:]\s*\S{12,}|\b(?:bearer|authorization|token|secret|password|passwd|api[_-]?key|private[_-]?key)\b|[A-Za-z0-9_\-+/]{24,}/i;
28
+ const TOKEN = /[^\s"'`,;()<>{}[\]]{12,}/g;
29
+ const USERINFO = /\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:([^\s@/]+)@/i;
30
+ const ASSIGNED = /(?:[=:]|\b(?:is|key|token|password|secret|passwd)\b)\s*(["']?)([^\s"'`,;]{8,})\1/i;
31
+
32
+ /** The secret itself on a flagged line: a URL's password, a token that mixes kinds of characters, or an assigned value. */
33
+ // A public wallet address is not a secret, and work for Bankr needs it whole (the credential model scores a line holding
34
+ // one as a credential; local-stack smoke 6b). An Ethereum or bech32 address always; base58 only where the line says it
35
+ // is an address, since API keys of that length look the same. Private keys stay secrets: 64 hex, or base58 of 51 or more.
36
+ const publicAddress = (token, line) => /^0x[0-9a-fA-F]{40}$/.test(token) || /^(?:bc1|tb1|ltc1)[a-z0-9]{25,62}$/.test(token)
37
+ || (/^[1-9A-HJ-NP-Za-km-z]{26,44}$/.test(token) && /\b(?:address|wallet|solana|sol|btc|bitcoin|send|sent|pay|to)\b/i.test(line));
38
+
39
+ export function secretSpan(line) {
40
+ const url = line.match(USERINFO);
41
+ if (url) return { at: url.index + url[0].lastIndexOf(url[1]), text: url[1] };
42
+ const classes = (token) => [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(token)).length;
43
+ // A NAME=value or name:value token keeps its name; only the value is the secret.
44
+ const value = (token) => { const split = token.match(/^[A-Za-z_][\w.-]*[=:](.{8,})$/); return split ? split[1] : token; };
45
+ const tokens = [...line.matchAll(TOKEN)].map((m) => ({ at: m.index, text: value(m[0].replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9=]+$/g, "")) }))
46
+ .map((t) => ({ ...t, at: line.indexOf(t.text, t.at) })).filter((t) => t.text.length >= 12 && (classes(t.text) >= 3 || (t.text.length >= 24 && classes(t.text) >= 2)) && !/^[\w.-]+\.[a-z]{2,}$/i.test(t.text) && !/^(?:\/|\.\.?\/)/.test(t.text) && !publicAddress(t.text, line));
47
+ if (tokens.length) return tokens.sort((a, b) => classes(b.text) - classes(a.text) || b.text.length - a.text.length)[0];
48
+ const assigned = line.match(ASSIGNED);
49
+ return assigned ? { at: assigned.index + assigned[0].lastIndexOf(assigned[2]), text: assigned[2] } : null;
50
+ }
51
+
52
+ /** Overlapping windows over a long text, the start and end always included. */
53
+ export function windows(text, size, max = Math.ceil(24_000 / size)) {
54
+ const value = String(text);
55
+ if (value.length <= size) return [value];
56
+ const out = [];
57
+ for (let at = 0; at < value.length; at += size - Math.min(OVERLAP, size / 4)) out.push(value.slice(at, at + size));
58
+ return out.length <= max ? out : [...out.slice(0, max - 2), ...out.slice(-2)];
59
+ }
60
+
61
+ export function createFirewall({ inference, log = () => {} }) {
62
+ const margin = async (id, text, size, lane) => Math.max(...(await inference.logits(id, windows(text, size), { lane, maxLength: 640 })).map((row) => row[1] - row[0]));
63
+ /** { score, holdScore, verdict: "clean" | "flag" | "hold" } for text one agent hands another. */
64
+ const inspect = async (text, { lane = "handoff" } = {}) => {
65
+ if (!String(text).trim()) return { score: -Infinity, holdScore: -Infinity, verdict: "clean" };
66
+ try {
67
+ const holdScore = await margin("guard", text, HOLD_WINDOW, lane);
68
+ if (holdScore >= GUARD_HOLD) return { score: holdScore, holdScore, verdict: "hold" };
69
+ const short = await margin("guard-short", text, FLAG_WINDOW, lane).catch((error) => { log(`firewall: the short-window guard could not run (${error.message})`); return null; });
70
+ if (short !== null) return { score: short, holdScore, verdict: short >= GUARD_FLAG ? "flag" : "clean" };
71
+ const score = await margin("guard", text, FALLBACK_WINDOW, lane);
72
+ return { score, holdScore, verdict: score >= FALLBACK_FLAG ? "flag" : "clean" };
73
+ } catch (error) {
74
+ log(`firewall: the guard could not run (${error.message})`);
75
+ return { score: null, verdict: "clean", unavailable: true };
76
+ }
77
+ };
78
+
79
+ /** Secret spans in a text: [{ start, end, reason }], from the patterns and the credential model. */
80
+ const secrets = async (text, { lane = "handoff" } = {}) => {
81
+ const value = String(text);
82
+ const spans = [];
83
+ let offset = 0;
84
+ const lines = value.split("\n").map((line) => { const start = offset; offset += line.length + 1; return { line, start }; });
85
+ const candidates = lines.filter(({ line }) => line.length <= 2_000 && (CANDIDATE_LINE.test(line) || secretReason(line)));
86
+ let probabilities = [];
87
+ if (candidates.length) {
88
+ try {
89
+ probabilities = (await inference.logits("credentials", candidates.map(({ line }) => line), { lane, maxLength: 256 }))
90
+ // The model's own calibration (policy.json): temperature 2.0954, threshold 0.74.
91
+ .map((row) => { const a = row[0] / 2.0954, b = row[1] / 2.0954; return 1 / (1 + Math.exp(a - b)); });
92
+ } catch (error) { log(`firewall: the credential model could not run (${error.message})`); }
93
+ }
94
+ candidates.forEach(({ line, start }, i) => {
95
+ const flagged = (probabilities[i] ?? 0) >= 0.74 || secretReason(line);
96
+ if (!flagged) return;
97
+ const span = secretSpan(line);
98
+ if (span) spans.push({ start: start + span.at, end: start + span.at + span.text.length, reason: secretReason(line) || "a credential" });
99
+ });
100
+ return spans.sort((a, b) => a.start - b.start);
101
+ };
102
+
103
+ /**
104
+ * The text with every secret replaced by [SECRET_n]; vault maps placeholders back. Restore with restore(text, vault).
105
+ * A vault passed in keeps placeholders stable across a thread. With personal set (a request that only decides, nothing
106
+ * acts on the values), emails, phone numbers, wallet addresses and amounts become [EMAIL_n], [PHONE_n], [WALLET_n] and
107
+ * [AMOUNT_n] too.
108
+ */
109
+ const scrub = async (text, { vault = new Map(), lane = "handoff", personal = false } = {}) => {
110
+ const value = String(text);
111
+ const found = await secrets(value, { lane });
112
+ const spans = personal ? [...found, ...personalSpans(value).filter((span) => !found.some((other) => other.start < span.end && span.start < other.end))].sort((a, b) => a.start - b.start) : found;
113
+ if (!spans.length) return { text: value, masked: 0, vault };
114
+ const reverse = new Map([...vault].map(([placeholder, original]) => [original, placeholder]));
115
+ let out = "";
116
+ let at = 0;
117
+ for (const span of spans) {
118
+ if (span.start < at) continue;
119
+ const original = value.slice(span.start, span.end);
120
+ let placeholder = reverse.get(original);
121
+ if (!placeholder) { placeholder = `[${LABELS[span.kind] ?? "SECRET"}_${vault.size + 1}]`; vault.set(placeholder, original); reverse.set(original, placeholder); }
122
+ out += value.slice(at, span.start) + placeholder;
123
+ at = span.end;
124
+ }
125
+ return { text: out + value.slice(at), masked: spans.length, vault };
126
+ };
127
+
128
+ return { inspect, secrets, scrub };
129
+ }
130
+
131
+ const LABELS = { email: "EMAIL", phone: "PHONE", wallet: "WALLET", money: "AMOUNT" };
132
+
133
+ /** Puts a vault's originals back in place of its placeholders (only ever for text staying on this computer). */
134
+ export function restore(text, vault) {
135
+ let out = String(text);
136
+ for (const [placeholder, original] of vault) out = out.split(placeholder).join(original);
137
+ return out;
138
+ }
139
+
140
+ /** restore over every string in a JSON-like value. */
141
+ export function restoreDeep(value, vault) {
142
+ if (typeof value === "string") return restore(value, vault);
143
+ if (Array.isArray(value)) return value.map((item) => restoreDeep(item, vault));
144
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, restoreDeep(item, vault)]));
145
+ return value;
146
+ }
147
+
148
+ /** Applies scrub to every string in a JSON-like value. */
149
+ export async function scrubDeep(firewall, value, vault = new Map(), options = {}) {
150
+ if (typeof value === "string") return (await firewall.scrub(value, { vault, ...options })).text;
151
+ if (Array.isArray(value)) { const out = []; for (const item of value) out.push(await scrubDeep(firewall, item, vault, options)); return out; }
152
+ if (value && typeof value === "object") { const out = {}; for (const [key, item] of Object.entries(value)) out[key] = await scrubDeep(firewall, item, vault, options); return out; }
153
+ return value;
154
+ }
package/lib/folders.mjs CHANGED
@@ -2,26 +2,23 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { spawnSync } from "node:child_process";
5
- import { mainCheckout } from "./worktrees.mjs";
6
5
  import { nameOf } from "./members.mjs";
7
6
  import { paths } from "./paths.mjs";
8
7
  import { t3Projects } from "./places.mjs";
9
8
 
10
9
  // Where the person's threads work. The folder is the person's to choose, any folder on the computer: the composer offers
11
- // the folders they work in (T3 Code's projects, the folders their agents' sessions and threads ran in) and browses from
12
- // their home folder. With none chosen, Harness picks one from the message among those folders (src/server/swarm/folder.ts),
13
- // else the folder the agent last worked in. The home folder itself and hidden folders are never offered or picked, nor
14
- // Harness's and T3's own copies (their repository's checkout stands for them): work there is never what was meant (the
15
- // owner's October 13 thread ran Codex over their whole home folder).
10
+ // the folders they work in (T3 Code's projects, the folders their agents' sessions and threads ran in, each as its
11
+ // repository) and browses from their home folder. With none chosen, Harness picks one from the message among those folders
12
+ // (src/server/swarm/folder.ts), else the folder the agent last worked in. The home folder itself and hidden folders are never
13
+ // offered or picked, nor Harness's and T3's own copies or a repository's subfolders (the repository stands for them): work
14
+ // there is never what was meant (the owner's October 13 thread ran Codex over their whole home folder; on October 14 a
15
+ // buddy message was picked into ~/harness/src).
16
16
 
17
17
  const CODING = new Set(["claude-code", "codex", "pi", "opencode"]);
18
18
  // Folders offered and sent to Jev: the most recently worked in. Browsing reaches any other.
19
19
  const OFFERED = 60;
20
20
  const LISTED = 200;
21
- const README_MAX = 100_000;
22
- const TOP_LEVEL_MAX = 2_000;
23
21
  const PICK_TTL_MS = 10 * 60_000;
24
- const README = /^readme(\.(md|markdown|txt|rst))?$/i;
25
22
 
26
23
  const realDir = (dir) => { try { return fs.realpathSync(dir); } catch { return null; } };
27
24
  export const isDir = (dir) => typeof dir === "string" && path.isAbsolute(dir) && fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory() === true;
@@ -41,36 +38,44 @@ function offerable(dir) {
41
38
 
42
39
  const git = (cwd, args) => { const found = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 2_000 }); return found.status === 0 ? (found.stdout || "").trim() : ""; };
43
40
 
44
- /** What Jev reads of a folder: its name, repository, top level and README, whole. */
41
+ // The repository a folder is in, as its main checkout (for a worktree's or a subfolder's sessions), else the folder itself.
42
+ const ROOT_TTL_MS = 10 * 60_000;
43
+ const roots = new Map();
44
+ function repositoryOf(dir) {
45
+ const cached = roots.get(dir);
46
+ 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;
49
+ roots.set(dir, { root, at: Date.now() });
50
+ return root;
51
+ }
52
+
53
+ /**
54
+ * What Jev reads of a folder: its name, its repository, who worked there and when, and the titles of the sessions there
55
+ * (each the start of its first message), newest first. What was worked on in a folder says where new work goes; READMEs
56
+ * mostly say how to install a project, and together they passed Jev's limit (October 14).
57
+ */
45
58
  function facts(place) {
46
- let entries = [];
47
- try { entries = fs.readdirSync(place.path, { withFileTypes: true }).filter((entry) => ![".git", ".DS_Store"].includes(entry.name)); } catch {}
48
- const readme = entries.find((entry) => entry.isFile() && README.test(entry.name));
49
- let text = null;
50
- try { text = readme ? fs.readFileSync(path.join(place.path, readme.name), "utf8") : null; } catch {}
51
59
  // A remote's address without any credentials in it.
52
60
  const remote = git(place.path, ["remote", "get-url", "origin"]).replace(/\/\/[^@/]+@/, "//");
53
- return {
54
- path: place.path, name: place.name, repository: remote || null,
55
- topLevel: entries.map((entry) => `${entry.name}${entry.isDirectory() ? "/" : ""}`).sort().slice(0, TOP_LEVEL_MAX),
56
- readme: text && text.length <= README_MAX ? text : null,
57
- agents: place.agents.map(nameOf), lastWorked: place.lastWorked,
58
- };
61
+ return { path: place.path, name: place.name, repository: remote || null, agents: place.agents.map(nameOf), lastWorked: place.lastWorked, titles: place.titles };
59
62
  }
60
63
 
61
- export function createFolders({ store, askHarness, canAsk, privacy, log = () => {} }) {
62
- /** The folders the person works in, most recently worked in first: { path, name, t3, agents, lastWorked }. */
64
+ export function createFolders({ store, askHarness, canAsk, privacy, log = () => {}, lastChanged = () => null, rank = async (_message, places) => places }) {
65
+ /** The folders the person works in, most recently worked in first: { path, name, t3, agents, lastWorked, titles }. */
63
66
  const known = () => {
64
67
  const byPath = new Map();
65
- const add = (dir, agent, at) => {
66
- const where = realDir(dir) && realDir(mainCheckout(realDir(dir)));
68
+ const add = (dir, agent, at, title) => {
69
+ const real = realDir(dir);
70
+ const where = real && repositoryOf(real);
67
71
  if (!where || !offerable(where)) return;
68
- const place = byPath.get(where) ?? { path: where, name: path.basename(where), t3: false, agents: [], lastWorked: null };
72
+ const place = byPath.get(where) ?? { path: where, name: path.basename(where), t3: false, agents: [], lastWorked: null, titles: [] };
69
73
  if (agent && CODING.has(agent) && !place.agents.includes(agent)) place.agents.push(agent);
70
74
  if (at && (!place.lastWorked || at > place.lastWorked)) place.lastWorked = at;
75
+ if (title && !place.titles.includes(title)) place.titles.push(title);
71
76
  byPath.set(where, place);
72
77
  };
73
- for (const row of store.sessionFolders(300)) add(row.cwd, row.agent, row.at);
78
+ for (const row of store.sessionPlaces()) add(row.cwd, row.agent, row.at, row.title);
74
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);
75
80
  const projects = t3Projects();
76
81
  for (const dir of projects) add(dir, null, null);
@@ -106,10 +111,26 @@ export function createFolders({ store, askHarness, canAsk, privacy, log = () =>
106
111
  const key = JSON.stringify([message, places.map((place) => place.path)]);
107
112
  const cached = picks.get(key);
108
113
  if (cached && cached.at > Date.now() - PICK_TTL_MS) return cached.pending;
109
- const pending = askHarness("pick_folder", { message, folders: places.map(facts) }, null).then((reply) => {
114
+ // How recently each folder's files changed, where Harness takes it (a daemon newer than the server would be refused).
115
+ 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);
117
+ const pending = (async () => {
118
+ let reply = await ask(places);
119
+ // Over Jev's limit: asked again about the most recently worked in half, halving until it fits. On the owner's
120
+ // sessions that half held the folder 99.5% of the time; the search model's closest half, 95.6% (eval folder-shortlist).
121
+ if (reply?.ok && reply.tooLarge) {
122
+ 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}`);
125
+ reply = await ask(ranked.slice(0, n));
126
+ }
127
+ }
110
128
  if (!reply?.ok) throw new Error(reply?.error || "Harness did not pick a folder");
111
- return places.some((place) => place.path === reply.folder) ? reply.folder : null;
112
- });
129
+ const folder = places.some((place) => place.path === reply.folder) ? reply.folder : null;
130
+ // 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)}`);
132
+ return folder;
133
+ })();
113
134
  picks.set(key, { at: Date.now(), pending });
114
135
  for (const [old, entry] of picks) if (entry.at <= Date.now() - PICK_TTL_MS || picks.size > 50) picks.delete(old);
115
136
  pending.catch(() => picks.delete(key));
@@ -117,22 +138,24 @@ export function createFolders({ store, askHarness, canAsk, privacy, log = () =>
117
138
  };
118
139
 
119
140
  /**
120
- * The folder for a thread the person starts without choosing one: { folder, by }, by "message" (Jev's pick from what
121
- * they wrote), "last" (where one of its agents last worked) or null when there is none to suggest.
141
+ * The folder for a thread the person starts without choosing one: { folder, by, lastWorked }, by "message" (Jev's pick
142
+ * from what they wrote), "last" (where one of its agents last worked) or null when there is none to suggest; lastWorked
143
+ * is that last folder when Jev picked another, for the person to take instead.
122
144
  */
123
145
  const suggest = async (text, agents) => {
124
146
  const places = known();
147
+ 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;
125
149
  const message = String(text).replace(/@([a-z][a-z0-9-]*)(:\S+)?\s*/g, "").trim();
126
150
  if (message && places.length && canAsk() && !privacy()) {
127
151
  try {
128
152
  const folder = await pickFromMessage(message, places);
129
- if (folder) return { folder, by: "message" };
153
+ if (folder) return { folder, by: "message", lastWorked: folder === last ? null : last };
130
154
  } catch (error) { log(`folder pick: ${error.message}`); }
131
155
  }
132
- const coding = agents.filter((agent) => CODING.has(agent));
133
- const last = places.filter((place) => place.lastWorked).find((place) => !coding.length || place.agents.some((agent) => coding.includes(agent)));
134
- return last ? { folder: last.path, by: "last" } : { folder: null, by: null };
156
+ return { folder: last, by: last ? "last" : null, lastWorked: null };
135
157
  };
136
158
 
137
- return { known, browse, suggest };
159
+ // Offered to the composer without the session titles, which only Jev reads.
160
+ return { known: () => known().map(({ titles, ...place }) => place), browse, suggest };
138
161
  }
package/lib/frames.mjs ADDED
@@ -0,0 +1,159 @@
1
+ import path from "node:path";
2
+ import { createHash } from "node:crypto";
3
+ import { repositoryOf } from "./worktrees.mjs";
4
+ import { nameOf } from "./members.mjs";
5
+
6
+ // The task frame: one record per thread, built by the relay from what is on this computer and refreshed at every
7
+ // handoff. What's asked: the files and code the person named, resolved to where they are (a function named in the words
8
+ // becomes the file and line that declare it). The environment: the repository's branch and what is changed and not
9
+ // committed. Peers: other open threads working in the same repository and what they change. Checks: each run's receipt
10
+ // (receipts.mjs), so "done" is read against what the run's own session shows. The evidence (the context pack) is the
11
+ // context engine's. Local agents get the frame in their brief and from get_task; the coordinator's round gets its peers,
12
+ // checks and repository state; nothing in it is a guess by a model.
13
+
14
+ const LINE_SUFFIX = /:(\d{1,6})(?::\d+)?$/;
15
+ const FILE_TOKEN = /(?:[\w@.-]+\/)+[\w@.-]+|[\w-]+\.[a-z][a-z0-9]{0,5}\b/gi;
16
+ // Names that only code has: camelCase, PascalCase with two humps, snake_case. A plain word, even written as a call
17
+ // ("address(es)"), is left alone: in the owner's sessions they resolved to unrelated files (task-frame eval).
18
+ const CODE_NAME = /\b([A-Za-z_$][\w$]{3,})\b/g;
19
+ const codeShaped = (name) => /[a-z][A-Z]/.test(name) || /^[A-Z][a-z]+[A-Z]/.test(name) || (/_/.test(name) && /[a-z]/i.test(name.replace(/_/g, "")));
20
+ 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);
23
+ const MAX_NAMED = 8;
24
+
25
+ /**
26
+ * Files and code named in the person's words, resolved against a repository snapshot: { files: [{ path, line }],
27
+ * symbols: [{ name, path, line }] }. scope: the snapshot's files ([{ path, blob }]); atlas: the context engine's index.
28
+ */
29
+ export function mentions(text, { scope = [], atlas = null } = {}) {
30
+ const words = String(text || "");
31
+ const files = [];
32
+ const seen = new Set();
33
+ for (const raw of words.match(FILE_TOKEN) ?? []) {
34
+ const token = raw.replace(/^\.?\/+/, "").replace(/[.,;:)]+$/, "");
35
+ const line = Number(token.match(LINE_SUFFIX)?.[1]) || null;
36
+ const rel = token.replace(LINE_SUFFIX, "");
37
+ if (rel.length < 4) continue;
38
+ const hits = scope.filter((row) => row.path === rel || row.path.endsWith(`/${rel}`));
39
+ 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 }); }
41
+ }
42
+ const symbols = [];
43
+ if (atlas) {
44
+ let inScope = null;
45
+ const names = new Map();
46
+ for (const match of words.matchAll(CODE_NAME)) if (codeShaped(match[1])) names.set(match[1], true);
47
+ for (const name of [...names.keys()].slice(0, 20)) {
48
+ // The atlas's word index finds the pieces that hold the name; one in this snapshot that declares it wins.
49
+ const hits = atlas.words([name], 200);
50
+ if (!hits.length) continue;
51
+ inScope ??= new Set(scope.flatMap((row) => atlas.blob(row.blob) ?? []));
52
+ const pattern = declares(name);
53
+ for (const hit of hits) {
54
+ if (!inScope.has(hit.hash)) continue;
55
+ const piece = atlas.piece(hit.hash);
56
+ const found = piece && pattern.exec(piece.text);
57
+ 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) });
60
+ break;
61
+ }
62
+ if (symbols.length >= MAX_NAMED) break;
63
+ }
64
+ }
65
+ return { files: files.slice(0, MAX_NAMED), symbols };
66
+ }
67
+
68
+ export function createFrames({ store, environment, worktrees, atlas = () => null, scope = () => [], log = () => {} }) {
69
+ const key = (threadId) => `frame:${threadId}`;
70
+ const get = (threadId) => { try { return JSON.parse(store.meta(key(threadId)) || "null"); } catch { return null; } };
71
+
72
+ /** Where a thread's files are: its own worktree while open, else its folder. */
73
+ const placeOf = (thread) => {
74
+ 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;
76
+ };
77
+
78
+ /** The files a thread is changing: its worktree's diff, or what its runs' receipts say they changed. */
79
+ const changing = (thread) => {
80
+ 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 ?? []))];
83
+ };
84
+
85
+ const receiptOf = (taskId) => { try { return JSON.parse(store.meta(`receipt:${taskId}`) || "null"); } catch { return null; } };
86
+
87
+ /** The frame for a thread, rebuilt from what is on the computer now. pack: the context engine's latest pack, if any. */
88
+ const build = (threadId, { pack = null } = {}) => {
89
+ const started = performance.now();
90
+ const thread = store.getTask(threadId);
91
+ if (!thread || thread.kind !== "goal") return null;
92
+ const now = Date.now();
93
+ const previous = get(threadId);
94
+ const place = placeOf(thread);
95
+ // 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");
97
+ const wordsHash = createHash("sha1").update(asked).digest("hex");
98
+ let named = previous?.asked?.hash === wordsHash ? previous.asked : null;
99
+ if (!named) {
100
+ const index = atlas();
101
+ named = { hash: wordsHash, ...mentions(asked, { scope: index && place ? scope(place.folder) : [], atlas: index }) };
102
+ }
103
+ 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;
110
+ const peers = [];
111
+ if (place?.repo) {
112
+ for (const other of store.goals({ all: false })) {
113
+ if (other.id === threadId || other.status !== "open") continue;
114
+ const there = placeOf(other);
115
+ if (!there?.repo || there.repo !== place.repo) continue;
116
+ const runs = store.threadRuns(other.id);
117
+ const files = changing(other);
118
+ const running = runs.filter((run) => run.status === "running").map((run) => run.target_agent);
119
+ 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) });
121
+ }
122
+ }
123
+ const checks = store.threadRuns(threadId).map((run) => ({ run: run.id, agent: run.target_agent, ...receiptOf(run.id) })).filter((check) => check.verdict).slice(-5);
124
+ 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,
128
+ ms: 0,
129
+ };
130
+ frame.ms = Math.round(performance.now() - started);
131
+ store.meta(key(threadId), JSON.stringify(frame));
132
+ return frame;
133
+ };
134
+
135
+ /** The frame as a local agent reads it in its brief; empty parts are left out. */
136
+ const briefText = (frame, agent) => {
137
+ if (!frame) return "";
138
+ 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("; ")}.`);
141
+ const repo = frame.repository;
142
+ 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)}")` : ""}.`);
145
+ }
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")}` : "";
149
+ };
150
+
151
+ /** 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;
157
+
158
+ return { get, build, briefText, roundFacts, receiptOf, log };
159
+ }