symbiot 0.4.0 → 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.
- package/README.md +19 -3
- package/index.mjs +210 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,9 +25,25 @@ 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.
|
|
29
|
-
|
|
30
|
-
|
|
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.
|
|
42
|
+
|
|
43
|
+
**Cross-platform:** the app works on **Linux, macOS, and Windows** — the server
|
|
44
|
+
and UI are just a local web page. The chrome-less window is detected per-OS
|
|
45
|
+
(PATH on Linux, the `/Applications` bundle on macOS, `Program Files` on Windows);
|
|
46
|
+
where no Chromium-family browser is found it opens your default browser instead.
|
|
31
47
|
|
|
32
48
|
## Install
|
|
33
49
|
|
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="
|
|
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-
|
|
610
|
+
<section id="panel-map">
|
|
611
|
+
<div class="profile muted" id="profile">Mapping your work…</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 · drag to pan · 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='
|
|
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
|
|
522
|
-
|
|
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
|
-
|
|
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==='&'?'&':ch==='<'?'<':'>';});}
|
|
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 · <b>"+g.stats.commits+"</b> of your commits · ";s+=langs.length?("mostly <b>"+esc(langs.slice(0,3).join(", "))+"</b>"):"no languages detected";if(top)s+=" · 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>—</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 · "+st.commits+" commits · "+st.languages+" languages · "+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) {
|
|
@@ -544,12 +720,33 @@ function readBody(req) {
|
|
|
544
720
|
});
|
|
545
721
|
}
|
|
546
722
|
function onPath(cands) { for (const cc of cands) { if (sh(`command -v ${cc} 2>/dev/null`).trim()) return cc; } return null; }
|
|
723
|
+
// Find a Chromium-family browser for the chrome-less --app window, per OS.
|
|
724
|
+
function chromeBinary() {
|
|
725
|
+
const p = process.platform;
|
|
726
|
+
const exists = (f) => { try { return existsSync(f) ? f : null; } catch { return null; } };
|
|
727
|
+
if (p === "darwin") {
|
|
728
|
+
return ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
729
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
730
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
731
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"].map(exists).find(Boolean) || null;
|
|
732
|
+
}
|
|
733
|
+
if (p === "win32") {
|
|
734
|
+
const bases = [process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"], process.env.LOCALAPPDATA].filter(Boolean);
|
|
735
|
+
const rels = ["Google\\Chrome\\Application\\chrome.exe", "Chromium\\Application\\chrome.exe",
|
|
736
|
+
"Microsoft\\Edge\\Application\\msedge.exe", "BraveSoftware\\Brave-Browser\\Application\\brave.exe"];
|
|
737
|
+
for (const base of bases) for (const r of rels) { const f = exists(join(base, r)); if (f) return f; }
|
|
738
|
+
const w = sh("where chrome 2>NUL").split(/\r?\n/).map((s) => s.trim()).find(Boolean);
|
|
739
|
+
return w || null;
|
|
740
|
+
}
|
|
741
|
+
return onPath(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave-browser", "microsoft-edge"]);
|
|
742
|
+
}
|
|
547
743
|
function openApp(url) {
|
|
548
744
|
try {
|
|
549
|
-
const chrome =
|
|
745
|
+
const chrome = chromeBinary();
|
|
550
746
|
if (chrome) { spawn(chrome, [`--app=${url}`, "--new-window"], { detached: true, stdio: "ignore" }).unref(); return "app window"; }
|
|
551
|
-
|
|
552
|
-
spawn(
|
|
747
|
+
// fall back to the OS default browser (a normal tab) — still fully functional
|
|
748
|
+
if (process.platform === "win32") { spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref(); return "browser tab"; }
|
|
749
|
+
spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
553
750
|
return "browser tab";
|
|
554
751
|
} catch { return null; }
|
|
555
752
|
}
|
|
@@ -566,6 +763,9 @@ async function cmdApp() {
|
|
|
566
763
|
}
|
|
567
764
|
try {
|
|
568
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 || ""))); }
|
|
569
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)); }
|
|
570
770
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
571
771
|
if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
|
package/package.json
CHANGED