symbiot 0.28.4 → 0.30.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/index.mjs +68 -3
- package/package.json +1 -1
- package/ui.mjs +4 -2
package/index.mjs
CHANGED
|
@@ -1144,6 +1144,24 @@ function detectFolder(path) {
|
|
|
1144
1144
|
for (const f of files) { const base = f.split("/").pop(); if (MANIFEST_TOOL[base]) tools.add(MANIFEST_TOOL[base]); const ext = (base.includes(".") ? base.split(".").pop() : "").toLowerCase(); if (EXT_LANG[ext]) count[EXT_LANG[ext]] = (count[EXT_LANG[ext]] || 0) + 1; }
|
|
1145
1145
|
return { path, name: path.split("/").pop(), langs: Object.entries(count).sort((a, b) => b[1] - a[1]).map((x) => x[0]), tools: [...tools], files: files.length };
|
|
1146
1146
|
}
|
|
1147
|
+
// Projects you've run an AI coding agent on. Claude Code stores one dir per
|
|
1148
|
+
// project under ~/.claude/projects, named by the project path with "/"→"-"
|
|
1149
|
+
// (verified). Returns { "<encoded path>": { agent, last } } — last = newest
|
|
1150
|
+
// session mtime (epoch secs). (Gemini's dir is global config, Codex's is empty,
|
|
1151
|
+
// so no reliable per-project signal there yet; add them here when there is.)
|
|
1152
|
+
function claudeProjects() {
|
|
1153
|
+
const base = join(homedir(), ".claude", "projects");
|
|
1154
|
+
const out = {};
|
|
1155
|
+
let dirs = [];
|
|
1156
|
+
try { dirs = sh(`ls -1 ${JSON.stringify(base)} 2>/dev/null`).split("\n").filter(Boolean); } catch {}
|
|
1157
|
+
for (const enc of dirs) {
|
|
1158
|
+
let last = 0;
|
|
1159
|
+
try { last = parseInt(sh(`find ${JSON.stringify(join(base, enc))} -maxdepth 1 -name '*.jsonl' -printf '%T@\\n' 2>/dev/null | sort -rn | head -1`).trim(), 10) || 0; } catch {}
|
|
1160
|
+
if (!last) { try { last = Math.floor(statSync(join(base, enc)).mtimeMs / 1000); } catch {} }
|
|
1161
|
+
out[enc] = { agent: "Claude Code", last };
|
|
1162
|
+
}
|
|
1163
|
+
return out;
|
|
1164
|
+
}
|
|
1147
1165
|
// Async only to YIELD between repos, so the app server can answer /api/scan
|
|
1148
1166
|
// (progress) mid-scan; the git calls themselves stay synchronous. Concurrent
|
|
1149
1167
|
// callers (e.g. Rescan while a scan runs) share the one in-flight build.
|
|
@@ -1204,6 +1222,29 @@ async function buildMapScan() {
|
|
|
1204
1222
|
const prov = resolveProvider();
|
|
1205
1223
|
if (prov) { const id = "ai:" + prov.provider; add({ id, type: "ai", label: PROVIDERS[prov.provider].label, weight: 14, meta: { model: prov.model, source: prov.source } }); edges.push({ source: "me", target: id }); }
|
|
1206
1224
|
} catch {}
|
|
1225
|
+
// Agent projects: badge the repo/folder nodes an AI coding agent has worked,
|
|
1226
|
+
// and surface agent-worked projects the scan missed as their own nodes.
|
|
1227
|
+
try {
|
|
1228
|
+
const ap = claudeProjects(); const used = new Set();
|
|
1229
|
+
const encOf = (p) => String(p || "").replace(/\//g, "-");
|
|
1230
|
+
for (const n of nodes) {
|
|
1231
|
+
if ((n.type === "repo" || n.type === "folder") && n.meta && n.meta.path) {
|
|
1232
|
+
const e = encOf(n.meta.path); if (ap[e]) { n.meta.agents = [ap[e]]; used.add(e); }
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
let extra = 0;
|
|
1236
|
+
for (const e of Object.keys(ap)) {
|
|
1237
|
+
if (used.has(e) || extra >= 20) continue;
|
|
1238
|
+
const decoded = e.replace(/-/g, "/"); // best-effort; hyphenated names won't resolve and are skipped
|
|
1239
|
+
if (!existsSync(decoded) || !statSync(decoded).isDirectory()) continue;
|
|
1240
|
+
if (nodes.some((n) => n.meta && n.meta.path === decoded)) continue;
|
|
1241
|
+
const isGit = existsSync(join(decoded, ".git"));
|
|
1242
|
+
const id = (isGit ? "repo:" : "folder:") + decoded; if (have.has(id)) continue;
|
|
1243
|
+
const det = isGit ? null : detectFolder(decoded);
|
|
1244
|
+
add({ id, type: isGit ? "repo" : "folder", label: decoded.split("/").pop(), weight: 10, meta: { path: decoded, agents: [ap[e]], agentOnly: true, langs: det ? det.langs.slice(0, 3) : [], tools: det ? det.tools : [], files: det ? det.files : 0 } });
|
|
1245
|
+
edges.push({ source: "me", target: id }); extra++;
|
|
1246
|
+
}
|
|
1247
|
+
} catch {}
|
|
1207
1248
|
const out = { nodes, edges, stats: {
|
|
1208
1249
|
repos: repos.length,
|
|
1209
1250
|
folders: nodes.filter((n) => n.type === "folder").length,
|
|
@@ -1229,7 +1270,7 @@ async function nodeDetail(id) {
|
|
|
1229
1270
|
branch: sh(`git -C ${JSON.stringify(path)} rev-parse --abbrev-ref HEAD 2>/dev/null`).trim(),
|
|
1230
1271
|
dirty: sh(`git -C ${JSON.stringify(path)} status --porcelain 2>/dev/null`).split("\n").filter(Boolean).length,
|
|
1231
1272
|
last: sh(`git -C ${JSON.stringify(path)} log -1 --format=%cd --date=short 2>/dev/null`).trim(),
|
|
1232
|
-
commits: m.commits || 0, langs: m.langs || [], tools: m.tools || [],
|
|
1273
|
+
commits: m.commits || 0, langs: m.langs || [], tools: m.tools || [], agents: m.agents || [],
|
|
1233
1274
|
};
|
|
1234
1275
|
}
|
|
1235
1276
|
if (id.startsWith("lang:") || id.startsWith("tool:")) {
|
|
@@ -1243,7 +1284,7 @@ async function nodeDetail(id) {
|
|
|
1243
1284
|
}
|
|
1244
1285
|
if (id.startsWith("folder:")) {
|
|
1245
1286
|
const path = id.slice(7); const n = map.nodes.find((x) => x.id === id); const m = (n && n.meta) || {};
|
|
1246
|
-
return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0 };
|
|
1287
|
+
return { type: "folder", label: n ? n.label : path.split("/").pop(), path, langs: m.langs || [], tools: m.tools || [], files: m.files || 0, agents: m.agents || [] };
|
|
1247
1288
|
}
|
|
1248
1289
|
return { error: "unknown node" };
|
|
1249
1290
|
}
|
|
@@ -1322,6 +1363,30 @@ async function repoSuggest(path) {
|
|
|
1322
1363
|
const text = await write(system, prompt);
|
|
1323
1364
|
return { text: text || "(couldn't reach the model)", footer };
|
|
1324
1365
|
}
|
|
1366
|
+
// Overview + suggestions for a NON-git project folder (no commits to read), from
|
|
1367
|
+
// its file tree, manifest and README. The folder-node equivalent of repoSuggest.
|
|
1368
|
+
async function folderSuggest(path) {
|
|
1369
|
+
if (!resolveProvider()) return { error: "not-connected" };
|
|
1370
|
+
if (!path) return { error: "no path" };
|
|
1371
|
+
const det = detectFolder(path);
|
|
1372
|
+
const files = sh(`find ${JSON.stringify(path)} -maxdepth 2 \\( -name node_modules -o -name .git \\) -prune -o -type f -print 2>/dev/null | head -200`).split("\n").filter(Boolean);
|
|
1373
|
+
const rels = files.map((f) => f.slice(path.length + 1));
|
|
1374
|
+
const pick = (names) => { for (const n of names) { const hit = files.find((f) => f.split("/").pop().toLowerCase() === n); if (hit) { try { return readFileSync(hit, "utf8").slice(0, 2000); } catch {} } } return ""; };
|
|
1375
|
+
const manifest = pick(["package.json", "requirements.txt", "pyproject.toml", "go.mod", "cargo.toml", "pom.xml", "composer.json", "gemfile", "pubspec.yaml"]);
|
|
1376
|
+
const readme = pick(["readme.md", "readme.txt", "readme"]);
|
|
1377
|
+
const stack = [...det.langs, ...det.tools].join(", ") || "unknown";
|
|
1378
|
+
const system =
|
|
1379
|
+
`You are a pragmatic senior engineer looking at a project FOLDER that is NOT under version control. ` +
|
|
1380
|
+
`From its files, write exactly three short sections with headings: "Overview" (what this project is, 1-2 sentences), ` +
|
|
1381
|
+
`"Next steps" (3-5 concrete actions — a strong first one is often "git init" if this looks like real work), ` +
|
|
1382
|
+
`"Ideas" (2-3 that fit where it's heading). Ground every point in the actual files/manifest/README shown. No preamble.`;
|
|
1383
|
+
const prompt =
|
|
1384
|
+
`Folder: ${det.name}\nStack: ${stack}\nFiles (${det.files}):\n${rels.slice(0, 120).join("\n")}\n\n` +
|
|
1385
|
+
(manifest ? `Manifest:\n${manifest}\n\n` : "") + (readme ? `README excerpt:\n${readme}\n\n` : "") + `Write the overview and suggestions.`;
|
|
1386
|
+
const text = await write(system, prompt);
|
|
1387
|
+
const footer = `symbiot ${VERSION} · folder · ${det.files} files · ${stack} · not a git repo`;
|
|
1388
|
+
return { text: text || "(couldn't reach the model)", footer };
|
|
1389
|
+
}
|
|
1325
1390
|
|
|
1326
1391
|
// Build a write-up for a command; returns { text, sub, error? } without printing.
|
|
1327
1392
|
// Shared by the CLI (cmdRun) and the web UI (symbiot app).
|
|
@@ -1543,7 +1608,7 @@ async function cmdApp() {
|
|
|
1543
1608
|
if (u.pathname === "/api/models") { const hw = detectHardware(); return json(res, { hardware: hw, rec: recommendModels(hw) }); }
|
|
1544
1609
|
if (u.pathname === "/api/drift") return json(res, computeDrift({ ci: u.searchParams.get("ci") === "1", fetch: u.searchParams.get("fetch") === "1" }));
|
|
1545
1610
|
if (u.pathname === "/api/node") return json(res, await nodeDetail(u.searchParams.get("id") || "")); // local
|
|
1546
|
-
if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req);
|
|
1611
|
+
if (u.pathname === "/api/suggest" && req.method === "POST") { const b = await readBody(req); const p = String(b.path || ""); const isRepo = p && existsSync(join(p, ".git")); return json(res, await (isRepo ? repoSuggest(p) : folderSuggest(p))); }
|
|
1547
1612
|
if (u.pathname === "/api/review" && req.method === "POST") { const b = await readBody(req); return json(res, await repoReview(String(b.path || ""))); }
|
|
1548
1613
|
if (u.pathname === "/api/tasks" && req.method !== "POST") { const arch = u.searchParams.get("archived") === "1"; return json(res, loadTasks().filter((x) => !!x.archived === arch).map((t) => ({ ...t, type: taskType(t.text) }))); }
|
|
1549
1614
|
if (u.pathname === "/api/tasks/add" && req.method === "POST") { const b = await readBody(req); return json(res, addTask(b.text, b.repo)); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/ui.mjs
CHANGED
|
@@ -260,15 +260,17 @@ function nodeById(id){for(var i=0;i<GRAPH.nodes.length;i++)if(GRAPH.nodes[i].id=
|
|
|
260
260
|
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>";}
|
|
261
261
|
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";}
|
|
262
262
|
function hideDetail(){document.getElementById('detail').classList.add('hidden');}
|
|
263
|
+
function ago(sec){if(!sec)return'';var s=Math.floor(Date.now()/1000)-sec;if(s<3600)return Math.max(1,Math.floor(s/60))+'m ago';if(s<86400)return Math.floor(s/3600)+'h ago';return Math.floor(s/86400)+'d ago';}
|
|
264
|
+
function agentBadge(d){if(!d.agents||!d.agents.length)return'';return d.agents.map(function(a){return "<div style='margin-top:8px;font-size:12px;color:var(--bone);background:var(--green-dim);border:1px solid #2a6b52;border-radius:8px;padding:6px 9px'>⚡ worked via <b>"+esc(a.agent)+"</b>"+(a.last?" · "+ago(a.last):"")+"</div>";}).join('');}
|
|
263
265
|
function showDetail(d){var el=document.getElementById('detail');el.classList.remove('hidden');
|
|
264
266
|
if(d.error){el.innerHTML="<h3>—</h3><div class='k'>"+esc(d.error)+"</div>";return;}
|
|
265
267
|
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>";});
|
|
266
|
-
el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='chips'>"+chips+"</div
|
|
268
|
+
el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='chips'>"+chips+"</div>"+agentBadge(d)+"<button class='act' id='suggest' style='margin-top:12px'>Suggest next steps</button><div id='sugout'></div>";
|
|
267
269
|
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>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");});});return;}
|
|
268
270
|
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;}
|
|
269
271
|
if(d.type==='agent'){var m=d.meta||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>"+(m.kind==='agent'?'AI coding agent you have installed':'editor you have installed')+"</div>"+(m.cmd?"<div class='out2' style='font-size:11px'>"+esc(m.cmd)+"</div>":"");return;}
|
|
270
272
|
if(d.type==='ai'){var m=d.meta||{};el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>Currently powering Symbiot · model <b>"+esc(m.model||'?')+"</b>"+(m.source?" · via "+esc(m.source):"")+"</div>";return;}
|
|
271
|
-
if(d.type==='folder'){var chips='';(d.langs||[]).concat(d.tools||[]).forEach(function(x){chips+="<span class='tag'>"+esc(x)+"</span>";});el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>project folder · not a git repo · "+(d.files||0)+" files</div><div class='chips'>"+chips+"</div><div class='k' style='font-family:ui-monospace,monospace'>"+esc(d.path||'')+"</div><div class='k'>Tasks can be sent here too — the agent runs in this folder.</div>";return;}
|
|
273
|
+
if(d.type==='folder'){var chips='';(d.langs||[]).concat(d.tools||[]).forEach(function(x){chips+="<span class='tag'>"+esc(x)+"</span>";});el.innerHTML="<h3>"+esc(d.label)+"</h3><div class='k'>project folder · not a git repo · "+(d.files||0)+" files</div><div class='chips'>"+chips+"</div><div class='k' style='font-family:ui-monospace,monospace'>"+esc(d.path||'')+"</div>"+agentBadge(d)+"<button class='act' id='suggest' style='margin-top:12px'>Overview & suggestions</button><div id='sugout'></div><div class='k' style='margin-top:8px'>Tasks can be sent here too — the agent runs in this folder.</div>";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 an overview - Ollama is free and runs locally.</div>";return;}o.innerHTML="<div class='out2'>"+esc(r.text||'(no output)')+"</div>"+(r.footer?"<div class='rfoot'>"+esc(r.footer)+"</div>":"");});});return;}
|
|
272
274
|
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"+(st.agents?" · "+st.agents+" agents/editors":"")+"</div>";return;}}
|
|
273
275
|
var reviewCache={};var IDEAS=[];var IREPO="";
|
|
274
276
|
function hideReview(){var el=document.getElementById('review');el.classList.add('hidden');el.innerHTML='';}
|