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.
- package/index.mjs +154 -28
- 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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
577
|
-
|
|
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 =
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
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(
|
|
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
|
|
816
|
-
|
|
817
|
-
|
|
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,
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
1005
|
-
|
|
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} · ${
|
|
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 · drag to pan · 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 & 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 "{prompt}" · 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 — 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 · model <b>"+esc(m.model||'?')+"</b>"+(m.source?" · 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 · not a git repo · "+(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 — 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 · "+st.commits+" commits · "+st.languages+" languages · "+st.tools+" tools"+(st.agents?" · "+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 — 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'>×</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…";
|
|
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> — 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.
|
|
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": {
|