symbiot 0.13.0 → 0.17.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 +210 -28
  2. 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").map((n) => ({ name: n.label, path: n.meta && n.meta.path })) : findAllRepos(BASE);
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,8 +161,10 @@ 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
- const tasks = loadTasks().filter((t) => !t.done);
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.
@@ -453,7 +481,10 @@ async function callOllama(r, system, prompt) {
453
481
  body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
454
482
  });
455
483
  const text = await res.text();
456
- if (!res.ok) throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
484
+ if (!res.ok) {
485
+ 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)`);
486
+ throw new Error(`Ollama ${res.status}: ${text.slice(0, 200)}`);
487
+ }
457
488
  return (JSON.parse(text).message?.content || "").trim();
458
489
  }
459
490
 
@@ -547,19 +578,74 @@ const AGENT_LIST = [["claude", "Claude Code", 'claude -p "{prompt}"'], ["aider",
547
578
  function detectHandoffs() {
548
579
  const editors = IDE_LIST.filter(([cmd]) => sh(`command -v ${cmd} 2>/dev/null`).trim()).map(([cmd, label]) => ({ label, tmpl: `${cmd} {dir}`, kind: "editor" }));
549
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" });
550
586
  return { agents, editors };
551
587
  }
552
588
  const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
553
589
  const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
554
590
  const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Confirm before anything destructive.";
555
591
  function handoffCmd() { const cfg = loadConfig(); return cfg.agentCmd || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
556
- function runHandoff(repoPath) {
592
+ // Run a shell command as a tracked, logged background job that shows up live in
593
+ // the Agents tab. Shared by the agent handoff and the local-model setup.
594
+ function track(name, cmd, cwd, onExit) {
557
595
  try {
558
- const tmpl = handoffCmd(); if (!tmpl || !repoPath) return false;
559
- const cmd = tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
560
- spawn(cmd, { shell: true, cwd: repoPath, detached: true, stdio: "ignore" }).unref();
561
- return true;
562
- } catch { return false; }
596
+ const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
597
+ const logp = join(dir, "agent.log");
598
+ let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
599
+ const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
600
+ const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd] });
601
+ entry.pid = child.pid;
602
+ child.on("exit", (code) => { entry.status = code === 0 ? "done" : "failed"; entry.exitCode = code; entry.endedAt = Date.now(); if (onExit) try { onExit(code); } catch {} });
603
+ child.on("error", () => { entry.status = "failed"; entry.endedAt = Date.now(); });
604
+ child.unref();
605
+ HANDOFFS.unshift(entry);
606
+ if (HANDOFFS.length > 30) HANDOFFS.length = 30;
607
+ return entry;
608
+ } catch { return null; }
609
+ }
610
+ function runHandoff(repoPath) {
611
+ const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
612
+ const cmd = tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
613
+ return track(repoPath.split("/").pop(), cmd, repoPath);
614
+ }
615
+ // ---- local model one-command setup (Ollama), OS-aware ---------------------
616
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
617
+ function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
618
+ function ollamaInstall() {
619
+ if (process.platform === "darwin") return { cmd: "brew install ollama", alt: "https://ollama.com/download" };
620
+ if (process.platform === "win32") return { cmd: "winget install Ollama.Ollama", alt: "https://ollama.com/download" };
621
+ return { cmd: "curl -fsSL https://ollama.com/install.sh | sh", alt: "https://ollama.com/download" };
622
+ }
623
+ async function ollamaUp() { try { const r = await fetch("http://127.0.0.1:11434/api/tags"); return r.ok; } catch { return false; } }
624
+ async function ensureOllama() {
625
+ if (await ollamaUp()) return true;
626
+ try { spawn("ollama", ["serve"], { detached: true, stdio: "ignore" }).unref(); } catch {}
627
+ for (let i = 0; i < 16; i++) { await sleep(500); if (await ollamaUp()) return true; }
628
+ return false;
629
+ }
630
+ function useOllamaModel(model) { const cfg = loadConfig(); cfg.provider = "ollama"; cfg.ollama = { baseUrl: "http://localhost:11434", model }; delete cfg.apiKey; saveConfig(cfg); }
631
+ async function cmdSetupLocal() {
632
+ const model = flag("model", null) || recommendModels(detectHardware()).best;
633
+ if (!hasOllama()) {
634
+ const inst = ollamaInstall();
635
+ console.log("\n" + c.y("Ollama isn't installed") + c.d(" — the free, private local-model runner.") + "\n");
636
+ console.log(` On ${process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux"}, install it then re-run ${c.b("symbiot setup-local")}:`);
637
+ console.log(" " + c.b(inst.cmd));
638
+ console.log(c.d(" or download: " + inst.alt));
639
+ return;
640
+ }
641
+ process.stdout.write(c.d("Starting Ollama… "));
642
+ if (!(await ensureOllama())) { console.log(c.y("couldn't reach it — start it with ") + c.b("ollama serve")); return; }
643
+ console.log(c.g("ready"));
644
+ console.log("\n" + c.b("Pulling " + model) + c.d(" (first time downloads a few GB — progress below)\n"));
645
+ const code = await new Promise((res) => { const ch = spawn("ollama", ["pull", model], { stdio: "inherit" }); ch.on("close", res); ch.on("error", () => res(1)); });
646
+ 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; }
647
+ useOllamaModel(model);
648
+ console.log("\n" + c.g("✓ ") + `Symbiot now runs on ${model} locally — free & private. Try: ` + c.b("symbiot week"));
563
649
  }
564
650
  function cmdModels() {
565
651
  const hw = detectHardware(); const rec = recommendModels(hw);
@@ -771,10 +857,24 @@ function buildMap() {
771
857
  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
858
  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
859
  }
860
+ // What you build WITH (person-level, not per-repo): detected agents/editors
861
+ // and the AI currently powering Symbiot — so the initial scan shows the whole
862
+ // setup, not just code. All local detection, nothing invasive.
863
+ try {
864
+ const hands = detectHandoffs();
865
+ for (const a of [...hands.agents, ...hands.editors]) {
866
+ const id = "agent:" + a.label;
867
+ add({ id, type: "agent", label: a.label.replace(/\s*\(.*\)$/, ""), weight: 11, meta: { cmd: a.tmpl, kind: a.kind } });
868
+ edges.push({ source: "me", target: id });
869
+ }
870
+ const prov = resolveProvider();
871
+ 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 }); }
872
+ } catch {}
774
873
  const out = { nodes, edges, stats: {
775
874
  repos: repos.length,
776
875
  languages: nodes.filter((n) => n.type === "lang").length,
777
876
  tools: nodes.filter((n) => n.type === "tool").length,
877
+ agents: nodes.filter((n) => n.type === "agent").length,
778
878
  commits: repos.reduce((s, r) => s + r.mine, 0),
779
879
  files: repos.reduce((s, r) => s + (r.files || 0), 0),
780
880
  base: BASE,
@@ -801,6 +901,10 @@ function nodeDetail(id) {
801
901
  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
902
  return { type: id.startsWith("lang:") ? "lang" : "tool", label: n ? n.label : id.split(":")[1], repos };
803
903
  }
904
+ if (id.startsWith("agent:") || id.startsWith("ai:")) {
905
+ const n = map.nodes.find((x) => x.id === id);
906
+ return { type: id.startsWith("agent:") ? "agent" : "ai", label: n ? n.label : id.split(":")[1], meta: (n && n.meta) || {} };
907
+ }
804
908
  return { error: "unknown node" };
805
909
  }
806
910
  // AI review of one repo: what it does, what it's for, possible upgrades.
@@ -1090,6 +1194,11 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1090
1194
  .task .rm:hover{color:var(--amber)}
1091
1195
  .tgroup{margin:14px 0 4px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--green);font-weight:700}
1092
1196
  .tcount{color:var(--faint);font-weight:400}
1197
+ .taskfilter{display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:10px 0 2px}
1198
+ .fl{font-size:10px;color:var(--faint);text-transform:uppercase;letter-spacing:.12em;margin:0 2px 0 8px}
1199
+ .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}
1200
+ .fchip.on{background:var(--green-dim);border-color:#2a6b52;color:var(--green)}
1201
+ .task .rm.restore{color:var(--green);font-size:15px}
1093
1202
  .drift{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1094
1203
  .drift .dh{display:flex;gap:8px;align-items:center}
1095
1204
  .drift .dn{color:var(--bone);font-weight:600}
@@ -1100,6 +1209,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1100
1209
  .drift .ev{color:var(--faint);font-family:ui-monospace,Menlo,monospace;font-size:11px}
1101
1210
  .dot-w{width:9px;height:9px;border-radius:50%;background:var(--amber);display:inline-block}
1102
1211
  .dot-c{width:9px;height:9px;border-radius:50%;background:var(--green);display:inline-block}
1212
+ .agent{border:1px solid var(--line);border-radius:10px;margin-top:10px;padding:12px 14px;background:var(--ink2)}
1213
+ .adot{width:10px;height:10px;border-radius:50%;display:inline-block;flex:none}
1214
+ .adot.run{background:var(--amber);animation:pulse 1s ease-in-out infinite}
1215
+ .adot.ok{background:var(--green)} .adot.fail{background:#E5695B}
1216
+ @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)}}
1217
+ .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}
1218
+ .bar{height:3px;background:var(--green-dim);border-radius:2px;overflow:hidden;margin-top:8px}
1219
+ .bar > i{display:block;height:100%;width:35%;background:var(--green);border-radius:2px;animation:slide 1.3s ease-in-out infinite}
1220
+ @keyframes slide{0%{margin-left:-35%}100%{margin-left:100%}}
1103
1221
  .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
1222
  .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
1105
1223
  .lg{display:inline-flex;gap:6px;align-items:center}
@@ -1114,6 +1232,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1114
1232
  <button class="tab" data-tab="standup">Standup</button>
1115
1233
  <button class="tab" data-tab="todo">Todo</button>
1116
1234
  <button class="tab" data-tab="tasks">Tasks</button>
1235
+ <button class="tab" data-tab="agents">Agents</button>
1117
1236
  <button class="tab" data-tab="settings">Settings</button>
1118
1237
  </div>
1119
1238
  <main>
@@ -1129,6 +1248,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1129
1248
  <span class="lg"><i style="background:var(--bone)"></i>repos</span>
1130
1249
  <span class="lg"><i style="background:var(--amber)"></i>languages</span>
1131
1250
  <span class="lg"><i style="background:#6bb3ff"></i>tools</span>
1251
+ <span class="lg"><i style="background:#c58af9"></i>agents</span>
1252
+ <span class="lg"><i style="background:#5fe3b0"></i>AI</span>
1132
1253
  <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
1133
1254
  <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
1134
1255
  </div>
@@ -1143,9 +1264,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1143
1264
  </section>
1144
1265
  <section id="panel-tasks" class="hidden">
1145
1266
  <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>
1267
+ <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> &mdash; filter by tag below to choose which.</div>
1146
1268
  <div id="pushout"></div>
1269
+ <div id="taskfilter" class="taskfilter"></div>
1147
1270
  <div id="tasklist"></div>
1148
1271
  </section>
1272
+ <section id="panel-agents" class="hidden">
1273
+ <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>
1274
+ <div id="agentslist"></div>
1275
+ </section>
1149
1276
  <section id="panel-drift" class="hidden">
1150
1277
  <div class="row"><span class="muted">What's out of sync, stuck or at risk across your repos — local git facts.</span>
1151
1278
  <label class="muted" style="margin-left:auto"><input type="checkbox" id="driftfetch"> fetch latest</label>
@@ -1177,6 +1304,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
1177
1304
  </div>
1178
1305
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
1179
1306
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
1307
+ <button class="ghost" id="setuplocal" style="margin-left:8px">Set up a free local model</button>
1308
+ <div id="setupout" class="note muted" style="margin-top:10px"></div>
1180
1309
  <div id="recout" style="margin-top:12px"></div>
1181
1310
  </div>
1182
1311
  </section>
@@ -1189,19 +1318,21 @@ var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'http
1189
1318
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
1190
1319
  function $(id){return document.getElementById(id);}
1191
1320
  var current='map';var mapLoaded=false;var driftLoaded=false;
1192
- var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
1321
+ var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0'};
1193
1322
  function tabs(){return document.querySelectorAll('.tab');}
1194
1323
  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');
1324
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isRun=(tab==='week'||tab==='standup'||tab==='todo');
1196
1325
  $('panel-map').classList.toggle('hidden',!isMap);
1197
1326
  $('panel-run').classList.toggle('hidden',!isRun);
1198
1327
  $('panel-settings').classList.toggle('hidden',!isSet);
1199
1328
  $('panel-tasks').classList.toggle('hidden',!isTasks);
1200
1329
  $('panel-drift').classList.toggle('hidden',!isDrift);
1330
+ $('panel-agents').classList.toggle('hidden',!isAgents);
1201
1331
  if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
1202
1332
  if(isMap&&!mapLoaded)loadMap();
1203
1333
  if(isTasks)loadTasks();
1204
- if(isDrift&&!driftLoaded)loadDrift();}
1334
+ if(isDrift&&!driftLoaded)loadDrift();
1335
+ if(isAgents)loadAgents(); else stopAgentsPoll();}
1205
1336
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
1206
1337
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
1207
1338
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
@@ -1241,7 +1372,9 @@ if(d.type==='repo'){var chips="";['branch: '+(d.branch||'?'),d.commits+' commits
1241
1372
  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
1373
  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
1374
  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==='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</div>";return;}}
1375
+ 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
+ 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;}
1377
+ 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;}}
1245
1378
  var reviewCache={};var IDEAS=[];var IREPO="";
1246
1379
  function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
1247
1380
  function reviewHtml(name,body,verdict,ideas,tasks,footer){IDEAS=ideas||[];IREPO=name;
@@ -1264,17 +1397,48 @@ el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h
1264
1397
  Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
1265
1398
  if(r.error==='not-connected'){el.innerHTML="<h4>AI review &middot; <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
1399
  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
- function taskRow(t){return "<div class='task"+(t.done?" done":"")+"' data-id='"+esc(t.id)+"'><input type='checkbox' class='taskchk'"+(t.done?" checked":"")+"><span class='t'>"+esc(t.text)+"</span>"+(t.repo?"<span class='rp'>"+esc(t.repo)+"</span>":"")+"<button class='rm' title='remove'>&times;</button></div>";}
1400
+ var ALLTASKS=[];var TFILTER={type:'',repo:''};var TARCH=false;
1401
+ var TORDER=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
1402
+ 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'>&#8630;</button>":"<button class='rm' title='remove'>&times;</button>")+"</div>";}
1268
1403
  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/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
1271
- function renderTasks(list){var el=document.getElementById('tasklist');if(!list||!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>No tasks yet. Tick an idea in a repo's review, or add one above.</div>";return;}
1272
- var order=['Fixes','Tests & CI','Security','Performance','Refactor','UI/UX','Docs','Features & other'];
1404
+ var cb=row.querySelector('.taskchk');if(cb)cb.addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
1405
+ var rm=row.querySelector('.rm');if(rm&&rm.classList.contains('restore'))rm.addEventListener('click',function(){api('/api/tasks/restore',{id:id}).then(loadArchived);});
1406
+ else if(rm)rm.addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();});});});}
1407
+ 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>";}
1408
+ function renderFilter(){var box=document.getElementById('taskfilter');
1409
+ if(TARCH){box.innerHTML="<button class='fchip on' id='archtoggle'>&#8617; back to active</button>";document.getElementById('archtoggle').addEventListener('click',function(){loadTasks();});return;}
1410
+ var types={},repos={};ALLTASKS.forEach(function(t){types[t.type||'Features & other']=1;if(t.repo)repos[t.repo]=1;});
1411
+ 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('');
1412
+ fb+="<span class='fl'>Repo</span>"+fchip('repo','','All')+Object.keys(repos).sort().map(function(r){return fchip('repo',r,r);}).join('');
1413
+ fb+="<button class='fchip' id='archtoggle' style='margin-left:auto'>&#128451; archived</button>";
1414
+ box.innerHTML=fb;
1415
+ box.querySelectorAll('.fchip[data-dim]').forEach(function(c){c.addEventListener('click',function(){TFILTER[c.getAttribute('data-dim')]=c.getAttribute('data-val');renderTasks();});});
1416
+ document.getElementById('archtoggle').addEventListener('click',loadArchived);}
1417
+ function renderTasks(){var el=document.getElementById('tasklist');TARCH=false;renderFilter();
1418
+ var list=ALLTASKS.filter(function(t){return (!TFILTER.type||(t.type||'Features & other')===TFILTER.type)&&(!TFILTER.repo||t.repo===TFILTER.repo);});
1419
+ 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
1420
  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=order.indexOf(a),ib=order.indexOf(b);return (ia<0?99:ia)-(ib<0?99:ib);});
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);});});
1421
+ 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);});
1422
+ 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
1423
  el.innerHTML=h;wireTaskRows(el);}
1277
- function loadTasks(){api('/api/tasks').then(renderTasks);}
1424
+ function loadTasks(){TARCH=false;api('/api/tasks/sync',{}).then(function(){api('/api/tasks').then(function(list){ALLTASKS=list;renderTasks();});});}
1425
+ function loadArchived(){TARCH=true;api('/api/tasks?archived=1').then(function(list){ALLTASKS=list;renderFilter();var el=document.getElementById('tasklist');
1426
+ if(!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>Nothing archived yet. Completed tasks land here.</div>";return;}
1427
+ 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);});}
1428
+ var agentsTimer=null;
1429
+ function stopAgentsPoll(){if(agentsTimer){clearTimeout(agentsTimer);agentsTimer=null;}}
1430
+ 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';}
1431
+ function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
1432
+ 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) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
1433
+ el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
1434
+ var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
1435
+ var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
1436
+ if(a.status==='running')b+="<div class='bar'><i></i></div>";
1437
+ b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
1438
+ return "<div class='agent'>"+b+"</div>";}).join("");
1439
+ el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
1440
+ var anyRunning=list.some(function(a){return a.status==='running';});
1441
+ stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
1278
1442
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
1279
1443
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
1280
1444
  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 +1454,13 @@ out.innerHTML=h;});}
1290
1454
  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
1455
  function pushTasksUI(){var o=document.getElementById('pushout');var btn=document.getElementById('pushtasks');btn.disabled=true;
1292
1456
  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&hellip;</div>";
1293
- api('/api/tasks/push',{}).then(function(r){btn.disabled=false;
1457
+ api('/api/tasks/push',{type:TFILTER.type||'',repo:TFILTER.repo||''}).then(function(r){btn.disabled=false;
1294
1458
  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
1459
  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 &mdash; wrote "+n+" file"+(n===1?"":"s")+"</span></div>";
1296
1460
  if(n){h+="<ul>";r.written.forEach(function(w){h+="<li class='info'>&#10003; <b>"+esc(w.name)+"</b> <span class='ev'>"+esc(w.file)+" ("+w.count+" task"+(w.count===1?"":"s")+")</span></li>";});h+="</ul>";}
1297
1461
  if(r.unresolved&&r.unresolved.length){h+="<div class='dd' style='margin-top:6px'>&#9888; 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'>&#129302; Handing "+n+" repo(s) to your agent (<b>"+esc(r.handoff)+"</b>)&hellip;</div>";}
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>";}
1462
+ 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'>&#129302; Handed "+n+" repo(s) to your agent &mdash; opening the <b>Agents</b> tab to watch it work&hellip;</div>";setTimeout(function(){setTab('agents');},500);}
1463
+ 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
1464
  h+="</div>";
1301
1465
  o.innerHTML=h;}).catch(function(e){btn.disabled=false;o.innerHTML="<div class='err' style='margin-top:10px'>Couldn&#39;t write tasks: "+esc(String((e&&e.message)||e))+"</div>";});}
1302
1466
  function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElementById('agentcmd').value=d.cmd||'';
@@ -1306,6 +1470,9 @@ if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Noth
1306
1470
  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("");
1307
1471
  box.querySelectorAll('.preset').forEach(function(btn){btn.addEventListener('click',function(){document.getElementById('agentcmd').value=chips[+btn.getAttribute('data-i')].tmpl;saveAgent();});});});}
1308
1472
  function saveAgent(){api('/api/agentcmd',{cmd:document.getElementById('agentcmd').value});}
1473
+ function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model&hellip;";
1474
+ 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
+ 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>";});}
1309
1476
  function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
1310
1477
  if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
1311
1478
  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,7 +1501,9 @@ rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"<
1334
1501
  h+="</div>";out.innerHTML=h;});}
1335
1502
  document.getElementById('remap').addEventListener('click',loadMap);
1336
1503
  document.getElementById('recbtn').addEventListener('click',loadRec);
1504
+ document.getElementById('setuplocal').addEventListener('click',setupLocalUI);
1337
1505
  document.getElementById('driftrun').addEventListener('click',function(){driftLoaded=false;loadDrift();});
1506
+ document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
1338
1507
  document.getElementById('addtask').addEventListener('click',addTaskUI);
1339
1508
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
1340
1509
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
@@ -1398,14 +1567,25 @@ async function cmdApp() {
1398
1567
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
1399
1568
  if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
1400
1569
  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) })));
1570
+ 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
1571
  if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
1403
1572
  if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
1404
1573
  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/push" && req.method === "POST") return json(res, pushTasks());
1574
+ if (u.pathname === "/api/tasks/restore" && req.method === "POST") { const b = await readBody(req); return json(res, restoreTask(String(b.id || ""))); }
1575
+ if (u.pathname === "/api/tasks/sync" && req.method === "POST") return json(res, syncTasks());
1576
+ if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
1406
1577
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors }); }
1407
1578
  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); return json(res, { opened: runHandoff(String(b.path || "")) }); }
1579
+ 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 : "" }); }
1580
+ if (u.pathname === "/api/setup-local" && req.method === "POST") {
1581
+ const b = await readBody(req);
1582
+ if (!hasOllama()) return json(res, { error: "not-installed", install: ollamaInstall() });
1583
+ const model = String(b.model || "") || recommendModels(detectHardware()).best;
1584
+ await ensureOllama();
1585
+ const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
1586
+ return json(res, { started: true, model, id: e ? e.id : "" });
1587
+ }
1588
+ 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
1589
  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
1590
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1411
1591
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
@@ -1431,6 +1611,7 @@ ${c.b("Usage")}
1431
1611
  symbiot drift what's out of sync / at risk across repos
1432
1612
  symbiot push [--open] write tasks into each repo (and open your IDE)
1433
1613
  symbiot models recommend AI models for your hardware
1614
+ symbiot setup-local [--model X] install/run a free local model (Ollama)
1434
1615
  symbiot login connect it to an AI (once)
1435
1616
  symbiot whoami show how it's connected
1436
1617
  symbiot logout forget saved credentials
@@ -1460,6 +1641,7 @@ async function main() {
1460
1641
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
1461
1642
  if (cmd === "app" || cmd === "ui") return cmdApp();
1462
1643
  if (cmd === "models" || cmd === "hardware") return cmdModels();
1644
+ if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
1463
1645
  if (cmd === "drift") return cmdDrift();
1464
1646
  if (cmd === "push") return cmdPush();
1465
1647
  if (cmd === "week") return cmdRun("week");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.13.0",
3
+ "version": "0.17.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": {