symbiot 0.4.1 → 0.6.1

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 +14 -3
  2. package/index.mjs +186 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -25,9 +25,20 @@ symbiot app
25
25
 
26
26
  Starts a tiny local server (127.0.0.1 only, protected by a one-time token) and
27
27
  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.
28
+ Chrome/Chromium/Edge/Brave (`--app` mode), otherwise a normal tab. No Electron,
29
+ no install — it's the same CLI. Press Ctrl+C (or click Quit) to stop.
30
+
31
+ Tabs:
32
+
33
+ - **Map** — a live, interactive node graph of your work, built from your local git:
34
+ you at the centre, your repos, and the languages and tools they share (so related
35
+ projects cluster). **Scroll to zoom, drag to pan, click a node.** Clicking a repo
36
+ shows its branch/commits/uncommitted/stack and a **Suggest next steps** button —
37
+ your model reads that repo's recent commits + open TODOs and proposes *In flight /
38
+ Next steps / Ideas*. The graph itself **needs no AI key** (pure local data);
39
+ suggestions use your chosen model (free with local Ollama). This is the landing view.
40
+ - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
41
+ - **Settings** — pick your AI. Free/private option: run **Ollama** locally, no key.
31
42
 
32
43
  **Cross-platform:** the app works on **Linux, macOS, and Windows** — the server
33
44
  and UI are just a local web page. The chrome-less window is detected per-OS
package/index.mjs CHANGED
@@ -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
@@ -299,6 +300,108 @@ function header(sub) {
299
300
  const SINCE_WEEK = Number(flag("since", "7"));
300
301
  const BASE = flag("dir", homedir());
301
302
 
303
+ // ---- work map: a node graph of your repos, languages, and tools -----------
304
+ const EXT_LANG = {
305
+ ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", mjs: "JavaScript",
306
+ py: "Python", go: "Go", rs: "Rust", java: "Java", kt: "Kotlin", rb: "Ruby", php: "PHP",
307
+ cs: "C#", cpp: "C++", cc: "C++", c: "C", swift: "Swift", vue: "Vue", svelte: "Svelte",
308
+ sql: "SQL", sh: "Shell", css: "CSS", scss: "CSS", html: "HTML",
309
+ };
310
+ const MANIFEST_TOOL = {
311
+ "package.json": "Node", "requirements.txt": "Python", "pyproject.toml": "Python",
312
+ "go.mod": "Go", "Cargo.toml": "Rust", "pom.xml": "Maven", "build.gradle": "Gradle",
313
+ "Gemfile": "Ruby", "composer.json": "PHP", "Dockerfile": "Docker", "docker-compose.yml": "Docker",
314
+ "terraform.tf": "Terraform", "kubernetes.yml": "Kubernetes", ".github": "GitHub Actions",
315
+ };
316
+ function findAllRepos(base) {
317
+ const out = sh(
318
+ `find ${JSON.stringify(base)} -maxdepth 5 -name .git \\( -type d -o -type f \\) 2>/dev/null ` +
319
+ `| grep -vE 'node_modules|/\\.cache/|/\\.local/|/venvs?/|/\\.npm' | head -300`,
320
+ );
321
+ const repos = [];
322
+ for (const g of out.split("\n").filter(Boolean)) {
323
+ const repo = g.replace(/\/\.git$/, "");
324
+ const last = Number(sh(`git -C ${JSON.stringify(repo)} log -1 --format=%ct 2>/dev/null`).trim()) || 0;
325
+ if (last) repos.push({ path: repo, name: repo.split("/").pop(), recency: last });
326
+ }
327
+ return repos.sort((a, b) => b.recency - a.recency).slice(0, 36);
328
+ }
329
+ function detectRepo(r) {
330
+ const files = sh(`git -C ${JSON.stringify(r.path)} ls-files 2>/dev/null | head -5000`).split("\n").filter(Boolean);
331
+ const count = {}; const tools = new Set();
332
+ for (const f of files) {
333
+ const base = f.split("/").pop();
334
+ if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]);
335
+ const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase();
336
+ if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1;
337
+ }
338
+ const langs = Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]);
339
+ const email = sh(`git -C ${JSON.stringify(r.path)} config user.email`).trim();
340
+ 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 };
342
+ }
343
+ function buildMap() {
344
+ const who = me();
345
+ const repos = findAllRepos(BASE).map(detectRepo);
346
+ const nodes = []; const edges = []; const have = new Set();
347
+ const add = (n) => { if (!have.has(n.id)) { have.add(n.id); nodes.push(n); } };
348
+ add({ id: "me", type: "person", label: who.name || "You", weight: 22 });
349
+ for (const r of repos) {
350
+ const rid = "repo:" + r.path;
351
+ 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 } });
353
+ edges.push({ source: "me", target: rid });
354
+ 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
+ for (const T of r.tools) { const id = "tool:" + T; add({ id, type: "tool", label: T, weight: 12 }); edges.push({ source: rid, target: id }); }
356
+ }
357
+ const out = { nodes, edges, stats: {
358
+ repos: repos.length,
359
+ languages: nodes.filter((n) => n.type === "lang").length,
360
+ tools: nodes.filter((n) => n.type === "tool").length,
361
+ commits: repos.reduce((s, r) => s + r.mine, 0),
362
+ } };
363
+ LAST_MAP = out;
364
+ return out;
365
+ }
366
+ // Local detail for a clicked node (no AI).
367
+ function nodeDetail(id) {
368
+ const map = LAST_MAP || buildMap();
369
+ if (id === "me") { const n = map.nodes.find((x) => x.id === "me"); return { type: "person", label: (n && n.label) || "You", stats: map.stats }; }
370
+ if (id.startsWith("repo:")) {
371
+ const path = id.slice(5); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
372
+ return {
373
+ type: "repo", label: n ? n.label : path.split("/").pop(), path,
374
+ branch: sh(`git -C ${JSON.stringify(path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim(),
375
+ dirty: sh(`git -C ${JSON.stringify(path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean).length,
376
+ last: sh(`git -C ${JSON.stringify(path)} log -1 --format=%cd --date=short 2>/dev/null`).trim(),
377
+ commits: m.commits || 0, langs: m.langs || [], tools: m.tools || [],
378
+ };
379
+ }
380
+ if (id.startsWith("lang:") || id.startsWith("tool:")) {
381
+ const n = map.nodes.find((x) => x.id === id);
382
+ 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);
383
+ return { type: id.startsWith("lang:") ? "lang" : "tool", label: n ? n.label : id.split(":")[1], repos };
384
+ }
385
+ return { error: "unknown node" };
386
+ }
387
+ // AI "value" suggestions for one repo, from its recent commits + open work.
388
+ async function repoSuggest(path) {
389
+ if (!resolveProvider()) return { error: "not-connected" };
390
+ if (!path) return { error: "no repo" };
391
+ const name = path.split("/").pop();
392
+ const email = sh(`git -C ${JSON.stringify(path)} config user.email`).trim();
393
+ const recent = sh(`git -C ${JSON.stringify(path)} log --oneline -30 ${email ? `--author=${JSON.stringify(email)}` : ""} 2>/dev/null`).trim();
394
+ const open = openWork([{ path, name }]);
395
+ const system =
396
+ `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: ` +
397
+ `"In flight" (what's clearly underway), "Next steps" (3-5 concrete, specific actions), and "Ideas" (2-3 that fit where this build is heading). ` +
398
+ `Be specific to the actual work shown — no filler, no preamble, no restating the commits verbatim.`;
399
+ const prompt =
400
+ `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.`;
401
+ const text = await write(system, prompt);
402
+ return { text: text || "(couldn't reach the model)" };
403
+ }
404
+
302
405
  // Build a write-up for a command; returns { text, sub, error? } without printing.
303
406
  // Shared by the CLI (cmdRun) and the web UI (symbiot app).
304
407
  async function produce(cmd) {
@@ -476,16 +579,50 @@ select:focus,input:focus{outline:none;border-color:var(--green)}
476
579
  a{color:var(--green);cursor:pointer}.hidden{display:none}
477
580
  .note{font-size:12px;margin-top:10px}.ok{color:var(--green)}.err{color:var(--amber)}
478
581
  footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
582
+ .profile{font-size:13px;margin-bottom:8px;line-height:1.5}
583
+ .profile b{color:var(--bone)}
584
+ .maprow{display:flex;gap:12px;align-items:stretch}
585
+ #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}
586
+ #graph:active{cursor:grabbing}
587
+ #graph text{font-family:var(--sans);fill:var(--text);font-size:11px;pointer-events:none}
588
+ #graph .lbl-me{fill:var(--bone);font-weight:700;font-size:13px}
589
+ .detail{width:290px;flex:none;background:var(--ink2);border:1px solid var(--line);border-radius:12px;padding:14px;overflow:auto;max-height:62vh}
590
+ .detail h3{margin:0 0 4px;color:var(--bone);font-size:15px}
591
+ .detail .k{font-size:12px;color:var(--faint);margin-top:8px}
592
+ .detail ul{margin:6px 0 0;padding-left:18px}.detail li{margin:2px 0}
593
+ .detail .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}
594
+ .tag{display:inline-flex;font-size:11px;padding:4px 9px;border-radius:999px;background:var(--green-dim);border:1px solid #2a6b52;color:var(--green)}
595
+ .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
+ .legend{display:flex;gap:14px;align-items:center;margin-top:10px;font-size:12px;color:var(--faint);flex-wrap:wrap}
597
+ .lg{display:inline-flex;gap:6px;align-items:center}
598
+ .lg i{width:10px;height:10px;border-radius:50%;display:inline-block}
599
+ @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
479
600
  </style></head><body>
480
601
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="status" id="status">...</span></header>
481
602
  <div class="tabs">
482
- <button class="tab active" data-tab="week">Week</button>
603
+ <button class="tab active" data-tab="map">Map</button>
604
+ <button class="tab" data-tab="week">Week</button>
483
605
  <button class="tab" data-tab="standup">Standup</button>
484
606
  <button class="tab" data-tab="todo">Todo</button>
485
607
  <button class="tab" data-tab="settings">Settings</button>
486
608
  </div>
487
609
  <main>
488
- <section id="panel-run">
610
+ <section id="panel-map">
611
+ <div class="profile muted" id="profile">Mapping your work&hellip;</div>
612
+ <div class="maprow">
613
+ <svg id="graph" viewBox="0 0 960 620" preserveAspectRatio="xMidYMid meet"></svg>
614
+ <aside id="detail" class="detail hidden"></aside>
615
+ </div>
616
+ <div class="legend">
617
+ <span class="lg"><i style="background:var(--green)"></i>you</span>
618
+ <span class="lg"><i style="background:var(--bone)"></i>repos</span>
619
+ <span class="lg"><i style="background:var(--amber)"></i>languages</span>
620
+ <span class="lg"><i style="background:#6bb3ff"></i>tools</span>
621
+ <span class="muted" style="margin-left:8px">scroll to zoom &middot; drag to pan &middot; click a node</span>
622
+ <button class="ghost" id="remap" style="margin-left:auto">Rescan</button>
623
+ </div>
624
+ </section>
625
+ <section id="panel-run" class="hidden">
489
626
  <div class="row"><button class="act" id="write">Write my <span id="what">week</span></button>
490
627
  <button class="ghost hidden" id="copy">Copy</button>
491
628
  <span class="muted">Reads your local git and writes it up.</span></div>
@@ -497,8 +634,9 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
497
634
  <option value="anthropic">Claude (Anthropic)</option>
498
635
  <option value="openai">OpenAI (GPT)</option>
499
636
  <option value="gemini">Gemini (Google)</option>
500
- <option value="ollama">Local model (Ollama)</option>
637
+ <option value="ollama">Local model (Ollama) - free, no key</option>
501
638
  </select>
639
+ <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
640
  <div id="keyWrap"><label>API key</label><input id="key" type="password" placeholder="paste your key">
503
641
  <div class="note"><a id="getkey">Where do I get a key?</a></div></div>
504
642
  <div id="baseWrap" class="hidden"><label>Ollama URL</label><input id="baseUrl" type="text" value="http://localhost:11434"></div>
@@ -515,11 +653,16 @@ function api(path,body){return fetch(path,{method:body?'POST':'GET',headers:{'x-
515
653
  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
654
  var DEFMODEL={anthropic:'claude-opus-5-5',openai:'gpt-4o-mini',gemini:'gemini-1.5-flash',ollama:'llama3.1'};
517
655
  function $(id){return document.getElementById(id);}
518
- var current='week';
656
+ var current='map';var mapLoaded=false;
657
+ var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff'};
519
658
  function tabs(){return document.querySelectorAll('.tab');}
520
659
  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');}}
660
+ var isMap=tab==='map',isSet=tab==='settings',isRun=!isMap&&!isSet;
661
+ $('panel-map').classList.toggle('hidden',!isMap);
662
+ $('panel-run').classList.toggle('hidden',!isRun);
663
+ $('panel-settings').classList.toggle('hidden',!isSet);
664
+ 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();}
523
666
  tabs().forEach(function(t){t.addEventListener('click',function(){setTab(t.dataset.tab);});});
524
667
  function refresh(){api('/api/status').then(function(s){$('status').textContent=s.connected?s.line:'Not connected - open Settings';});}
525
668
  $('write').addEventListener('click',function(){$('out').textContent='Writing...';$('out').classList.add('muted');$('copy').classList.add('hidden');
@@ -534,7 +677,40 @@ $('save').addEventListener('click',function(){var p=$('provider').value;$('saveM
534
677
  var cfg={provider:p,model:$('model').value.trim()};if(p==='ollama')cfg.baseUrl=$('baseUrl').value.trim();else cfg.key=$('key').value.trim();
535
678
  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
679
  $('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();
680
+ var GRAPH=null,sel=null,view={k:1,x:0,y:0},GW=960,GH=620;
681
+ function esc(s){return String(s).replace(/[&<>]/g,function(ch){return ch==='&'?'&amp;':ch==='<'?'&lt;':'&gt;';});}
682
+ 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;});
683
+ 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;}
684
+ 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;});
685
+ 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));});}}
686
+ 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;}
687
+ function render(){if(!GRAPH)return;var idx={};GRAPH.nodes.forEach(function(n){idx[n.id]=n;});var nb=sel?nbrs(sel):null;
688
+ var s="<g id='vp' transform='translate("+view.x.toFixed(1)+","+view.y.toFixed(1)+") scale("+view.k.toFixed(3)+")'>";
689
+ 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+"'/>";});
690
+ 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"):"");
691
+ 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
+ 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
+ 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;}
695
+ function hideDetail(){document.getElementById('detail').classList.add('hidden');}
696
+ function showDetail(d){var el=document.getElementById('detail');el.classList.remove('hidden');
697
+ if(d.error){el.innerHTML="<h3>&mdash;</h3><div class='k'>"+esc(d.error)+"</div>";return;}
698
+ 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>";});
699
+ 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>";
700
+ 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
+ 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
+ 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;
705
+ function nodeAt(ev){var t=ev.target;var g=t&&t.closest?t.closest('.node'):null;return g?g.getAttribute('data-id'):null;}
706
+ 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();});}
712
+ document.getElementById('remap').addEventListener('click',loadMap);
713
+ initGraphEvents();syncP();refresh();loadMap();
538
714
  </script></body></html>`;
539
715
 
540
716
  function readBody(req) {
@@ -587,6 +763,9 @@ async function cmdApp() {
587
763
  }
588
764
  try {
589
765
  if (u.pathname === "/api/status") { const r = resolveProvider(); return json(res, r ? { connected: true, line: `${PROVIDERS[r.provider].label} · ${r.model}` } : { connected: false }); }
766
+ if (u.pathname === "/api/map") return json(res, buildMap()); // local git only — no AI key needed
767
+ if (u.pathname === "/api/node") return json(res, nodeDetail(u.searchParams.get("id") || "")); // local
768
+ if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); return json(res, await repoSuggest(String(b.path || ""))); }
590
769
  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
770
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
592
771
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.4.1",
3
+ "version": "0.6.1",
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": {