symbiot 0.17.0 → 0.22.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.
Files changed (2) hide show
  1. package/index.mjs +154 -28
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -112,7 +112,7 @@ function completedInRepo(repoPath) {
112
112
  }
113
113
  function repoPathMap() {
114
114
  const byName = {};
115
- const src = (LAST_MAP && LAST_MAP.nodes) ? LAST_MAP.nodes.filter((n) => n.type === "repo").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos(BASE);
115
+ 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();
116
116
  for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
117
117
  return byName;
118
118
  }
@@ -169,7 +169,7 @@ function pushTasks(filter) {
169
169
  // Resolve repo name -> path from the already-scanned map when we have it
170
170
  // (avoids a fresh full scan); fall back to a scan only if needed.
171
171
  const byName = {};
172
- const src = (LAST_MAP && LAST_MAP.nodes) ? LAST_MAP.nodes.filter((n) => n.type === "repo").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos(BASE);
172
+ 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();
173
173
  for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
174
174
  const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
175
175
  const written = [], unresolved = [];
@@ -260,8 +260,8 @@ function sh(cmd) {
260
260
  // crawling into them — this is the big speedup for the map scan.
261
261
  function findGitDirs(base, limit) {
262
262
  return sh(
263
- `find ${JSON.stringify(base)} -maxdepth 6 ` +
264
- `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .git-crypt \\) -prune ` +
263
+ `find ${JSON.stringify(base)} -maxdepth 7 ` +
264
+ `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .gradle -o -name Pods -o -name .git-crypt \\) -prune ` +
265
265
  `-o -name .git -print 2>/dev/null | head -${limit}`,
266
266
  ).split("\n").filter(Boolean);
267
267
  }
@@ -372,10 +372,30 @@ function reportFooter(repoPath, auth, state, rd) {
372
372
  if (state.behind) b.push(`${state.behind} behind upstream`);
373
373
  return b.join(" · ");
374
374
  }
375
+ function expandRoot(p) { p = String(p || "").trim(); return p.startsWith("~") ? join(homedir(), p.slice(1)) : p; }
376
+ // Where to look for repos: configured folders, or --dir, else your home folder.
377
+ function scanRoots() {
378
+ if (flag("dir", null)) return [BASE];
379
+ const r = loadConfig().scanRoots;
380
+ const roots = (Array.isArray(r) ? r : []).map(expandRoot).filter((x) => { try { return existsSync(x); } catch { return false; } });
381
+ return roots.length ? roots : [homedir()];
382
+ }
383
+ function addScanRoot(p) {
384
+ p = expandRoot(p); if (!p) return { error: "empty" };
385
+ try { if (!existsSync(p)) return { error: "folder not found: " + p }; } catch { return { error: "can't read: " + p }; }
386
+ const cfg = loadConfig();
387
+ let list = Array.isArray(cfg.scanRoots) && cfg.scanRoots.length ? cfg.scanRoots : [homedir()]; // keep home when adding the first extra folder
388
+ if (!list.includes(p)) list.push(p);
389
+ cfg.scanRoots = list; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots };
390
+ }
391
+ function removeScanRoot(p) {
392
+ const cfg = loadConfig(); cfg.scanRoots = (Array.isArray(cfg.scanRoots) ? cfg.scanRoots : []).filter((x) => x !== p);
393
+ if (!cfg.scanRoots.length) delete cfg.scanRoots; saveConfig(cfg); LAST_MAP = null; return { ok: true, roots: cfg.scanRoots || [] };
394
+ }
375
395
  function findRepos(base, sinceDays) {
376
- const repos = [];
377
- for (const g of findGitDirs(base, 200)) {
378
- const repo = g.replace(/\/\.git$/, "");
396
+ const repos = []; const seen = new Set();
397
+ for (const root of scanRoots()) for (const g of findGitDirs(root, 200)) {
398
+ const repo = g.replace(/\/\.git$/, ""); if (seen.has(repo)) continue; seen.add(repo);
379
399
  const n = Number(sh(`git -C ${JSON.stringify(repo)} log --since="${sinceDays} days ago" --oneline 2>/dev/null | wc -l`).trim());
380
400
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
381
401
  if (n > 0) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
@@ -573,16 +593,57 @@ function recommendModels(hw) {
573
593
  // claude -p "{prompt}" · aider --message "{prompt}" · code {dir}
574
594
  // gnome-terminal --working-directory={dir} -- claude "{prompt}"
575
595
  // Not tied to any one tool — you decide what runs.
576
- const IDE_LIST = [["code", "VS Code"], ["cursor", "Cursor"], ["windsurf", "Windsurf"], ["zed", "Zed"], ["subl", "Sublime Text"], ["idea", "IntelliJ IDEA"], ["nvim", "Neovim"]];
577
- const AGENT_LIST = [["claude", "Claude Code", 'claude -p "{prompt}"'], ["aider", "Aider", 'aider --message "{prompt}"'], ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"']];
596
+ // [cmd, label, macAppName] — macApp used to launch GUI editors on macOS where
597
+ // the CLI isn't on PATH (they're .app bundles).
598
+ const IDE_LIST = [["code", "VS Code", "Visual Studio Code"], ["cursor", "Cursor", "Cursor"], ["windsurf", "Windsurf", "Windsurf"], ["zed", "Zed", "Zed"], ["subl", "Sublime Text", "Sublime Text"], ["idea", "IntelliJ IDEA", "IntelliJ IDEA"], ["nvim", "Neovim", ""]];
599
+ const AGENT_LIST = [
600
+ ["claude", "Claude Code — make changes", 'claude -p "{prompt}" --permission-mode acceptEdits'],
601
+ ["claude", "Claude Code — plan only (asks first)", 'claude -p "{prompt}"'],
602
+ ["codex", "Codex (OpenAI) — make changes", 'codex exec --full-auto "{prompt}"'],
603
+ ["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
604
+ ["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
605
+ ["gemini", "Gemini CLI", 'gemini -p "{prompt}"'],
606
+ ];
607
+ // Cross-platform "is this command available?" (command -v on posix, where on win).
608
+ function hasCmd(cmd) { try { return !!sh(process.platform === "win32" ? `where ${cmd}` : `command -v ${cmd}`).trim(); } catch { return false; } }
609
+ function macApp(name) { if (process.platform !== "darwin" || !name) return ""; for (const base of ["/Applications", join(homedir(), "Applications")]) { try { if (existsSync(join(base, name + ".app"))) return name; } catch {} } return ""; }
610
+ // Find the Orca IDE CLI across OSes (known locations, then a bounded search).
611
+ let ORCA_CLI; // cached per process: undefined=unchecked, ""=none, string=path
612
+ function findOrcaCli() {
613
+ if (ORCA_CLI !== undefined) return ORCA_CLI;
614
+ ORCA_CLI = _findOrcaCli();
615
+ return ORCA_CLI;
616
+ }
617
+ function _findOrcaCli() {
618
+ const home = homedir(); const cands = [];
619
+ if (process.platform === "linux") cands.push(join(home, ".local/share/orca-ide/app/resources/bin/orca-ide"));
620
+ if (process.platform === "darwin") { cands.push(join(home, "Library/Application Support/orca-ide/app/resources/bin/orca-ide"), "/Applications/Orca.app/Contents/Resources/app/resources/bin/orca-ide", join(home, "Applications/Orca.app/Contents/Resources/app/resources/bin/orca-ide")); }
621
+ if (process.platform === "win32") { const la = process.env.LOCALAPPDATA || ""; cands.push(join(la, "orca-ide", "app", "resources", "bin", "orca-ide"), join(la, "Programs", "orca-ide", "resources", "app", "resources", "bin", "orca-ide")); }
622
+ for (const c of cands) { try { if (existsSync(c)) return c; } catch {} }
623
+ const roots = process.platform === "darwin" ? [join(home, "Library/Application Support"), "/Applications", join(home, "Applications")]
624
+ : process.platform === "win32" ? [process.env.LOCALAPPDATA || "", process.env.PROGRAMFILES || ""]
625
+ : [join(home, ".local/share"), "/opt", join(home, ".config")];
626
+ for (const r of roots) { if (!r) continue; const hit = sh(`find ${JSON.stringify(r)} -maxdepth 6 -name orca-ide -type f 2>/dev/null | head -1`).trim(); if (hit) return hit; }
627
+ return "";
628
+ }
578
629
  function detectHandoffs() {
579
- const editors = IDE_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label]) => ({ label, tmpl: `${cmd} {dir}`, kind: "editor" }));
580
- const agents = AGENT_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
581
- // Orca IDE: its own CLI (not the ~/.local/bin GPU wrapper, which ignores args).
582
- // Registers the repo, then opens a terminal tab running the agent — Orca shows
583
- // the live session itself.
584
- const orca = join(homedir(), ".local/share/orca-ide/app/resources/bin/orca-ide");
585
- if (existsSync(orca)) agents.unshift({ label: "Orca IDE (terminal tab)", tmpl: `${orca} repo add --path {dir}; ${orca} terminal create --worktree path:{dir} --command "claude {prompt}" --focus`, kind: "agent" });
630
+ const editors = [];
631
+ for (const [cmd, label, app] of IDE_LIST) {
632
+ if (hasCmd(cmd)) editors.push({ label, tmpl: `${cmd} {dir}`, kind: "editor" });
633
+ else { const a = macApp(app); if (a) editors.push({ label, tmpl: `open -a ${JSON.stringify(a)} {dir}`, kind: "editor" }); }
634
+ }
635
+ const agents = AGENT_LIST.filter(([cmd]) => hasCmd(cmd)).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
636
+ // Orca IDE (any OS): register the repo + open a terminal tab. Two variants —
637
+ // one that just opens the repo (use Orca's own agent, e.g. GPT), and one that
638
+ // runs Claude in the tab for Claude users. Not Claude-only.
639
+ const orca = findOrcaCli();
640
+ if (orca) {
641
+ const q = JSON.stringify(orca);
642
+ agents.unshift(
643
+ { label: "Orca IDE — open repo (use your Orca agent)", tmpl: `${q} repo add --path {dir}; ${q} terminal create --worktree path:{dir} --focus`, kind: "agent" },
644
+ { label: "Orca IDE — run Claude in a tab", tmpl: `${q} repo add --path {dir}; ${q} terminal create --worktree path:{dir} --command "claude {prompt}" --focus`, kind: "agent" },
645
+ );
646
+ }
586
647
  return { agents, editors };
587
648
  }
588
649
  const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
@@ -779,7 +840,7 @@ function driftRepo(p, opts = {}) {
779
840
  }
780
841
  function computeDrift(opts = {}) {
781
842
  const deploys = loadDeploys();
782
- const repos = findAllRepos(BASE).slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch }));
843
+ const repos = findAllRepos().slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch }));
783
844
  return { repos, ci: !!opts.ci };
784
845
  }
785
846
  function cmdDrift() {
@@ -812,9 +873,10 @@ const MANIFEST_TOOL = {
812
873
  "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
813
874
  };
814
875
  function findAllRepos(base) {
815
- const repos = [];
816
- for (const g of findGitDirs(base, 120)) {
817
- const repo = g.replace(/\/\.git$/, "");
876
+ const roots = base ? [base] : scanRoots();
877
+ const repos = []; const seen = new Set();
878
+ for (const root of roots) for (const g of findGitDirs(root, 300)) {
879
+ const repo = g.replace(/\/\.git$/, ""); if (seen.has(repo)) continue; seen.add(repo);
818
880
  const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
819
881
  if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
820
882
  }
@@ -825,7 +887,7 @@ function findAllRepos(base) {
825
887
  const key = cd.startsWith("/") ? cd : join(r.path, cd);
826
888
  if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
827
889
  }
828
- return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 30);
890
+ return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
829
891
  }
830
892
  function detectRepo(r) {
831
893
  const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
@@ -843,9 +905,31 @@ function detectRepo(r) {
843
905
  const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
844
906
  return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
845
907
  }
908
+ // Non-git PROJECT folders inside your scan roots (you added them = consent):
909
+ // a directory with a manifest but no .git — "not everything is a repo".
910
+ function findProjectFolders(root) {
911
+ const manifests = sh(
912
+ `find ${JSON.stringify(root)} -maxdepth 3 ` +
913
+ `\\( -name node_modules -o -name .cache -o -name .local -o -name .npm -o -name venv -o -name .venv -o -name .gradle -o -name Pods -o -name .git \\) -prune ` +
914
+ `-o -type f \\( -name package.json -o -name requirements.txt -o -name pyproject.toml -o -name go.mod -o -name Cargo.toml -o -name pom.xml -o -name build.gradle -o -name Gemfile -o -name composer.json -o -name Dockerfile -o -name pubspec.yaml -o -name CMakeLists.txt \\) -print 2>/dev/null | head -200`,
915
+ ).split("\n").filter(Boolean);
916
+ const dirs = new Set(); for (const m of manifests) dirs.add(m.replace(/\/[^/]+$/, ""));
917
+ const out = [];
918
+ for (const d of dirs) {
919
+ if (sh(`git -C ${JSON.stringify(d)} rev-parse --is-inside-work-tree 2>/dev/null`).trim() === "true") continue; // inside a repo already
920
+ out.push(d); if (out.length >= 24) break;
921
+ }
922
+ return out;
923
+ }
924
+ function detectFolder(path) {
925
+ 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);
926
+ const count = {}; const tools = new Set();
927
+ 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; }
928
+ 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 };
929
+ }
846
930
  function buildMap() {
847
931
  const who = me();
848
- const repos = findAllRepos(BASE).map(detectRepo);
932
+ const repos = findAllRepos().map(detectRepo);
849
933
  const nodes = []; const edges = []; const have = new Set();
850
934
  const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
851
935
  add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
@@ -857,6 +941,17 @@ function buildMap() {
857
941
  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 }); }
858
942
  for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
859
943
  }
944
+ // Non-git project folders in the scan roots (capped; deduped against repos).
945
+ try {
946
+ const repoPaths = new Set(repos.map((r) => r.path)); let folders = 0;
947
+ for (const root of scanRoots()) { for (const d of findProjectFolders(root)) {
948
+ if (repoPaths.has(d) || have.has("folder:" + d) || folders >= 20) continue;
949
+ const det = detectFolder(d); folders++;
950
+ 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 } });
951
+ edges.push({ source: "me", target: "folder:" + d });
952
+ 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 }); }
953
+ } }
954
+ } catch {}
860
955
  // What you build WITH (person-level, not per-repo): detected agents/editors
861
956
  // and the AI currently powering Symbiot — so the initial scan shows the whole
862
957
  // setup, not just code. All local detection, nothing invasive.
@@ -872,6 +967,7 @@ function buildMap() {
872
967
  } catch {}
873
968
  const out = { nodes, edges, stats: {
874
969
  repos: repos.length,
970
+ folders: nodes.filter((n) => n.type === "folder").length,
875
971
  languages: nodes.filter((n) => n.type === "lang").length,
876
972
  tools: nodes.filter((n) => n.type === "tool").length,
877
973
  agents: nodes.filter((n) => n.type === "agent").length,
@@ -905,6 +1001,10 @@ function nodeDetail(id) {
905
1001
  const n = map.nodes.find((x) => x.id === id);
906
1002
  return { type: id.startsWith("agent:") ? "agent" : "ai", label: n ? n.label : id.split(":")[1], meta: (n && n.meta) || {} };
907
1003
  }
1004
+ if (id.startsWith("folder:")) {
1005
+ const path = id.slice(7); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
1006
+ return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0 };
1007
+ }
908
1008
  return { error: "unknown node" };
909
1009
  }
910
1010
  // AI review of one repo: what it does, what it's for, possible upgrades.
@@ -985,10 +1085,16 @@ async function repoSuggest(path) {
985
1085
 
986
1086
  // Build a write-up for a command; returns { text, sub, error? } without printing.
987
1087
  // Shared by the CLI (cmdRun) and the web UI (symbiot app).
1088
+ // The SAME repo set the Map uses — reuse its cached scan when present, else do
1089
+ // the identical discovery. So week/standup/todo/drift all agree with the Map.
1090
+ function discoveredRepos() {
1091
+ 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 }));
1092
+ return findAllRepos();
1093
+ }
988
1094
  async function produce(cmd) {
989
1095
  if (!resolveProvider()) return { error: "not-connected" };
990
1096
  if (cmd === "todo") {
991
- const repos = findRepos(BASE, 60);
1097
+ const repos = discoveredRepos();
992
1098
  const open = openWork(repos);
993
1099
  if (!open.length) return { text: "Nothing outstanding found (no TODOs or uncommitted work).", sub: "0 open items · todo" };
994
1100
  const system =
@@ -1001,8 +1107,10 @@ async function produce(cmd) {
1001
1107
  const label = cmd === "standup" ? "standup" : "week";
1002
1108
  const days = cmd === "standup" ? 2 : SINCE_WEEK;
1003
1109
  const who = me();
1004
- const repos = findRepos(BASE, days);
1005
- if (!repos.length) return { text: `No git activity in the last ${days} days under ${BASE}.\nPoint it at your repos with --dir.`, sub: "no activity" };
1110
+ // Discover the Map's repo set, then keep only those with commits in the window.
1111
+ const all = discoveredRepos();
1112
+ const repos = all.filter((r) => sh(`git -C ${JSON.stringify(r.path)} log --since="${days} days ago" --oneline -1 2>/dev/null`).trim());
1113
+ if (!repos.length) return { text: `No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, sub: "no activity" };
1006
1114
  let cs = commits(repos, `${days} days ago`, !has("all"));
1007
1115
  if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
1008
1116
  const open = label === "week" ? openWork(repos) : [];
@@ -1021,7 +1129,7 @@ async function produce(cmd) {
1021
1129
  `Write the ${label === "standup" ? "standup" : "update"}.`;
1022
1130
 
1023
1131
  const text = await write(system, prompt);
1024
- return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`, footer: `symbiot ${VERSION} · ${repos.length} repos scanned · ${cs.length} commits (yours) in last ${days}d` };
1132
+ return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d` };
1025
1133
  }
1026
1134
 
1027
1135
  async function cmdRun(cmd) {
@@ -1250,6 +1358,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1250
1358
  <span class="lg"><i style="background:#6bb3ff"></i>tools</span>
1251
1359
  <span class="lg"><i style="background:#c58af9"></i>agents</span>
1252
1360
  <span class="lg"><i style="background:#5fe3b0"></i>AI</span>
1361
+ <span class="lg"><i style="background:#b7a98c"></i>folders (no git)</span>
1253
1362
  <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
1254
1363
  <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
1255
1364
  </div>
@@ -1297,6 +1406,12 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1297
1406
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
1298
1407
  <span class="note" id="saveMsg"></span></div>
1299
1408
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1409
+ <label>Folders to scan for repos</label>
1410
+ <div id="scanroots"></div>
1411
+ <div class="row" style="margin-top:6px"><input id="newroot" placeholder="/path/to/folder (or ~/work) — where your projects live" style="flex:1"><button class="ghost" id="addroot">Add folder</button></div>
1412
+ <div class="note muted" id="scanrootnote">Point Symbiot at where your work lives — inside or outside your home folder. Defaults to your home folder.</div>
1413
+ </div>
1414
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1300
1415
  <label>Hand off to your agent when you "Send to repos"</label>
1301
1416
  <input id="agentcmd" type="text" placeholder="e.g. claude -p &quot;{prompt}&quot; · code {dir} · leave blank to just write the file">
1302
1417
  <div class="note muted">Runs in each repo after tasks are written. Use <b>{dir}</b> = repo path, <b>{prompt}</b> = the task instruction. Works with any agent or editor &mdash; it's your command.</div>
@@ -1318,7 +1433,7 @@ var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'http
1318
1433
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
1319
1434
  function $(id){return document.getElementById(id);}
1320
1435
  var current='map';var mapLoaded=false;var driftLoaded=false;
1321
- var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0'};
1436
+ var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0',folder:'#b7a98c'};
1322
1437
  function tabs(){return document.querySelectorAll('.tab');}
1323
1438
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
1324
1439
  var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isRun=(tab==='week'||tab==='standup'||tab==='todo');
@@ -1374,6 +1489,7 @@ document.getElementById('suggest').addEventListener('click',function(){var o=doc
1374
1489
  if(d.type==='lang'||d.type==='tool'){var lis=(d.repos||[]).map(function(r){return "<li>"+esc(r)+"</li>";}).join("");el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>Used in "+((d.repos||[]).length)+" repos</div><ul>"+lis+"</ul>";return;}
1375
1490
  if(d.type==='agent'){var m=d.meta||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+(m.kind==='agent'?'AI coding agent you have installed':'editor you have installed')+"</div>"+(m.cmd?"<div class='out2' style='font-size:11px'>"+esc(m.cmd)+"</div>":"");return;}
1376
1491
  if(d.type==='ai'){var m=d.meta||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>Currently powering Symbiot &middot; model <b>"+esc(m.model||'?')+"</b>"+(m.source?" &middot; via "+esc(m.source):"")+"</div>";return;}
1492
+ if(d.type==='folder'){var chips='';(d.langs||[]).concat(d.tools||[]).forEach(function(x){chips+="<span class='tag'>"+esc(x)+"</span>";});el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>project folder &middot; not a git repo &middot; "+(d.files||0)+" files</div><div class='chips'>"+chips+"</div><div class='k' style='font-family:ui-monospace,monospace'>"+esc(d.path||'')+"</div><div class='k'>Tasks can be sent here too &mdash; the agent runs in this folder.</div>";return;}
1377
1493
  if(d.type==='person'){var st=d.stats||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+st.repos+" repos &middot; "+st.commits+" commits &middot; "+st.languages+" languages &middot; "+st.tools+" tools"+(st.agents?" &middot; "+st.agents+" agents/editors":"")+"</div>";return;}}
1378
1494
  var reviewCache={};var IDEAS=[];var IREPO="";
1379
1495
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
@@ -1470,6 +1586,11 @@ if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Noth
1470
1586
  box.innerHTML="<span class='muted' style='font-size:12px'>Detected &mdash; click to use:</span><br>"+chips.map(function(c,i){return "<button class='ghost preset' data-i='"+i+"' style='padding:4px 10px;font-size:12px;margin:5px 5px 0 0'>"+esc(c.label)+"</button>";}).join("");
1471
1587
  box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
1472
1588
  function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
1589
+ function loadScanRoots(){api('/api/scanroots').then(function(d){var box=document.getElementById('scanroots');var roots=d.effective||[];
1590
+ box.innerHTML=roots.map(function(r){var custom=(d.roots||[]).indexOf(r)>=0;return "<div class='task' data-p='"+esc(r)+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(r)+"</span>"+(r===d.home?"<span class='rp'>home</span>":"")+(custom?"<button class='rm rmroot' title='remove'>&times;</button>":"")+"</div>";}).join("");
1591
+ box.querySelectorAll('.rmroot').forEach(function(btn){btn.addEventListener('click',function(){api('/api/scanroots/remove',{path:btn.closest('.task').getAttribute('data-p')}).then(function(){loadScanRoots();mapLoaded=false;driftLoaded=false;});});});});}
1592
+ function addRootUI(){var i=document.getElementById('newroot');var v=(i.value||'').trim();if(!v)return;var n=document.getElementById('scanrootnote');
1593
+ api('/api/scanroots/add',{path:v}).then(function(r){if(r.error){n.innerHTML="<span class='err'>"+esc(r.error)+"</span>";return;}i.value='';n.textContent='Added — the Map/Drift will rescan.';loadScanRoots();mapLoaded=false;driftLoaded=false;});}
1473
1594
  function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model&hellip;";
1474
1595
  api('/api/setup-local',{}).then(function(r){if(r.error==='not-installed'){var i=r.install||{};o.innerHTML="Ollama isn't installed. Run this once, then click again:<div class='out2'>"+esc(i.cmd||'')+"</div><div class='muted' style='font-size:11px'>or download: "+esc(i.alt||'https://ollama.com/download')+"</div>";return;}
1475
1596
  o.innerHTML="Pulling <b>"+esc(r.model)+"</b> &mdash; watch it in the <b>Agents</b> tab. Symbiot switches to it automatically when the download finishes.";setTimeout(function(){setTab('agents');},700);}).catch(function(e){o.innerHTML="<span class='err'>Setup failed: "+esc(String((e&&e.message)||e))+"</span>";});}
@@ -1502,13 +1623,15 @@ h+="</div>";out.innerHTML=h;});}
1502
1623
  document.getElementById('remap').addEventListener('click',loadMap);
1503
1624
  document.getElementById('recbtn').addEventListener('click',loadRec);
1504
1625
  document.getElementById('setuplocal').addEventListener('click',setupLocalUI);
1626
+ document.getElementById('addroot').addEventListener('click',addRootUI);
1627
+ document.getElementById('newroot').addEventListener('keydown',function(e){if(e.key==='Enter')addRootUI();});
1505
1628
  document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
1506
1629
  document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
1507
1630
  document.getElementById('addtask').addEventListener('click',addTaskUI);
1508
1631
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1509
1632
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
1510
1633
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
1511
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();
1634
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
1512
1635
  </script></body></html>`;
1513
1636
 
1514
1637
  function readBody(req) {
@@ -1574,6 +1697,9 @@ async function cmdApp() {
1574
1697
  if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1575
1698
  if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
1576
1699
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
1700
+ if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: homedir() });
1701
+ if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
1702
+ if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
1577
1703
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1578
1704
  if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); const cfg = loadConfig(); const v = String(b.cmd || "").trim(); if (v) cfg.agentCmd = v; else delete cfg.agentCmd; delete cfg.ide; saveConfig(cfg); return json(res, { ok: true, cmd: cfg.agentCmd || "" }); }
1579
1705
  if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req); const e = runHandoff(String(b.path || "")); return json(res, { opened: !!e, id: e ? e.id : "" }); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.17.0",
3
+ "version": "0.22.0",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {