symbiot 0.6.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 +12 -0
  2. package/index.mjs +195 -13
  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
@@ -51,6 +52,17 @@ where no Chromium-family browser is found it opens your default browser instead.
51
52
  npm install -g symbiot # or run without installing: npx symbiot week
52
53
  ```
53
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
+
54
66
  ## Connect it (once)
55
67
 
56
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";
@@ -86,6 +86,19 @@ function saveConfig(cfg) {
86
86
  function antProfileExists() {
87
87
  try { return existsSync(join(homedir(), ".config", "anthropic")); } catch { return false; }
88
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 }; }
89
102
  function envKey(provider) {
90
103
  for (const e of (PROVIDERS[provider].env || [])) if (process.env[e]) return process.env[e];
91
104
  return null;
@@ -300,6 +313,52 @@ function header(sub) {
300
313
  const SINCE_WEEK = Number(flag("since", "7"));
301
314
  const BASE = flag("dir", homedir());
302
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
+
303
362
  // ---- work map: a node graph of your repos, languages, and tools -----------
304
363
  const EXT_LANG = {
305
364
  ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript",
@@ -338,7 +397,9 @@ function detectRepo(r) {
338
397
  const langs = Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]);
339
398
  const email = sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim();
340
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;
341
- return { ...r, langs, tools: [...tools], mine };
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 };
342
403
  }
343
404
  function buildMap() {
344
405
  const who = me();
@@ -349,7 +410,7 @@ function buildMap() {
349
410
  for (const r of repos) {
350
411
  const rid = "repo:" + r.path;
351
412
  add({ id: rid, type: "repo", label: r.name, weight: Math.min(9 + Math.log2(1 + r.mine) * 3, 26),
352
- meta: { commits: r.mine, langs: r.langs.slice(0, 3), tools: r.tools } });
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 } });
353
414
  edges.push({ source: "me", target: rid });
354
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 }); }
355
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 }); }
@@ -359,6 +420,8 @@ function buildMap() {
359
420
  languages: nodes.filter((n) => n.type === "lang").length,
360
421
  tools: nodes.filter((n) => n.type === "tool").length,
361
422
  commits: repos.reduce((s, r) => s + r.mine, 0),
423
+ files: repos.reduce((s, r) => s + (r.files || 0), 0),
424
+ base: BASE,
362
425
  } };
363
426
  LAST_MAP = out;
364
427
  return out;
@@ -384,6 +447,39 @@ function nodeDetail(id) {
384
447
  }
385
448
  return { error: "unknown node" };
386
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
+ }
387
483
  // AI "value" suggestions for one repo, from its recent commits + open work.
388
484
  async function repoSuggest(path) {
389
485
  if (!resolveProvider()) return { error: "not-connected" };
@@ -592,6 +688,22 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
592
688
  .detail ul{margin:6px 0 0;padding-left:18px}.detail li{margin:2px 0}
593
689
  .detail .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
594
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)}
595
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)}
596
708
  .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
597
709
  .lg{display:inline-flex;gap:6px;align-items:center}
@@ -604,6 +716,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
604
716
  <button class="tab" data-tab="week">Week</button>
605
717
  <button class="tab" data-tab="standup">Standup</button>
606
718
  <button class="tab" data-tab="todo">Todo</button>
719
+ <button class="tab" data-tab="tasks">Tasks</button>
607
720
  <button class="tab" data-tab="settings">Settings</button>
608
721
  </div>
609
722
  <main>
@@ -613,6 +726,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
613
726
  <svg id="graph" viewBox="0 0 960 620" preserveAspectRatio="xMidYMid meet"></svg>
614
727
  <aside id="detail" class="detail hidden"></aside>
615
728
  </div>
729
+ <div id="mapbar" class="mapbar">Hover a node for a summary &middot; click for details &middot; drag to move</div>
616
730
  <div class="legend">
617
731
  <span class="lg"><i style="background:var(--green)"></i>you</span>
618
732
  <span class="lg"><i style="background:var(--bone)"></i>repos</span>
@@ -621,6 +735,7 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
621
735
  <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
622
736
  <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
623
737
  </div>
738
+ <div id="review" class="review hidden"></div>
624
739
  </section>
625
740
  <section id="panel-run" class="hidden">
626
741
  <div class="row"><button class="act" id="write">Write my <span id="what">week</span></button>
@@ -628,6 +743,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
628
743
  <span class="muted">Reads your local git and writes it up.</span></div>
629
744
  <div class="out muted" id="out">Nothing yet - hit the button.</div>
630
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>
631
750
  <section id="panel-settings" class="hidden">
632
751
  <label>Which AI should Symbiot write with?</label>
633
752
  <select id="provider">
@@ -644,6 +763,10 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
644
763
  <input id="model" type="text" placeholder="(blank = default)">
645
764
  <div class="row" style="margin-top:16px"><button class="act" id="save">Save &amp; connect</button>
646
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>
647
770
  </section>
648
771
  </main>
649
772
  <footer><button class="ghost" id="quit" style="margin-left:auto">Quit</button></footer>
@@ -657,12 +780,14 @@ var current='map';var mapLoaded=false;
657
780
  var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
658
781
  function tabs(){return document.querySelectorAll('.tab');}
659
782
  function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
660
- var isMap=tab==='map',isSet=tab==='settings',isRun=!isMap&&!isSet;
783
+ var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isRun=(tab==='week'||tab==='standup'||tab==='todo');
661
784
  $('panel-map').classList.toggle('hidden',!isMap);
662
785
  $('panel-run').classList.toggle('hidden',!isRun);
663
786
  $('panel-settings').classList.toggle('hidden',!isSet);
787
+ $('panel-tasks').classList.toggle('hidden',!isTasks);
664
788
  if(isRun){$('what').textContent=tab;$('out').textContent='Nothing yet - hit the button.';$('out').classList.add('muted');$('copy').classList.add('hidden');}
665
- if(isMap&&!mapLoaded)loadMap();}
789
+ if(isMap&&!mapLoaded)loadMap();
790
+ if(isTasks)loadTasks();}
666
791
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
667
792
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
668
793
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
@@ -691,7 +816,10 @@ GRAPH.nodes.forEach(function(n){var r=Math.max(5,Math.sqrt(n.weight)*2);var col=
691
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>";
692
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>";});
693
818
  document.getElementById("graph").innerHTML=s+"</g>";}
694
- function profileLine(g){var repos=g.nodes.filter(function(n){return n.type==="repo";});if(!repos.length)return "No git repositories found under your home folder 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="<b>"+g.stats.repos+"</b> repos &middot; <b>"+g.stats.commits+"</b> of your commits &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;}
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";}
695
823
  function hideDetail(){document.getElementById('detail').classList.add('hidden');}
696
824
  function showDetail(d){var el=document.getElementById('detail');el.classList.remove('hidden');
697
825
  if(d.error){el.innerHTML="<h3>&mdash;</h3><div class='k'>"+esc(d.error)+"</div>";return;}
@@ -700,16 +828,62 @@ el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='chips'>"+chips+"</div><button
700
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;}
701
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;}
702
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;}}
703
- function selectNode(id){sel=id;render();api('/api/node?id='+encodeURIComponent(id)).then(showDetail);}
704
- function initGraphEvents(){var el=document.getElementById('graph');var drag=false,moved=0,sx=0,sy=0,ox=0,oy=0,downId=null;
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;
705
861
  function nodeAt(ev){var t=ev.target;var g=t&&t.closest?t.closest('.node'):null;return g?g.getAttribute('data-id'):null;}
706
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});
707
- el.addEventListener('pointerdown',function(ev){drag=true;moved=0;sx=ev.clientX;sy=ev.clientY;ox=view.x;oy=view.y;downId=nodeAt(ev);try{el.setPointerCapture(ev.pointerId);}catch(e){}});
708
- el.addEventListener('pointermove',function(ev){if(!drag)return;var dx=ev.clientX-sx,dy=ev.clientY-sy;moved+=Math.abs(dx)+Math.abs(dy);if(moved<4)return;var rc=el.getBoundingClientRect();view.x=ox+dx/rc.width*GW;view.y=oy+dy/rc.height*GH;render();});
709
- el.addEventListener('pointerup',function(ev){drag=false;if(moved<6){if(downId){selectNode(downId);}else{sel=null;hideDetail();render();}}downId=null;});
710
- el.addEventListener('click',function(ev){var id=nodeAt(ev);if(id)selectNode(id);}); }
711
- function loadMap(){var p=document.getElementById("profile");p.textContent="Mapping your work...";document.getElementById("graph").innerHTML="";sel=null;hideDetail();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();});}
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;});}
712
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();});
713
887
  initGraphEvents();syncP();refresh();loadMap();
714
888
  </script></body></html>`;
715
889
 
@@ -764,8 +938,14 @@ async function cmdApp() {
764
938
  try {
765
939
  if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
766
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) }); }
767
942
  if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
768
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 || ""))); }
769
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)); }
770
950
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
771
951
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
@@ -788,6 +968,7 @@ ${c.b("Usage")}
788
968
  symbiot standup yesterday + today, for standup
789
969
  symbiot todo what's still on your plate
790
970
  symbiot app open the visual app in your browser
971
+ symbiot models recommend AI models for your hardware
791
972
  symbiot login connect it to an AI (once)
792
973
  symbiot whoami show how it's connected
793
974
  symbiot logout forget saved credentials
@@ -816,6 +997,7 @@ ${c.d("with a local Ollama model).")}`;
816
997
  if (cmd === "logout") return cmdLogout();
817
998
  if (cmd === "whoami" || cmd === "status") return cmdWhoami();
818
999
  if (cmd === "app" || cmd === "ui") return cmdApp();
1000
+ if (cmd === "models" || cmd === "hardware") return cmdModels();
819
1001
  if (cmd === "week") return cmdRun("week");
820
1002
  if (cmd === "standup") return cmdRun("standup");
821
1003
  if (cmd === "todo") return cmdRun("todo");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.6.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": {