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.
- package/README.md +26 -3
- package/index.mjs +369 -8
- 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.
|
|
29
|
-
|
|
30
|
-
|
|
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="
|
|
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-
|
|
723
|
+
<section id="panel-map">
|
|
724
|
+
<div class="profile muted" id="profile">Mapping your work…</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 · click for details · 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 · drag to pan · 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 & 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='
|
|
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
|
|
522
|
-
|
|
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
|
-
|
|
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==='&'?'&':ch==='<'?'<':'>';});}
|
|
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> · <b>"+g.stats.repos+"</b> repos · <b>"+g.stats.commits+"</b> of your commits · <b>"+(g.stats.files||0)+"</b> files · ";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;}
|
|
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 · click for details · 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>—</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 · "+st.commits+" commits · "+st.languages+" languages · "+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 · <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 · 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 · <span class='rname'>"+esc(name)+"</span></h4><div class='body'>Reading the project…</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 · <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'>×</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 · "+hw.cpuCount+"-core · "+esc(hw.platform)+"/"+esc(hw.arch)+(hw.gpu?" · GPU: "+esc(hw.gpu.name)+(hw.gpu.vramGB?" ("+hw.gpu.vramGB+"GB)":""):"")+"</div>";
|
|
877
|
+
h+="<div class='ideas'><h4>Local models · 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> · <code>ollama pull "+esc(m.model)+"</code> <span class='muted'>~"+m.needGB+"GB · "+esc(m.note)+"</span></span> "+fitBadge(m)+"</div>";});
|
|
879
|
+
h+="<div class='muted' style='margin-top:6px'>Best fit: <b>"+esc(rec.best)+"</b> — pull it, then pick <b>Local model (Ollama)</b> above.</div></div>";
|
|
880
|
+
h+="<div class='ideas'><h4>Paid models · bring an API key</h4>";
|
|
881
|
+
rec.paid.forEach(function(p){h+="<div class='idea'><span><b>"+esc(p.provider)+"</b> · "+esc(p.tier)+" · <code>"+esc(p.model)+"</code> <span class='muted'>· "+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