symbiot 0.53.4 → 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,22 @@
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
+
9
+ ## 0.54.0
10
+
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.
12
+ - **A pass over every screen, in every look.**
13
+ - Scrollbars match the look everywhere: thin, no arrows, no white track.
14
+ - On the Dashboard, emails that land minutes apart fan out along their current instead of melting into one blob.
15
+ - The **Go** button no longer peeks out under the top of an open panel.
16
+ - A project's page no longer shows the same Approve twice ("Needs you here" and "Awaiting your review").
17
+ - The Agents and Reports **Refresh** buttons are round icons, like the Dashboard's.
18
+ - In Pearl, the main buttons lose the old chrome stripe for a soft pearl sheen.
19
+ - In the light looks, the Tasks screen's status line is crisp instead of washed out.
20
+
5
21
  ## 0.53.4
6
22
 
7
23
  - **Updates wait for npm instead of giving up.** npm lists a new version a little before its download is there, so an update straight after a release could fail and tell you to run `npm install` in a terminal. Now Symbiot tries again every 30 seconds (up to 5 times) and the bar says it's waiting on npm; there's nothing for you to do.
package/README.md CHANGED
@@ -599,7 +599,10 @@ time; **Close** or Esc closes a part. In a text box, or over text you've selecte
599
599
  right-click still gives the usual menu. Whatever needs you glows **amber**, here and
600
600
  on the Dashboard. Talk to it in the bar at the bottom: "open tasks" opens Tasks,
601
601
  "go" in the work view starts what's waiting, anything else goes to the same Symbiot
602
- as every chat. Leave it alone and it rests as one orb. There are no settings for any
602
+ as every chat. Paste a screenshot into the bar (Ctrl+V) or drop one on it, and
603
+ Symbiot sees it with what you say (up to 4, with any AI you've connected that reads
604
+ images); if it hands the work to an agent, the agent gets the screenshot too.
605
+ Leave it alone and it rests as one orb. There are no settings for any
603
606
  of this: it adapts to you.
604
607
 
605
608
  - **Where things sit: nearest neighbours.** Inside a group, the parts you move
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/ai.mjs CHANGED
@@ -48,9 +48,11 @@ function resolveProvider() {
48
48
  }
49
49
 
50
50
  // ---- model calls (one per provider, same in/out) --------------------------
51
- async function callAnthropic(r, system, prompt) {
51
+ // Screenshots you dropped into the talk: [{ mime, data (base64) }], in each model's own shape.
52
+ async function callAnthropic(r, system, prompt, images = []) {
52
53
  const client = new Anthropic(r.key ? { apiKey: r.key } : {});
53
- const base = { model: r.model, max_tokens: MAX_TOKENS, system, messages: [{ role: "user", content: prompt }] };
54
+ const content = images.length ? [...images.map((i) => ({ type: "image", source: { type: "base64", media_type: i.mime, data: i.data } })), { type: "text", text: prompt }] : prompt;
55
+ const base = { model: r.model, max_tokens: MAX_TOKENS, system, messages: [{ role: "user", content }] };
54
56
  let res;
55
57
  try { res = await client.messages.create({ ...base, output_config: { effort: "low" } }); }
56
58
  catch (e) {
@@ -61,8 +63,8 @@ async function callAnthropic(r, system, prompt) {
61
63
  if (res.stop_reason === "refusal") return "(the model declined this one — odd for a work summary; try again)";
62
64
  return res.content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
63
65
  }
64
- async function callOpenAI(r, system, prompt) {
65
- const messages = [{ role: "system", content: system }, { role: "user", content: prompt }];
66
+ async function callOpenAI(r, system, prompt, images = []) {
67
+ const messages = [{ role: "system", content: system }, { role: "user", content: images.length ? [{ type: "text", text: prompt }, ...images.map((i) => ({ type: "image_url", image_url: { url: `data:${i.mime};base64,${i.data}` } }))] : prompt }];
66
68
  // Newer models want max_completion_tokens instead of max_tokens; try both.
67
69
  for (const tokKey of ["max_tokens", "max_completion_tokens"]) {
68
70
  const res = await fetch("https://api.openai.com/v1/chat/completions", {
@@ -77,13 +79,13 @@ async function callOpenAI(r, system, prompt) {
77
79
  }
78
80
  return "";
79
81
  }
80
- async function callGemini(r, system, prompt) {
82
+ async function callGemini(r, system, prompt, images = []) {
81
83
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(r.model)}:generateContent?key=${encodeURIComponent(r.key)}`;
82
84
  const res = await fetch(url, {
83
85
  method: "POST", headers: { "Content-Type": "application/json" },
84
86
  body: JSON.stringify({
85
87
  systemInstruction: { parts: [{ text: system }] },
86
- contents: [{ role: "user", parts: [{ text: prompt }] }],
88
+ contents: [{ role: "user", parts: [{ text: prompt }, ...images.map((i) => ({ inline_data: { mime_type: i.mime, data: i.data } }))] }],
87
89
  generationConfig: { maxOutputTokens: MAX_TOKENS },
88
90
  }),
89
91
  });
@@ -92,10 +94,10 @@ async function callGemini(r, system, prompt) {
92
94
  const parts = JSON.parse(text).candidates?.[0]?.content?.parts || [];
93
95
  return parts.map((p) => p.text || "").join("").trim();
94
96
  }
95
- async function callOllama(r, system, prompt) {
97
+ async function callOllama(r, system, prompt, images = []) {
96
98
  const res = await fetch(`${r.baseUrl}/api/chat`, {
97
99
  method: "POST", headers: { "Content-Type": "application/json" },
98
- body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt }] }),
100
+ body: JSON.stringify({ model: r.model, stream: false, messages: [{ role: "system", content: system }, { role: "user", content: prompt, ...(images.length ? { images: images.map((i) => i.data) } : {}) }] }),
99
101
  });
100
102
  const text = await res.text();
101
103
  if (!res.ok) {
@@ -112,15 +114,15 @@ const AI_UI = {
112
114
  notConnected: () => console.log("Symbiot needs an AI to write your updates. Connect one with: symbiot login"),
113
115
  rejected: (label) => console.log(`Your ${label} credentials were rejected. Reconnect with: symbiot login --force`),
114
116
  };
115
- async function write(system, prompt) {
117
+ async function write(system, prompt, { images = [] } = {}) {
116
118
  const r = resolveProvider();
117
119
  if (!r) { AI_UI.notConnected(); return null; }
118
120
  const stop = AI_UI.spinner("thinking…");
119
121
  try {
120
- if (r.provider === "anthropic") return await callAnthropic(r, system, prompt);
121
- if (r.provider === "openai") return await callOpenAI(r, system, prompt);
122
- if (r.provider === "gemini") return await callGemini(r, system, prompt);
123
- if (r.provider === "ollama") return await callOllama(r, system, prompt);
122
+ if (r.provider === "anthropic") return await callAnthropic(r, system, prompt, images);
123
+ if (r.provider === "openai") return await callOpenAI(r, system, prompt, images);
124
+ if (r.provider === "gemini") return await callGemini(r, system, prompt, images);
125
+ if (r.provider === "ollama") return await callOllama(r, system, prompt, images);
124
126
  return null;
125
127
  } catch (err) {
126
128
  if (/\b401\b|\b403\b|invalid|authentication|api key|unauthor/i.test(err?.message || "")) {
package/home.mjs CHANGED
@@ -143,17 +143,21 @@ function homeContext(h) {
143
143
  }
144
144
 
145
145
  // Home's talk: the same Symbiot, acting in the right lane (mind.mjs actIn/actNow).
146
- async function homeAsk(question, { ask, now = Date.now(), state } = {}) {
146
+ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } = {}) {
147
147
  question = String(question || "").trim().slice(0, 2000);
148
+ if (!question && images.length) question = "Take a look at this screenshot.";
148
149
  if (!question) return { error: "empty" };
150
+ // the screenshots go to the model, and their files with any work it hands an agent
151
+ let pics = []; try { pics = images.map((p) => ({ path: p, mime: /\.png$/i.test(p) ? "image/png" : /\.webp$/i.test(p) ? "image/webp" : /\.gif$/i.test(p) ? "image/gif" : "image/jpeg", data: readFileSync(p).toString("base64") })); } catch {}
152
+ const shotNote = pics.length ? `\n\nScreenshots the user attached (open them with your Read tool): ${pics.map((i) => i.path).join(", ")}` : "";
149
153
  if (!ask && !resolveProvider()) return { error: "not-connected" };
150
154
  const h = state || homeState({ now });
151
155
  let map = {}; try { map = repoPathMap(); } catch {}
152
156
  const role = "Here they're on Symbiot's home: one liquid surface that shows what only they can do, what's new on what they watch, and the lanes. Answer from what it shows, and act on what they ask.";
153
- const r = await converse({ where: "Home", role, context: homeContext(h), question, map, now, ...(ask ? { ask } : {}),
157
+ const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data }) => ({ mime, data })), ...(ask ? { ask } : {}),
154
158
  act: {
155
- agent: (req, known, repo) => (repo ? actIn(req, repo, { map, known, title: "Home" }) : actNow(req, { title: "Home", known })),
156
- task: (text, repo) => taskIn(text, repo, { map }),
159
+ agent: (req, known, repo) => (repo ? actIn(req + shotNote, repo, { map, known, title: "Home" }) : actNow(req + shotNote, { title: "Home", known })),
160
+ task: (text, repo) => taskIn(text + shotNote, repo, { map }),
157
161
  } });
158
162
  return { answer: r.reply, ...(r.did ? { did: r.did } : {}), steps: r.steps || [] };
159
163
  }
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/mind.mjs CHANGED
@@ -191,7 +191,7 @@ function parseReply(raw) {
191
191
  // what this page is for; context: what the page shows; history: this chat's
192
192
  // own last turns. act: { agent(request) -> result, task(text, repo) -> result },
193
193
  // what "do" runs here. Gives { reply, did?, remembered }.
194
- async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now() }) {
194
+ async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now(), images = [] }) {
195
195
  const d = loadMind(), hits = recall(question, now, d), known = recallText(hits), elsewhere = lately(where, d);
196
196
  // what it did to answer, to show under the reply (as the app shows an agent's work)
197
197
  const steps = [];
@@ -207,7 +207,7 @@ async function converse({ where, role = "", context = "", history = "", question
207
207
  const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
208
208
  const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (kn.text ? kn.text + "\n\n" : "") + (elsewhere ? `Lately, elsewhere in the app:\n${elsewhere}\n\n` : "") +
209
209
  (context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
210
- const raw = await ask(system, prompt);
210
+ const raw = await ask(system, prompt, images.length ? { images } : undefined);
211
211
  if (!raw || /^\(?couldn't reach the model/i.test(String(raw))) return { reply: "(couldn't reach the model)", remembered: 0 };
212
212
  const j = parseReply(raw);
213
213
  let reply = String(j.reply || "").trim().slice(0, 4000) || "(no answer)", did = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.53.4",
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,11 +7,12 @@ 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 { readFileSync, existsSync } from "node:fs";
10
+ import { readRunLog } from "./work.mjs";
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";
13
14
  import { EMBEDDED_UI } from "./ui.mjs";
14
- import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
15
+ import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
15
16
  import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
16
17
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
17
18
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
@@ -98,6 +99,21 @@ async function registryChangelog(version, registry = REGISTRY) {
98
99
  // Self-contained HTML served at / — no backticks or ${} inside (it lives in a
99
100
  // template literal). Talks to the local API with the per-launch token.
100
101
 
102
+ // Screenshots dropped into the talk: data: URLs of PNG, JPEG, WebP or GIF, up to 4 of
103
+ // 8 MB each, saved under Symbiot's config (uploads/, readable by you only, kept a week), so the
104
+ // model can see them and an agent can open them. Returns their paths.
105
+ const UPLOADS = join(CONFIG_DIR, "uploads");
106
+ function saveShots(list) {
107
+ const out = [];
108
+ try { for (const f of readdirSync(UPLOADS)) { const fp = join(UPLOADS, f); if (Date.now() - statSync(fp).mtimeMs > 7 * 86400000) unlinkSync(fp); } } catch {} // kept a week
109
+ for (const it of (Array.isArray(list) ? list : []).slice(0, 4)) {
110
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,([A-Za-z0-9+/=]+)$/.exec(String((it && it.data) || ""));
111
+ if (!m) continue;
112
+ const buf = Buffer.from(m[2], "base64"); if (!buf.length || buf.length > 8 * 1024 * 1024) continue;
113
+ try { mkdirSync(UPLOADS, { recursive: true, mode: 0o700 }); const f = join(UPLOADS, `${Date.now()}-${randomBytes(3).toString("hex")}.${m[1] === "jpeg" ? "jpg" : m[1]}`); writeFileSync(f, buf, { mode: 0o600 }); out.push(f); } catch {}
114
+ }
115
+ return out;
116
+ }
101
117
  function readBody(req) {
102
118
  return new Promise((resolve) => {
103
119
  let d = ""; req.on("data", (ch) => (d += ch));
@@ -328,7 +344,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
328
344
  if (u.pathname === "/api/work") return json(res, workScene());
329
345
  if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
330
346
  if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
331
- if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question)); }
347
+ if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
332
348
  if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
333
349
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
334
350
  // Lanes (lanes.mjs): work agents handed to each other, and where it stands.
@@ -398,7 +414,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
398
414
  // giving up and sending you to a terminal.
399
415
  const attempt = (n) => track("symbiot update", inst, homedir(), (code) => {
400
416
  if (code !== 0) {
401
- 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);
402
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(); }
403
419
  else UPDATING = null;
404
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/ui.mjs CHANGED
@@ -64,7 +64,7 @@ body.lq-light #lqform.lqglass{background:rgba(255,255,255,.62);border-color:rgba
64
64
  body.lq-light #lqask::placeholder{color:#6A7480}
65
65
  #lqsend{flex:none;width:40px;height:40px;padding:0;display:grid;place-items:center;border-radius:50%;border:0;background:#F3F0EA;color:#0A0B0D;cursor:pointer}
66
66
  body.lq-light #lqsend{background:#151A21;color:#FFFFFF}
67
- body.lq-look-pearl #lqsend{background:linear-gradient(180deg,#FFFFFF 0%,#D9DDE2 45%,#9AA1AA 55%,#E8EAED 100%);color:#1B1E23;box-shadow:0 2px 6px rgba(0,0,0,.18),inset 0 0 0 1px rgba(0,0,0,.08)}
67
+ body.lq-look-pearl #lqsend{background:linear-gradient(180deg,#FFFFFF 0%,#F1F2F5 60%,#E6E9EE 100%);color:#1B1E23;box-shadow:0 1px 2px rgba(21,26,33,.10),0 8px 18px -10px rgba(40,52,74,.45),inset 0 0 0 1px rgba(21,26,33,.10)}
68
68
  #lqtalk{position:absolute;left:50%;bottom:88px;transform:translateX(-50%);width:min(640px,calc(100vw - 32px));display:flex;flex-direction:column;gap:16px;z-index:3;max-height:min(34vh,320px);overflow-x:hidden;overflow-y:auto;scrollbar-width:none;padding-top:90px;scroll-behavior:smooth;-webkit-mask-image:linear-gradient(to bottom,transparent 0,rgba(0,0,0,.12) 22%,rgba(0,0,0,.6) 48%,#000 72%);mask-image:linear-gradient(to bottom,transparent 0,rgba(0,0,0,.12) 22%,rgba(0,0,0,.6) 48%,#000 72%);transition:max-height .3s ease}
69
69
  #lqtalk:hover,#lqtalk:focus-within,#lqtalk.back{max-height:min(56vh,560px);-webkit-mask-image:linear-gradient(to bottom,transparent 0,#000 60px);mask-image:linear-gradient(to bottom,transparent 0,#000 60px)}
70
70
  #lqtalk .lqmsg{transition:opacity .4s ease}
@@ -124,7 +124,7 @@ body.lq-light #quit{border-color:rgba(20,30,45,.16);background:rgba(255,255,255,
124
124
  body.lq-pooled #lqtalk,body.lq-pooled #lqmore,body.lq-pooled #lqcore{display:none}
125
125
  /* the work view says where things stand in its line under the heading, not in the core */
126
126
  body.lq-work #lqcore{display:none}
127
- body.lq-pooled #lqgo,body.lq-pooled #lqgo.on{display:none}
127
+ body.lq-pooled #lqgo,body.lq-pooled.lq-work #lqgo.on{display:none}
128
128
  body.lq-talking .lqd{opacity:.55}
129
129
  @media (prefers-reduced-motion: reduce){.lqd,#lqcore{transition:none}}
130
130
  /* ---- the Symbiot look on every part: glass over the liquid, chrome, a silver edge ---- */
@@ -136,7 +136,7 @@ body.lq-liquid.lq-light .task,body.lq-liquid.lq-light .agent,body.lq-liquid.lq-l
136
136
  body.lq-liquid button.act{background:#F3F0EA;color:#0A0B0D;border:0;border-radius:999px;box-shadow:none;font-weight:600}
137
137
  body.lq-liquid button.act:hover{background:#FFFFFF}
138
138
  body.lq-liquid.lq-light button.act{background:#151A21;color:#FFFFFF}
139
- body.lq-liquid.lq-look-pearl button.act,body.lq-look-pearl #lqgo.on,body.lq-liquid.lq-look-pearl .updatebar.show{background:linear-gradient(180deg,#FFFFFF 0%,#D9DDE2 45%,#9AA1AA 55%,#E8EAED 100%);color:#1B1E23;box-shadow:0 2px 6px rgba(0,0,0,.18),inset 0 0 0 1px rgba(0,0,0,.08)}
139
+ body.lq-liquid.lq-look-pearl button.act,body.lq-look-pearl #lqgo.on,body.lq-liquid.lq-look-pearl .updatebar.show{background:linear-gradient(180deg,#FFFFFF 0%,#F1F2F5 60%,#E6E9EE 100%);color:#1B1E23;box-shadow:0 1px 2px rgba(21,26,33,.10),0 8px 18px -10px rgba(40,52,74,.45),inset 0 0 0 1px rgba(21,26,33,.10)}
140
140
  body.lq-liquid button.ghost{background:rgba(255,255,255,.05);border:1px solid rgba(255,255,255,.14);border-radius:999px;color:var(--bone);backdrop-filter:blur(6px)}
141
141
  body.lq-liquid.lq-light button.ghost{background:rgba(255,255,255,.7);border-color:rgba(20,30,45,.18)}
142
142
  body.lq-liquid input,body.lq-liquid select,body.lq-liquid textarea{border-radius:12px;border:1px solid rgba(220,228,240,.2);background:rgba(255,255,255,.04);color:var(--bone)}
@@ -185,7 +185,7 @@ body.lq-work.lq-byproj #lqsort{display:inline-flex;position:absolute;left:112px;
185
185
  body.lq-light #lqsort{background:rgba(21,26,33,.05);border-color:rgba(21,26,33,.1)}
186
186
  body.lq-light #lqsort button{color:#5A6470}
187
187
  body.lq-light #lqsort button[aria-checked='true']{background:#151A21;color:#FFFFFF}
188
- body.lq-look-pearl #lqsort button[aria-checked='true']{background:linear-gradient(180deg,#FFFFFF 0%,#D9DDE2 45%,#9AA1AA 55%,#E8EAED 100%);color:#1B1E23}
188
+ body.lq-look-pearl #lqsort button[aria-checked='true']{background:linear-gradient(180deg,#FFFFFF 0%,#F1F2F5 60%,#E6E9EE 100%);color:#1B1E23}
189
189
  body.lq-touch #lqsort button{min-height:44px}
190
190
  body.lq-pooled #lqsort{display:none!important}
191
191
  @media (max-width:700px){body.lq-work.lq-byproj #lqsort{left:16px;top:104px}}
@@ -225,7 +225,7 @@ body.lq-light .lqblob .nopt.rec{background:#151A21;color:#FFFFFF;border-color:#1
225
225
  body.lq-light .lqblob .bfx input{background:#FFFFFF;border-color:rgba(20,30,45,.18)}
226
226
  body.lq-light .lqblob .bfx button{background:#151A21;color:#FFFFFF}
227
227
  body.lq-light .lqblob .bfree,body.lq-light .lqd.lqblob .lt i,body.lq-light .lqblob .bmore{color:#5A6470}
228
- body.lq-look-pearl .lqblob .nopt.rec,body.lq-look-pearl .lqblob .bfx button{background:linear-gradient(180deg,#FFFFFF 0%,#D9DDE2 45%,#9AA1AA 55%,#E8EAED 100%);color:#1B1E23;border-color:rgba(0,0,0,.08)}
228
+ body.lq-look-pearl .lqblob .nopt.rec,body.lq-look-pearl .lqblob .bfx button{background:linear-gradient(180deg,#FFFFFF 0%,#F1F2F5 60%,#E6E9EE 100%);color:#1B1E23;border-color:rgba(0,0,0,.08)}
229
229
  body.lq-contrast .lqblob .nopt{border:2px solid currentColor}
230
230
  body.lq-touch .lqblob .nopt,body.lq-touch .lqblob .bfx input{min-height:44px}
231
231
  body.lq-talking .lqd.lqblob{opacity:.55}
@@ -696,7 +696,7 @@ body.lq-liquid.lq-light.lq-pooled #lqsinkrow{background:linear-gradient(180deg,r
696
696
  #lqline,#lqrelay{display:none}
697
697
  body.lq-work:not(.lq-pooled) #lqline{display:block;position:absolute;left:50%;top:84px;transform:translateX(-50%);z-index:3;max-width:calc(100% - 32px);text-align:center;font:400 13.5px/1.4 var(--sans);color:#A1A8B1;text-shadow:0 1px 10px rgba(0,0,0,.7)}
698
698
  #lqline b{color:#F2A541;font-weight:600}
699
- body.lq-light #lqline{color:#4A5565;text-shadow:0 1px 8px rgba(255,255,255,.8)}body.lq-light #lqline b{color:#9A5200}
699
+ body.lq-light #lqline{color:#2E3743;text-shadow:none}body.lq-light #lqline b{color:#9A5200}
700
700
  body.lq-work:not(.lq-pooled) #lqrelay:not(:empty){display:flex;flex-direction:column;gap:12px;position:absolute;right:16px;top:116px;bottom:96px;width:min(420px,32vw);overflow-y:auto;scrollbar-width:none;z-index:4;padding:2px 2px 24px;-webkit-mask-image:linear-gradient(to bottom,#000 calc(100% - 28px),transparent);mask-image:linear-gradient(to bottom,#000 calc(100% - 28px),transparent)}
701
701
  #lqrelay::-webkit-scrollbar{display:none}
702
702
  @media (max-width:899px){body.lq-work:not(.lq-pooled) #lqrelay:not(:empty){display:none}}
@@ -775,6 +775,25 @@ body.lq-liquid .rcard .task{margin-top:6px;padding:9px 12px;border-radius:12px}
775
775
  .rwork i{flex:none;width:9px;height:9px;border-radius:50%;background:#F2A541;box-shadow:0 0 0 0 rgba(242,165,65,.5);animation:rwork 1.8s ease-out infinite}
776
776
  @keyframes rwork{0%{box-shadow:0 0 0 0 rgba(242,165,65,.5)}100%{box-shadow:0 0 0 9px rgba(242,165,65,0)}}
777
777
  @media (prefers-reduced-motion: reduce){.rwork i{animation:none}}
778
+
779
+ /* scrollbars, everywhere: thin, no arrows, in the look's ink */
780
+ body.lq-liquid,body.lq-liquid *{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.16) transparent}
781
+ body.lq-liquid.lq-light,body.lq-liquid.lq-light *{scrollbar-color:rgba(21,26,33,.18) transparent}
782
+ body.lq-liquid ::-webkit-scrollbar{width:8px;height:8px}
783
+ body.lq-liquid ::-webkit-scrollbar-button{display:none}
784
+ body.lq-liquid ::-webkit-scrollbar-track{background:transparent}
785
+ body.lq-liquid ::-webkit-scrollbar-thumb{background:rgba(255,255,255,.14);border-radius:999px}
786
+ body.lq-liquid.lq-light ::-webkit-scrollbar-thumb{background:rgba(21,26,33,.16)}
787
+
788
+ /* screenshots in the talk */
789
+ #lqatts{position:absolute;left:50%;bottom:78px;transform:translateX(-50%);width:min(640px,calc(100vw - 32px));display:flex;gap:8px;z-index:3;pointer-events:none}
790
+ #lqatts:empty{display:none}
791
+ .lqatt{position:relative;pointer-events:auto}
792
+ .lqatt img{display:block;width:64px;height:48px;object-fit:cover;border-radius:10px;border:1px solid rgba(255,255,255,.2);box-shadow:0 8px 20px -10px rgba(0,0,0,.8)}
793
+ .lqatt button{position:absolute;top:-7px;right:-7px;width:20px;height:20px;border-radius:50%;border:0;background:#F3F0EA;color:#0A0B0D;font:600 13px/20px var(--sans);text-align:center;cursor:pointer;padding:0}
794
+ body.lq-light .lqatt img{border-color:rgba(21,26,33,.15)}body.lq-light .lqatt button{background:#151A21;color:#FFFFFF}
795
+ #lqform.drop{outline:2px dashed rgba(242,165,65,.7);outline-offset:4px}
796
+ .lqmimgs{display:flex;gap:6px;margin-bottom:6px}.lqmimgs img{width:120px;max-height:90px;object-fit:cover;border-radius:8px}
778
797
  /* the Dashboard as a stream: time runs left to right, a current per feed, now on the right */
779
798
  .bstream{position:relative;margin-top:10px;border-radius:18px;overflow:hidden;isolation:isolate;background:#08090B;box-shadow:0 0 0 1px rgba(255,255,255,.08)}
780
799
  body.lq-light .bstream{background:#EBEEF0;box-shadow:0 0 0 1px rgba(21,26,33,.08)}
@@ -843,7 +862,8 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
843
862
  <button type="button" id="lqcore" title="talk to Symbiot"></button>
844
863
  <div id="lqmore"></div>
845
864
  <div id="lqtalk" aria-live="polite"></div>
846
- <form id="lqform" class="lqglass"><label for="lqask" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Talk to Symbiot</label><input id="lqask" autocomplete="off" placeholder="Talk to Symbiot, or tell it what to do"><button type="submit" id="lqsend" aria-label="Send"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button></form>
865
+ <div id="lqatts" aria-live="polite"></div>
866
+ <form id="lqform" class="lqglass"><label for="lqask" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)">Talk to Symbiot</label><input id="lqask" autocomplete="off" placeholder="Talk to Symbiot, or tell it what to do (paste a screenshot to show it)"><button type="submit" id="lqsend" aria-label="Send"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 13V3M3.5 7.5 8 3l4.5 4.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></button></form>
847
867
  </div>
848
868
 
849
869
  <div id="updatebar" class="updatebar"></div>
@@ -921,13 +941,13 @@ body.lq-light .btip .bta button:not(.quiet){background:#151A21;color:#FFFFFF}
921
941
  <div id="tasklist"></div>
922
942
  </section>
923
943
  <section id="panel-agents" class="hidden">
924
- <div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output. Questions, options and ideas an agent leaves for you show up on its block, whichever model it runs.</span><button class="ghost" id="agentsrefresh" style="margin-left:auto">Refresh</button></div>
944
+ <div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output. Questions, options and ideas an agent leaves for you show up on its block, whichever model it runs.</span><button class="iconbtn" id="agentsrefresh" style="margin-left:auto" title="Refresh" aria-label="Refresh"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.5-3.6M13 2.5V5h-2.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
925
945
  <div id="agentsmsg"></div>
926
946
  <div id="laneslist"></div>
927
947
  <div id="agentslist"></div>
928
948
  </section>
929
949
  <section id="panel-reports" class="hidden">
930
- <div class="row"><span class="muted" style="flex:1">What agents wrote up for you (findings, audits, plans), newest first. They leave each one in a project's .symbiot folder; it shows here as soon as it's written.</span><button class="ghost" id="reportsrefresh">Refresh</button></div>
950
+ <div class="row"><span class="muted" style="flex:1">What agents wrote up for you (findings, audits, plans), newest first. They leave each one in a project's .symbiot folder; it shows here as soon as it's written.</span><button class="iconbtn" id="reportsrefresh" title="Refresh" aria-label="Refresh"><svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 8a5 5 0 1 1-1.5-3.6M13 2.5V5h-2.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></button></div>
931
951
  <div id="reportslist"></div>
932
952
  <div id="reportview"></div>
933
953
  </section>
@@ -1944,6 +1964,7 @@ cs.forEach(function(c,i){(c.items||[]).forEach(function(n,j){var age=Math.max(0,
1944
1964
  BS.beads.push({key:key,i:i,j:j,n:n,need:!!n.need&&!!(n.mail||n.chat),tx:g.x0+(1-age/H)*(g.x1-g.x0),ty:laneY(i),tr:n.need&&(n.mail||n.chat)?13:n.read?6:9,x:o?o.x:g.x1,y:o?o.y:laneY(i),r:o?o.r:0});});
1945
1965
  (c.past||[]).forEach(function(n,j){var age=Math.max(0,now-(n.ts||now));if(age>H)return;var key=c.id+':p:'+(n.id||j),o=old[key];BS.beads.push({key:key,i:i,j:-1-j,n:n,past:true,need:false,tx:g.x0+(1-age/H)*(g.x1-g.x0),ty:laneY(i),tr:4.5,x:o?o.x:g.x1,y:o?o.y:laneY(i),r:o?o.r:0});});});
1946
1966
  html+=BS.beads.map(function(x,k){return "<button type='button' class='bbead' data-k='"+k+"' aria-label='"+esc(x.n.text||'')+"'></button>";}).join('');
1967
+ var byLane={};BS.beads.forEach(function(x){(byLane[x.i]=byLane[x.i]||[]).push(x);});Object.keys(byLane).forEach(function(k){var L=byLane[k].slice().sort(function(a,b){return a.tx-b.tx;}),run=0;for(var q=1;q<L.length;q++){if(L[q].tx-L[q-1].tx<26){run++;L[q].ty+=(run%2?-1:1)*Math.ceil(run/2)*20;}else run=0;}});
1947
1968
  var ml=bsMailLane();BS.waits=[];if(ml>=0)(AWAITS||[]).forEach(function(w,k){if(w.status!=='waiting'&&!(w.reply&&now-w.reply.ts<H))return;var sent=w.sent||now,age=Math.max(0,now-sent),x=g.x0+(1-Math.min(age,H)/H)*(g.x1-g.x0),y=laneY(ml)+(k%2?14:-14);BS.waits.push({w:w,x:x,y:y});
1948
1969
  html+=(w.status==='waiting'?"<div class='bwthread' style='left:"+Math.round(x)+"px;width:"+Math.max(0,Math.round(g.x1-x))+"px;top:"+Math.round(y)+"px'></div>":"")+"<button type='button' class='bwait"+(w.status==='waiting'?'':' in')+(age>H?' early':'')+"' data-w='"+(BS.waits.length-1)+"' style='left:"+Math.round(x)+"px;top:"+Math.round(y)+"px' aria-label='"+escQ((w.status==='waiting'?'waiting on a reply from ':'reply in from ')+(w.to||[]).join(', '))+"'></button>";});
1949
1970
  html+="<div class='btip' role='dialog' aria-live='polite'></div>";ov.innerHTML=html;
@@ -2501,7 +2522,7 @@ api('/api/home?fresh=1').then(function(h){if(h&&h.you)LQ.home=h;renderNeeds();})
2501
2522
  // Needs you here: what only you can do in the project the Tasks list is filtered
2502
2523
  // to, at its top, each answerable where it is; its held handovers under them.
2503
2524
  function renderNeeds(){var el=$('needsbox');if(!el)return;var repo=TFILTER.repo;if(!repo||TARCH){el.innerHTML='';return;}
2504
- var ys=((LQ.home&&LQ.home.you)||[]).filter(function(y){return y.repo===repo&&(y.kind==='ask'||y.kind==='approve');});
2525
+ var ys=((LQ.home&&LQ.home.you)||[]).filter(function(y){return y.repo===repo&&y.kind==='ask';});
2505
2526
  var paint=function(held){if(TFILTER.repo!==repo)return;if(!ys.length&&!held.length){el.innerHTML='';lqLight();return;}
2506
2527
  el.innerHTML="<div class='tgroup' style='color:var(--amber)'>Needs you here <span class='tcount'>"+(ys.length+held.length)+"</span></div>"+
2507
2528
  ys.map(function(y,i){return "<div class='task nitem lqblob' data-k='"+i+"'>"+blobBody(y,false)+"</div>";}).join('')+
@@ -2535,17 +2556,23 @@ function lqPool(shape){if(shape==='map'&&LM.items&&LM.items.length)setTimeout(fu
2535
2556
  function lqSink(){lqRemember();LQ.focus=null;var b=document.body;if(b&&b.classList)b.classList.remove('lq-pooled');LQ.mode='aware';lqAct();lqLoad(false);}
2536
2557
  function lqAct(){LQ.lastAct=Date.now();if(LQ.mode==='rest'){LQ.mode='aware';lqLoad(true);}}
2537
2558
  function lqTalkMode(on){LQ.talking=on;var b=document.body;if(b&&b.classList)b.classList.toggle('lq-talking',on);}
2538
- function lqTalkShow(wait){var el=$('lqtalk');if(!el)return;var n=LQ.talk.length;el.innerHTML=LQ.talk.map(function(m,i){var age=n-1-i,op=age<2?1:age===2?0.7:age===3?0.45:0.25;return "<div class='lqmsg"+(m.me?' me':'')+"' style='opacity:"+op+"'>"+esc(m.text)+stepsHtml(m.steps)+"</div>";}).join('')+(wait?"<div class='lqmsg thinking'>Thinking: recalling what it knows, reading what's here&hellip;</div>":'');if(el.scrollHeight)el.scrollTop=el.scrollHeight;
2559
+ function lqTalkShow(wait){var el=$('lqtalk');if(!el)return;var n=LQ.talk.length;el.innerHTML=LQ.talk.map(function(m,i){var age=n-1-i,op=age<2?1:age===2?0.7:age===3?0.45:0.25;return "<div class='lqmsg"+(m.me?' me':'')+"' style='opacity:"+op+"'>"+(m.imgs&&m.imgs.length?"<span class='lqmimgs'>"+m.imgs.map(function(d){return "<img src='"+d+"' alt='your screenshot'>";}).join('')+"</span>":"")+esc(m.text)+stepsHtml(m.steps)+"</div>";}).join('')+(wait?"<div class='lqmsg thinking'>Thinking: recalling what it knows, reading what's here&hellip;</div>":'');if(el.scrollHeight)el.scrollTop=el.scrollHeight;
2539
2560
  // scrolled up to read back: the whole history shows, clear; back at the bottom it rolls on again
2540
2561
  if(!el.wired&&el.addEventListener){el.wired=true;el.addEventListener('scroll',function(){var up=el.scrollHeight-el.scrollTop-el.clientHeight>24;if(el.classList)el.classList.toggle('back',up);});}}
2541
- function lqSay(){var i=$('lqask');var q=((i&&i.value)||'').trim();if(!q)return;i.value='';lqAct();
2562
+ // Screenshots in the talk: paste one (Ctrl+V) or drop it on the bar; it shows as a
2563
+ // thumbnail above it, goes with what you say, and the model sees it.
2564
+ LQ.atts=[];
2565
+ function lqAttsShow(){var el=$('lqatts');if(!el)return;el.innerHTML=LQ.atts.map(function(a,i){return "<span class='lqatt'><img src='"+a.data+"' alt='screenshot "+(i+1)+"'><button type='button' data-i='"+i+"' aria-label='remove screenshot "+(i+1)+"'>&times;</button></span>";}).join('');
2566
+ el.querySelectorAll('button').forEach(function(b){b.addEventListener('click',function(){LQ.atts.splice(+b.getAttribute('data-i'),1);lqAttsShow();});});}
2567
+ function lqAttach(file){if(!file||['image/png','image/jpeg','image/webp','image/gif'].indexOf(file.type)<0||file.size>8*1024*1024||LQ.atts.length>=4)return;var r=new FileReader();r.onload=function(){LQ.atts.push({data:String(r.result)});lqAttsShow();};r.readAsDataURL(file);}
2568
+ function lqSay(){var i=$('lqask');var q=((i&&i.value)||'').trim(),shots=LQ.atts.slice();if(!q&&!shots.length)return;if(!q)q='Take a look at this screenshot.';i.value='';LQ.atts=[];lqAttsShow();lqAct();
2542
2569
  var low=q.toLowerCase().replace(/^(please |can you |could you )/,''),hit='';
2543
2570
  Object.keys(LQNAMES).forEach(function(k){var nm=LQNAMES[k].toLowerCase();if(hit)return;['open ','show ','go to ','take me to '].forEach(function(v){if(low.indexOf(v+nm)===0||low.indexOf(v+'my '+nm)===0)hit=k;});if(low===nm)hit=k;});
2544
2571
  if(hit){lqTalkMode(true);lqUse(hit,null,'talk');lqGo(hit);return;}
2545
2572
  if(/^(go|start|go for it|start them|start it)[.! ]*$/.test(low)){lqTalkMode(true);lqGoWork();return;}
2546
2573
  if(/^(home|back)[.! ]*$/.test(low)&&LQ.scene==='work'){lqHome();return;}
2547
- LQ.talk.push({me:true,text:q});LQ.talk=LQ.talk.slice(-8);lqTalkShow(true);
2548
- api('/api/home/ask',{question:q}).then(function(r){LQ.talk.push({me:false,steps:r.steps||[],text:r.error==='not-connected'?'Connect an AI in Settings to talk to me. Say "open settings".':(r.answer||r.error||'(no answer)')});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);LQ.ripple=[0.5,0.47,LQ.t];lqLoad(false);}).catch(function(e){LQ.talk.push({me:false,text:String((e&&e.message)||e)});lqTalkShow(false);});}
2574
+ LQ.talk.push({me:true,text:q,imgs:shots.map(function(a){return a.data;})});LQ.talk=LQ.talk.slice(-8);lqTalkShow(true);
2575
+ api('/api/home/ask',{question:q,images:shots}).then(function(r){LQ.talk.push({me:false,steps:r.steps||[],text:r.error==='not-connected'?'Connect an AI in Settings to talk to me. Say "open settings".':(r.answer||r.error||'(no answer)')});LQ.talk=LQ.talk.slice(-8);lqTalkShow(false);LQ.ripple=[0.5,0.47,LQ.t];lqLoad(false);}).catch(function(e){LQ.talk.push({me:false,text:String((e&&e.message)||e)});lqTalkShow(false);});}
2549
2576
  // The physics: each droplet a critically damped spring to its place (no wobble,
2550
2577
  // no overshoot), pushed off its neighbours and the core where they'd overlap.
2551
2578
  function lqStep(){var S0=lqSize();if(S0.w!==LQ.bw||S0.h!==LQ.bh){if(LQ.scene==='work'&&LQ.workData)lqBuildWork();else if(LQ.adapt)lqBuild();}var S=S0,cx=S.w/2,cy=(LQ.scene!=='work'&&LQ.cy)||S.h*0.47,k=0.022,c=2*Math.sqrt(k),th=LQ.theme,still=th.still,rest=LQ.mode==='rest',pool=LQ.mode==='pool',talk=LQ.talking&&!pool;
@@ -2640,7 +2667,9 @@ if(document.addEventListener){document.addEventListener('contextmenu',function(e
2640
2667
  document.addEventListener('mouseup',function(ev){var b=document.body;if(ev&&ev.button===3&&b&&b.classList&&b.classList.contains('lq-liquid')){ev.preventDefault();lqBack();}});}
2641
2668
  var L=$('liquid');if(L&&L.addEventListener){L.addEventListener('click',function(ev){var t=ev&&ev.target;if(t&&t.closest&&t.closest('button,input,form,.lqd'))return;var x=ev.clientX,y=ev.clientY,hit=null;LQ.drops.forEach(function(d){if(!hit&&Math.hypot(x-d.x,y-d.y)<=d.cr+8)hit=d;});if(hit)lqOpen(hit,ev);else if(LQ.openTag)lqTag(LQ.openTag);});L.addEventListener('pointermove',function(ev){LQ.pointer=[ev.clientX,ev.clientY];if(ev.pointerType==='touch')LQ.touch=true;lqAct();});L.addEventListener('pointerdown',function(ev){if(ev.pointerType==='touch')LQ.touch=true;lqAct();});}
2642
2669
  var f=$('lqform');if(f&&f.addEventListener)f.addEventListener('submit',function(ev){if(ev&&ev.preventDefault)ev.preventDefault();lqSay();});
2643
- var ia=$('lqask');if(ia&&ia.addEventListener){ia.addEventListener('focus',function(){lqTalkMode(true);lqAct();});ia.addEventListener('blur',function(){if(!ia.value&&!LQ.talk.length)lqTalkMode(false);});ia.addEventListener('keydown',function(ev){if(ev&&ev.key==='Escape'){if(LQ.mode==='pool'){lqSink();return;}if(LQ.scene==='work'&&!ia.value){lqHome();return;}ia.value='';LQ.talk=[];lqTalkShow(false);lqTalkMode(false);if(ia.blur)ia.blur();}});}
2670
+ var ia=$('lqask');if(ia&&ia.addEventListener){ia.addEventListener('paste',function(ev){var it=(ev.clipboardData&&ev.clipboardData.items)||[],got=false;for(var k=0;k<it.length;k++){if(it[k].kind==='file'&&String(it[k].type).indexOf('image/')===0){lqAttach(it[k].getAsFile());got=true;}}if(got)ev.preventDefault();});
2671
+ var fm=$('lqform');if(fm&&fm.addEventListener){fm.addEventListener('dragover',function(ev){ev.preventDefault();if(fm.classList)fm.classList.add('drop');});fm.addEventListener('dragleave',function(){if(fm.classList)fm.classList.remove('drop');});fm.addEventListener('drop',function(ev){ev.preventDefault();if(fm.classList)fm.classList.remove('drop');var fs=(ev.dataTransfer&&ev.dataTransfer.files)||[];for(var k=0;k<fs.length;k++)lqAttach(fs[k]);});}
2672
+ ia.addEventListener('focus',function(){lqTalkMode(true);lqAct();});ia.addEventListener('blur',function(){if(!ia.value&&!LQ.talk.length)lqTalkMode(false);});ia.addEventListener('keydown',function(ev){if(ev&&ev.key==='Escape'){if(LQ.mode==='pool'){lqSink();return;}if(LQ.scene==='work'&&!ia.value){lqHome();return;}ia.value='';LQ.talk=[];lqTalkShow(false);lqTalkMode(false);if(ia.blur)ia.blur();}});}
2644
2673
  var co=$('lqcore');if(co&&co.addEventListener)co.addEventListener('click',function(){var i=$('lqask');if(i&&i.focus)i.focus();});
2645
2674
  var sk=$('lqsink');if(sk&&sk.addEventListener)sk.addEventListener('click',lqSink);
2646
2675
  var bk=$('lqback');if(bk&&bk.addEventListener)bk.addEventListener('click',lqHome);
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 };