symbiot 0.53.3 → 0.54.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/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.0
6
+
7
+ - **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.
8
+ - **A pass over every screen, in every look.**
9
+ - Scrollbars match the look everywhere: thin, no arrows, no white track.
10
+ - On the Dashboard, emails that land minutes apart fan out along their current instead of melting into one blob.
11
+ - The **Go** button no longer peeks out under the top of an open panel.
12
+ - A project's page no longer shows the same Approve twice ("Needs you here" and "Awaiting your review").
13
+ - The Agents and Reports **Refresh** buttons are round icons, like the Dashboard's.
14
+ - In Pearl, the main buttons lose the old chrome stripe for a soft pearl sheen.
15
+ - In the light looks, the Tasks screen's status line is crisp instead of washed out.
16
+
17
+ ## 0.53.4
18
+
19
+ - **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.
20
+
5
21
  ## 0.53.3
6
22
 
7
23
  - **The window's top bar matches the look.** Symbiot now tells the browser its colour, so the bar the app window opens with is the look's own (warm grey in Pearl, near-black in Ferrofluid, cool grey in Glass) instead of your desktop's accent colour.
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/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/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.3",
3
+ "version": "0.54.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 \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
package/server.mjs CHANGED
@@ -7,11 +7,11 @@ 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 { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
11
11
  import { createServer } from "node:http";
12
12
  import { randomBytes } from "node:crypto";
13
13
  import { EMBEDDED_UI } from "./ui.mjs";
14
- import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
14
+ import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
15
15
  import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
16
16
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
17
17
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
@@ -98,6 +98,21 @@ async function registryChangelog(version, registry = REGISTRY) {
98
98
  // Self-contained HTML served at / — no backticks or ${} inside (it lives in a
99
99
  // template literal). Talks to the local API with the per-launch token.
100
100
 
101
+ // Screenshots dropped into the talk: data: URLs of PNG, JPEG, WebP or GIF, up to 4 of
102
+ // 8 MB each, saved under Symbiot's config (uploads/, readable by you only, kept a week), so the
103
+ // model can see them and an agent can open them. Returns their paths.
104
+ const UPLOADS = join(CONFIG_DIR, "uploads");
105
+ function saveShots(list) {
106
+ const out = [];
107
+ 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
108
+ for (const it of (Array.isArray(list) ? list : []).slice(0, 4)) {
109
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,([A-Za-z0-9+/=]+)$/.exec(String((it && it.data) || ""));
110
+ if (!m) continue;
111
+ const buf = Buffer.from(m[2], "base64"); if (!buf.length || buf.length > 8 * 1024 * 1024) continue;
112
+ 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 {}
113
+ }
114
+ return out;
115
+ }
101
116
  function readBody(req) {
102
117
  return new Promise((resolve) => {
103
118
  let d = ""; req.on("data", (ch) => (d += ch));
@@ -186,6 +201,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
186
201
  keepBrowserOpen(BROWSER_KEEP);
187
202
  const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
188
203
  const screenOut =(s) => (s && s.id ? { ...s, blueprint: blueprint(s), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}) } : s && s.screens ? { ...s, screens: s.screens.map(screenOut) } : s);
204
+ let UPDATING = null; // an update in flight: { target, attempt, retrying? }
189
205
  const server = createServer(async (req, res) => {
190
206
  const u = new URL(req.url, "http://127.0.0.1");
191
207
  if (req.method === "GET" && u.pathname === "/") { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(EMBEDDED_UI); return; }
@@ -327,7 +343,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
327
343
  if (u.pathname === "/api/work") return json(res, workScene());
328
344
  if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
329
345
  if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
330
- if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question)); }
346
+ 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) })); }
331
347
  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" } : {}) }));
332
348
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
333
349
  // Lanes (lanes.mjs): work agents handed to each other, and where it stands.
@@ -371,7 +387,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
371
387
  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, cmd === "week" ? await runWeekly(writeup, { notify: false }) : await writeup(cmd)); }
372
388
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
373
389
  // a sandbox (symbiot app --fresh) never offers an update: it would replace your real install
374
- if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
390
+ if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && semverGt(LATEST_VERSION, VERSION), ...(UPDATING && UPDATING.retrying ? { retrying: UPDATING } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
375
391
  // What's new: after an update, since the version you last saw (until you click Got it);
376
392
  // ?latest=1, what the update on offer brings, from its package on npm
377
393
  if (u.pathname === "/api/whatsnew") {
@@ -392,12 +408,22 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
392
408
  if (SANDBOX) return json(res, { error: "This is a sandbox (symbiot app --fresh): update your real Symbiot instead." });
393
409
  const { target, cmd } = updateCmd(LATEST_VERSION, VERSION);
394
410
  const inst = process.env.SYMBIOT_UPDATE_CMD || cmd;
395
- const e = track("symbiot update", inst, homedir(), (code) => {
396
- if (code !== 0) return;
411
+ // npm lists a new version a little before its download is there (a 404 for the
412
+ // .tgz): then it tries again every 30s, up to 5 times, and says so, instead of
413
+ // giving up and sending you to a terminal.
414
+ const attempt = (n) => track("symbiot update", inst, homedir(), (code) => {
415
+ if (code !== 0) {
416
+ let tail = ""; try { tail = readFileSync(join(homedir(), ".symbiot", "agent.log"), "utf8").slice(-3000); } catch {}
417
+ 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
+ else UPDATING = null;
419
+ return;
420
+ }
397
421
  server.close(); if (server.closeAllConnections) server.closeAllConnections();
398
422
  try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" } }); ch.unref(); } catch {}
399
423
  setTimeout(() => process.exit(0), 1200);
400
424
  });
425
+ UPDATING = { target, attempt: 1, at: Date.now() };
426
+ const e = attempt(1);
401
427
  return json(res, { started: true, id: e ? e.id : "", target });
402
428
  }
403
429
  if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
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;
@@ -2145,6 +2166,9 @@ function heartbeat(fresh){
2145
2166
  var tried=null;try{tried=localStorage.getItem('symbiot_update_tried');}catch(e){}
2146
2167
  if(tried){
2147
2168
  if(p.version!==tried){try{localStorage.removeItem('symbiot_update_tried');}catch(e){} } // advanced → success, clear
2169
+ else if(p.retrying){ // npm hasn't finished publishing it: the app is trying again by itself
2170
+ b.className='updatebar show';b.innerHTML="Updating to "+esc(p.retrying.target)+": npm is still publishing it, so Symbiot tries again every 30 seconds (try "+p.retrying.attempt+" of 5). Nothing to do.";return;
2171
+ }
2148
2172
  else if(p.latest&&p.newer){ // still on the same version → the install didn't advance
2149
2173
  b.className='updatebar reconnect show';
2150
2174
  b.innerHTML="Auto-update to "+esc(p.latest)+" didn't take (still on "+esc(p.version)+"). Run <code>npm install -g symbiot@latest</code> in a terminal, then restart.";
@@ -2498,7 +2522,7 @@ api('/api/home?fresh=1').then(function(h){if(h&&h.you)LQ.home=h;renderNeeds();})
2498
2522
  // Needs you here: what only you can do in the project the Tasks list is filtered
2499
2523
  // to, at its top, each answerable where it is; its held handovers under them.
2500
2524
  function renderNeeds(){var el=$('needsbox');if(!el)return;var repo=TFILTER.repo;if(!repo||TARCH){el.innerHTML='';return;}
2501
- 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';});
2502
2526
  var paint=function(held){if(TFILTER.repo!==repo)return;if(!ys.length&&!held.length){el.innerHTML='';lqLight();return;}
2503
2527
  el.innerHTML="<div class='tgroup' style='color:var(--amber)'>Needs you here <span class='tcount'>"+(ys.length+held.length)+"</span></div>"+
2504
2528
  ys.map(function(y,i){return "<div class='task nitem lqblob' data-k='"+i+"'>"+blobBody(y,false)+"</div>";}).join('')+
@@ -2532,17 +2556,23 @@ function lqPool(shape){if(shape==='map'&&LM.items&&LM.items.length)setTimeout(fu
2532
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);}
2533
2557
  function lqAct(){LQ.lastAct=Date.now();if(LQ.mode==='rest'){LQ.mode='aware';lqLoad(true);}}
2534
2558
  function lqTalkMode(on){LQ.talking=on;var b=document.body;if(b&&b.classList)b.classList.toggle('lq-talking',on);}
2535
- 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;
2536
2560
  // scrolled up to read back: the whole history shows, clear; back at the bottom it rolls on again
2537
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);});}}
2538
- 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();
2539
2569
  var low=q.toLowerCase().replace(/^(please |can you |could you )/,''),hit='';
2540
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;});
2541
2571
  if(hit){lqTalkMode(true);lqUse(hit,null,'talk');lqGo(hit);return;}
2542
2572
  if(/^(go|start|go for it|start them|start it)[.! ]*$/.test(low)){lqTalkMode(true);lqGoWork();return;}
2543
2573
  if(/^(home|back)[.! ]*$/.test(low)&&LQ.scene==='work'){lqHome();return;}
2544
- LQ.talk.push({me:true,text:q});LQ.talk=LQ.talk.slice(-8);lqTalkShow(true);
2545
- 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);});}
2546
2576
  // The physics: each droplet a critically damped spring to its place (no wobble,
2547
2577
  // no overshoot), pushed off its neighbours and the core where they'd overlap.
2548
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;
@@ -2637,7 +2667,9 @@ if(document.addEventListener){document.addEventListener('contextmenu',function(e
2637
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();}});}
2638
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();});}
2639
2669
  var f=$('lqform');if(f&&f.addEventListener)f.addEventListener('submit',function(ev){if(ev&&ev.preventDefault)ev.preventDefault();lqSay();});
2640
- 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();}});}
2641
2673
  var co=$('lqcore');if(co&&co.addEventListener)co.addEventListener('click',function(){var i=$('lqask');if(i&&i.focus)i.focus();});
2642
2674
  var sk=$('lqsink');if(sk&&sk.addEventListener)sk.addEventListener('click',lqSink);
2643
2675
  var bk=$('lqback');if(bk&&bk.addEventListener)bk.addEventListener('click',lqHome);