symbiot 0.13.0 → 0.20.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 +306 -42
- package/package.json +1 -1
package/index.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import Anthropic from "@anthropic-ai/sdk";
|
|
|
21
21
|
import { execSync, spawn } from "node:child_process";
|
|
22
22
|
import { homedir, totalmem, cpus as oscpus } from "node:os";
|
|
23
23
|
import { join } from "node:path";
|
|
24
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, statSync, realpathSync } from "node:fs";
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, statSync, realpathSync, openSync, writeSync } from "node:fs";
|
|
25
25
|
import { createInterface } from "node:readline";
|
|
26
26
|
import { createServer } from "node:http";
|
|
27
27
|
import { randomBytes } from "node:crypto";
|
|
@@ -56,6 +56,7 @@ const has = (name) => argv.includes(`--${name}`);
|
|
|
56
56
|
const PLAIN = has("plain") || !process.stdout.isTTY;
|
|
57
57
|
let SERVING = false; // set while `symbiot app` runs — silences the CLI spinner
|
|
58
58
|
let LAST_MAP = null; // cached graph so node clicks don't rescan
|
|
59
|
+
const HANDOFFS = []; // live registry of agents Symbiot has handed work to
|
|
59
60
|
|
|
60
61
|
// ---- colour + spinner ------------------------------------------------------
|
|
61
62
|
const c = PLAIN
|
|
@@ -103,6 +104,31 @@ function addTask(text, repo) {
|
|
|
103
104
|
}
|
|
104
105
|
function toggleTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.done = !it.done; saveTasks(t); } return it || { error: "not found" }; }
|
|
105
106
|
function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
|
|
107
|
+
function restoreTask(id) { const t = loadTasks(); const it = t.find((x) => x.id === id); if (it) { it.archived = false; it.done = false; delete it.archivedAt; saveTasks(t); } return it || { error: "not found" }; }
|
|
108
|
+
// Which task texts the agent checked off in a repo's .symbiot/TASKS.md
|
|
109
|
+
function completedInRepo(repoPath) {
|
|
110
|
+
try { return readFileSync(join(repoPath, ".symbiot", "TASKS.md"), "utf8").split("\n").filter((l) => /^\s*-\s*\[x\]/i.test(l)).map((l) => l.replace(/^\s*-\s*\[x\]\s*/i, "").trim().toLowerCase()); }
|
|
111
|
+
catch { return []; }
|
|
112
|
+
}
|
|
113
|
+
function repoPathMap() {
|
|
114
|
+
const byName = {};
|
|
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
|
+
for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
|
|
117
|
+
return byName;
|
|
118
|
+
}
|
|
119
|
+
// "Check what was handed out, see what's completed, then archive it": mark done
|
|
120
|
+
// any task the agent ticked in TASKS.md, then auto-archive every done task.
|
|
121
|
+
function syncTasks() {
|
|
122
|
+
const t = loadTasks(); const map = repoPathMap(); let completed = 0, archived = 0; const checkedByRepo = {};
|
|
123
|
+
for (const x of t) {
|
|
124
|
+
if (x.archived || x.done || !x.repo) continue;
|
|
125
|
+
if (!(x.repo in checkedByRepo)) checkedByRepo[x.repo] = map[x.repo] ? completedInRepo(map[x.repo]) : [];
|
|
126
|
+
if (checkedByRepo[x.repo].includes(x.text.toLowerCase())) { x.done = true; completed++; }
|
|
127
|
+
}
|
|
128
|
+
for (const x of t) { if (x.done && !x.archived) { x.archived = true; x.archivedAt = Date.now(); archived++; } }
|
|
129
|
+
saveTasks(t);
|
|
130
|
+
return { completed, archived };
|
|
131
|
+
}
|
|
106
132
|
// Deterministic task classification (no model) so tasks batch by kind instead
|
|
107
133
|
// of arriving as a flat, mixed pile. Order matters (most specific first).
|
|
108
134
|
const TASK_ORDER = ["Fixes", "Tests & CI", "Security", "Performance", "Refactor", "UI/UX", "Docs", "Features & other"];
|
|
@@ -135,13 +161,15 @@ function buildTasksMd(name, ctx, list) {
|
|
|
135
161
|
L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Confirm with me before anything destructive."');
|
|
136
162
|
return L.join("\n") + "\n";
|
|
137
163
|
}
|
|
138
|
-
function pushTasks() {
|
|
139
|
-
|
|
164
|
+
function pushTasks(filter) {
|
|
165
|
+
let tasks = loadTasks().filter((t) => !t.done && !t.archived);
|
|
166
|
+
if (filter && filter.type) tasks = tasks.filter((t) => taskType(t.text) === filter.type);
|
|
167
|
+
if (filter && filter.repo) tasks = tasks.filter((t) => t.repo === filter.repo);
|
|
140
168
|
if (!tasks.length) return { empty: true, written: [], unresolved: [] };
|
|
141
169
|
// Resolve repo name -> path from the already-scanned map when we have it
|
|
142
170
|
// (avoids a fresh full scan); fall back to a scan only if needed.
|
|
143
171
|
const byName = {};
|
|
144
|
-
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();
|
|
145
173
|
for (const r of src) if (r.path && !byName[r.name]) byName[r.name] = r.path;
|
|
146
174
|
const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
|
|
147
175
|
const written = [], unresolved = [];
|
|
@@ -232,8 +260,8 @@ function sh(cmd) {
|
|
|
232
260
|
// crawling into them — this is the big speedup for the map scan.
|
|
233
261
|
function findGitDirs(base, limit) {
|
|
234
262
|
return sh(
|
|
235
|
-
`find ${JSON.stringify(base)} -maxdepth
|
|
236
|
-
`\\( -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 ` +
|
|
237
265
|
`-o -name .git -print 2>/dev/null | head -${limit}`,
|
|
238
266
|
).split("\n").filter(Boolean);
|
|
239
267
|
}
|
|
@@ -344,10 +372,30 @@ function reportFooter(repoPath, auth, state, rd) {
|
|
|
344
372
|
if (state.behind) b.push(`${state.behind} behind upstream`);
|
|
345
373
|
return b.join(" · ");
|
|
346
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
|
+
}
|
|
347
395
|
function findRepos(base, sinceDays) {
|
|
348
|
-
const repos = [];
|
|
349
|
-
for (const g of findGitDirs(
|
|
350
|
-
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);
|
|
351
399
|
const n = Number(sh(`git -C ${JSON.stringify(repo)} log --since="${sinceDays} days ago" --oneline 2>/dev/null | wc -l`).trim());
|
|
352
400
|
const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
|
|
353
401
|
if (n > 0) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
|
|
@@ -453,7 +501,10 @@ async function callOllama(r, system, prompt) {
|
|
|
453
501
|
body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
|
|
454
502
|
});
|
|
455
503
|
const text = await res.text();
|
|
456
|
-
if (!res.ok)
|
|
504
|
+
if (!res.ok) {
|
|
505
|
+
if (/not found|no such model|try pulling/i.test(text)) throw new Error(`the model "${r.model}" isn't downloaded yet — run symbiot setup-local --model ${r.model} (or click "Set up a free local model" in Settings)`);
|
|
506
|
+
throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
|
|
507
|
+
}
|
|
457
508
|
return (JSON.parse(text).message?.content || "").trim();
|
|
458
509
|
}
|
|
459
510
|
|
|
@@ -543,23 +594,83 @@ function recommendModels(hw) {
|
|
|
543
594
|
// gnome-terminal --working-directory={dir} -- claude "{prompt}"
|
|
544
595
|
// Not tied to any one tool — you decide what runs.
|
|
545
596
|
const IDE_LIST = [["code", "VS Code"], ["cursor", "Cursor"], ["windsurf", "Windsurf"], ["zed", "Zed"], ["subl", "Sublime Text"], ["idea", "IntelliJ IDEA"], ["nvim", "Neovim"]];
|
|
546
|
-
const AGENT_LIST = [
|
|
597
|
+
const AGENT_LIST = [
|
|
598
|
+
["claude", "Claude Code — make changes", 'claude -p "{prompt}" --permission-mode acceptEdits'],
|
|
599
|
+
["claude", "Claude Code — plan only (asks first)", 'claude -p "{prompt}"'],
|
|
600
|
+
["aider", "Aider — make changes", 'aider --message "{prompt}" --yes'],
|
|
601
|
+
["cursor-agent", "Cursor agent", 'cursor-agent -p "{prompt}"'],
|
|
602
|
+
];
|
|
547
603
|
function detectHandoffs() {
|
|
548
604
|
const editors = IDE_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label]) => ({ label, tmpl: `${cmd} {dir}`, kind: "editor" }));
|
|
549
605
|
const agents = AGENT_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label, tmpl]) => ({ label, tmpl, kind: "agent" }));
|
|
606
|
+
// Orca IDE: its own CLI (not the ~/.local/bin GPU wrapper, which ignores args).
|
|
607
|
+
// Registers the repo, then opens a terminal tab running the agent — Orca shows
|
|
608
|
+
// the live session itself.
|
|
609
|
+
const orca = join(homedir(), ".local/share/orca-ide/app/resources/bin/orca-ide");
|
|
610
|
+
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" });
|
|
550
611
|
return { agents, editors };
|
|
551
612
|
}
|
|
552
613
|
const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
|
|
553
614
|
const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
|
|
554
615
|
const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Confirm before anything destructive.";
|
|
555
616
|
function handoffCmd() { const cfg = loadConfig(); return cfg.agentCmd || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
|
|
556
|
-
|
|
617
|
+
// Run a shell command as a tracked, logged background job that shows up live in
|
|
618
|
+
// the Agents tab. Shared by the agent handoff and the local-model setup.
|
|
619
|
+
function track(name, cmd, cwd, onExit) {
|
|
557
620
|
try {
|
|
558
|
-
const
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
621
|
+
const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
|
|
622
|
+
const logp = join(dir, "agent.log");
|
|
623
|
+
let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
|
|
624
|
+
const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
|
|
625
|
+
const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd] });
|
|
626
|
+
entry.pid = child.pid;
|
|
627
|
+
child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
|
|
628
|
+
child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
|
|
629
|
+
child.unref();
|
|
630
|
+
HANDOFFS.unshift(entry);
|
|
631
|
+
if (HANDOFFS.length > 30) HANDOFFS.length = 30;
|
|
632
|
+
return entry;
|
|
633
|
+
} catch { return null; }
|
|
634
|
+
}
|
|
635
|
+
function runHandoff(repoPath) {
|
|
636
|
+
const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
|
|
637
|
+
const cmd = tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
|
|
638
|
+
return track(repoPath.split("/").pop(), cmd, repoPath);
|
|
639
|
+
}
|
|
640
|
+
// ---- local model one-command setup (Ollama), OS-aware ---------------------
|
|
641
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
642
|
+
function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
|
|
643
|
+
function ollamaInstall() {
|
|
644
|
+
if (process.platform === "darwin") return { cmd: "brew install ollama", alt: "https://ollama.com/download" };
|
|
645
|
+
if (process.platform === "win32") return { cmd: "winget install Ollama.Ollama", alt: "https://ollama.com/download" };
|
|
646
|
+
return { cmd: "curl -fsSL https://ollama.com/install.sh | sh", alt: "https://ollama.com/download" };
|
|
647
|
+
}
|
|
648
|
+
async function ollamaUp() { try { const r = await fetch("http://127.0.0.1:11434/api/tags"); return r.ok; } catch { return false; } }
|
|
649
|
+
async function ensureOllama() {
|
|
650
|
+
if (await ollamaUp()) return true;
|
|
651
|
+
try { spawn("ollama", ["serve"], { detached: true, stdio: "ignore" }).unref(); } catch {}
|
|
652
|
+
for (let i = 0; i < 16; i++) { await sleep(500); if (await ollamaUp()) return true; }
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
function useOllamaModel(model) { const cfg = loadConfig(); cfg.provider = "ollama"; cfg.ollama = { baseUrl: "http://localhost:11434", model }; delete cfg.apiKey; saveConfig(cfg); }
|
|
656
|
+
async function cmdSetupLocal() {
|
|
657
|
+
const model = flag("model", null) || recommendModels(detectHardware()).best;
|
|
658
|
+
if (!hasOllama()) {
|
|
659
|
+
const inst = ollamaInstall();
|
|
660
|
+
console.log("\n" + c.y("Ollama isn't installed") + c.d(" — the free, private local-model runner.") + "\n");
|
|
661
|
+
console.log(` On ${process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"}, install it then re-run ${c.b("symbiot setup-local")}:`);
|
|
662
|
+
console.log(" " + c.b(inst.cmd));
|
|
663
|
+
console.log(c.d(" or download: " + inst.alt));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
process.stdout.write(c.d("Starting Ollama… "));
|
|
667
|
+
if (!(await ensureOllama())) { console.log(c.y("couldn't reach it — start it with ") + c.b("ollama serve")); return; }
|
|
668
|
+
console.log(c.g("ready"));
|
|
669
|
+
console.log("\n" + c.b("Pulling " + model) + c.d(" (first time downloads a few GB — progress below)\n"));
|
|
670
|
+
const code = await new Promise((res) => { const ch = spawn("ollama", ["pull", model], { stdio: "inherit" }); ch.on("close", res); ch.on("error", () => res(1)); });
|
|
671
|
+
if (code !== 0) { console.log("\n" + c.y("Pull failed.") + c.d(" Try a smaller model: ") + c.b("symbiot setup-local --model llama3.2:3b")); return; }
|
|
672
|
+
useOllamaModel(model);
|
|
673
|
+
console.log("\n" + c.g("✓ ") + `Symbiot now runs on ${model} locally — free & private. Try: ` + c.b("symbiot week"));
|
|
563
674
|
}
|
|
564
675
|
function cmdModels() {
|
|
565
676
|
const hw = detectHardware(); const rec = recommendModels(hw);
|
|
@@ -693,7 +804,7 @@ function driftRepo(p, opts = {}) {
|
|
|
693
804
|
}
|
|
694
805
|
function computeDrift(opts = {}) {
|
|
695
806
|
const deploys = loadDeploys();
|
|
696
|
-
const repos = findAllRepos(
|
|
807
|
+
const repos = findAllRepos().slice(0, 20).map((r) => driftRepo(r.path, { deploys, ci: opts.ci, fetch: opts.fetch }));
|
|
697
808
|
return { repos, ci: !!opts.ci };
|
|
698
809
|
}
|
|
699
810
|
function cmdDrift() {
|
|
@@ -726,9 +837,10 @@ const MANIFEST_TOOL = {
|
|
|
726
837
|
"terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
|
|
727
838
|
};
|
|
728
839
|
function findAllRepos(base) {
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
|
|
840
|
+
const roots = base ? [base] : scanRoots();
|
|
841
|
+
const repos = []; const seen = new Set();
|
|
842
|
+
for (const root of roots) for (const g of findGitDirs(root, 300)) {
|
|
843
|
+
const repo = g.replace(/\/\.git$/, ""); if (seen.has(repo)) continue; seen.add(repo);
|
|
732
844
|
const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
|
|
733
845
|
if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
|
|
734
846
|
}
|
|
@@ -739,7 +851,7 @@ function findAllRepos(base) {
|
|
|
739
851
|
const key = cd.startsWith("/") ? cd : join(r.path, cd);
|
|
740
852
|
if (!byCommon[key] || byCommon[key].recency < r.recency) byCommon[key] = r;
|
|
741
853
|
}
|
|
742
|
-
return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0,
|
|
854
|
+
return Object.values(byCommon).sort((a, b) => b.recency - a.recency).slice(0, 60);
|
|
743
855
|
}
|
|
744
856
|
function detectRepo(r) {
|
|
745
857
|
const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -3000`).split("\n").filter(Boolean);
|
|
@@ -757,9 +869,31 @@ function detectRepo(r) {
|
|
|
757
869
|
const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
|
|
758
870
|
return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
|
|
759
871
|
}
|
|
872
|
+
// Non-git PROJECT folders inside your scan roots (you added them = consent):
|
|
873
|
+
// a directory with a manifest but no .git — "not everything is a repo".
|
|
874
|
+
function findProjectFolders(root) {
|
|
875
|
+
const manifests = sh(
|
|
876
|
+
`find ${JSON.stringify(root)} -maxdepth 3 ` +
|
|
877
|
+
`\\( -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 ` +
|
|
878
|
+
`-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`,
|
|
879
|
+
).split("\n").filter(Boolean);
|
|
880
|
+
const dirs = new Set(); for (const m of manifests) dirs.add(m.replace(/\/[^/]+$/, ""));
|
|
881
|
+
const out = [];
|
|
882
|
+
for (const d of dirs) {
|
|
883
|
+
if (sh(`git -C ${JSON.stringify(d)} rev-parse --is-inside-work-tree 2>/dev/null`).trim() === "true") continue; // inside a repo already
|
|
884
|
+
out.push(d); if (out.length >= 24) break;
|
|
885
|
+
}
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
function detectFolder(path) {
|
|
889
|
+
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);
|
|
890
|
+
const count = {}; const tools = new Set();
|
|
891
|
+
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; }
|
|
892
|
+
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 };
|
|
893
|
+
}
|
|
760
894
|
function buildMap() {
|
|
761
895
|
const who = me();
|
|
762
|
-
const repos = findAllRepos(
|
|
896
|
+
const repos = findAllRepos().map(detectRepo);
|
|
763
897
|
const nodes = []; const edges = []; const have = new Set();
|
|
764
898
|
const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
|
|
765
899
|
add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
|
|
@@ -771,10 +905,36 @@ function buildMap() {
|
|
|
771
905
|
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 }); }
|
|
772
906
|
for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
|
|
773
907
|
}
|
|
908
|
+
// Non-git project folders in the scan roots (capped; deduped against repos).
|
|
909
|
+
try {
|
|
910
|
+
const repoPaths = new Set(repos.map((r) => r.path)); let folders = 0;
|
|
911
|
+
for (const root of scanRoots()) { for (const d of findProjectFolders(root)) {
|
|
912
|
+
if (repoPaths.has(d) || have.has("folder:" + d) || folders >= 20) continue;
|
|
913
|
+
const det = detectFolder(d); folders++;
|
|
914
|
+
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 } });
|
|
915
|
+
edges.push({ source: "me", target: "folder:" + d });
|
|
916
|
+
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 }); }
|
|
917
|
+
} }
|
|
918
|
+
} catch {}
|
|
919
|
+
// What you build WITH (person-level, not per-repo): detected agents/editors
|
|
920
|
+
// and the AI currently powering Symbiot — so the initial scan shows the whole
|
|
921
|
+
// setup, not just code. All local detection, nothing invasive.
|
|
922
|
+
try {
|
|
923
|
+
const hands = detectHandoffs();
|
|
924
|
+
for (const a of [...hands.agents, ...hands.editors]) {
|
|
925
|
+
const id = "agent:" + a.label;
|
|
926
|
+
add({ id, type: "agent", label: a.label.replace(/\s*\(.*\)$/, ""), weight: 11, meta: { cmd: a.tmpl, kind: a.kind } });
|
|
927
|
+
edges.push({ source: "me", target: id });
|
|
928
|
+
}
|
|
929
|
+
const prov = resolveProvider();
|
|
930
|
+
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 }); }
|
|
931
|
+
} catch {}
|
|
774
932
|
const out = { nodes, edges, stats: {
|
|
775
933
|
repos: repos.length,
|
|
934
|
+
folders: nodes.filter((n) => n.type === "folder").length,
|
|
776
935
|
languages: nodes.filter((n) => n.type === "lang").length,
|
|
777
936
|
tools: nodes.filter((n) => n.type === "tool").length,
|
|
937
|
+
agents: nodes.filter((n) => n.type === "agent").length,
|
|
778
938
|
commits: repos.reduce((s, r) => s + r.mine, 0),
|
|
779
939
|
files: repos.reduce((s, r) => s + (r.files || 0), 0),
|
|
780
940
|
base: BASE,
|
|
@@ -801,6 +961,14 @@ function nodeDetail(id) {
|
|
|
801
961
|
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);
|
|
802
962
|
return { type: id.startsWith("lang:") ? "lang" : "tool", label: n ? n.label : id.split(":")[1], repos };
|
|
803
963
|
}
|
|
964
|
+
if (id.startsWith("agent:") || id.startsWith("ai:")) {
|
|
965
|
+
const n = map.nodes.find((x) => x.id === id);
|
|
966
|
+
return { type: id.startsWith("agent:") ? "agent" : "ai", label: n ? n.label : id.split(":")[1], meta: (n && n.meta) || {} };
|
|
967
|
+
}
|
|
968
|
+
if (id.startsWith("folder:")) {
|
|
969
|
+
const path = id.slice(7); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
|
|
970
|
+
return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0 };
|
|
971
|
+
}
|
|
804
972
|
return { error: "unknown node" };
|
|
805
973
|
}
|
|
806
974
|
// AI review of one repo: what it does, what it's for, possible upgrades.
|
|
@@ -1090,6 +1258,11 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1090
1258
|
.task .rm:hover{color:var(--amber)}
|
|
1091
1259
|
.tgroup{margin:14px 0 4px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--green);font-weight:700}
|
|
1092
1260
|
.tcount{color:var(--faint);font-weight:400}
|
|
1261
|
+
.taskfilter{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:10px 0 2px}
|
|
1262
|
+
.fl{font-size:10px;color:var(--faint);text-transform:uppercase;letter-spacing:.12em;margin:0 2px 0 8px}
|
|
1263
|
+
.fchip{font-size:12px;padding:4px 10px;border-radius:999px;border:1px solid var(--line);background:var(--ink3);color:var(--text);cursor:pointer;font:inherit}
|
|
1264
|
+
.fchip.on{background:var(--green-dim);border-color:#2a6b52;color:var(--green)}
|
|
1265
|
+
.task .rm.restore{color:var(--green);font-size:15px}
|
|
1093
1266
|
.drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
|
|
1094
1267
|
.drift .dh{display:flex;gap:8px;align-items:center}
|
|
1095
1268
|
.drift .dn{color:var(--bone);font-weight:600}
|
|
@@ -1100,6 +1273,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1100
1273
|
.drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
|
|
1101
1274
|
.dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
|
|
1102
1275
|
.dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
|
|
1276
|
+
.agent{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
|
|
1277
|
+
.adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
|
|
1278
|
+
.adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
|
|
1279
|
+
.adot.ok{background:var(--green)} .adot.fail{background:#E5695B}
|
|
1280
|
+
@keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(242,165,65,.5)}50%{opacity:.4;box-shadow:0 0 0 5px rgba(242,165,65,0)}}
|
|
1281
|
+
.alogout{white-space:pre-wrap;background:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;color:var(--text);max-height:260px;overflow:auto}
|
|
1282
|
+
.bar{height:3px;background:var(--green-dim);border-radius:2px;overflow:hidden;margin-top:8px}
|
|
1283
|
+
.bar > i{display:block;height:100%;width:35%;background:var(--green);border-radius:2px;animation:slide 1.3s ease-in-out infinite}
|
|
1284
|
+
@keyframes slide{0%{margin-left:-35%}100%{margin-left:100%}}
|
|
1103
1285
|
.out2{white-space:pre-wrap;background:var(--ink);border:1px solid var(--line);border-radius:9px;padding:10px;margin-top:10px;font-size:13px;line-height:1.55;color:var(--bone)}
|
|
1104
1286
|
.legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
|
|
1105
1287
|
.lg{display:inline-flex;gap:6px;align-items:center}
|
|
@@ -1114,6 +1296,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1114
1296
|
<button class="tab" data-tab="standup">Standup</button>
|
|
1115
1297
|
<button class="tab" data-tab="todo">Todo</button>
|
|
1116
1298
|
<button class="tab" data-tab="tasks">Tasks</button>
|
|
1299
|
+
<button class="tab" data-tab="agents">Agents</button>
|
|
1117
1300
|
<button class="tab" data-tab="settings">Settings</button>
|
|
1118
1301
|
</div>
|
|
1119
1302
|
<main>
|
|
@@ -1129,6 +1312,9 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1129
1312
|
<span class="lg"><i style="background:var(--bone)"></i>repos</span>
|
|
1130
1313
|
<span class="lg"><i style="background:var(--amber)"></i>languages</span>
|
|
1131
1314
|
<span class="lg"><i style="background:#6bb3ff"></i>tools</span>
|
|
1315
|
+
<span class="lg"><i style="background:#c58af9"></i>agents</span>
|
|
1316
|
+
<span class="lg"><i style="background:#5fe3b0"></i>AI</span>
|
|
1317
|
+
<span class="lg"><i style="background:#b7a98c"></i>folders (no git)</span>
|
|
1132
1318
|
<span class="muted" style="margin-left:8px">scroll to zoom · drag to pan · click a node</span>
|
|
1133
1319
|
<button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
|
|
1134
1320
|
</div>
|
|
@@ -1143,9 +1329,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1143
1329
|
</section>
|
|
1144
1330
|
<section id="panel-tasks" class="hidden">
|
|
1145
1331
|
<div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><button class="act" id="addtask">Add</button><button class="ghost" id="pushtasks" title="Write .symbiot/TASKS.md into each repo for your coding agent">Send to repos</button></div>
|
|
1332
|
+
<div class="note muted" style="margin-top:2px">The checkbox marks a task <b>done</b> (it auto-archives). To give tasks to your agent, use <b>Send to repos</b> — filter by tag below to choose which.</div>
|
|
1146
1333
|
<div id="pushout"></div>
|
|
1334
|
+
<div id="taskfilter" class="taskfilter"></div>
|
|
1147
1335
|
<div id="tasklist"></div>
|
|
1148
1336
|
</section>
|
|
1337
|
+
<section id="panel-agents" class="hidden">
|
|
1338
|
+
<div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output.</span><button class="ghost" id="agentsrefresh" style="margin-left:auto">Refresh</button></div>
|
|
1339
|
+
<div id="agentslist"></div>
|
|
1340
|
+
</section>
|
|
1149
1341
|
<section id="panel-drift" class="hidden">
|
|
1150
1342
|
<div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
|
|
1151
1343
|
<label class="muted" style="margin-left:auto"><input type="checkbox" id="driftfetch"> fetch latest</label>
|
|
@@ -1170,6 +1362,12 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1170
1362
|
<div class="row" style="margin-top:16px"><button class="act" id="save">Save & connect</button>
|
|
1171
1363
|
<span class="note" id="saveMsg"></span></div>
|
|
1172
1364
|
<div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
|
|
1365
|
+
<label>Folders to scan for repos</label>
|
|
1366
|
+
<div id="scanroots"></div>
|
|
1367
|
+
<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>
|
|
1368
|
+
<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>
|
|
1369
|
+
</div>
|
|
1370
|
+
<div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
|
|
1173
1371
|
<label>Hand off to your agent when you "Send to repos"</label>
|
|
1174
1372
|
<input id="agentcmd" type="text" placeholder="e.g. claude -p "{prompt}" · code {dir} · leave blank to just write the file">
|
|
1175
1373
|
<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>
|
|
@@ -1177,6 +1375,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
|
|
|
1177
1375
|
</div>
|
|
1178
1376
|
<div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
|
|
1179
1377
|
<button class="ghost" id="recbtn">Recommend models for my machine</button>
|
|
1378
|
+
<button class="ghost" id="setuplocal" style="margin-left:8px">Set up a free local model</button>
|
|
1379
|
+
<div id="setupout" class="note muted" style="margin-top:10px"></div>
|
|
1180
1380
|
<div id="recout" style="margin-top:12px"></div>
|
|
1181
1381
|
</div>
|
|
1182
1382
|
</section>
|
|
@@ -1189,19 +1389,21 @@ var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'http
|
|
|
1189
1389
|
var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
|
|
1190
1390
|
function $(id){return document.getElementById(id);}
|
|
1191
1391
|
var current='map';var mapLoaded=false;var driftLoaded=false;
|
|
1192
|
-
var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
|
|
1392
|
+
var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0',folder:'#b7a98c'};
|
|
1193
1393
|
function tabs(){return document.querySelectorAll('.tab');}
|
|
1194
1394
|
function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
|
|
1195
|
-
var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isRun=(tab==='week'||tab==='standup'||tab==='todo');
|
|
1395
|
+
var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isRun=(tab==='week'||tab==='standup'||tab==='todo');
|
|
1196
1396
|
$('panel-map').classList.toggle('hidden',!isMap);
|
|
1197
1397
|
$('panel-run').classList.toggle('hidden',!isRun);
|
|
1198
1398
|
$('panel-settings').classList.toggle('hidden',!isSet);
|
|
1199
1399
|
$('panel-tasks').classList.toggle('hidden',!isTasks);
|
|
1200
1400
|
$('panel-drift').classList.toggle('hidden',!isDrift);
|
|
1401
|
+
$('panel-agents').classList.toggle('hidden',!isAgents);
|
|
1201
1402
|
if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
|
|
1202
1403
|
if(isMap&&!mapLoaded)loadMap();
|
|
1203
1404
|
if(isTasks)loadTasks();
|
|
1204
|
-
if(isDrift&&!driftLoaded)loadDrift();
|
|
1405
|
+
if(isDrift&&!driftLoaded)loadDrift();
|
|
1406
|
+
if(isAgents)loadAgents(); else stopAgentsPoll();}
|
|
1205
1407
|
tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
|
|
1206
1408
|
function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
|
|
1207
1409
|
$('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
|
|
@@ -1241,7 +1443,10 @@ if(d.type==='repo'){var chips="";['branch: '+(d.branch||'?'),d.commits+' commits
|
|
|
1241
1443
|
el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='chips'>"+chips+"</div><button class='act' id='suggest' style='margin-top:12px'>Suggest next steps</button><div id='sugout'></div>";
|
|
1242
1444
|
document.getElementById('suggest').addEventListener('click',function(){var o=document.getElementById('sugout');o.innerHTML="<div class='out2'>Thinking...</div>";api('/api/suggest',{path:d.path}).then(function(r){if(r.error==='not-connected'){o.innerHTML="<div class='out2'>Connect a model in Settings to get suggestions - Ollama is free and runs locally.</div>";return;}o.innerHTML="<div class='out2'>"+esc(r.text||'(no output)')+"</div>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");});});return;}
|
|
1243
1445
|
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;}
|
|
1244
|
-
if(d.type==='
|
|
1446
|
+
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;}
|
|
1447
|
+
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;}
|
|
1448
|
+
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;}
|
|
1449
|
+
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;}}
|
|
1245
1450
|
var reviewCache={};var IDEAS=[];var IREPO="";
|
|
1246
1451
|
function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
|
|
1247
1452
|
function reviewHtml(name,body,verdict,ideas,tasks,footer){IDEAS=ideas||[];IREPO=name;
|
|
@@ -1264,17 +1469,48 @@ el.innerHTML="<h4>AI review · <span class='rname'>"+esc(name)+"</span></h
|
|
|
1264
1469
|
Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
|
|
1265
1470
|
if(r.error==='not-connected'){el.innerHTML="<h4>AI review · <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Connect a model in Settings to get a review - Ollama is free and runs locally.</div>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");return;}
|
|
1266
1471
|
var body=r.text||'(no output)';reviewCache[path]={text:body,verdict:r.verdict||"",ideas:r.ideas||[],footer:r.footer};el.innerHTML=reviewHtml(name,body,r.verdict||"",r.ideas||[],tasks,r.footer);wireIdeas();});}
|
|
1267
|
-
|
|
1472
|
+
var ALLTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
|
|
1473
|
+
var TORDER=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
|
|
1474
|
+
function taskRow(t,arch){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'>"+(arch?"":"<input type='checkbox' class='taskchk' title='mark done (auto-archives on sync)'"+(t.done?" checked":"")+">")+"<span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+(arch?"<button class='rm restore' title='restore to active'>↶</button>":"<button class='rm' title='remove'>×</button>")+"</div>";}
|
|
1268
1475
|
function wireTaskRows(el){el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
|
|
1269
|
-
row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
|
|
1270
|
-
row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/
|
|
1271
|
-
|
|
1272
|
-
var
|
|
1476
|
+
var cb=row.querySelector('.taskchk');if(cb)cb.addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
|
|
1477
|
+
var rm=row.querySelector('.rm');if(rm&&rm.classList.contains('restore'))rm.addEventListener('click',function(){api('/api/tasks/restore',{id:id}).then(loadArchived);});
|
|
1478
|
+
else if(rm)rm.addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();});});});}
|
|
1479
|
+
function fchip(dim,val,label){var on=(TFILTER[dim]||'')===val;return "<button class='fchip"+(on?' on':'')+"' data-dim='"+dim+"' data-val='"+esc(val)+"'>"+esc(label)+"</button>";}
|
|
1480
|
+
function renderFilter(){var box=document.getElementById('taskfilter');
|
|
1481
|
+
if(TARCH){box.innerHTML="<button class='fchip on' id='archtoggle'>↩ back to active</button>";document.getElementById('archtoggle').addEventListener('click',function(){loadTasks();});return;}
|
|
1482
|
+
var types={},repos={};ALLTASKS.forEach(function(t){types[t.type||'Features & other']=1;if(t.repo)repos[t.repo]=1;});
|
|
1483
|
+
var fb="<span class='fl'>Type</span>"+fchip('type','','All')+Object.keys(types).sort(function(a,b){return TORDER.indexOf(a)-TORDER.indexOf(b);}).map(function(t){return fchip('type',t,t);}).join('');
|
|
1484
|
+
fb+="<span class='fl'>Repo</span>"+fchip('repo','','All')+Object.keys(repos).sort().map(function(r){return fchip('repo',r,r);}).join('');
|
|
1485
|
+
fb+="<button class='fchip' id='archtoggle' style='margin-left:auto'>🗃 archived</button>";
|
|
1486
|
+
box.innerHTML=fb;
|
|
1487
|
+
box.querySelectorAll('.fchip[data-dim]').forEach(function(c){c.addEventListener('click',function(){TFILTER[c.getAttribute('data-dim')]=c.getAttribute('data-val');renderTasks();});});
|
|
1488
|
+
document.getElementById('archtoggle').addEventListener('click',loadArchived);}
|
|
1489
|
+
function renderTasks(){var el=document.getElementById('tasklist');TARCH=false;renderFilter();
|
|
1490
|
+
var list=ALLTASKS.filter(function(t){return (!TFILTER.type||(t.type||'Features & other')===TFILTER.type)&&(!TFILTER.repo||t.repo===TFILTER.repo);});
|
|
1491
|
+
if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>"+(ALLTASKS.length?"Nothing matches this filter.":"No tasks yet. Tick ideas in a repo review, or add one above.")+"</div>";return;}
|
|
1273
1492
|
var groups={};list.forEach(function(t){var ty=t.type||'Features & other';(groups[ty]=groups[ty]||[]).push(t);});
|
|
1274
|
-
var keys=Object.keys(groups).sort(function(a,b){var ia=
|
|
1275
|
-
var h="";keys.forEach(function(ty){h+="<div class='tgroup'>"+esc(ty)+" <span class='tcount'>"+groups[ty].length+"</span></div>";groups[ty].forEach(function(t){h+=taskRow(t);});});
|
|
1493
|
+
var keys=Object.keys(groups).sort(function(a,b){var ia=TORDER.indexOf(a),ib=TORDER.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib);});
|
|
1494
|
+
var h="";keys.forEach(function(ty){h+="<div class='tgroup'>"+esc(ty)+" <span class='tcount'>"+groups[ty].length+"</span></div>";groups[ty].forEach(function(t){h+=taskRow(t,false);});});
|
|
1276
1495
|
el.innerHTML=h;wireTaskRows(el);}
|
|
1277
|
-
function loadTasks(){api('/api/tasks').then(renderTasks);}
|
|
1496
|
+
function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});});}
|
|
1497
|
+
function loadArchived(){TARCH=true;api('/api/tasks?archived=1').then(function(list){ALLTASKS=list;renderFilter();var el=document.getElementById('tasklist');
|
|
1498
|
+
if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>Nothing archived yet. Completed tasks land here.</div>";return;}
|
|
1499
|
+
var h="<div class='tgroup'>Archived <span class='tcount'>"+list.length+"</span></div>";list.forEach(function(t){h+=taskRow(t,true);});el.innerHTML=h;wireTaskRows(el);});}
|
|
1500
|
+
var agentsTimer=null;
|
|
1501
|
+
function stopAgentsPoll(){if(agentsTimer){clearTimeout(agentsTimer);agentsTimer=null;}}
|
|
1502
|
+
function fmtE(ms){var s=Math.floor(ms/1000);if(s<60)return s+'s';var m=Math.floor(s/60);return m+'m '+(s%60)+'s';}
|
|
1503
|
+
function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
|
|
1504
|
+
if(!list||!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) — you'll watch it work here.</div>";stopAgentsPoll();return;}
|
|
1505
|
+
el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
|
|
1506
|
+
var st=a.status==='running'?('working · '+fmtE(a.elapsed)):(esc(a.status)+' · '+fmtE(a.elapsed)+(a.exitCode!=null?' · exit '+a.exitCode:''));
|
|
1507
|
+
var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
|
|
1508
|
+
if(a.status==='running')b+="<div class='bar'><i></i></div>";
|
|
1509
|
+
b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
|
|
1510
|
+
return "<div class='agent'>"+b+"</div>";}).join("");
|
|
1511
|
+
el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
|
|
1512
|
+
var anyRunning=list.some(function(a){return a.status==='running';});
|
|
1513
|
+
stopAgentsPoll();if(anyRunning&¤t==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
|
|
1278
1514
|
function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos…</div>";
|
|
1279
1515
|
var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
|
|
1280
1516
|
api('/api/drift?ci='+ci+'&fetch='+ft).then(function(d){driftLoaded=true;var repos=d.repos||[];var risky=repos.filter(function(r){return r.flags.some(function(f){return f.level==='warn';});});
|
|
@@ -1290,13 +1526,13 @@ out.innerHTML=h;});}
|
|
|
1290
1526
|
function addTaskUI(){var i=document.getElementById('newtask');var v=(i.value||'').trim();if(!v)return;api('/api/tasks/add',{text:v,repo:''}).then(function(){i.value='';loadTasks();});}
|
|
1291
1527
|
function pushTasksUI(){var o=document.getElementById('pushout');var btn=document.getElementById('pushtasks');btn.disabled=true;
|
|
1292
1528
|
o.innerHTML="<div class='muted' style='margin-top:10px'><span class='dot-c' style='background:var(--amber)'></span> Writing .symbiot/TASKS.md into your repos…</div>";
|
|
1293
|
-
api('/api/tasks/push',{}).then(function(r){btn.disabled=false;
|
|
1529
|
+
api('/api/tasks/push',{type:TFILTER.type||'',repo:TFILTER.repo||''}).then(function(r){btn.disabled=false;
|
|
1294
1530
|
if(r.empty){o.innerHTML="<div class='muted' style='margin-top:10px'>No open tasks to send. Tick ideas in a repo review, or add tasks above.</div>";return;}
|
|
1295
1531
|
var n=(r.written||[]).length;var h="<div class='drift' style='margin-top:10px'><div class='dh'><span class='dot-c'></span><span class='dn'>Done — wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
|
|
1296
1532
|
if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>✓ <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span></li>";});h+="</ul>";}
|
|
1297
1533
|
if(r.unresolved&&r.unresolved.length){h+="<div class='dd' style='margin-top:6px'>⚠ not written (repo not found under your home folder): "+esc(r.unresolved.map(function(u){return u.name;}).join(", "))+"</div>";}
|
|
1298
|
-
if(r.handoff&&r.written&&r.written.length){r.written.forEach(function(w){api('/api/open',{path:w.path});});h+="<div class='dd' style='margin-top:8px'>🤖
|
|
1299
|
-
else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send. For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
|
|
1534
|
+
if(r.handoff&&r.written&&r.written.length){r.written.forEach(function(w){api('/api/open',{path:w.path});});h+="<div class='dd' style='margin-top:8px'>🤖 Handed "+n+" repo(s) to your agent — opening the <b>Agents</b> tab to watch it work…</div>";setTimeout(function(){setTab('agents');},500);}
|
|
1535
|
+
else{h+="<div class='dd' style='margin-top:8px'>Set an <b>agent command</b> in Settings to auto-run it on send (and watch it in the Agents tab). For now, tell your agent: <b>“Read .symbiot/TASKS.md and implement the unchecked items.”</b></div>";}
|
|
1300
1536
|
h+="</div>";
|
|
1301
1537
|
o.innerHTML=h;}).catch(function(e){btn.disabled=false;o.innerHTML="<div class='err' style='margin-top:10px'>Couldn't write tasks: "+esc(String((e&&e.message)||e))+"</div>";});}
|
|
1302
1538
|
function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElementById('agentcmd').value=d.cmd||'';
|
|
@@ -1306,6 +1542,14 @@ if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Noth
|
|
|
1306
1542
|
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("");
|
|
1307
1543
|
box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
|
|
1308
1544
|
function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
|
|
1545
|
+
function loadScanRoots(){api('/api/scanroots').then(function(d){var box=document.getElementById('scanroots');var roots=d.effective||[];
|
|
1546
|
+
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("");
|
|
1547
|
+
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;});});});});}
|
|
1548
|
+
function addRootUI(){var i=document.getElementById('newroot');var v=(i.value||'').trim();if(!v)return;var n=document.getElementById('scanrootnote');
|
|
1549
|
+
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;});}
|
|
1550
|
+
function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model…";
|
|
1551
|
+
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;}
|
|
1552
|
+
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>";});}
|
|
1309
1553
|
function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
|
|
1310
1554
|
if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
|
|
1311
1555
|
function screenToGraph(el,ev){var rc=el.getBoundingClientRect();var mx=(ev.clientX-rc.left)/rc.width*GW;var my=(ev.clientY-rc.top)/rc.height*GH;return {x:(mx-view.x)/view.k,y:(my-view.y)/view.k};}
|
|
@@ -1334,12 +1578,16 @@ rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"<
|
|
|
1334
1578
|
h+="</div>";out.innerHTML=h;});}
|
|
1335
1579
|
document.getElementById('remap').addEventListener('click',loadMap);
|
|
1336
1580
|
document.getElementById('recbtn').addEventListener('click',loadRec);
|
|
1581
|
+
document.getElementById('setuplocal').addEventListener('click',setupLocalUI);
|
|
1582
|
+
document.getElementById('addroot').addEventListener('click',addRootUI);
|
|
1583
|
+
document.getElementById('newroot').addEventListener('keydown',function(e){if(e.key==='Enter')addRootUI();});
|
|
1337
1584
|
document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
|
|
1585
|
+
document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
|
|
1338
1586
|
document.getElementById('addtask').addEventListener('click',addTaskUI);
|
|
1339
1587
|
document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
|
|
1340
1588
|
document.getElementById('agentcmd').addEventListener('change',saveAgent);
|
|
1341
1589
|
document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
|
|
1342
|
-
initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();
|
|
1590
|
+
initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
|
|
1343
1591
|
</script></body></html>`;
|
|
1344
1592
|
|
|
1345
1593
|
function readBody(req) {
|
|
@@ -1398,14 +1646,28 @@ async function cmdApp() {
|
|
|
1398
1646
|
if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
|
|
1399
1647
|
if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
|
|
1400
1648
|
if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
|
|
1401
|
-
if (u.pathname === "/api/tasks" && req.method !== "POST") return json(res, loadTasks().map((t) => ({ ...t, type: taskType(t.text) })));
|
|
1649
|
+
if (u.pathname === "/api/tasks" && req.method !== "POST") { const arch = u.searchParams.get("archived") === "1"; return json(res, loadTasks().filter((x) => !!x.archived === arch).map((t) => ({ ...t, type: taskType(t.text) }))); }
|
|
1402
1650
|
if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
|
|
1403
1651
|
if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
|
|
1404
1652
|
if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
|
|
1405
|
-
if (u.pathname === "/api/tasks/
|
|
1653
|
+
if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
|
|
1654
|
+
if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
|
|
1655
|
+
if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
|
|
1656
|
+
if (u.pathname === "/api/scanroots") return json(res, { roots: loadConfig().scanRoots || [], effective: scanRoots(), home: homedir() });
|
|
1657
|
+
if (u.pathname === "/api/scanroots/add" && req.method === "POST") { const b = await readBody(req); return json(res, addScanRoot(String(b.path || ""))); }
|
|
1658
|
+
if (u.pathname === "/api/scanroots/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeScanRoot(String(b.path || ""))); }
|
|
1406
1659
|
if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
|
|
1407
1660
|
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 || "" }); }
|
|
1408
|
-
if (u.pathname === "/api/open" && req.method === "POST") { const b = await readBody(req);
|
|
1661
|
+
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 : "" }); }
|
|
1662
|
+
if (u.pathname === "/api/setup-local" && req.method === "POST") {
|
|
1663
|
+
const b = await readBody(req);
|
|
1664
|
+
if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
|
|
1665
|
+
const model = String(b.model || "") || recommendModels(detectHardware()).best;
|
|
1666
|
+
await ensureOllama();
|
|
1667
|
+
const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
|
|
1668
|
+
return json(res, { started: true, model, id: e ? e.id : "" });
|
|
1669
|
+
}
|
|
1670
|
+
if (u.pathname === "/api/agents") return json(res, HANDOFFS.map((e) => { let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {} return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail }; }));
|
|
1409
1671
|
if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await produce(cmd)); }
|
|
1410
1672
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
1411
1673
|
if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
|
|
@@ -1431,6 +1693,7 @@ ${c.b("Usage")}
|
|
|
1431
1693
|
symbiot drift what's out of sync / at risk across repos
|
|
1432
1694
|
symbiot push [--open] write tasks into each repo (and open your IDE)
|
|
1433
1695
|
symbiot models recommend AI models for your hardware
|
|
1696
|
+
symbiot setup-local [--model X] install/run a free local model (Ollama)
|
|
1434
1697
|
symbiot login connect it to an AI (once)
|
|
1435
1698
|
symbiot whoami show how it's connected
|
|
1436
1699
|
symbiot logout forget saved credentials
|
|
@@ -1460,6 +1723,7 @@ async function main() {
|
|
|
1460
1723
|
if (cmd === "whoami" || cmd === "status") return cmdWhoami();
|
|
1461
1724
|
if (cmd === "app" || cmd === "ui") return cmdApp();
|
|
1462
1725
|
if (cmd === "models" || cmd === "hardware") return cmdModels();
|
|
1726
|
+
if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
|
|
1463
1727
|
if (cmd === "drift") return cmdDrift();
|
|
1464
1728
|
if (cmd === "push") return cmdPush();
|
|
1465
1729
|
if (cmd === "week") return cmdRun("week");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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": {
|