symbiot 0.40.4 → 0.41.0

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/scan.mjs ADDED
@@ -0,0 +1,540 @@
1
+ // Reading your repos: where to look (scan folders, --dir, Termux's proot
2
+ // distros), finding repos and project folders without hanging, the facts each
3
+ // one gives (who you are in it, its commits, open work, README, rules), the
4
+ // Map's graph, and the one repo set every view shares.
5
+ import { spawnSync } from "node:child_process";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
9
+ import { VERSION, loadConfig, saveConfig, sh, repoState } from "./core.mjs";
10
+ import { detectHandoffs } from "./agents.mjs";
11
+ import { PROVIDERS, resolveProvider } from "./ai.mjs";
12
+
13
+ const MAX_COMMITS = 140;
14
+
15
+ let LAST_MAP = null; // cached graph so node clicks don't rescan
16
+ // How this process scans: dir (--dir, one folder this run instead of the scan
17
+ // folders), quiet (the app: no progress line) and plain (no colour). Set by
18
+ // index.mjs from its flags.
19
+ const OPTS = { dir: "", quiet: false, plain: false };
20
+ function setScanOptions(o) { Object.assign(OPTS, o); }
21
+ const paint = (code) => (s) => OPTS.plain ? s : `\x1b[38;5;${code}m${s}\x1b[0m`;
22
+ const green = paint(42), faint = paint(66), amber = paint(179);
23
+
24
+ // ---- scan deadline + progress ---------------------------------------------
25
+ // One slow disk or giant tree must never hang a scan (the 0.7.1 map hang): each
26
+ // scan gets an overall deadline (SYMBIOT_SCAN_TIMEOUT seconds), after which it
27
+ // stops and returns what it has, marked partial. Progress is a live stderr line
28
+ // in the CLI, and served at /api/scan for the app, which polls it while the Map
29
+ // loads. Nested scans (findAllRepos inside buildMap) share the outer deadline.
30
+ const SCAN_TIMEOUT_MS = (Number(process.env.SYMBIOT_SCAN_TIMEOUT) || 60) * 1000;
31
+ const FIND_TIMEOUT_MS = 20000; // one `find` walk; partial results are kept past it
32
+ const SCAN = { active: false, phase: "", done: 0, total: 0, item: "", startedAt: 0, deadline: 0, partial: false };
33
+ function scanBegin() {
34
+ if (SCAN.active) return false;
35
+ Object.assign(SCAN, { active: true, phase: "", done: 0, total: 0, item: "", startedAt: Date.now(), deadline: Date.now() + SCAN_TIMEOUT_MS, partial: false });
36
+ return true;
37
+ }
38
+ function scanPhase(phase, total) { Object.assign(SCAN, { phase, done: 0, total: total || 0, item: "" }); scanDraw(); }
39
+ function scanTick(item) { SCAN.done++; SCAN.item = item || ""; scanDraw(); }
40
+ function scanExpired() { if (SCAN.active && Date.now() > SCAN.deadline) SCAN.partial = true; return SCAN.partial; }
41
+ function scanEnd(owner) {
42
+ if (!owner) return;
43
+ SCAN.active = false;
44
+ if (OPTS.quiet) return;
45
+ if (!OPTS.plain && process.stderr.isTTY) process.stderr.write("\r\x1b[K");
46
+ if (SCAN.partial) process.stderr.write(amber(`⚠ scan stopped after ${SCAN_TIMEOUT_MS / 1000}s — results are partial (raise SYMBIOT_SCAN_TIMEOUT, or narrow the scan folders)`) + "\n");
47
+ }
48
+ // Written synchronously (the scan blocks the event loop, so a timer-driven
49
+ // spinner would freeze): one overwritten stderr line, TTY only.
50
+ function scanDraw() {
51
+ if (OPTS.quiet || OPTS.plain || !process.stderr.isTTY) return;
52
+ const n = SCAN.total ? ` ${SCAN.done}/${SCAN.total}` : SCAN.done ? ` ${SCAN.done}` : "";
53
+ process.stderr.write(`\r\x1b[K${green("⠿")} ${faint(`${SCAN.phase}${n}${SCAN.item ? " · " + SCAN.item : ""}`.slice(0, (process.stderr.columns || 80) - 3))}`);
54
+ }
55
+ // Run `find` directly (no shell, no pipe): a timeout then kills find itself
56
+ // rather than a shell whose piped children keep running, and whatever it found
57
+ // before the timeout is kept instead of discarded.
58
+ function findPaths(args, limit) {
59
+ const timeout = SCAN.active ? Math.max(1000, Math.min(FIND_TIMEOUT_MS, SCAN.deadline - Date.now())) : FIND_TIMEOUT_MS;
60
+ const r = spawnSync("find", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 32 * 1024 * 1024, timeout, killSignal: "SIGKILL" });
61
+ if (r.error && r.error.code !== "ETIMEDOUT") return [];
62
+ if (r.error && SCAN.active) SCAN.partial = true;
63
+ return String(r.stdout || "").split("\n").filter(Boolean).slice(0, limit);
64
+ }
65
+ const PRUNE = ["node_modules", ".cache", ".local", ".npm", "venv", ".venv", ".gradle", "Pods"];
66
+ const anyName = (names) => ["(", ...names.flatMap((n, i) => (i ? ["-o", "-name", n] : ["-name", n])), ")"];
67
+ // find .git dirs quickly by PRUNING heavy trees (node_modules etc.) instead of
68
+ // crawling into them — this is the big speedup for the map scan.
69
+ function findGitDirs(base, limit) {
70
+ return findPaths([base, "-maxdepth", "7", ...anyName([...PRUNE, ".git-crypt"]), "-prune", "-o", "-name", ".git", "-print"], limit);
71
+ }
72
+ function me() {
73
+ return { email: sh("git config --global user.email").trim(), name: sh("git config --global user.name").trim() };
74
+ }
75
+
76
+ // ---- accurate repo signals (the field-report fixes) -----------------------
77
+ // All author identities in this repo that plausibly belong to the current user,
78
+ // with a fallback when the filter would keep almost nothing of an active repo.
79
+ function authorship(repoPath) {
80
+ const gName = sh("git config --global user.name 2>/dev/null").trim();
81
+ const gEmail = sh("git config --global user.email 2>/dev/null").trim();
82
+ const rEmail = sh(`git -C ${JSON.stringify(repoPath)} config user.email 2>/dev/null`).trim();
83
+ const rName = sh(`git -C ${JSON.stringify(repoPath)} config user.name 2>/dev/null`).trim();
84
+ const total = Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count HEAD 2>/dev/null`).trim()) || 0;
85
+ const rows = sh(`git -C ${JSON.stringify(repoPath)} log --format='%ae|%an' 2>/dev/null | sort | uniq -c | sort -rn | head -60`)
86
+ .split("\n").map((l) => l.trim()).filter(Boolean).map((l) => {
87
+ const m = l.match(/^(\d+)\s+(.*)$/); if (!m) return null;
88
+ const parts = m[2].split("|"); return { count: Number(m[1]), email: parts[0], name: parts.slice(1).join("|") };
89
+ }).filter(Boolean);
90
+ const myEmails = new Set([rEmail, gEmail].filter(Boolean));
91
+ const myNames = new Set([gName, rName].filter(Boolean).map((n) => n.toLowerCase()));
92
+ const nameKeys = new Set([...myNames].map((n) => n.replace(/\s+/g, "")));
93
+ const mine = new Set();
94
+ for (const r of rows) {
95
+ const nore = r.email.match(/^\d+\+(.+)@users\.noreply\.github\.com$/i);
96
+ const isMine = (r.email && myEmails.has(r.email))
97
+ || (r.name && myNames.has(r.name.toLowerCase()))
98
+ || (nore && nameKeys.has(nore[1].toLowerCase().replace(/\s+/g, "")));
99
+ if (isMine) mine.add(r.email);
100
+ }
101
+ if (rEmail) mine.add(rEmail);
102
+ let mineCount = rows.filter((r) => mine.has(r.email)).reduce((s, r) => s + r.count, 0);
103
+ const filterDropped = total >= 20 && mineCount / Math.max(total, 1) < 0.10;
104
+ const emails = filterDropped ? rows.map((r) => r.email) : [...mine];
105
+ return { emails, mineCount, total, filterDropped, names: [...new Set(rows.filter((r) => mine.has(r.email)).map((r) => r.email))] };
106
+ }
107
+ function authorArgs(emails) { return (emails || []).filter(Boolean).map((e) => `--author=${JSON.stringify(e)}`).join(" "); }
108
+
109
+ function readmeInfo(repoPath) {
110
+ for (const f of ["README.md", "README.MD", "Readme.md", "readme.md", "README.txt", "README"]) {
111
+ try {
112
+ const p = join(repoPath, f);
113
+ if (existsSync(p)) {
114
+ const hash = sh(`git -C ${JSON.stringify(repoPath)} log -1 --format=%H -- ${JSON.stringify(f)} 2>/dev/null`).trim();
115
+ return {
116
+ file: f, excerpt: readFileSync(p, "utf8").slice(0, 2500),
117
+ lastDate: sh(`git -C ${JSON.stringify(repoPath)} log -1 --format=%cd --date=short -- ${JSON.stringify(f)} 2>/dev/null`).trim(),
118
+ commitsAgo: hash ? (Number(sh(`git -C ${JSON.stringify(repoPath)} rev-list --count ${hash}..HEAD 2>/dev/null`).trim()) || 0) : 0,
119
+ };
120
+ }
121
+ } catch {}
122
+ }
123
+ return { file: null, excerpt: "", lastDate: "", commitsAgo: 0 };
124
+ }
125
+ function repoShape(repoPath) {
126
+ const files = sh(`git -C ${JSON.stringify(repoPath)} ls-files 2>/dev/null | head -4000`).split("\n").filter(Boolean);
127
+ const top = {}; const docs = []; const manifests = [];
128
+ for (const f of files) {
129
+ const seg = f.includes("/") ? f.split("/")[0] : "(root)";
130
+ top[seg] = (top[seg] || 0) + 1;
131
+ const base = f.split("/").pop();
132
+ if (MANIFEST_TOOL[base]) manifests.push(f);
133
+ if (/^(readme|contributing|claude|agents|changelog|architecture|design)/i.test(base) || /(^|\/)(docs|knowledge)\//i.test(f)) docs.push(f);
134
+ }
135
+ return {
136
+ total: files.length,
137
+ dirs: Object.entries(top).sort((a, b) => b[1] - a[1]).slice(0, 14).map(([d, n]) => `${d}/ (${n})`),
138
+ manifests: manifests.slice(0, 12), docs: [...new Set(docs)].slice(0, 16),
139
+ };
140
+ }
141
+ // House rules the model must respect (e.g. "we commit .env on purpose").
142
+ function houseRules(repoPath) {
143
+ const out = [];
144
+ for (const [label, f] of [["Project rules", "CLAUDE.md"], ["Agent rules", "AGENTS.md"], ["Contributing", "CONTRIBUTING.md"]]) {
145
+ try { const p = join(repoPath, f); if (existsSync(p)) out.push(`## ${label} (${f})\n` + readFileSync(p, "utf8").slice(0, 1500)); } catch {}
146
+ }
147
+ try { const g = join(homedir(), ".config", "symbiot", "rules.md"); if (existsSync(g)) out.push("## Your global rules\n" + readFileSync(g, "utf8").slice(0, 1500)); } catch {}
148
+ return out.join("\n\n");
149
+ }
150
+ // "Show what was read" footer for every report.
151
+ function reportFooter(repoPath, auth, state, rd) {
152
+ const b = [`symbiot ${VERSION}`, `path ${repoPath}`];
153
+ if (state.branch) b.push(`branch ${state.branch}`);
154
+ b.push(`${auth.mineCount} of ${auth.total} commits matched you${auth.filterDropped ? " — filter dropped, counting everyone" : ""}`);
155
+ if (rd && rd.lastDate) b.push(`README changed ${rd.lastDate}${rd.commitsAgo ? ` (${rd.commitsAgo} commits ago)` : ""}`);
156
+ if (state.stale) b.push(`⚠ STALE checkout: working tree ≈ ${state.staleBy ? "HEAD~" + state.staleBy : "an older commit"} — not new work`);
157
+ else if (state.dirty) b.push(`${state.dirty} uncommitted (${state.mod} mod / ${state.del} del / ${state.add} new)`);
158
+ if (state.behind) b.push(`${state.behind} behind upstream`);
159
+ return b.join(" · ");
160
+ }
161
+ function expandRoot(p) { p = String(p || "").trim(); return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; }
162
+ // The default place to look: your home folder. The Android app's HOME is private
163
+ // to it, so it sets SYMBIOT_SCAN_HOME to the phone's shared storage instead.
164
+ function scanHome() { return process.env.SYMBIOT_SCAN_HOME || homedir(); }
165
+ // The folder reports name as where they looked: --dir, else that home.
166
+ function scanBase() { return OPTS.dir || scanHome(); }
167
+ // Without "All files access", the Android app sees shared storage as empty (it
168
+ // always has Download, DCIM...), which would read as "no repos". The map says so
169
+ // instead, with a button that asks for access.
170
+ function storageBlocked() {
171
+ if (process.env.SYMBIOT_ANDROID_APP !== "1") return false;
172
+ try { return readdirSync(scanHome()).length === 0; } catch { return true; }
173
+ }
174
+ // Termux: a Linux run with proot-distro (`proot-distro login debian`) keeps its
175
+ // home folders in $PREFIX/var/lib/proot-distro/installed-rootfs/<distro>, outside
176
+ // Termux's home, and that's where projects worked on in it live. Symbiot in
177
+ // Termux reads them there: /root and /home/<user> of each installed distro.
178
+ const subdirs = (p) => { try { return readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join(p, d.name)); } catch { return []; } };
179
+ // The folder each installed distro sees as "/".
180
+ function prootDistros() {
181
+ if (!process.env.PREFIX) return [];
182
+ return subdirs(join(process.env.PREFIX, "var", "lib", "proot-distro", "installed-rootfs"));
183
+ }
184
+ // Each distro's home folders, with the distro they're in.
185
+ function prootHomeDirs() {
186
+ const out = [];
187
+ for (const rootfs of prootDistros()) {
188
+ if (existsSync(join(rootfs, "root"))) out.push({ rootfs, home: join(rootfs, "root") });
189
+ for (const home of subdirs(join(rootfs, "home"))) out.push({ rootfs, home });
190
+ }
191
+ return out;
192
+ }
193
+ function prootHomes() { return prootHomeDirs().map((h) => h.home); }
194
+ // Where to look when you haven't picked folders: your home folder (and in Termux,
195
+ // the homes of its proot-distro Linuxes).
196
+ function scanDefaults() { return [scanHome(), ...prootHomes()]; }
197
+ // Where to look for repos: configured folders, or --dir, else the defaults.
198
+ function scanRoots() {
199
+ if (OPTS.dir) return [OPTS.dir];
200
+ const r = loadConfig().scanRoots;
201
+ const roots = (Array.isArray(r) ? r : []).map(expandRoot).filter((x) => { try { return existsSync(x); } catch { return false; } });
202
+ return roots.length ? roots : scanDefaults();
203
+ }
204
+ function addScanRoot(p) {
205
+ p = expandRoot(p); if (!p) return { error: "empty" };
206
+ try { if (!existsSync(p)) return { error: "folder not found: " + p }; } catch { return { error: "can't read: " + p }; }
207
+ const cfg = loadConfig();
208
+ let list = Array.isArray(cfg.scanRoots) && cfg.scanRoots.length ? cfg.scanRoots : scanDefaults(); // keep home when adding the first extra folder
209
+ if (!list.includes(p)) list.push(p);
210
+ cfg.scanRoots = list; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots };
211
+ }
212
+ function removeScanRoot(p) {
213
+ const cfg = loadConfig(); cfg.scanRoots = (Array.isArray(cfg.scanRoots) ? cfg.scanRoots : []).filter((x) => x !== p);
214
+ if (!cfg.scanRoots.length) delete cfg.scanRoots; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots || [] };
215
+ }
216
+
217
+ function commits(repos, sinceExpr, mineOnly = true) {
218
+ const all = [];
219
+ const seen = new Set();
220
+ for (const r of repos) {
221
+ // Match ALL of your identities in this repo (per-repo email, global email,
222
+ // GitHub noreply login, matching name), with a fallback when almost nothing
223
+ // matches an active repo. No filter with --all.
224
+ const authorArg = mineOnly ? authorArgs(authorship(r.path).emails) : "";
225
+ const raw = sh(
226
+ `git -C ${JSON.stringify(r.path)} log --since=${JSON.stringify(sinceExpr)} ${authorArg} ` +
227
+ `--no-merges --date=short --name-only --pretty=format:'@@@%H|%ad|%s' -n 60`,
228
+ );
229
+ let cur = null;
230
+ const flush = () => {
231
+ if (cur && !seen.has(cur.h)) { seen.add(cur.h); all.push({ ...cur, repo: r.name }); }
232
+ };
233
+ for (const line of raw.split("\n")) {
234
+ if (line.startsWith("@@@")) {
235
+ flush();
236
+ const [h, date, subject] = line.slice(3).split("|");
237
+ cur = { h, date, subject: subject || "", files: [] };
238
+ } else if (line.trim() && cur) cur.files.push(line.trim());
239
+ }
240
+ flush();
241
+ }
242
+ return all.slice(0, MAX_COMMITS);
243
+ }
244
+ function openWork(repos) {
245
+ const items = [];
246
+ for (const r of repos) {
247
+ const todos = sh(
248
+ `git -C ${JSON.stringify(r.path)} grep -nEI '(TODO|FIXME|HACK)[:( ]' -- ` +
249
+ `'*.ts' '*.tsx' '*.js' '*.py' '*.go' '*.rs' '*.java' '*.sql' '*.vue' 2>/dev/null | head -8`,
250
+ );
251
+ for (const l of todos.split("\n").filter(Boolean)) {
252
+ const m = l.match(/^([^:]+):(\d+):(.*)$/);
253
+ if (m) items.push(`${r.name}: ${m[3].replace(/^[\s/*#-]+/, "").trim().slice(0, 120)} (${m[1]}:${m[2]})`);
254
+ }
255
+ const st = repoState(r.path);
256
+ if (st.stale) {
257
+ items.push(`${r.name}: STALE checkout — working tree ≈ ${st.staleBy ? "HEAD~" + st.staleBy : "an older commit"}, NOT new work; do not commit (would revert history)`);
258
+ } else if (st.dirty) {
259
+ items.push(`${r.name}: ${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new) on ${st.branch || "?"}`);
260
+ }
261
+ }
262
+ return items;
263
+ }
264
+
265
+ // ---- work map: a node graph of your repos, languages, and tools -----------
266
+ const EXT_LANG = {
267
+ ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript",
268
+ py: "Python", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", rb: "Ruby", php: "PHP",
269
+ cs: "C#", cpp: "C++", cc: "C++", c: "C", swift: "Swift", vue: "Vue", svelte: "Svelte",
270
+ sql: "SQL", sh: "Shell", css: "CSS", scss: "CSS", html: "HTML",
271
+ };
272
+ const MANIFEST_TOOL = {
273
+ "package.json": "Node", "requirements.txt": "Python", "pyproject.toml": "Python",
274
+ "go.mod": "Go", "Cargo.toml": "Rust", "pom.xml": "Maven", "build.gradle": "Gradle",
275
+ "Gemfile": "Ruby", "composer.json": "PHP", "Dockerfile": "Docker", "docker-compose.yml": "Docker",
276
+ "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
277
+ };
278
+ function findAllRepos(base) {
279
+ const own = scanBegin();
280
+ try {
281
+ const roots = base ? [base] : scanRoots();
282
+ const repos = []; const seen = new Set();
283
+ for (const root of roots) {
284
+ if (scanExpired()) break;
285
+ scanPhase("finding repos in " + root.replace(homedir(), "~"));
286
+ const gits = findGitDirs(root, 300);
287
+ scanPhase("reading repos", gits.length);
288
+ for (const g of gits) {
289
+ if (scanExpired()) break;
290
+ const repo = g.replace(/\/\.git$/, ""); scanTick(repo.split("/").pop());
291
+ if (seen.has(repo)) continue; seen.add(repo);
292
+ const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
293
+ if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
294
+ }
295
+ }
296
+ // Group worktrees by shared git dir; keep only the freshest checkout of each.
297
+ // (Not deadline-bound: it's what makes the result correct, and it's cheap.)
298
+ const byCommon = {};
299
+ for (const r of repos) {
300
+ const cd = sh(`git -C ${JSON.stringify(r.path)} rev-parse --git-common-dir 2>/dev/null`).trim() || r.path;
301
+ const key = cd.startsWith("/") ? cd : join(r.path, cd);
302
+ if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
303
+ }
304
+ return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
305
+ } finally { scanEnd(own); }
306
+ }
307
+ function detectRepo(r) {
308
+ const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
309
+ const count = {}; const tools = new Set();
310
+ for (const f of files) {
311
+ const base = f.split("/").pop();
312
+ if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]);
313
+ const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase();
314
+ if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1;
315
+ }
316
+ const langs = Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]);
317
+ const email = sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim();
318
+ const mine = Number(sh(`git -C ${JSON.stringify(r.path)} log ${email ? `--author=${JSON.stringify(email)}` : ""} --oneline 2>/dev/null | wc -l`).trim()) || 0;
319
+ const branch = sh(`git -C ${JSON.stringify(r.path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
320
+ const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
321
+ return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
322
+ }
323
+ // Non-git PROJECT folders inside your scan roots (you added them = consent):
324
+ // a directory with a manifest but no .git — "not everything is a repo".
325
+ function findProjectFolders(root) {
326
+ const manifests = findPaths([root, "-maxdepth", "3", ...anyName([...PRUNE, ".git"]), "-prune", "-o", "-type", "f",
327
+ ...anyName(["package.json", "requirements.txt", "pyproject.toml", "go.mod", "Cargo.toml", "pom.xml", "build.gradle", "Gemfile", "composer.json", "Dockerfile", "pubspec.yaml", "CMakeLists.txt"]), "-print"], 200);
328
+ const dirs = new Set(); for (const m of manifests) dirs.add(m.replace(/\/[^/]+$/, ""));
329
+ const out = [];
330
+ for (const d of dirs) {
331
+ if (sh(`git -C ${JSON.stringify(d)} rev-parse --is-inside-work-tree 2>/dev/null`).trim() === "true") continue; // inside a repo already
332
+ out.push(d); if (out.length >= 24) break;
333
+ }
334
+ return out;
335
+ }
336
+ function detectFolder(path) {
337
+ const files = sh(`find ${JSON.stringify(path)} -maxdepth 2 \\( -name node_modules -o -name .git \\) -prune -o -type f -print 2>/dev/null | head -2000`).split("\n").filter(Boolean);
338
+ const count = {}; const tools = new Set();
339
+ for (const f of files) { const base = f.split("/").pop(); if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]); const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase(); if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1; }
340
+ return { path, name: path.split("/").pop(), langs: Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]), tools: [...tools], files: files.length };
341
+ }
342
+ // Projects you've run an AI coding agent on. Claude Code stores one dir per
343
+ // project under ~/.claude/projects, named by the project path with every
344
+ // non-alphanumeric char → "-" (so "/" "." "_" "-" all collide). Returns
345
+ // { "<key>": { agent, last, path, rootfs, enc } } — last = newest session mtime
346
+ // (epoch secs); path = the real project dir, read from the "cwd" a session
347
+ // records (empty when no session says, then callers fall back to decoding enc).
348
+ // In Termux it also reads Claude Code's history inside each proot-distro (in
349
+ // the distro's /root and /home/<user>). Paths there are the distro's own
350
+ // ("/root/work/proj"), so rootfs is the distro's folder ("" outside one), path
351
+ // is prefixed with it, and key = agentKey(rootfs, enc).
352
+ // (Gemini's dir is global config, Codex's is empty, so no reliable per-project
353
+ // signal there yet; add them here when there is.)
354
+ const claudeEnc = (p) => String(p || "").replace(/[^a-zA-Z0-9]/g, "-");
355
+ function sessionCwd(file) {
356
+ const head = sh(`head -c 262144 ${JSON.stringify(file)} 2>/dev/null`);
357
+ const m = head.match(/"cwd":"((?:[^"\\]|\\.)*)"/);
358
+ if (!m) return "";
359
+ try { return JSON.parse(`"${m[1]}"`); } catch { return ""; }
360
+ }
361
+ const agentKey = (rootfs, enc) => (rootfs ? rootfs + "\0" : "") + enc;
362
+ // The keys a project folder's history could be under: its path, and its path as
363
+ // seen from inside the proot-distro it's in (if any), each encoded both ways.
364
+ function agentKeys(path, distros) {
365
+ const enc = (p) => [claudeEnc(p), p.replace(/\//g, "-")]; // older Claude Code mapped only "/"
366
+ const rootfs = distros.find((r) => path.startsWith(r + "/"));
367
+ return [...enc(path), ...(rootfs ? enc(path.slice(rootfs.length)).map((e) => agentKey(rootfs, e)) : [])];
368
+ }
369
+ function claudeProjects() {
370
+ const out = {};
371
+ readClaudeProjects(join(homedir(), ".claude", "projects"), "", out);
372
+ for (const { rootfs, home } of prootHomeDirs()) readClaudeProjects(join(home, ".claude", "projects"), rootfs, out);
373
+ return out;
374
+ }
375
+ function readClaudeProjects(base, rootfs, out) {
376
+ let dirs = [];
377
+ try { dirs = sh(`ls -1 ${JSON.stringify(base)} 2>/dev/null`).split("\n").filter(Boolean); } catch {}
378
+ for (const enc of dirs) {
379
+ let last = 0, path = "";
380
+ const newest = sh(`find ${JSON.stringify(join(base, enc))} -maxdepth 1 -name '*.jsonl' -printf '%T@ %p\\n' 2>/dev/null | sort -rn | head -1`).trim();
381
+ if (newest) {
382
+ const sp = newest.indexOf(" ");
383
+ last = parseInt(newest.slice(0, sp), 10) || 0;
384
+ const cwd = sessionCwd(newest.slice(sp + 1));
385
+ if (cwd && (claudeEnc(cwd) === enc || cwd.replace(/\//g, "-") === enc)) path = cwd; // only trust a cwd that matches this dir
386
+ }
387
+ if (!last) { try { last = Math.floor(statSync(join(base, enc)).mtimeMs / 1000); } catch {} }
388
+ const key = agentKey(rootfs, enc);
389
+ if (out[key] && out[key].last >= last) continue; // one project worked on from two of the distro's homes: keep the newest
390
+ out[key] = { agent: "Claude Code", last, path: path && rootfs + path, rootfs, enc };
391
+ }
392
+ }
393
+ // Async only to YIELD between repos, so the app server can answer /api/scan
394
+ // (progress) mid-scan; the git calls themselves stay synchronous. Concurrent
395
+ // callers (e.g. Rescan while a scan runs) share the one in-flight build.
396
+ let MAP_BUILD = null;
397
+ function buildMap() {
398
+ if (!MAP_BUILD) MAP_BUILD = buildMapNow().finally(() => { MAP_BUILD = null; });
399
+ return MAP_BUILD;
400
+ }
401
+ const yieldTick = () => new Promise((r) => setImmediate(r));
402
+ async function buildMapNow() {
403
+ const own = scanBegin();
404
+ try { return await buildMapScan(); } finally { scanEnd(own); }
405
+ }
406
+ async function buildMapScan() {
407
+ const who = me();
408
+ const found = findAllRepos();
409
+ const repos = [];
410
+ scanPhase("reading repo details", found.length);
411
+ for (const r of found) {
412
+ if (scanExpired()) break;
413
+ await yieldTick();
414
+ repos.push(detectRepo(r)); scanTick(r.name);
415
+ }
416
+ const nodes = []; const edges = []; const have = new Set();
417
+ const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
418
+ add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
419
+ for (const r of repos) {
420
+ const rid = "repo:" + r.path;
421
+ add({ id: rid, type: "repo", label: r.name, weight: Math.min(9 + Math.log2(1 + r.mine) * 3, 26),
422
+ meta: { commits: r.mine, langs: r.langs.slice(0, 3), tools: r.tools, files: r.files, branch: r.branch, last: r.last, path: r.path } });
423
+ edges.push({ source: "me", target: rid });
424
+ for (const L of r.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: rid, target: id }); }
425
+ for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
426
+ }
427
+ // Non-git project folders in the scan roots (capped; deduped against repos).
428
+ try {
429
+ const repoPaths = new Set(repos.map((r) => r.path)); let folders = 0;
430
+ for (const root of scanRoots()) { if (scanExpired()) break; scanPhase("finding project folders"); for (const d of findProjectFolders(root)) {
431
+ if (repoPaths.has(d) || have.has("folder:" + d) || folders >= 20) continue;
432
+ if (scanExpired()) break;
433
+ await yieldTick();
434
+ const det = detectFolder(d); folders++; scanTick(det.name);
435
+ add({ id: "folder:" + d, type: "folder", label: det.name, weight: 10, meta: { path: d, langs: det.langs.slice(0, 3), tools: det.tools, files: det.files } });
436
+ edges.push({ source: "me", target: "folder:" + d });
437
+ for (const L of det.langs.slice(0, 3)) { const id = "lang:" + L; add({ id, type: "lang", label: L, weight: 15 }); edges.push({ source: "folder:" + d, target: id }); }
438
+ } }
439
+ } catch {}
440
+ // What you build WITH (person-level, not per-repo): detected agents/editors
441
+ // and the AI currently powering Symbiot — so the initial scan shows the whole
442
+ // setup, not just code. All local detection, nothing invasive.
443
+ try {
444
+ const hands = detectHandoffs();
445
+ for (const a of [...hands.agents, ...hands.editors]) {
446
+ const id = "agent:" + a.label;
447
+ add({ id, type: "agent", label: a.label.replace(/\s*\(.*\)$/, ""), weight: 11, meta: { cmd: a.tmpl, kind: a.kind } });
448
+ edges.push({ source: "me", target: id });
449
+ }
450
+ const prov = resolveProvider();
451
+ if (prov) { const id = "ai:" + prov.provider; add({ id, type: "ai", label: PROVIDERS[prov.provider].label, weight: 14, meta: { model: prov.model, source: prov.source } }); edges.push({ source: "me", target: id }); }
452
+ } catch {}
453
+ // Agent projects: badge the repo/folder nodes an AI coding agent has worked,
454
+ // and surface agent-worked projects the scan missed as their own nodes.
455
+ try {
456
+ const ap = claudeProjects(), distros = prootDistros(); const used = new Set();
457
+ for (const n of nodes) {
458
+ if ((n.type === "repo" || n.type === "folder") && n.meta && n.meta.path) {
459
+ const e = agentKeys(n.meta.path, distros).find((k) => ap[k]);
460
+ if (e) { n.meta.agents = [{ agent: ap[e].agent, last: ap[e].last }]; used.add(e); }
461
+ }
462
+ }
463
+ let extra = 0;
464
+ for (const e of Object.keys(ap)) {
465
+ if (used.has(e) || extra >= 20) continue;
466
+ // the session's recorded cwd; else best-effort decode (names with - . _ won't resolve and are skipped)
467
+ const decoded = ap[e].path || ap[e].rootfs + ap[e].enc.replace(/-/g, "/");
468
+ if (!existsSync(decoded) || !statSync(decoded).isDirectory()) continue;
469
+ if (nodes.some((n) => n.meta && n.meta.path === decoded)) continue;
470
+ const isGit = existsSync(join(decoded, ".git"));
471
+ const id = (isGit ? "repo:" : "folder:") + decoded; if (have.has(id)) continue;
472
+ const det = isGit ? null : detectFolder(decoded);
473
+ add({ id, type: isGit ? "repo" : "folder", label: decoded.split("/").pop(), weight: 10, meta: { path: decoded, agents: [{ agent: ap[e].agent, last: ap[e].last }], agentOnly: true, langs: det ? det.langs.slice(0, 3) : [], tools: det ? det.tools : [], files: det ? det.files : 0 } });
474
+ edges.push({ source: "me", target: id }); extra++;
475
+ }
476
+ } catch {}
477
+ const out = { nodes, edges, stats: {
478
+ repos: repos.length,
479
+ folders: nodes.filter((n) => n.type === "folder").length,
480
+ languages: nodes.filter((n) => n.type === "lang").length,
481
+ tools: nodes.filter((n) => n.type === "tool").length,
482
+ agents: nodes.filter((n) => n.type === "agent").length,
483
+ commits: repos.reduce((s, r) => s + r.mine, 0),
484
+ files: repos.reduce((s, r) => s + (r.files || 0), 0),
485
+ base: scanBase(),
486
+ roots: scanRoots(), // every folder scanned (base is the first, unless you picked others)
487
+ partial: SCAN.partial, // the scan hit its deadline — this is what it found so far
488
+ android: process.env.SYMBIOT_ANDROID_APP === "1",
489
+ noStorage: storageBlocked(),
490
+ } };
491
+ LAST_MAP = out;
492
+ return out;
493
+ }
494
+ // Local detail for a clicked node (no AI).
495
+ async function nodeDetail(id) {
496
+ const map = LAST_MAP || await buildMap();
497
+ if (id === "me") { const n = map.nodes.find((x) => x.id === "me"); return { type: "person", label: (n && n.label) || "You", stats: map.stats }; }
498
+ if (id.startsWith("repo:")) {
499
+ const path = id.slice(5); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
500
+ return {
501
+ type: "repo", label: n ? n.label : path.split("/").pop(), path,
502
+ branch: sh(`git -C ${JSON.stringify(path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim(),
503
+ dirty: sh(`git -C ${JSON.stringify(path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean).length,
504
+ last: sh(`git -C ${JSON.stringify(path)} log -1 --format=%cd --date=short 2>/dev/null`).trim(),
505
+ commits: m.commits || 0, langs: m.langs || [], tools: m.tools || [], agents: m.agents || [],
506
+ };
507
+ }
508
+ if (id.startsWith("lang:") || id.startsWith("tool:")) {
509
+ const n = map.nodes.find((x) => x.id === id);
510
+ const repos = map.edges.filter((e) => e.target === id).map((e) => { const r = map.nodes.find((x) => x.id === e.source); return r ? r.label : null; }).filter(Boolean);
511
+ return { type: id.startsWith("lang:") ? "lang" : "tool", label: n ? n.label : id.split(":")[1], repos };
512
+ }
513
+ if (id.startsWith("agent:") || id.startsWith("ai:")) {
514
+ const n = map.nodes.find((x) => x.id === id);
515
+ return { type: id.startsWith("agent:") ? "agent" : "ai", label: n ? n.label : id.split(":")[1], meta: (n && n.meta) || {} };
516
+ }
517
+ if (id.startsWith("folder:")) {
518
+ const path = id.slice(7); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
519
+ return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0, agents: m.agents || [] };
520
+ }
521
+ return { error: "unknown node" };
522
+ }
523
+
524
+ // Each repo or project folder's name -> its path, from the Map's last scan (or
525
+ // a fresh one): how a task's repo name finds its folder.
526
+ function repoPathMap() {
527
+ const byName = {};
528
+ const src = (LAST_MAP && LAST_MAP.nodes) ? LAST_MAP.nodes.filter((n) => n.type === "repo" || n.type === "folder").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos();
529
+ for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
530
+ return byName;
531
+ }
532
+
533
+ // The SAME repo set the Map uses — reuse its cached scan when present, else do
534
+ // the identical discovery. So week/standup/todo/drift all agree with the Map.
535
+ function discoveredRepos() {
536
+ if (LAST_MAP && LAST_MAP.nodes) return LAST_MAP.nodes.filter((n) => n.type === "repo" && n.meta && n.meta.path).map((n) => ({ path: n.meta.path, name: n.label, recency: 0 }));
537
+ return findAllRepos();
538
+ }
539
+
540
+ export { SCAN, SCAN_TIMEOUT_MS, setScanOptions, scanBegin, scanPhase, scanTick, scanExpired, scanEnd, me, authorship, authorArgs, readmeInfo, repoShape, houseRules, reportFooter, expandRoot, scanHome, scanBase, storageBlocked, prootDistros, prootHomes, scanRoots, addScanRoot, removeScanRoot, commits, openWork, findAllRepos, detectRepo, findProjectFolders, detectFolder, claudeProjects, buildMap, nodeDetail, repoPathMap, discoveredRepos };