symbiot 0.4.1 → 0.7.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 (3) hide show
  1. package/README.md +26 -3
  2. package/index.mjs +369 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -12,6 +12,7 @@ symbiot your last 7 days, written up (same as: symbiot week)
12
12
  symbiot standup yesterday + today, for standup
13
13
  symbiot todo what's still on your plate
14
14
  symbiot app open the visual app in your browser
15
+ symbiot models recommend AI models for your hardware
15
16
  symbiot login connect it to an AI (once)
16
17
  symbiot whoami show how it's connected
17
18
  symbiot help
@@ -25,9 +26,20 @@ symbiot app
25
26
 
26
27
  Starts a tiny local server (127.0.0.1 only, protected by a one-time token) and
27
28
  opens the visual app in your browser — in a clean, chrome-less window if you have
28
- Chrome/Chromium/Edge/Brave (`--app` mode), otherwise a normal tab. Tabs for
29
- Week / Standup / Todo, a Copy button, and a Settings panel to pick your AI. No
30
- Electron, no install — it's the same CLI. Press Ctrl+C (or click Quit) to stop.
29
+ Chrome/Chromium/Edge/Brave (`--app` mode), otherwise a normal tab. No Electron,
30
+ no install — it's the same CLI. Press Ctrl+C (or click Quit) to stop.
31
+
32
+ Tabs:
33
+
34
+ - **Map** — a live, interactive node graph of your work, built from your local git:
35
+ you at the centre, your repos, and the languages and tools they share (so related
36
+ projects cluster). **Scroll to zoom, drag to pan, click a node.** Clicking a repo
37
+ shows its branch/commits/uncommitted/stack and a **Suggest next steps** button —
38
+ your model reads that repo's recent commits + open TODOs and proposes *In flight /
39
+ Next steps / Ideas*. The graph itself **needs no AI key** (pure local data);
40
+ suggestions use your chosen model (free with local Ollama). This is the landing view.
41
+ - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
42
+ - **Settings** — pick your AI. Free/private option: run **Ollama** locally, no key.
31
43
 
32
44
  **Cross-platform:** the app works on **Linux, macOS, and Windows** — the server
33
45
  and UI are just a local web page. The chrome-less window is detected per-OS
@@ -40,6 +52,17 @@ where no Chromium-family browser is found it opens your default browser instead.
40
52
  npm install -g symbiot # or run without installing: npx symbiot week
41
53
  ```
42
54
 
55
+ ## Which model? Ask your machine
56
+
57
+ ```bash
58
+ symbiot models
59
+ ```
60
+
61
+ Reads your RAM / CPU / GPU and recommends **local models by tier** (min / med / max,
62
+ marking which fit your RAM) to run free & private via [Ollama](https://ollama.com),
63
+ plus **paid** options (Claude / OpenAI / Gemini, cheap → top). Also available as
64
+ a button in the app's Settings.
65
+
43
66
  ## Connect it (once)
44
67
 
45
68
  Pick the AI you want it to write with:
package/index.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
 
20
20
  import Anthropic from "@anthropic-ai/sdk";
21
21
  import { execSync, spawn } from "node:child_process";
22
- import { homedir } from "node:os";
22
+ import { homedir, totalmem, cpus as oscpus } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from "node:fs";
25
25
  import { createInterface } from "node:readline";
@@ -51,6 +51,7 @@ const flag = (name, def) => {
51
51
  const has = (name) => argv.includes(`--${name}`);
52
52
  const PLAIN = has("plain") || !process.stdout.isTTY;
53
53
  let SERVING = false; // set while `symbiot app` runs — silences the CLI spinner
54
+ let LAST_MAP = null; // cached graph so node clicks don't rescan
54
55
 
55
56
  // ---- colour + spinner ------------------------------------------------------
56
57
  const c = PLAIN
@@ -85,6 +86,19 @@ function saveConfig(cfg) {
85
86
  function antProfileExists() {
86
87
  try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
87
88
  }
89
+ // ---- tasks: a persistent checklist (~/.config/symbiot/tasks.json) ---------
90
+ const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
91
+ function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
92
+ function saveTasks(t) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(TASKS_PATH, JSON.stringify(t, null, 2)); return true; } catch { return false; } }
93
+ function addTask(text, repo) {
94
+ text = String(text || "").trim().slice(0, 300);
95
+ if (!text) return { error: "empty" };
96
+ const t = loadTasks();
97
+ const item = { id: randomBytes(6).toString("hex"), text, repo: repo || "", done: false, ts: Date.now() };
98
+ t.unshift(item); saveTasks(t); return item;
99
+ }
100
+ 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" }; }
101
+ function removeTask(id) { saveTasks(loadTasks().filter((x) => x.id !== id)); return { ok: true }; }
88
102
  function envKey(provider) {
89
103
  for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
90
104
  return null;
@@ -299,6 +313,191 @@ function header(sub) {
299
313
  const SINCE_WEEK = Number(flag("since", "7"));
300
314
  const BASE = flag("dir", homedir());
301
315
 
316
+ // ---- hardware -> model recommendations ------------------------------------
317
+ function detectGpu() {
318
+ const nv = sh("nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null").trim();
319
+ if (nv) { const p = nv.split("\n")[0].split(","); return { name: (p[0] || "NVIDIA GPU").trim(), vramGB: Math.round(Number(p[1]) / 1024) || null, kind: "nvidia" }; }
320
+ if (process.platform === "linux") { const vga = sh("lspci 2>/dev/null | grep -iE 'vga|3d controller|display' | head -1").replace(/^.*?: /, "").trim(); if (vga) return { name: vga, vramGB: null, kind: "other" }; }
321
+ if (process.platform === "darwin") { const chip = sh("sysctl -n machdep.cpu.brand_string 2>/dev/null").trim(); if (/apple/i.test(chip)) return { name: chip + " (unified memory)", vramGB: null, kind: "apple" }; }
322
+ return null;
323
+ }
324
+ function detectHardware() {
325
+ const cpus = oscpus() || [];
326
+ return { platform: process.platform, arch: process.arch, ramGB: Math.round(totalmem() / 1073741824), cpuCount: cpus.length, cpuModel: ((cpus[0] && cpus[0].model) || "CPU").trim(), gpu: detectGpu() };
327
+ }
328
+ function recommendModels(hw) {
329
+ const ram = hw.ramGB || 8;
330
+ const local = [
331
+ { tier: "min", model: "llama3.2:3b", needGB: 6, note: "fast & light — fine for summaries" },
332
+ { tier: "med", model: "llama3.1:8b", needGB: 10, note: "solid all-rounder" },
333
+ { tier: "max", model: "qwen2.5:14b", needGB: 18, note: "stronger reasoning" },
334
+ ];
335
+ if (ram >= 40) local.push({ tier: "max+", model: "llama3.1:70b", needGB: 48, note: "top local quality — big machine/GPU" });
336
+ local.forEach((m) => { m.fits = ram >= m.needGB; });
337
+ const paid = [
338
+ { provider: "anthropic", tier: "cheap", model: "claude-haiku-4-5", note: "cheapest Claude" },
339
+ { provider: "anthropic", tier: "top", model: "claude-opus-5-5", note: "best Claude (default)" },
340
+ { provider: "openai", tier: "cheap", model: "gpt-4o-mini", note: "cheap OpenAI" },
341
+ { provider: "openai", tier: "top", model: "gpt-4o", note: "stronger OpenAI" },
342
+ { provider: "gemini", tier: "cheap", model: "gemini-1.5-flash", note: "cheap Google" },
343
+ { provider: "gemini", tier: "top", model: "gemini-1.5-pro", note: "stronger Google" },
344
+ ];
345
+ const best = (local.slice().reverse().find((m) => m.fits) || local[0]).model;
346
+ return { local, paid, best };
347
+ }
348
+ function cmdModels() {
349
+ const hw = detectHardware(); const rec = recommendModels(hw);
350
+ const pad = (s, n) => String(s).padEnd(n);
351
+ console.log("\n" + c.b("Your machine"));
352
+ console.log(` ${c.b(hw.ramGB + " GB")} RAM · ${hw.cpuCount}-core ${hw.cpuModel} · ${hw.platform}/${hw.arch}`);
353
+ if (hw.gpu) console.log(` GPU: ${hw.gpu.name}${hw.gpu.vramGB ? ` (${hw.gpu.vramGB} GB VRAM)` : ""}`);
354
+ console.log("\n" + c.b("Local models") + c.d(" (free & private via Ollama — install: ollama pull <model>)"));
355
+ rec.local.forEach((m) => console.log(` ${m.fits ? c.g("✓") : c.d("·")} ${pad(m.tier, 5)} ${pad(m.model, 16)} ${c.d("~" + m.needGB + "GB " + m.note + (m.fits ? "" : " (needs more RAM)"))}`));
356
+ console.log(c.d(` Best fit for you: `) + c.b(rec.best) + c.d(` → ollama pull ${rec.best} then symbiot login (choose Ollama)`));
357
+ console.log("\n" + c.b("Paid models") + c.d(" (bring an API key — symbiot login)"));
358
+ rec.paid.forEach((p) => console.log(` ${pad(p.provider, 10)} ${pad(p.tier, 6)} ${pad(p.model, 20)} ${c.d(p.note)}`));
359
+ console.log("");
360
+ }
361
+
362
+ // ---- work map: a node graph of your repos, languages, and tools -----------
363
+ const EXT_LANG = {
364
+ ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript",
365
+ py: "Python", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", rb: "Ruby", php: "PHP",
366
+ cs: "C#", cpp: "C++", cc: "C++", c: "C", swift: "Swift", vue: "Vue", svelte: "Svelte",
367
+ sql: "SQL", sh: "Shell", css: "CSS", scss: "CSS", html: "HTML",
368
+ };
369
+ const MANIFEST_TOOL = {
370
+ "package.json": "Node", "requirements.txt": "Python", "pyproject.toml": "Python",
371
+ "go.mod": "Go", "Cargo.toml": "Rust", "pom.xml": "Maven", "build.gradle": "Gradle",
372
+ "Gemfile": "Ruby", "composer.json": "PHP", "Dockerfile": "Docker", "docker-compose.yml": "Docker",
373
+ "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
374
+ };
375
+ function findAllRepos(base) {
376
+ const out = sh(
377
+ `find ${JSON.stringify(base)} -maxdepth 5 -name .git \\( -type d -o -type f \\) 2>/dev/null ` +
378
+ `| grep -vE 'node_modules|/\\.cache/|/\\.local/|/venvs?/|/\\.npm' | head -300`,
379
+ );
380
+ const repos = [];
381
+ for (const g of out.split("\n").filter(Boolean)) {
382
+ const repo = g.replace(/\/\.git$/, "");
383
+ const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
384
+ if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
385
+ }
386
+ return repos.sort((a, b) => b.recency - a.recency).slice(0, 36);
387
+ }
388
+ function detectRepo(r) {
389
+ const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -5000`).split("\n").filter(Boolean);
390
+ const count = {}; const tools = new Set();
391
+ for (const f of files) {
392
+ const base = f.split("/").pop();
393
+ if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]);
394
+ const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase();
395
+ if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1;
396
+ }
397
+ const langs = Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]);
398
+ const email = sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim();
399
+ const mine = Number(sh(`git -C ${JSON.stringify(r.path)} log ${email ? `--author=${JSON.stringify(email)}` : ""} --oneline 2>/dev/null | wc -l`).trim()) || 0;
400
+ const branch = sh(`git -C ${JSON.stringify(r.path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim();
401
+ const last = sh(`git -C ${JSON.stringify(r.path)} log -1 --format=%cd --date=short 2>/dev/null`).trim();
402
+ return { ...r, langs, tools: [...tools], mine, files: files.length, branch, last };
403
+ }
404
+ function buildMap() {
405
+ const who = me();
406
+ const repos = findAllRepos(BASE).map(detectRepo);
407
+ const nodes = []; const edges = []; const have = new Set();
408
+ const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
409
+ add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
410
+ for (const r of repos) {
411
+ const rid = "repo:" + r.path;
412
+ add({ id: rid, type: "repo", label: r.name, weight: Math.min(9 + Math.log2(1 + r.mine) * 3, 26),
413
+ meta: { commits: r.mine, langs: r.langs.slice(0, 3), tools: r.tools, files: r.files, branch: r.branch, last: r.last, path: r.path } });
414
+ edges.push({ source: "me", target: rid });
415
+ 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 }); }
416
+ for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
417
+ }
418
+ const out = { nodes, edges, stats: {
419
+ repos: repos.length,
420
+ languages: nodes.filter((n) => n.type === "lang").length,
421
+ tools: nodes.filter((n) => n.type === "tool").length,
422
+ commits: repos.reduce((s, r) => s + r.mine, 0),
423
+ files: repos.reduce((s, r) => s + (r.files || 0), 0),
424
+ base: BASE,
425
+ } };
426
+ LAST_MAP = out;
427
+ return out;
428
+ }
429
+ // Local detail for a clicked node (no AI).
430
+ function nodeDetail(id) {
431
+ const map = LAST_MAP || buildMap();
432
+ if (id === "me") { const n = map.nodes.find((x) => x.id === "me"); return { type: "person", label: (n && n.label) || "You", stats: map.stats }; }
433
+ if (id.startsWith("repo:")) {
434
+ const path = id.slice(5); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
435
+ return {
436
+ type: "repo", label: n ? n.label : path.split("/").pop(), path,
437
+ branch: sh(`git -C ${JSON.stringify(path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim(),
438
+ dirty: sh(`git -C ${JSON.stringify(path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean).length,
439
+ last: sh(`git -C ${JSON.stringify(path)} log -1 --format=%cd --date=short 2>/dev/null`).trim(),
440
+ commits: m.commits || 0, langs: m.langs || [], tools: m.tools || [],
441
+ };
442
+ }
443
+ if (id.startsWith("lang:") || id.startsWith("tool:")) {
444
+ const n = map.nodes.find((x) => x.id === id);
445
+ 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);
446
+ return { type: id.startsWith("lang:") ? "lang" : "tool", label: n ? n.label : id.split(":")[1], repos };
447
+ }
448
+ return { error: "unknown node" };
449
+ }
450
+ // AI review of one repo: what it does, what it's for, possible upgrades.
451
+ async function repoReview(path) {
452
+ if (!resolveProvider()) return { error: "not-connected" };
453
+ if (!path) return { error: "no repo" };
454
+ const name = path.split("/").pop();
455
+ let readme = "";
456
+ for (const f of ["README.md", "README.MD", "Readme.md", "readme.md", "README.txt", "README"]) {
457
+ try { const p = join(path, f); if (existsSync(p)) { readme = readFileSync(p, "utf8").slice(0, 2500); break; } } catch {}
458
+ }
459
+ const files = sh(`git -C ${JSON.stringify(path)} ls-files 2>/dev/null | head -60`).trim();
460
+ const recent = sh(`git -C ${JSON.stringify(path)} log --oneline -12 2>/dev/null`).trim();
461
+ const system =
462
+ `You are reviewing one software project for its owner. Respond with ONLY a JSON object, no markdown fences, no prose outside it, shaped exactly: ` +
463
+ `{"review": string, "ideas": string[]}. "review" is 3-6 sentences covering what it does and what it's meant for (who'd use it, why). ` +
464
+ `"ideas" is 3-6 short, concrete upgrade or next-feature items, each a single actionable phrase (something you could put on a to-do list). ` +
465
+ `Be specific and grounded in the README, files, and commits — infer from real evidence, don't invent.`;
466
+ const prompt =
467
+ `Project: ${name}\nPath: ${path}\n\n` +
468
+ (readme ? `README (excerpt):\n${readme}\n\n` : "(no README found)\n\n") +
469
+ `Files:\n${files || "(none)"}\n\nRecent commits:\n${recent || "(none)"}\n\nReturn the JSON.`;
470
+ const raw = await write(system, prompt);
471
+ if (!raw) return { text: "(couldn't reach the model)", ideas: [] };
472
+ const j = extractJson(raw);
473
+ if (j && (j.review || j.ideas)) return { text: String(j.review || "").trim() || raw, ideas: Array.isArray(j.ideas) ? j.ideas.map((x) => String(x).trim()).filter(Boolean).slice(0, 8) : [] };
474
+ return { text: raw, ideas: [] }; // model didn't return clean JSON — show prose, no checkboxes
475
+ }
476
+ function extractJson(s) {
477
+ if (!s) return null;
478
+ let t = String(s).trim().replace(/^```(?:json)?/i, "").replace(/```$/,"").trim();
479
+ const a = t.indexOf("{"), b = t.lastIndexOf("}");
480
+ if (a >= 0 && b > a) { try { return JSON.parse(t.slice(a, b + 1)); } catch {} }
481
+ return null;
482
+ }
483
+ // AI "value" suggestions for one repo, from its recent commits + open work.
484
+ async function repoSuggest(path) {
485
+ if (!resolveProvider()) return { error: "not-connected" };
486
+ if (!path) return { error: "no repo" };
487
+ const name = path.split("/").pop();
488
+ const email = sh(`git -C ${JSON.stringify(path)} config user.email`).trim();
489
+ const recent = sh(`git -C ${JSON.stringify(path)} log --oneline -30 ${email ? `--author=${JSON.stringify(email)}` : ""} 2>/dev/null`).trim();
490
+ const open = openWork([{ path, name }]);
491
+ const system =
492
+ `You are a pragmatic senior engineer advising on one project. From its recent commits and open items, write exactly three short sections with these headings: ` +
493
+ `"In flight" (what's clearly underway), "Next steps" (3-5 concrete, specific actions), and "Ideas" (2-3 that fit where this build is heading). ` +
494
+ `Be specific to the actual work shown — no filler, no preamble, no restating the commits verbatim.`;
495
+ const prompt =
496
+ `Project: ${name}.\n\nRecent commits:\n${recent || "(none)"}\n\nOpen / unfinished:\n${open.length ? open.map((o) => "- " + o).join("\n") : "(none found)"}\n\nGive the advice.`;
497
+ const text = await write(system, prompt);
498
+ return { text: text || "(couldn't reach the model)" };
499
+ }
500
+
302
501
  // Build a write-up for a command; returns { text, sub, error? } without printing.
303
502
  // Shared by the CLI (cmdRun) and the web UI (symbiot app).
304
503
  async function produce(cmd) {
@@ -476,29 +675,87 @@ select:focus,input:focus{outline:none;border-color:var(--green)}
476
675
  a{color:var(--green);cursor:pointer}.hidden{display:none}
477
676
  .note{font-size:12px;margin-top:10px}.ok{color:var(--green)}.err{color:var(--amber)}
478
677
  footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
678
+ .profile{font-size:13px;margin-bottom:8px;line-height:1.5}
679
+ .profile b{color:var(--bone)}
680
+ .maprow{display:flex;gap:12px;align-items:stretch}
681
+ #graph{flex:1;width:100%;height:62vh;min-height:340px;background:var(--ink2);border:1px solid var(--line);border-radius:12px;touch-action:none;cursor:grab}
682
+ #graph:active{cursor:grabbing}
683
+ #graph text{font-family:var(--sans);fill:var(--text);font-size:11px;pointer-events:none}
684
+ #graph .lbl-me{fill:var(--bone);font-weight:700;font-size:13px}
685
+ .detail{width:290px;flex:none;background:var(--ink2);border:1px solid var(--line);border-radius:12px;padding:14px;overflow:auto;max-height:62vh}
686
+ .detail h3{margin:0 0 4px;color:var(--bone);font-size:15px}
687
+ .detail .k{font-size:12px;color:var(--faint);margin-top:8px}
688
+ .detail ul{margin:6px 0 0;padding-left:18px}.detail li{margin:2px 0}
689
+ .detail .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
690
+ .tag{display:inline-flex;font-size:11px;padding:4px 9px;border-radius:999px;background:var(--green-dim);border:1px solid #2a6b52;color:var(--green)}
691
+ .mapbar{margin-top:8px;font-size:12.5px;color:var(--text);background:var(--ink3);border:1px solid var(--line);border-radius:9px;padding:9px 12px;min-height:18px;font-family:ui-monospace,Menlo,Consolas,monospace}
692
+ .mapbar b{color:var(--bone)}
693
+ .review{margin-top:12px;background:var(--ink2);border:1px solid var(--line);border-radius:12px;padding:16px;min-height:120px}
694
+ .review h4{margin:0 0 8px;color:var(--green);font-size:11px;letter-spacing:.15em;text-transform:uppercase;font-weight:700}
695
+ .review .body{color:var(--text);line-height:1.65;font-size:14px;white-space:pre-wrap}
696
+ .review .rname{color:var(--bone);font-weight:600}
697
+ .ideas{margin-top:14px}
698
+ .ideas h4{margin:0 0 6px;color:var(--amber);font-size:11px;letter-spacing:.14em;text-transform:uppercase;font-weight:700}
699
+ .idea{display:flex;align-items:flex-start;gap:9px;padding:5px 0;font-size:13.5px;color:var(--text)}
700
+ .idea input{margin-top:3px;flex:none;width:15px;height:15px;cursor:pointer}
701
+ .task{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--line);border-radius:9px;margin-top:8px;background:var(--ink2)}
702
+ .task input[type=checkbox]{width:16px;height:16px;flex:none;cursor:pointer}
703
+ .task .t{flex:1}.task.done .t{color:var(--faint);text-decoration:line-through}
704
+ .task .rp{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px}
705
+ .task .rm{background:none;border:0;color:var(--faint);cursor:pointer;font-size:18px;line-height:1}
706
+ .task .rm:hover{color:var(--amber)}
707
+ .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)}
708
+ .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
709
+ .lg{display:inline-flex;gap:6px;align-items:center}
710
+ .lg i{width:10px;height:10px;border-radius:50%;display:inline-block}
711
+ @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
479
712
  </style></head><body>
480
713
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
481
714
  <div class="tabs">
482
- <button class="tab active" data-tab="week">Week</button>
715
+ <button class="tab active" data-tab="map">Map</button>
716
+ <button class="tab" data-tab="week">Week</button>
483
717
  <button class="tab" data-tab="standup">Standup</button>
484
718
  <button class="tab" data-tab="todo">Todo</button>
719
+ <button class="tab" data-tab="tasks">Tasks</button>
485
720
  <button class="tab" data-tab="settings">Settings</button>
486
721
  </div>
487
722
  <main>
488
- <section id="panel-run">
723
+ <section id="panel-map">
724
+ <div class="profile muted" id="profile">Mapping your work&hellip;</div>
725
+ <div class="maprow">
726
+ <svg id="graph" viewBox="0 0 960 620" preserveAspectRatio="xMidYMid meet"></svg>
727
+ <aside id="detail" class="detail hidden"></aside>
728
+ </div>
729
+ <div id="mapbar" class="mapbar">Hover a node for a summary &middot; click for details &middot; drag to move</div>
730
+ <div class="legend">
731
+ <span class="lg"><i style="background:var(--green)"></i>you</span>
732
+ <span class="lg"><i style="background:var(--bone)"></i>repos</span>
733
+ <span class="lg"><i style="background:var(--amber)"></i>languages</span>
734
+ <span class="lg"><i style="background:#6bb3ff"></i>tools</span>
735
+ <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
736
+ <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
737
+ </div>
738
+ <div id="review" class="review hidden"></div>
739
+ </section>
740
+ <section id="panel-run" class="hidden">
489
741
  <div class="row"><button class="act" id="write">Write my <span id="what">week</span></button>
490
742
  <button class="ghost hidden" id="copy">Copy</button>
491
743
  <span class="muted">Reads your local git and writes it up.</span></div>
492
744
  <div class="out muted" id="out">Nothing yet - hit the button.</div>
493
745
  </section>
746
+ <section id="panel-tasks" class="hidden">
747
+ <div class="row"><input id="newtask" placeholder="Add a task..." style="flex:1"><button class="act" id="addtask">Add</button></div>
748
+ <div id="tasklist"></div>
749
+ </section>
494
750
  <section id="panel-settings" class="hidden">
495
751
  <label>Which AI should Symbiot write with?</label>
496
752
  <select id="provider">
497
753
  <option value="anthropic">Claude (Anthropic)</option>
498
754
  <option value="openai">OpenAI (GPT)</option>
499
755
  <option value="gemini">Gemini (Google)</option>
500
- <option value="ollama">Local model (Ollama)</option>
756
+ <option value="ollama">Local model (Ollama) - free, no key</option>
501
757
  </select>
758
+ <div class="note muted">The Map above needs no key. For the write-ups, a hosted model needs an API key - or run <b>Ollama</b> locally for a free, private option (nothing leaves your machine).</div>
502
759
  <div id="keyWrap"><label>API key</label><input id="key" type="password" placeholder="paste your key">
503
760
  <div class="note"><a id="getkey">Where do I get a key?</a></div></div>
504
761
  <div id="baseWrap" class="hidden"><label>Ollama URL</label><input id="baseUrl" type="text" value="http://localhost:11434"></div>
@@ -506,6 +763,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
506
763
  <input id="model" type="text" placeholder="(blank = default)">
507
764
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
508
765
  <span class="note" id="saveMsg"></span></div>
766
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
767
+ <button class="ghost" id="recbtn">Recommend models for my machine</button>
768
+ <div id="recout" style="margin-top:12px"></div>
769
+ </div>
509
770
  </section>
510
771
  </main>
511
772
  <footer><button class="ghost" id="quit" style="margin-left:auto">Quit</button></footer>
@@ -515,11 +776,18 @@ function api(path,body){return fetch(path,{method:body?'POST':'GET',headers:{'x-
515
776
  var KEYURL={anthropic:'https://console.anthropic.com/settings/keys',openai:'https://platform.openai.com/api-keys',gemini:'https://aistudio.google.com/apikey',ollama:'https://ollama.com'};
516
777
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
517
778
  function $(id){return document.getElementById(id);}
518
- var current='week';
779
+ var current='map';var mapLoaded=false;
780
+ var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
519
781
  function tabs(){return document.querySelectorAll('.tab');}
520
782
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
521
- var s=tab==='settings';$('panel-run').classList.toggle('hidden',s);$('panel-settings').classList.toggle('hidden',!s);
522
- if(!s){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}}
783
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isRun=(tab==='week'||tab==='standup'||tab==='todo');
784
+ $('panel-map').classList.toggle('hidden',!isMap);
785
+ $('panel-run').classList.toggle('hidden',!isRun);
786
+ $('panel-settings').classList.toggle('hidden',!isSet);
787
+ $('panel-tasks').classList.toggle('hidden',!isTasks);
788
+ if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
789
+ if(isMap&&!mapLoaded)loadMap();
790
+ if(isTasks)loadTasks();}
523
791
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
524
792
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
525
793
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
@@ -534,7 +802,89 @@ $('save').addEventListener('click',function(){var p=$('provider').value;$('saveM
534
802
  var cfg={provider:p,model:$('model').value.trim()};if(p==='ollama')cfg.baseUrl=$('baseUrl').value.trim();else cfg.key=$('key').value.trim();
535
803
  api('/api/connect',cfg).then(function(r){$('saveMsg').textContent=r.message||(r.ok?'Connected.':'Could not connect.');$('saveMsg').className='note '+(r.ok?'ok':'err');if(r.ok){$('key').value='';refresh();}});});
536
804
  $('quit').addEventListener('click',function(){api('/api/quit');document.body.innerHTML='<div style=\\'padding:40px;color:#7E9690;font-family:sans-serif\\'>Symbiot stopped. You can close this window.</div>';});
537
- syncP();refresh();
805
+ var GRAPH=null,sel=null,view={k:1,x:0,y:0},GW=960,GH=620;
806
+ function esc(s){return String(s).replace(/[&<>]/g,function(ch){return ch==='&'?'&amp;':ch==='<'?'&lt;':'&gt;';});}
807
+ function layout(nodes,edges){var idx={};nodes.forEach(function(n){n.x=GW/2+(Math.random()-0.5)*GW*0.8;n.y=GH/2+(Math.random()-0.5)*GH*0.8;n.vx=0;n.vy=0;idx[n.id]=n;});
808
+ for(var it=0;it<340;it++){for(var i=0;i<nodes.length;i++)for(var j=i+1;j<nodes.length;j++){var a=nodes[i],b=nodes[j];var dx=a.x-b.x,dy=a.y-b.y;var d2=dx*dx+dy*dy+0.01;var d=Math.sqrt(d2);var f=4600/d2;a.vx+=f*dx/d;a.vy+=f*dy/d;b.vx-=f*dx/d;b.vy-=f*dy/d;}
809
+ edges.forEach(function(e){var a=idx[e.source],b=idx[e.target];if(!a||!b)return;var dx=b.x-a.x,dy=b.y-a.y;var d=Math.sqrt(dx*dx+dy*dy)+0.01;var f=(d-115)*0.03;a.vx+=f*dx/d;a.vy+=f*dy/d;b.vx-=f*dx/d;b.vy-=f*dy/d;});
810
+ nodes.forEach(function(n){n.vx+=(GW/2-n.x)*0.002;n.vy+=(GH/2-n.y)*0.002;n.x+=Math.max(-9,Math.min(9,n.vx));n.y+=Math.max(-9,Math.min(9,n.vy));n.vx*=0.86;n.vy*=0.86;n.x=Math.max(30,Math.min(GW-30,n.x));n.y=Math.max(24,Math.min(GH-28,n.y));});}}
811
+ function nbrs(id){var s={};s[id]=1;GRAPH.edges.forEach(function(e){if(e.source===id)s[e.target]=1;if(e.target===id)s[e.source]=1;});return s;}
812
+ function render(){if(!GRAPH)return;var idx={};GRAPH.nodes.forEach(function(n){idx[n.id]=n;});var nb=sel?nbrs(sel):null;
813
+ var s="<g id='vp' transform='translate("+view.x.toFixed(1)+","+view.y.toFixed(1)+") scale("+view.k.toFixed(3)+")'>";
814
+ GRAPH.edges.forEach(function(e){var a=idx[e.source],b=idx[e.target];if(!a||!b)return;var op=nb?((nb[e.source]&&nb[e.target])?0.75:0.06):0.45;s+="<line x1='"+a.x.toFixed(1)+"' y1='"+a.y.toFixed(1)+"' x2='"+b.x.toFixed(1)+"' y2='"+b.y.toFixed(1)+"' stroke='#24404A' stroke-width='1' opacity='"+op+"'/>";});
815
+ GRAPH.nodes.forEach(function(n){var r=Math.max(5,Math.sqrt(n.weight)*2);var col=COLORS[n.type]||"#888";var op=nb?(nb[n.id]?1:0.14):0.95;var st=(n.id===sel)?" stroke='#F4F1EA' stroke-width='2'":"";var title=esc(n.label)+(n.meta?(" - "+(n.meta.commits||0)+" commits"):"");
816
+ s+="<g class='node' data-id='"+esc(n.id)+"' opacity='"+op+"'><circle cx='"+n.x.toFixed(1)+"' cy='"+n.y.toFixed(1)+"' r='"+r.toFixed(1)+"' fill='"+col+"'"+st+"><title>"+title+"</title></circle>";
817
+ var cls=n.type==="person"?"lbl-me":"";s+="<text x='"+n.x.toFixed(1)+"' y='"+(n.y+r+12).toFixed(1)+"' text-anchor='middle' class='"+cls+"'>"+esc(n.label)+"</text></g>";});
818
+ document.getElementById("graph").innerHTML=s+"</g>";}
819
+ function profileLine(g){var repos=g.nodes.filter(function(n){return n.type==="repo";});var base=g.stats.base||"your home folder";if(!repos.length)return "No git repositories found under "+esc(base)+" yet.";var langs=g.nodes.filter(function(n){return n.type==="lang";}).map(function(n){return n.label;});var top=repos.slice().sort(function(a,b){return (b.meta.commits||0)-(a.meta.commits||0);})[0];var s="Scanned <b>"+esc(base)+"</b> &middot; <b>"+g.stats.repos+"</b> repos &middot; <b>"+g.stats.commits+"</b> of your commits &middot; <b>"+(g.stats.files||0)+"</b> files &middot; ";s+=langs.length?("mostly <b>"+esc(langs.slice(0,3).join(", "))+"</b>"):"no languages detected";if(top)s+=" &middot; most active: <b>"+esc(top.label)+"</b>";return s;}
820
+ function nodeById(id){for(var i=0;i<GRAPH.nodes.length;i++)if(GRAPH.nodes[i].id===id)return GRAPH.nodes[i];return null;}
821
+ function summarize(n){if(!n)return "";if(n.type==='repo'){var m=n.meta||{};var bits=[m.commits+' commits'];if(m.branch)bits.push('branch '+m.branch);if(m.last)bits.push('last '+m.last);if(typeof m.files==='number')bits.push(m.files+' files');if(m.langs&&m.langs.length)bits.push(m.langs.join(', '));if(m.tools&&m.tools.length)bits.push(m.tools.join(', '));return "<b>"+esc(n.label)+"</b> "+esc(m.path||'')+" — "+esc(bits.join(' · '));}if(n.type==='lang')return "<b>"+esc(n.label)+"</b> — language (click to see repos)";if(n.type==='tool')return "<b>"+esc(n.label)+"</b> — tool (click to see repos)";if(n.type==='person')return "<b>"+esc(n.label)+"</b> — you";return "<b>"+esc(n.label)+"</b>";}
822
+ function updateBar(id){var bar=document.getElementById('mapbar');if(!bar)return;var n=id?nodeById(id):(sel?nodeById(sel):null);bar.innerHTML=n?summarize(n):"Hover a node for a summary &middot; click for details &middot; drag to move";}
823
+ function hideDetail(){document.getElementById('detail').classList.add('hidden');}
824
+ function showDetail(d){var el=document.getElementById('detail');el.classList.remove('hidden');
825
+ if(d.error){el.innerHTML="<h3>&mdash;</h3><div class='k'>"+esc(d.error)+"</div>";return;}
826
+ if(d.type==='repo'){var chips="";['branch: '+(d.branch||'?'),d.commits+' commits',(d.dirty?d.dirty+' uncommitted':'clean'),(d.last?'last '+d.last:'')].concat(d.langs||[]).concat(d.tools||[]).forEach(function(x){if(x)chips+="<span class='tag'>"+esc(x)+"</span>";});
827
+ 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>";
828
+ 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>";});});return;}
829
+ 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;}
830
+ 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;}}
831
+ var reviewCache={};
832
+ function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
833
+ function reviewHtml(name,body,ideas,tasks){
834
+ var h="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>"+esc(body)+"</div>";
835
+ if(ideas&&ideas.length){h+="<div class='ideas'><h4>Upgrade ideas &middot; tick to add to Tasks</h4>";
836
+ ideas.forEach(function(idea){var t=(tasks||[]).filter(function(x){return x.text===idea&&x.repo===name;})[0];var tid=t?t.id:"";
837
+ h+="<label class='idea'><input type='checkbox' class='ideachk' data-text=\""+esc(idea)+"\" data-repo=\""+esc(name)+"\" data-tid='"+tid+"'"+(t?" checked":"")+"><span>"+esc(idea)+"</span></label>";});
838
+ h+="</div>";}
839
+ return h;}
840
+ function wireIdeas(){document.querySelectorAll('.ideachk').forEach(function(cb){cb.addEventListener('change',function(){
841
+ if(cb.checked){api('/api/tasks/add',{text:cb.getAttribute('data-text'),repo:cb.getAttribute('data-repo')}).then(function(it){if(it&&it.id)cb.setAttribute('data-tid',it.id);});}
842
+ else{var id=cb.getAttribute('data-tid');if(id){api('/api/tasks/remove',{id:id}).then(function(){cb.setAttribute('data-tid','');});}}});});}
843
+ function loadReview(name,path){var el=document.getElementById('review');el.classList.remove('hidden');
844
+ if(reviewCache[path]){var c=reviewCache[path];api('/api/tasks').then(function(tasks){el.innerHTML=reviewHtml(name,c.text,c.ideas,tasks);wireIdeas();});return;}
845
+ el.innerHTML="<h4>AI review &middot; <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Reading the project&hellip;</div>";
846
+ Promise.all([api('/api/review',{path:path}),api('/api/tasks')]).then(function(res){var r=res[0]||{},tasks=res[1]||[];
847
+ 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>";return;}
848
+ var body=r.text||'(no output)';reviewCache[path]={text:body,ideas:r.ideas||[]};el.innerHTML=reviewHtml(name,body,r.ideas||[],tasks);wireIdeas();});}
849
+ 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;}
850
+ var h="";list.forEach(function(t){h+="<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>";});
851
+ el.innerHTML=h;
852
+ el.querySelectorAll('.task').forEach(function(row){var id=row.getAttribute('data-id');
853
+ row.querySelector('.taskchk').addEventListener('change',function(){api('/api/tasks/toggle',{id:id}).then(function(){row.classList.toggle('done');});});
854
+ row.querySelector('.rm').addEventListener('click',function(){api('/api/tasks/remove',{id:id}).then(function(){row.remove();if(!el.querySelector('.task'))renderTasks([]);});});});}
855
+ function loadTasks(){api('/api/tasks').then(renderTasks);}
856
+ 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();});}
857
+ function selectNode(id){sel=id;render();updateBar(id);var n=nodeById(id);api('/api/node?id='+encodeURIComponent(id)).then(showDetail);
858
+ if(n&&n.type==='repo'&&n.meta&&n.meta.path){loadReview(n.label,n.meta.path);}else{hideReview();}}
859
+ 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};}
860
+ function initGraphEvents(){var el=document.getElementById('graph');var mode=null,moved=0,sx=0,sy=0,ox=0,oy=0,downId=null,dnode=null;
861
+ function nodeAt(ev){var t=ev.target;var g=t&&t.closest?t.closest('.node'):null;return g?g.getAttribute('data-id'):null;}
862
+ el.addEventListener('wheel',function(ev){ev.preventDefault();if(!GRAPH)return;var rc=el.getBoundingClientRect();var mx=(ev.clientX-rc.left)/rc.width*GW;var my=(ev.clientY-rc.top)/rc.height*GH;var nk=Math.max(0.3,Math.min(4,view.k*(ev.deltaY<0?1.12:0.89)));view.x=mx-(mx-view.x)*(nk/view.k);view.y=my-(my-view.y)*(nk/view.k);view.k=nk;render();},{passive:false});
863
+ el.addEventListener('pointerdown',function(ev){if(!GRAPH)return;moved=0;sx=ev.clientX;sy=ev.clientY;ox=view.x;oy=view.y;downId=nodeAt(ev);if(downId){mode='node';dnode=nodeById(downId);}else{mode='pan';}try{el.setPointerCapture(ev.pointerId);}catch(e){}});
864
+ el.addEventListener('pointermove',function(ev){
865
+ if(!mode){updateBar(nodeAt(ev));return;}
866
+ var dx=ev.clientX-sx,dy=ev.clientY-sy;moved+=Math.abs(dx)+Math.abs(dy);if(moved<4)return;
867
+ if(mode==='node'&&dnode){var p=screenToGraph(el,ev);dnode.x=p.x;dnode.y=p.y;render();}
868
+ else if(mode==='pan'){var rc=el.getBoundingClientRect();view.x=ox+dx/rc.width*GW;view.y=oy+dy/rc.height*GH;render();}});
869
+ el.addEventListener('pointerup',function(ev){var id=downId,wasClick=moved<6;mode=null;dnode=null;downId=null;
870
+ if(id){selectNode(id);}else if(wasClick){sel=null;hideDetail();hideReview();render();updateBar(null);}});
871
+ el.addEventListener('mouseleave',function(){if(!mode)updateBar(null);}); }
872
+ function loadMap(){var p=document.getElementById("profile");p.textContent="Mapping your work...";document.getElementById("graph").innerHTML="";sel=null;hideDetail();hideReview();api("/api/map").then(function(g){mapLoaded=true;if(!g.nodes||!g.nodes.length){p.textContent="No git repositories found under your home folder.";return;}GRAPH=g;layout(g.nodes,g.edges);view={k:1,x:0,y:0};p.innerHTML=profileLine(g);render();});}
873
+ function fitBadge(m){return m.fits?"<span class='tag'>fits your RAM</span>":"<span class='tag' style='background:#3a2a12;border-color:#6b4a1f;color:#F2A541'>needs more RAM</span>";}
874
+ function loadRec(){var out=document.getElementById('recout');out.innerHTML="<div class='muted'>Reading your hardware...</div>";
875
+ api('/api/models').then(function(d){var hw=d.hardware,rec=d.rec;
876
+ var h="<div class='k'><b>"+hw.ramGB+" GB</b> RAM &middot; "+hw.cpuCount+"-core &middot; "+esc(hw.platform)+"/"+esc(hw.arch)+(hw.gpu?" &middot; GPU: "+esc(hw.gpu.name)+(hw.gpu.vramGB?" ("+hw.gpu.vramGB+"GB)":""):"")+"</div>";
877
+ h+="<div class='ideas'><h4>Local models &middot; free via Ollama</h4>";
878
+ rec.local.forEach(function(m){h+="<div class='idea' style='justify-content:space-between'><span><b>"+esc(m.tier)+"</b> &middot; <code>ollama pull "+esc(m.model)+"</code> <span class='muted'>~"+m.needGB+"GB &middot; "+esc(m.note)+"</span></span> "+fitBadge(m)+"</div>";});
879
+ h+="<div class='muted' style='margin-top:6px'>Best fit: <b>"+esc(rec.best)+"</b> &mdash; pull it, then pick <b>Local model (Ollama)</b> above.</div></div>";
880
+ h+="<div class='ideas'><h4>Paid models &middot; bring an API key</h4>";
881
+ rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"</b> &middot; "+esc(p.tier)+" &middot; <code>"+esc(p.model)+"</code> <span class='muted'>&middot; "+esc(p.note)+"</span></span></div>";});
882
+ h+="</div>";out.innerHTML=h;});}
883
+ document.getElementById('remap').addEventListener('click',loadMap);
884
+ document.getElementById('recbtn').addEventListener('click',loadRec);
885
+ document.getElementById('addtask').addEventListener('click',addTaskUI);
886
+ document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
887
+ initGraphEvents();syncP();refresh();loadMap();
538
888
  </script></body></html>`;
539
889
 
540
890
  function readBody(req) {
@@ -587,6 +937,15 @@ async function cmdApp() {
587
937
  }
588
938
  try {
589
939
  if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
940
+ if (u.pathname === "/api/map") return json(res, buildMap()); // local git only — no AI key needed
941
+ if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
942
+ if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
943
+ if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
944
+ if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
945
+ if (u.pathname === "/api/tasks" && req.method !== "POST") return json(res, loadTasks());
946
+ if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
947
+ if (u.pathname === "/api/tasks/toggle" && req.method === "POST") { const b = await readBody(req); return json(res, toggleTask(String(b.id || ""))); }
948
+ if (u.pathname === "/api/tasks/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeTask(String(b.id || ""))); }
590
949
  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)); }
591
950
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
592
951
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
@@ -609,6 +968,7 @@ ${c.b("Usage")}
609
968
  symbiot standup yesterday + today, for standup
610
969
  symbiot todo what's still on your plate
611
970
  symbiot app open the visual app in your browser
971
+ symbiot models recommend AI models for your hardware
612
972
  symbiot login connect it to an AI (once)
613
973
  symbiot whoami show how it's connected
614
974
  symbiot logout forget saved credentials
@@ -637,6 +997,7 @@ ${c.d("with a local Ollama model).")}`;
637
997
  if (cmd === "logout") return cmdLogout();
638
998
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
639
999
  if (cmd === "app" || cmd === "ui") return cmdApp();
1000
+ if (cmd === "models" || cmd === "hardware") return cmdModels();
640
1001
  if (cmd === "week") return cmdRun("week");
641
1002
  if (cmd === "standup") return cmdRun("standup");
642
1003
  if (cmd === "todo") return cmdRun("todo");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.4.1",
3
+ "version": "0.7.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": {