symbiot 0.54.0 → 0.54.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/CHANGELOG.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
4
4
 
5
+ ## 0.54.1
6
+
7
+ - **No more lag from a big agent log.** An agent's log keeps every run and everything it reads, screenshots included; one reached 144 MB, and Symbiot reread all of it on almost every refresh, so the app stalled for 5–28 seconds at a time and ate a core. Now it reads only the newest run, from the end of the file, and keeps it until the file changes. A log past 8 MB starts afresh at the next run, with the old one kept as `agent.log.old`.
8
+
5
9
  ## 0.54.0
6
10
 
7
11
  - **Show Symbiot a screenshot.** Paste one into "Talk to Symbiot" (Ctrl+V) or drop it on the bar: it shows as a thumbnail you can remove, goes with what you say, and the AI you connected sees it (Claude, OpenAI, Gemini, or a local model that reads images). If Symbiot hands the work to an agent, the agent gets the screenshot too. Up to 4 at a time; they're kept a week, readable by you only.
package/agents.mjs CHANGED
@@ -5,10 +5,10 @@ import { spawn } from "node:child_process";
5
5
  import { homedir } from "node:os";
6
6
  import { join, dirname, resolve, relative } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
8
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync, renameSync } from "node:fs";
9
9
  import { randomBytes, createHash } from "node:crypto";
10
10
  import { CONFIG_DIR, loadConfig, saveConfig, loadTasks, TASK_MAX, clipWords, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
11
- import { parseRun, lastRunText } from "./work.mjs";
11
+ import { parseRun, lastRunText, readRunLog } from "./work.mjs";
12
12
  import { parseFacts } from "./handover.mjs";
13
13
  import { scanRoots, repoPathMap } from "./scan.mjs";
14
14
  import { knowledgeFolders } from "./knowledge.mjs";
@@ -273,7 +273,7 @@ function earlierRuns() {
273
273
  const busy = runningHandoff(r.path), ask = agentQuestions(r.path, r.name), wait = waitingFor(r.path), facts = busy ? [] : factsOf(r.path);
274
274
  if (!busy && !ask.questions.length && !wait && !facts.length) continue;
275
275
  const log = join(r.path, ".symbiot", "agent.log"), startedAt = (busy && busy.startedAt) || Number(r.startedAt) || Date.now();
276
- let tail = "", work = null, progress = null, end = Date.now(); try { ({ tail, work, progress } = workOf(r.path, readFileSync(log, "utf8"))); if (!busy) end = statSync(log).mtimeMs; } catch {}
276
+ let tail = "", work = null, progress = null, end = Date.now(); try { ({ tail, work, progress } = workOf(r.path, readRunLog(log))); if (!busy) end = statSync(log).mtimeMs; } catch {}
277
277
  out.push({ id: "earlier-" + digest(r.path).slice(0, 8), name: String(r.name || r.path.split("/").pop()), path: r.path, status: busy ? "running" : "done", earlier: true, elapsed: Math.max(0, end - startedAt), exitCode: null, tail, work, progress, changed: agentChanges(r.path, startedAt), ask, held: heldTasks(r.path), waiting: wait, remember: facts.length ? facts : null, fromHeld: false });
278
278
  }
279
279
  return out;
@@ -576,10 +576,12 @@ function _findOrcaCli() {
576
576
  // ---- background jobs --------------------------------------------------------
577
577
  // Run a shell command as a tracked, logged background job that shows up live in
578
578
  // the Agents tab. Shared by the agent handoff and the local-model setup.
579
+ const LOG_KEEP = 8 * 1024 * 1024;
579
580
  function track(name, cmd, cwd, onExit, env) {
580
581
  try {
581
582
  const dir = join(cwd, ".symbiot"); mkdirSync(dir, { recursive: true });
582
583
  const logp = join(dir, "agent.log");
584
+ try { if (statSync(logp).size > LOG_KEEP) renameSync(logp, logp + ".old"); } catch {} // past 8 MB: kept as agent.log.old, and a fresh one starts
583
585
  let fd = "ignore"; try { fd = openSync(logp, "a"); writeSync(fd, `\n=== ${name} ${new Date().toISOString()} ===\n$ ${cmd}\n`); } catch {}
584
586
  const entry = { id: randomBytes(4).toString("hex"), name, path: cwd, log: logp, startedAt: Date.now(), status: "running", exitCode: null, endedAt: null };
585
587
  const child = spawn(cmd, { shell: true, cwd, detached: true, stdio: ["ignore", fd === "ignore" ? "ignore" : fd, fd === "ignore" ? "ignore" : fd], ...(env ? { env: { ...process.env, ...env } } : {}) });
@@ -623,7 +625,7 @@ const QUESTIONS_MAX = 5;
623
625
  // the next moves up. The rest are a click away.
624
626
  const OPTIONS_SHOWN = 2, IDEAS_SHOWN = 2;
625
627
  const qKey = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
626
- const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
628
+ const readSymbiot = (path, f) => { if (f === "agent.log") return readRunLog(join(path, ".symbiot", f)); try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
627
629
  // "## Questions" → "### question", context lines, "- option" bullets;
628
630
  // "## Suggestions" (or Ideas / Follow-ups) → "- idea" bullets. Forgiving: a bare
629
631
  // bullet under Questions is a question with no options.
@@ -880,8 +882,7 @@ function workOf(path, log) {
880
882
  function agentsList() {
881
883
  const seen = new Set();
882
884
  return HANDOFFS.map((e) => {
883
- let log = ""; try { log = readFileSync(e.log, "utf8"); } catch {}
884
- const { tail, work, progress } = workOf(e.path, log);
885
+ const { tail, work, progress } = workOf(e.path, readRunLog(e.log));
885
886
  const first = !seen.has(e.path); seen.add(e.path);
886
887
  const facts = first && e.status !== "running" ? factsOf(e.path) : [];
887
888
  return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
package/lanes.mjs CHANGED
@@ -23,7 +23,7 @@ import { runHandoff, runningHandoff, waitingFor, agentQuestions } from "./agents
23
23
  import { addTask, pushTasks } from "./tasks.mjs";
24
24
  import { actNow } from "./mind.mjs";
25
25
  import { repoPathMap } from "./scan.mjs";
26
- import { parseRun, lastRunText } from "./work.mjs";
26
+ import { parseRun, lastRunText, readRunLog } from "./work.mjs";
27
27
  import { OPS, parseHandoffs } from "./handover.mjs";
28
28
 
29
29
  const LEDGER = join(CONFIG_DIR, "lanes.json");
@@ -33,7 +33,7 @@ const MAX_KEEP = 200, MAX_CHAIN = 4, TAIL = 4000;
33
33
  function loadLedger() { try { const d = JSON.parse(readFileSync(LEDGER, "utf8")); return { handoffs: Array.isArray(d.handoffs) ? d.handoffs : [] }; } catch { return { handoffs: [] }; } }
34
34
  // It can name accounts and what's in them: yours only (0600).
35
35
  function saveLedger(d) { try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(LEDGER, JSON.stringify({ handoffs: d.handoffs.slice(-MAX_KEEP) }, null, 1), { mode: 0o600 }); try { chmodSync(LEDGER, 0o600); } catch {} return true; } catch { return false; } }
36
- const readSym = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
36
+ const readSym = (path, f) => { if (f === "agent.log") return readRunLog(join(path, ".symbiot", f)); try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
37
37
  const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
38
38
 
39
39
  // A folder's lane: the repo it is, or ops for a run of its own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
package/server.mjs CHANGED
@@ -7,6 +7,7 @@ import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { gunzipSync } from "node:zlib";
10
+ import { readRunLog } from "./work.mjs";
10
11
  import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
11
12
  import { createServer } from "node:http";
12
13
  import { randomBytes } from "node:crypto";
@@ -413,7 +414,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
413
414
  // giving up and sending you to a terminal.
414
415
  const attempt = (n) => track("symbiot update", inst, homedir(), (code) => {
415
416
  if (code !== 0) {
416
- let tail = ""; try { tail = readFileSync(join(homedir(), ".symbiot", "agent.log"), "utf8").slice(-3000); } catch {}
417
+ const tail = readRunLog(join(homedir(), ".symbiot", "agent.log")).slice(-3000);
417
418
  if (n < 5 && /E404|ETARGET|notarget|No matching version|is not in this registry/i.test(tail)) { UPDATING = { target, retrying: true, attempt: n + 1, at: Date.now() }; setTimeout(() => attempt(n + 1), 30000).unref(); }
418
419
  else UPDATING = null;
419
420
  return;
package/tasks.mjs CHANGED
@@ -11,6 +11,7 @@ import { handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
11
11
  import { userStyleLine } from "./adapt.mjs";
12
12
  import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, droppedTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
13
13
  import { gitDefaultBranch } from "./drift.mjs";
14
+ import { readRunLog } from "./work.mjs";
14
15
  import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
15
16
 
16
17
  // ---- tasks: a persistent checklist (stored by core.mjs) -------------------
@@ -342,7 +343,7 @@ function shipWithBump(path, texts, opts) {
342
343
  // What the last run in a folder said when it finished: agent.log after the last
343
344
  // run's "=== name time ===" and "$ command" lines.
344
345
  function runSummary(path) {
345
- let log = ""; try { log = readFileSync(join(path, ".symbiot", "agent.log"), "utf8"); } catch { return ""; }
346
+ const log = readRunLog(join(path, ".symbiot", "agent.log")); if (!log) return "";
346
347
  const at = log.lastIndexOf("\n=== "), rest = at < 0 ? log : log.slice(log.indexOf("\n", at + 1) + 1);
347
348
  return rest.replace(/^\$ .*\n?/, "").trim().slice(-8000);
348
349
  }
package/work.mjs CHANGED
@@ -8,6 +8,8 @@
8
8
  // time. Another agent (Codex, Aider, Gemini) writes plain text: its last lines
9
9
  // come through as what it said. Pure: text in, data out.
10
10
 
11
+ import { statSync, openSync, readSync, closeSync } from "node:fs";
12
+
11
13
  const MAX_STEPS = 60;
12
14
  const short = (s, n = 80) => { s = String(s ?? "").replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
13
15
  const base = (p) => String(p || "").split("/").filter(Boolean).slice(-2).join("/");
@@ -103,6 +105,29 @@ function parseRun(text) {
103
105
  return out;
104
106
  }
105
107
 
108
+ // The newest run of an agent.log, from its "=== " header on, read from the end of the
109
+ // file. A log keeps every run and everything they read (one reached 144 MB: 96 runs and
110
+ // the screenshots they looked at), and every view only needs the newest, so it never
111
+ // reads the rest; and it's kept until the file changes, so a view that asks again
112
+ // costs nothing. Past RUN_CAP of one run, it's that run's last RUN_CAP.
113
+ const RUN_CAP = 16 * 1024 * 1024, RUN_CACHE = new Map();
114
+ function readRunLog(file) {
115
+ let st; try { st = statSync(file); } catch { return ""; }
116
+ const hit = RUN_CACHE.get(file); if (hit && hit.size === st.size && hit.mtime === st.mtimeMs) return hit.text;
117
+ let fd; try { fd = openSync(file, "r"); } catch { return ""; }
118
+ let buf = Buffer.alloc(0), at = -1;
119
+ try {
120
+ for (let end = st.size; end > 0 && buf.length < RUN_CAP;) {
121
+ const start = Math.max(0, end - (1 << 20)), b = Buffer.alloc(end - start);
122
+ readSync(fd, b, 0, b.length, start); buf = Buffer.concat([b, buf]); end = start;
123
+ at = buf.lastIndexOf("\n=== "); if (at >= 0) break;
124
+ }
125
+ } catch { buf = Buffer.alloc(0); } finally { try { closeSync(fd); } catch {} }
126
+ const text = (at >= 0 ? buf.subarray(at) : buf).toString("utf8");
127
+ RUN_CACHE.delete(file); RUN_CACHE.set(file, { size: st.size, mtime: st.mtimeMs, text });
128
+ if (RUN_CACHE.size > 40) RUN_CACHE.delete(RUN_CACHE.keys().next().value);
129
+ return text;
130
+ }
106
131
  // The newest run's part of an agent.log (after its header and command line).
107
132
  function lastRunText(log) {
108
133
  const s = String(log || ""), at = s.lastIndexOf("\n=== ");
@@ -111,4 +136,4 @@ function lastRunText(log) {
111
136
  // What the agent said last: its final answer, from either kind of log.
112
137
  function finalOf(log) { return parseRun(lastRunText(log)).final; }
113
138
 
114
- export { parseRun, lastRunText, finalOf, describe, testsIn };
139
+ export { parseRun, lastRunText, finalOf, describe, testsIn, readRunLog };