symbiot 0.44.1 → 0.45.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/server.mjs CHANGED
@@ -5,17 +5,19 @@
5
5
  import { spawn, spawnSync } from "node:child_process";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { gunzipSync } from "node:zlib";
8
10
  import { readFileSync, existsSync } from "node:fs";
9
11
  import { createServer } from "node:http";
10
12
  import { randomBytes } from "node:crypto";
11
13
  import { EMBEDDED_UI } from "./ui.mjs";
12
- import { VERSION, LATEST_VERSION, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
14
+ import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary } from "./core.mjs";
13
15
  import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting } from "./agents.mjs";
14
16
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
15
17
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
16
18
  import { computeDrift } from "./drift.mjs";
17
- import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
18
- import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce } from "./writeups.mjs";
19
+ import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
20
+ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
19
21
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
20
22
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
21
23
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
@@ -23,6 +25,8 @@ import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, chec
23
25
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
24
26
  import { mindState, forget } from "./mind.mjs";
25
27
  import { lanesTick, lanesState } from "./lanes.mjs";
28
+ import { adaptState, noteUse } from "./adapt.mjs";
29
+ import { homeState, homeAsk } from "./home.mjs";
26
30
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
27
31
 
28
32
  // The in-app update installs the EXACT newest version (not the `latest` tag, which
@@ -34,6 +38,57 @@ function updateCmd(latest, current, platform = process.platform) {
34
38
  return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
35
39
  }
36
40
 
41
+ // ---- what's new: the changelog's releases between two versions -------------------
42
+ // CHANGELOG.md's "## 0.45.0 — 2026-10-06" sections newer than `from` and up to
43
+ // `to`, newest first: [{ version, date, items }]. It ships with Symbiot, so after an
44
+ // update the app shows once what came with it (config.seenVersion is the version
45
+ // you last saw it for). Before an update, the new version's own CHANGELOG.md is
46
+ // read from its package on npm (registryChangelog).
47
+ const CHANGELOG = fileURLToPath(new URL("./CHANGELOG.md", import.meta.url));
48
+ function changesSince(md, from, to) {
49
+ const out = [];
50
+ for (const part of String(md || "").split(/^## /m).slice(1)) {
51
+ const m = part.match(/^\[?v?(\d+\.\d+\.\d+)\]?(?:\s*[—–-]\s*(\d{4}-\d{2}-\d{2}))?/); if (!m) continue;
52
+ if ((from && !semverGt(m[1], from)) || (to && semverGt(m[1], to))) continue;
53
+ out.push({ version: m[1], date: m[2] || "", items: part.split("\n").filter((l) => /^[-*]\s+/.test(l)).map((l) => l.replace(/^[-*]\s+/, "").trim()).slice(0, 30) });
54
+ }
55
+ return out.slice(0, 15);
56
+ }
57
+ const localChangelog = () => { try { return readFileSync(CHANGELOG, "utf8"); } catch { return ""; } };
58
+ // After an update: what's new since the version you last saw it for. The first
59
+ // time (no seenVersion yet) that's just this version's own release.
60
+ function whatsNew(cfg = loadConfig(), md = localChangelog()) {
61
+ const seen = typeof cfg.seenVersion === "string" ? cfg.seenVersion : "";
62
+ if (seen && !semverGt(VERSION, seen)) return { version: VERSION, changes: [] };
63
+ const all = changesSince(md, "", VERSION);
64
+ return { version: VERSION, changes: seen ? changesSince(md, seen, VERSION) : all.slice(0, 1) };
65
+ }
66
+ // A file in a package tarball (.tgz: gzip, then tar's 512-byte headers, each
67
+ // followed by its file's bytes), "" if it isn't there.
68
+ function tarFile(tgz, name) {
69
+ let buf; try { buf = gunzipSync(tgz); } catch { return ""; }
70
+ const str = (a, b, at) => buf.subarray(at + a, at + b).toString("utf8").replace(/\0[\s\S]*$/, "");
71
+ for (let at = 0; at + 512 <= buf.length;) {
72
+ if (!buf[at]) break;
73
+ const size = parseInt(str(124, 136, at).trim() || "0", 8) || 0, pre = str(345, 500, at), path = (pre ? pre + "/" : "") + str(0, 100, at);
74
+ if (path === name) return buf.subarray(at + 512, at + 512 + size).toString("utf8");
75
+ at += 512 + Math.ceil(size / 512) * 512;
76
+ }
77
+ return "";
78
+ }
79
+ // The new version's CHANGELOG.md, from its package on npm (kept once found).
80
+ const REGISTRY_LOGS = new Map();
81
+ async function registryChangelog(version, registry = REGISTRY) {
82
+ if (REGISTRY_LOGS.has(version)) return REGISTRY_LOGS.get(version);
83
+ let md = "";
84
+ try {
85
+ const r = await fetch(`${registry}/symbiot/-/symbiot-${encodeURIComponent(version)}.tgz`, { signal: AbortSignal.timeout(15000) });
86
+ if (r.ok) md = tarFile(Buffer.from(await r.arrayBuffer()), "package/CHANGELOG.md");
87
+ } catch {}
88
+ if (md) REGISTRY_LOGS.set(version, md);
89
+ return md;
90
+ }
91
+
37
92
  // ---- `symbiot app` : the same UI in a chrome-less browser window ----------
38
93
  // Self-contained HTML served at / — no backticks or ${} inside (it lives in a
39
94
  // template literal). Talks to the local API with the per-launch token.
@@ -156,8 +211,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
156
211
  await learnNpm(ask); return json(res, pendingReview());
157
212
  }
158
213
  if (u.pathname === "/api/pending/diff") { const p = repoPathMap()[u.searchParams.get("repo") || ""]; return json(res, { diff: p ? workingDiff(p) : "" }); }
159
- if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveRepo(String(b.repo || ""), { bump: b.bump })); }
160
- if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req); if (b.bump) await learnNpm([repoPathMap()[String(b.repo || "")]]); return json(res, approveChanges(String(b.repo || ""), { bump: b.bump, tick: b.tick })); }
214
+ if (u.pathname === "/api/pending/approve" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([repoPathMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump })); return json(res, approveRepo(repo, { bump: b.bump, notes })); }
215
+ if (u.pathname === "/api/pending/approve-changes" && req.method === "POST") { const b = await readBody(req), repo = String(b.repo || ""); if (b.bump) await learnNpm([repoPathMap()[repo]]); const notes = await releaseNotes(releaseInput(repo, { bump: b.bump, tick: b.tick })); return json(res, approveChanges(repo, { bump: b.bump, tick: b.tick, notes })); }
161
216
  if (u.pathname === "/api/pending/sendback" && req.method === "POST") { const b = await readBody(req); return json(res, sendBack(String(b.id || ""))); }
162
217
  if (u.pathname === "/api/automerge" && req.method === "POST") { const b = await readBody(req); return json(res, setAutoMerge(String(b.repo || ""), !!b.on)); }
163
218
  if (u.pathname === "/api/tasks/push" && req.method === "POST") { const b = await readBody(req); return json(res, pushTasks(b)); }
@@ -222,6 +277,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
222
277
  if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
223
278
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
224
279
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
280
+ // Home (home.mjs): the liquid's live data, and its talk; Adapt (adapt.mjs): its
281
+ // shape from how you use it (commit=1 when it wakes from rest, never mid-gesture).
282
+ if (u.pathname === "/api/home") return json(res, homeState({ fresh: u.searchParams.get("fresh") === "1" }));
283
+ if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question)); }
284
+ 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" } : {}) }));
285
+ if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
225
286
  // Lanes (lanes.mjs): work agents handed to each other, and where it stands.
226
287
  if (u.pathname === "/api/lanes") return json(res, lanesState());
227
288
  // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
@@ -260,6 +321,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
260
321
  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)); }
261
322
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
262
323
  if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION), ...(IN_TERMUX ? { termux: true } : {}) }); }
324
+ // What's new: after an update, since the version you last saw (until you click Got it);
325
+ // ?latest=1, what the update on offer brings, from its package on npm
326
+ if (u.pathname === "/api/whatsnew") {
327
+ if (u.searchParams.get("latest") !== "1") return json(res, whatsNew());
328
+ const to = LATEST_VERSION; if (!semverGt(to, VERSION)) return json(res, { version: to, changes: [] });
329
+ const md = await registryChangelog(to);
330
+ return json(res, { version: to, changes: changesSince(md, VERSION, to), ...(md ? {} : { error: `Couldn't read ${to}'s changelog from npm.` }) });
331
+ }
332
+ if (u.pathname === "/api/whatsnew/seen" && req.method === "POST") { const cfg = loadConfig(); cfg.seenVersion = VERSION; return json(res, saveConfig(cfg) ? { ok: true } : { error: "Couldn't write the config file." }); }
263
333
  if (u.pathname === "/api/update" && req.method === "POST") {
264
334
  // Install the exact newest version (see updateCmd), then relaunch this
265
335
  // same app (same port+token => same URL) and exit. The page's heartbeat
@@ -304,4 +374,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
304
374
  setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
305
375
  }
306
376
 
307
- export { updateCmd, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
377
+ export { updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
package/tasks.mjs CHANGED
@@ -8,7 +8,8 @@ import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
8
8
  import { randomBytes } from "node:crypto";
9
9
  import { VERSION, LATEST_VERSION, REGISTRY, semverGt, loadConfig, saveConfig, loadTasks, saveTasks, taskWords, sameTask, uniqueTasks, sh, hasCmd, repoState } from "./core.mjs";
10
10
  import { handoverRules, ONLY_YOU } from "./handover.mjs";
11
- import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, startHeldTasks } from "./agents.mjs";
11
+ import { userStyleLine } from "./adapt.mjs";
12
+ import { QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, handoffCmd, runningHandoff, writeTasks, startHeldTasks, connectorsLine } from "./agents.mjs";
12
13
  import { gitDefaultBranch } from "./drift.mjs";
13
14
  import { repoPathMap, openWork, detectRepo } from "./scan.mjs";
14
15
 
@@ -250,14 +251,61 @@ function setVersion(path, to) {
250
251
  }
251
252
  return () => { for (const [file, text] of saved) try { writeFileSync(file, text); } catch {} };
252
253
  }
254
+ // ---- the changelog: what each release brought ---------------------------------
255
+ // A repo that keeps a CHANGELOG.md gets a section for each version Approve bumps
256
+ // to, "## 0.45.0 — 2026-10-06", newest first, with what was approved: each task
257
+ // (near-duplicates once), up to its first sentence. Changes approved without a
258
+ // task are named by the files they touch. With an AI connected, Approve has it
259
+ // word them as release notes first (writeups.mjs releaseNotes): opts.notes, one
260
+ // per task. Symbiot ships its own, and the app shows it as what's new in an
261
+ // update (server.mjs changesSince).
262
+ const ENTRY_MAX = 220;
263
+ const NOT_OWN = /(^|\/)(package(-lock)?\.json|npm-shrinkwrap\.json|CHANGELOG\.md)$/;
264
+ function changelogEntry(text) {
265
+ let s = String(text || "").replace(/\s+/g, " ").trim();
266
+ const m = s.match(/^(.{40,}?(?<!\be\.g|\bi\.e|\betc|\bvs)[.!?])\s+(?=[^a-z])/); if (m && m[1].length <= ENTRY_MAX) s = m[1]; // not at "e.g. `x`" or "e.g. when"
267
+ return s.length > ENTRY_MAX ? s.slice(0, ENTRY_MAX - 1).replace(/\s+\S*$/, "") + "…" : s;
268
+ }
269
+ function changelogSection(version, texts, files = [], day = new Date().toISOString().slice(0, 10), notes = null) {
270
+ const own = files.filter((f) => !NOT_OWN.test(f));
271
+ const items = notes && notes.length ? [...new Set(notes)] : texts.length ? uniqueTasks(texts.map(changelogEntry))
272
+ : [own.length ? `Changes approved without a task, in ${own.slice(0, 6).join(", ")}${own.length > 6 ? ` and ${own.length - 6} more` : ""}.` : "Version bump only."];
273
+ return `## ${version} — ${day}\n\n${items.map((x) => "- " + x).join("\n")}\n`;
274
+ }
275
+ // Put the section in above the newest one (after the file's own heading and
276
+ // intro), once per version. Gives back a function that restores the file.
277
+ function noteChangelog(path, version, texts, files, notes) {
278
+ const file = join(path, "CHANGELOG.md"); let text = "";
279
+ try { text = readFileSync(file, "utf8"); } catch { return () => {}; }
280
+ if (new RegExp(`^##\\s+\\[?v?${version.replace(/\./g, "\\.")}\\b`, "m").test(text)) return () => {};
281
+ const at = text.search(/^## /m), sec = changelogSection(version, texts, files, undefined, notes) + "\n";
282
+ try { writeFileSync(file, at < 0 ? text.replace(/\s*$/, "\n\n") + sec : text.slice(0, at) + sec + text.slice(at)); } catch { return () => {}; }
283
+ return () => { try { writeFileSync(file, text); } catch {} };
284
+ }
285
+ // What Approve will put in the changelog, for your AI to word (writeups.mjs
286
+ // releaseNotes): the tasks it ships, in the order it ships them, and the files
287
+ // and diff behind them. null when it won't write one: no bump, no CHANGELOG.md,
288
+ // or the agent is still working there. opts as approveRepo / approveChanges take them.
289
+ function releaseInput(repo, opts = {}) {
290
+ const path = repoPathMap()[repo];
291
+ if (!path || !(opts.bump === "patch" || opts.bump === "minor") || !bumpOffer(path) || !existsSync(join(path, "CHANGELOG.md")) || runningHandoff(path)) return null;
292
+ const t = loadTasks(), ids = new Set(Array.isArray(opts.tick) ? opts.tick.map(String) : []);
293
+ const review = t.filter((x) => x.repo === repo && x.review && !x.done && !x.archived);
294
+ const items = review.length ? review : t.filter((x) => ids.has(x.id) && x.repo === repo && !x.done && !x.archived && !x.review);
295
+ const files = workingChanges(path).files.map((f) => f.file).filter((f) => !NOT_OWN.test(f));
296
+ const texts = uniqueTasks(items.map((x) => x.text));
297
+ return texts.length || files.length ? { path, texts, files, diff: workingDiff(path, 30000) } : null;
298
+ }
253
299
  // Ship with the version bumped when opts.bump is "patch" or "minor" and the repo
254
- // is offered one (bumpOffer). The bump is put back if the ship fails.
300
+ // is offered one (bumpOffer), noted in its CHANGELOG.md if it keeps one (in
301
+ // opts.notes' words, when given). Both are put back if the ship fails.
255
302
  function shipWithBump(path, texts, opts) {
256
303
  const offer = (opts.bump === "patch" || opts.bump === "minor") && bumpOffer(path);
257
304
  if (!offer) return shipChanges(path, texts, opts);
258
- const to = offer[opts.bump], undo = setVersion(path, to);
305
+ const to = offer[opts.bump], files = workingChanges(path).files.map((f) => f.file);
306
+ const undo = setVersion(path, to), undoLog = noteChangelog(path, to, texts, files, opts.notes);
259
307
  const r = shipChanges(path, texts, { ...opts, bumped: to });
260
- if (r.error) { undo(); return r; }
308
+ if (r.error) { undo(); undoLog(); return r; }
261
309
  return { ...r, bumped: to };
262
310
  }
263
311
  // What the last run in a folder said when it finished: agent.log after the last
@@ -472,6 +520,7 @@ function buildTasksMd(name, ctx, list) {
472
520
  if (ctx.commits && ctx.commits.length) { L.push("- **Recent commits:**"); for (const c of ctx.commits) L.push(` - ${c}`); }
473
521
  if (ctx.open && ctx.open.length) { L.push("- **Open markers (TODO/FIXME + uncommitted):**"); for (const o of ctx.open) L.push(` - ${o}`); }
474
522
  if (ctx.drift && ctx.drift.length) { L.push("- **Current drift / risk:**"); for (const d of ctx.drift) L.push(` - ${d}`); }
523
+ if (ctx.connectors) L.push(`- **Connectors:** ${ctx.connectors}`);
475
524
  L.push("", "## Tasks");
476
525
  const byType = {}; for (const t of list) { const ty = taskType(t.text); (byType[ty] = byType[ty] || []).push(t); }
477
526
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
@@ -482,6 +531,7 @@ function buildTasksMd(name, ctx, list) {
482
531
  `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then a line of context, then exactly ${OPTIONS_SHOWN} options as \`- \` bullets, the one you recommend first, marked \`(recommended)\`. Symbiot shows only the first ${OPTIONS_SHOWN}; the user can always answer in their own words.`,
483
532
  "- Judge the options before you ask. Most people pick the recommended option without weighing the other, and Symbiot works for a whole company (developers, sales, everyone), not one person, so the choice is really yours. Both options must be good routes to the best solution, never filler or one you wouldn't take. Each says in plain words, with no jargon, what it does and what it changes from then on for the project, the people working on it and the company. Recommend the one that's best for, in this order, the company, the people doing the work, then the task's goal. Base that on evidence you can check here (git history, tests, logs, how it's used, the answers so far), not on what's quickest, and give that evidence in the context line in a sentence.",
484
533
  ONLY_YOU,
534
+ ...(userStyleLine() ? ["- Writing to the user (questions, options, your last message): " + userStyleLine()] : []),
485
535
  "- A `👤 You:` answer holds the next run back until the user's step is done. If their step changes a file, name the file in backticks (`.env`, `~/.termux/termux.properties`): the next run starts once it changes.",
486
536
  "- If a step needs a release that isn't out yet, name its version in the question (\"Once 0.41.0 is installed: …\"). Symbiot shows it next to the installed and npm versions, and holds back an answer that starts \"Done\" until that release is out and installed.",
487
537
  `- Ideas, options or follow-ups outside these tasks go under \`## Suggestions\` as \`- \` bullets. The user can add them to their tasks in one click, and often adds every one, so judge them the same way: only ideas you'd recommend, best first, each saying what it changes and why it's worth doing. Symbiot shows them ${IDEAS_SHOWN} at a time. Ideas the user turned down are in \`.symbiot/SKIPPED.md\`: don't suggest them again.`,
@@ -498,7 +548,7 @@ function pushTasks(filter) {
498
548
  if (!tasks.length) return { empty: true, written: [], unresolved: [] };
499
549
  const byName = repoPathMap(); // from the already-scanned map when there is one
500
550
  const groups = {}; for (const t of tasks) { const k = t.repo || ""; (groups[k] = groups[k] || []).push(t); }
501
- const written = [], unresolved = [];
551
+ const written = [], unresolved = [], connectors = connectorsLine(); // the same for every repo's run
502
552
  for (const name of Object.keys(groups)) {
503
553
  const list = uniqueTasks(groups[name].map((t) => t.text)).map((text) => ({ text })), path = name && byName[name]; // near-duplicates once
504
554
  if (!path) { unresolved.push({ name: name || "(no repo)", count: list.length }); continue; }
@@ -515,11 +565,11 @@ function pushTasks(filter) {
515
565
  if (st.behind) risk.push(`${st.behind} behind upstream on ${st.branch}`);
516
566
  if (st.dirty && !st.stale) risk.push(`${st.dirty} uncommitted (${st.mod} mod / ${st.del} del / ${st.add} new)`);
517
567
  // held: an agent is still running there, so it lands when that one exits
518
- const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName) }, list));
568
+ const held = writeTasks(path, buildTasksMd(name, { branch: st.branch, commits, open, drift: risk, stack, lanes: Object.keys(byName), connectors }, list));
519
569
  written.push({ name, file: join(path, ".symbiot", "TASKS.md"), path, count: list.length, held });
520
570
  } catch (e) { unresolved.push({ name, count: list.length, error: String((e && e.message) || e) }); }
521
571
  }
522
572
  return { empty: false, written, unresolved, handoff: handoffCmd() };
523
573
  }
524
574
 
525
- export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
575
+ export { addTask, toggleTask, removeTask, restoreTask, completedInRepo, removalOf, applyRemovals, syncTasks, workingChanges, workingDiff, publishesOnMerge, unreleased, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, changelogEntry, changelogSection, noteChangelog, releaseInput, shipWithBump, runSummary, saidFinished, pendingReview, commitSubject, shipChanges, autoMergeRepos, setAutoMerge, approveRepo, approveChanges, sendBack, TASK_ORDER, taskType, buildTasksMd, pushTasks };
package/ui.mjs CHANGED
@@ -8,6 +8,59 @@ export const EMBEDDED_UI = `<!doctype html><html><head><meta charset="utf8">
8
8
  <meta name="viewport" content="width=device-width,initial-scale=1"><title>Symbiot</title>
9
9
  <style>
10
10
  :root{--ink:#0E1A1F;--ink2:#15262C;--ink3:#1D333A;--line:#24404A;--bone:#F4F1EA;--text:#B7C9C4;--faint:#7E9690;--green:#3DDC97;--amber:#F2A541;--green-dim:#16322D;--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}
11
+ /* ---- the liquid: the app's home (adapt.mjs shapes it, home.mjs fills it) ---- */
12
+ /* Your colours, from the system, live: light/dark, contrast, transparency, forced colours. */
13
+ body.lq-light{--ink:#F4F6F9;--ink2:#FFFFFF;--ink3:#EDF0F4;--line:#D3D9E2;--bone:#0F1720;--text:#2B3644;--faint:#5C6878;--green:#0B7A55;--amber:#9A5200;--green-dim:#DDF2E9}
14
+ body.lq-contrast{--line:#9FB3BB;--faint:#C9D6D2}
15
+ body.lq-light.lq-contrast{--line:#4A5565;--faint:#2B3644;--text:#10151C}
16
+ #liquid{display:none}
17
+ body.lq-liquid #liquid{display:block;position:fixed;inset:0;z-index:0;overflow:hidden;background:#030405}
18
+ body.lq-liquid.lq-light #liquid{background:#EDEFF3}
19
+ #lq{position:absolute;inset:0;width:100%;height:100%;display:block}
20
+ body.lq-forced #lq{display:none}
21
+ #lqdrops{position:absolute;inset:0;pointer-events:none}
22
+ .lqd{position:absolute;left:0;top:0;display:flex;flex-direction:column;align-items:center;gap:2px;min-height:44px;padding:8px 14px;border-radius:999px;border:1px solid rgba(220,228,240,.35);background:rgba(4,5,7,.72);backdrop-filter:blur(10px);color:#EEF2F8;font:600 14px/1.15 var(--sans);white-space:nowrap;cursor:pointer;pointer-events:auto;transition:opacity .5s ease;will-change:transform}
23
+ .lqd small{font-weight:500;font-size:12px;color:#A9B3C2;max-width:300px;overflow:hidden;text-overflow:ellipsis}
24
+ .lqd.lq-you{border-color:rgba(255,255,255,.9)}
25
+ .lqd:hover{border-color:rgba(255,255,255,.95)}
26
+ .lqd:focus-visible,#lqcore:focus-visible,#lqask:focus-visible{outline:2px solid currentColor;outline-offset:3px}
27
+ body.lq-light .lqd{background:rgba(255,255,255,.84);color:#10151C;border-color:rgba(20,30,45,.25)}
28
+ body.lq-light .lqd small{color:#4A5565}
29
+ body.lq-light .lqd.lq-you{border-color:#10151C}
30
+ body.lq-contrast .lqd{background:#000;color:#FFF;border:2px solid #FFF}
31
+ body.lq-contrast .lqd small{color:#FFF}
32
+ body.lq-light.lq-contrast .lqd{background:#FFF;color:#000;border:2px solid #000}
33
+ body.lq-light.lq-contrast .lqd small{color:#000}
34
+ body.lq-solid .lqd,body.lq-solid .lqglass{backdrop-filter:none;background:#05070A}
35
+ body.lq-light.lq-solid .lqd,body.lq-light.lq-solid .lqglass{background:#FFFFFF}
36
+ body.lq-touch .lqd{min-height:48px;padding:11px 16px}
37
+ body.lq-keys .lqd:focus{outline:2px solid currentColor;outline-offset:3px}
38
+ #lqcore{position:absolute;left:0;top:0;min-height:44px;padding:0 16px;border:1px solid rgba(220,228,240,.22);border-radius:999px;background:rgba(4,5,7,.6);backdrop-filter:blur(8px);color:#C3CBD7;font:500 13px var(--sans);letter-spacing:.05em;cursor:pointer;transition:opacity .6s ease;white-space:nowrap}
39
+ body.lq-light #lqcore{color:#2B3644;background:rgba(255,255,255,.78);border-color:rgba(20,30,45,.18)}
40
+ .lqglass{background:rgba(4,5,8,.78);border:1px solid rgba(220,228,240,.32);backdrop-filter:blur(14px)}
41
+ body.lq-light .lqglass{background:rgba(255,255,255,.82);border-color:rgba(20,30,45,.2)}
42
+ #lqform{position:absolute;left:50%;bottom:20px;transform:translateX(-50%);width:min(640px,calc(100vw - 32px));display:flex;gap:8px;padding:6px;border-radius:999px;z-index:3;color:#EEF2F8}
43
+ body.lq-light #lqform{color:#10151C}
44
+ #lqask{flex:1;min-width:0;min-height:48px;padding:0 18px;border:0;border-radius:999px;background:transparent;color:inherit;font:400 16px var(--sans);outline:none}
45
+ #lqsend{min-height:48px;padding:0 20px;border-radius:999px;border:1px solid #FFFFFF;background:linear-gradient(180deg,#FFFFFF 0%,#C9CFD9 45%,#8E97A6 55%,#E6EAF0 100%);color:#06080B;font:600 14px var(--sans);cursor:pointer}
46
+ #lqtalk{position:absolute;left:50%;bottom:92px;transform:translateX(-50%);width:min(640px,calc(100vw - 32px));display:flex;flex-direction:column;gap:8px;z-index:3;max-height:46vh;overflow:auto}
47
+ .lqmsg{align-self:flex-start;max-width:85%;padding:10px 14px;border-radius:16px;background:rgba(4,5,8,.84);border:1px solid rgba(220,228,240,.3);color:#E8ECF3;line-height:1.45;white-space:pre-wrap}
48
+ .lqmsg.me{align-self:flex-end}
49
+ body.lq-light .lqmsg{background:#FFFFFF;color:#10151C;border-color:rgba(20,30,45,.2)}
50
+ #lqmore{position:absolute;right:16px;bottom:92px;display:flex;flex-direction:column;align-items:flex-end;gap:6px;z-index:3}
51
+ body.lq-liquid header{position:fixed;top:0;left:0;right:0;z-index:4;background:transparent;pointer-events:none}
52
+ body.lq-liquid header .status,body.lq-liquid header .ver{pointer-events:auto}
53
+ body.lq-liquid header .brand{letter-spacing:.38em;font-weight:500;text-transform:uppercase}
54
+ body.lq-liquid .tabs{display:none}
55
+ body.lq-liquid main{display:none}
56
+ body.lq-liquid.lq-pooled main{display:block;position:fixed;top:64px;left:max(12px,4vw);right:max(12px,4vw);bottom:88px;z-index:5;border-radius:24px;background:var(--ink);border:1px solid rgba(220,228,240,.25);box-shadow:0 30px 80px -30px rgba(0,0,0,.8)}
57
+ #lqsinkrow{display:none}
58
+ body.lq-liquid.lq-pooled #lqsinkrow{display:flex;justify-content:flex-end;position:sticky;top:0;z-index:2;margin:-8px -6px 6px}
59
+ body.lq-liquid footer{position:fixed;right:12px;top:38px;border:0;padding:0;z-index:4}
60
+ body.lq-pooled #lqtalk,body.lq-pooled #lqmore,body.lq-pooled #lqcore{display:none}
61
+ body.lq-talking .lqd{opacity:.55}
62
+ @media (prefers-reduced-motion: reduce){.lqd,#lqcore{transition:none}}
63
+
11
64
  *{box-sizing:border-box}html,body{margin:0;height:100%}
12
65
  body{background:var(--ink);color:var(--text);font-family:var(--sans);font-size:14px;display:flex;flex-direction:column}
13
66
  header{padding:16px 18px 10px;display:flex;align-items:center;gap:10px}
@@ -33,6 +86,8 @@ a{color:var(--green);cursor:pointer}.hidden{display:none}
33
86
  .updatebar{display:none;align-items:center;gap:12px;padding:9px 16px;font-size:13px;font-weight:600;background:var(--green);color:var(--ink)}
34
87
  .updatebar.show{display:flex}
35
88
  .updatebar.reconnect{background:var(--amber)}
89
+ .whatsnew{display:none;padding:10px 16px;font-size:13px;background:var(--ink2);border-bottom:1px solid var(--line);max-height:40vh;overflow:auto}
90
+ .whatsnew.show{display:block}
36
91
  .updatebar button,.updatebar a.b{font:inherit;font-weight:700;border:0;border-radius:8px;padding:5px 12px;background:var(--ink);color:var(--bone);cursor:pointer;text-decoration:none}
37
92
  .ver{font-size:11px;color:var(--faint);background:var(--ink3);border:1px solid var(--line);border-radius:999px;padding:2px 8px;margin-left:2px;font-family:ui-monospace,Menlo,Consolas,monospace}
38
93
  footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
@@ -150,8 +205,18 @@ label.check input{width:auto}
150
205
  @media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
151
206
  @media(max-width:600px){#panel-tasks .row>#newtask,#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
152
207
  </style></head><body>
208
+ <div id="liquid" aria-label="Symbiot">
209
+ <canvas id="lq" aria-hidden="true"></canvas>
210
+ <div id="lqdrops"></div>
211
+ <button type="button" id="lqcore" title="talk to Symbiot"></button>
212
+ <div id="lqmore"></div>
213
+ <div id="lqtalk" aria-live="polite"></div>
214
+ <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">Send</button></form>
215
+ </div>
216
+
153
217
  <div id="updatebar" class="updatebar"></div>
154
218
  <div id="appbar" class="updatebar"></div>
219
+ <div id="whatsnew" class="whatsnew"></div>
155
220
  <header><span class="dot"></span><span class="brand">Symbiot</span><span class="ver" id="ver"></span><span class="status" id="status">...</span></header>
156
221
  <div class="tabs">
157
222
  <button class="tab active" data-tab="map">Map</button>
@@ -165,6 +230,7 @@ label.check input{width:auto}
165
230
  <button class="tab" data-tab="settings">Settings</button>
166
231
  </div>
167
232
  <main>
233
+ <div id="lqsinkrow"><button class="ghost" type="button" id="lqsink" title="close it: it sinks back into the liquid (Esc)">Sink back</button></div>
168
234
  <section id="panel-map">
169
235
  <div class="profile muted" id="profile">Mapping your work&hellip;</div>
170
236
  <div class="maprow">
@@ -640,14 +706,17 @@ o.innerHTML=h;
640
706
  // re-enable only once every handoff has started (or been refused), so a double click can't send twice
641
707
  Promise.all(opens).then(function(){btn.disabled=false;});}).catch(function(e){btn.disabled=false;o.innerHTML="<div class='err' style='margin-top:10px'>Couldn&#39;t write tasks: "+esc(String((e&&e.message)||e))+"</div>";});}
642
708
  // Connectors linked to Claude (claude.ai's Drive, Gmail…) reach Claude runs only: say which, or that this command doesn't get them.
643
- var CONNECTORS=[];
644
- function renderConnectors(){var el=document.getElementById('agentconnectors');if(!CONNECTORS.length){el.innerHTML='';return;}
709
+ var CONNECTORS=[],LINKCONN=[];
710
+ // a site linked in Symbiot (Links) only signs Symbiot's browser in: without Claude's own connector for it, runs have no tools for it
711
+ function linkGap(){var off=LINKCONN.filter(function(x){return !x.ready;});if(!off.length)return '';var n=off.map(function(x){return "<b>"+esc(x.name)+"</b>";}).join(' and '),one=off.length===1;
712
+ return "&#9993;&#65039; "+n+(one?" is":" are")+" linked in Symbiot, but not "+(off.some(function(x){return x.connector;})?"ready ":"")+"as a Claude connector, so your agent&#39;s runs have no tools for "+(one?"it":"them")+": linking a site here signs Symbiot&#39;s browser in, not Claude. Connect "+n+" in claude.ai &rarr; Settings &rarr; Connectors, and the next run gets "+(one?"it":"them")+" by itself. Until then a run sees only what&#39;s new there (Watch).";}
713
+ function renderConnectors(){var el=document.getElementById('agentconnectors'),gap=linkGap();if(!CONNECTORS.length){el.innerHTML=/^\\s*claude\\b/.test(document.getElementById('agentcmd').value)?gap:'';return;}
645
714
  var nm=function(c){return "<b>"+esc(c.name.replace(/^claude\\.ai\\s+/i,''))+"</b>";},ready=CONNECTORS.filter(function(c){return c.ready;}),wait=CONNECTORS.filter(function(c){return !c.ready;});
646
715
  var cmd=document.getElementById('agentcmd').value;if(/orca-ide/.test(cmd)&&/claude/.test(cmd)){el.innerHTML="&#128268; Claude in Orca&#39;s tab asks you before it uses your connectors ("+CONNECTORS.map(nm).join(", ")+").";return;}
647
716
  if(!/^\\s*claude\\b/.test(cmd)){el.innerHTML="&#128268; Your Claude connectors ("+CONNECTORS.map(nm).join(", ")+") reach Claude Code runs only. This command isn&#39;t Claude, so its runs can&#39;t use them.";return;}
648
- el.innerHTML=(ready.length?"&#128268; Your agent&#39;s runs can use "+ready.map(nm).join(", ")+": linked to Claude, so each run allows their tools.":"")+(wait.length?(ready.length?" ":"&#128268; ")+wait.map(nm).join(", ")+(wait.length===1?" needs":" need")+" authorizing in your claude.ai connector settings first.":"");}
717
+ el.innerHTML=(ready.length?"&#128268; Your agent&#39;s runs can use "+ready.map(nm).join(", ")+": linked to Claude, so each run allows their tools.":"")+(wait.length?(ready.length?" ":"&#128268; ")+wait.map(nm).join(", ")+(wait.length===1?" needs":" need")+" authorizing in your claude.ai connector settings first.":"")+(gap?" "+gap:"");}
649
718
  function loadAgentCfg(){api('/api/agentcfg').then(function(d){document.getElementById('agentcmd').value=d.cmd||'';
650
- CONNECTORS=(d.connectors&&d.connectors.list)||[];renderConnectors();
719
+ CONNECTORS=(d.connectors&&d.connectors.list)||[];LINKCONN=(d.connectors&&d.connectors.links)||[];renderConnectors();
651
720
  var chips=[];(d.agents||[]).forEach(function(a){chips.push(a);});(d.editors||[]).forEach(function(e){chips.push(e);});
652
721
  var box=document.getElementById('agentpresets');
653
722
  if(!chips.length){box.innerHTML="<span class='muted' style='font-size:12px'>Nothing detected on PATH &mdash; type your own command above.</span>";return;}
@@ -890,6 +959,8 @@ say('ok',"&#10003; Opened WhatsApp in Symbiot's browser, in a window of its own.
890
959
  var DRAFTING="&#10003; Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.";
891
960
  // ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
892
961
  var BOARD=null;var BOARDICON={mail:'&#9993;&#65039;',github:'&#128276;',chat:'&#128172;',page:'&#127760;'};
962
+ // a chat's line says who its last message is from (watch.mjs fromOf): only unread ones count as waiting on you
963
+ var CHATFROM={them:'unread from them',you:'you sent the last one',unknown:'nothing unread, maybe yours'};
893
964
  // Talk it over (watch.mjs boardChat): a chat on a card, open by its id, with what's half typed kept across a refresh
894
965
  var BTALK={},BTALKDRAFT={},BTALKBUSY=0;
895
966
  var TALKHINT="<div class='muted' style='font-size:12px'>Go over what's new here with your AI: what needs you, and what to say to whom. Then click <b>Draft a reply</b> on one, and your agent writes it the way you agreed here. Your AI is sent what this card lists (for mail: the sender, subject and preview).</div>";
@@ -909,7 +980,7 @@ return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'
909
980
  +"<div class='bc'>"+c.count+"</div><div class='bl'>"+esc(c.count?c.label.replace(/^\\d+ /,''):'nothing new')+" &middot; "+(c.checked?"read "+agoTxt(c.checked):c.last?"tried "+agoTxt(c.last):"first read within a minute")+(c.via==='gh'?" &middot; through gh":"")+"</div>"
910
981
  +(c.error?"<div class='note err'>"+esc(c.error)+"</div>":"")
911
982
  +(c.brief?"<div class='bbrief'>"+esc(c.brief.text)+"</div>":"")
912
- +c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost bdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+(n.chat&&n.drafted?"<button class='ghost bopenwa' title='"+OPEN_WA_TIP+"'>Open in WhatsApp</button>":"")+"</div>";}).join('')
983
+ +c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+(n.from&&CHATFROM[n.from]?" &middot; "+CHATFROM[n.from]:"")+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail||n.chat?"<button class='ghost bdraft' title='"+(n.chat?DRAFT_CHAT_TIP:"your coding agent writes a reply and leaves it in Drafts. It never presses Send")+"'>"+(n.drafted?"Drafted &middot; again":"Draft a reply")+"</button>":"")+(n.chat&&n.drafted?"<button class='ghost bopenwa' title='"+OPEN_WA_TIP+"'>Open in WhatsApp</button>":"")+"</div>";}).join('')
913
984
  +(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>&hellip;and "+(c.count-c.items.length)+" more</div>":"")
914
985
  +(BTALK[c.id]?"<div class='tchat btalk'><div class='msgs'>"+(nt?chatMsgs(c.chat):TALKHINT)+"</div><div class='row'><input class='talkq' placeholder='Ask about these, or say how to answer one...' style='flex:1'><button class='ghost talksend'>Ask</button><a class='talkclear' title='forget this conversation'>clear</a></div></div>":"")+"</div>";}).join('');
915
986
  function card(btn){return cs[+btn.closest('.bcard').getAttribute('data-i')];}
@@ -1068,8 +1139,9 @@ function heartbeat(fresh){
1068
1139
  }
1069
1140
  if(p.latest&&p.newer){
1070
1141
  b.className='updatebar show';
1071
- b.innerHTML="A new Symbiot ("+esc(p.latest)+") is available &mdash; you're on "+esc(p.version)+". <button id='doupd'>Update &amp; restart</button>";
1142
+ b.innerHTML="A new Symbiot ("+esc(p.latest)+") is available &mdash; you're on "+esc(p.version)+". <button id='updnew'>What&#39;s new</button> <button id='doupd'>Update &amp; restart</button>";
1072
1143
  var btn=document.getElementById('doupd');if(btn)btn.onclick=doUpdate;
1144
+ var nb=document.getElementById('updnew');if(nb)nb.onclick=function(){api('/api/whatsnew?latest=1').then(function(r){showNews(r,"What Symbiot "+(r.version||p.latest)+" brings",false);});};
1073
1145
  }else{b.className='updatebar';}
1074
1146
  }).catch(function(){srvDown=true;var b=ubar();b.className='updatebar reconnect show';b.textContent='Reconnecting to Symbiot…';});
1075
1147
  }
@@ -1080,8 +1152,158 @@ function appBar(p){var b=$('appbar');if(!b||!p.termux||window.SymbiotAndroid||ty
1080
1152
  var go=('intent://'+location.host+'/'+location.search+'#Intent;scheme=symbiot;package=co.symbiot.app;end').replace(/'/g,'%27');
1081
1153
  b.className='updatebar show';b.innerHTML="Got Symbiot's Android app? It shows this Symbiot full screen. <a class='b' href='"+esc(go)+"'>Open in the app</a> <button id='appbaroff'>Hide</button>";
1082
1154
  $('appbaroff').onclick=function(){b.className='updatebar';try{localStorage.setItem('symbiot_appbar_off','1');}catch(e){}};}
1155
+ // What's new (server.mjs whatsNew, from CHANGELOG.md): what an update brings, from its package on npm, and
1156
+ // once after an update, what came with it, until Got it
1157
+ function showNews(r,title,after){var el=$('whatsnew');if(!el)return;var cs=(r&&r.changes)||[];
1158
+ if(!cs.length){if(after||!r){el.className='whatsnew';return;}el.className='whatsnew show';el.innerHTML="<div class='row'><span style='flex:1'>"+esc(r.error||"No changelog for "+(r.version||'that version')+" yet.")+"</span><button class='ghost' id='wnok'>Close</button></div>";$('wnok').onclick=function(){el.className='whatsnew';};return;}
1159
+ var md=function(s){return esc(s).replace(/\\x60([^\\x60]+)\\x60/g,'<code>$1</code>').replace(/\\*\\*([^*]+)\\*\\*/g,'<b>$1</b>');};
1160
+ el.className='whatsnew show';el.innerHTML="<div class='row'><b style='flex:1;color:var(--bone)'>"+esc(title)+"</b><button class='ghost' id='wnok'>"+(after?'Got it':'Close')+"</button></div>"
1161
+ +cs.map(function(c){return "<div style='margin-top:8px'><b>"+esc(c.version)+"</b>"+(c.date?" <span class='muted'>"+esc(c.date)+"</span>":"")+"<ul style='margin:4px 0 0 18px;padding:0'>"+c.items.slice(0,12).map(function(x){return "<li>"+md(x)+"</li>";}).join('')+(c.items.length>12?"<li class='muted'>&hellip;and "+(c.items.length-12)+" more</li>":"")+"</ul></div>";}).join('');
1162
+ $('wnok').onclick=function(){el.className='whatsnew';if(after)api('/api/whatsnew/seen',{});};}
1163
+ function loadWhatsNew(){api('/api/whatsnew').then(function(r){if(r&&r.changes&&r.changes.length)showNews(r,"What's new in Symbiot "+r.version,true);});}
1083
1164
  function doUpdate(){updBusy=true;try{localStorage.setItem('symbiot_update_tried',document.getElementById('ver').textContent.replace(/^v/,''));}catch(e){}var b=ubar();b.className='updatebar show';b.textContent='Updating & restarting… this page will reload itself when it is back.';api('/api/update',{});}
1084
1165
  setInterval(heartbeat,4000);heartbeat(true);
1085
1166
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
1086
- initGraphEvents();syncP();refresh();loadMap();firstTab();loadAgentCfg();loadScanRoots();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
1167
+
1168
+ // ---- the liquid: the app's home ---------------------------------------------------
1169
+ // One silver surface is the app. Its droplets: what only you can do, what's new on
1170
+ // what you watch, and the parts of the app, sized and placed from how you use them
1171
+ // (adapt.mjs: Fitts, Hick, hysteresis); filled from real data (home.mjs). Opening
1172
+ // one pools it: that part's panel opens over the liquid. Droplets move as
1173
+ // critically damped springs that push each other apart, and the liquid they hold
1174
+ // comes out of the core (mass is conserved). Your colours come from the system,
1175
+ // live: light or dark, contrast, transparency, forced colours, your accent, night.
1176
+ var LQ={drops:[],btns:[],core:{x:0,y:0,cr:0},mode:'aware',last:'',lastAct:Date.now(),talk:[],talking:false,theme:{},ripple:[0.5,0.5,-10],t:0,pointer:null,touch:false,frame:0};
1177
+ var LQNAMES={board:'Dashboard',map:'Map',tasks:'Tasks',agents:'Agents',week:'Week',standup:'Standup',todo:'Todo',drift:'Drift',settings:'Settings'};
1178
+ var LQ_REST=60000,LQ_MAX=11;
1179
+ function lqMM(q){try{return !!(window.matchMedia&&window.matchMedia(q).matches);}catch(e){return false;}}
1180
+ function lqTheme(){var th={light:lqMM('(prefers-color-scheme: light)'),contrast:lqMM('(prefers-contrast: more)'),forced:lqMM('(forced-colors: active)'),solid:lqMM('(prefers-reduced-transparency: reduce)'),still:lqMM('(prefers-reduced-motion: reduce)'),wide:lqMM('(color-gamut: p3)'),night:false,accent:null};
1181
+ var h=new Date().getHours();th.night=h>=22||h<6;
1182
+ try{if(document.createElement&&typeof getComputedStyle==='function'&&document.body&&document.body.appendChild){var a=document.createElement('span'),b=document.createElement('span');a.style.color='AccentColor';b.style.color='CanvasText';document.body.appendChild(a);document.body.appendChild(b);
1183
+ if(a.style.color){var ca=getComputedStyle(a).color,cb=getComputedStyle(b).color;if(ca&&ca!==cb){var m=ca.match(/[0-9.]+/g);if(m&&m.length>=3)th.accent=[m[0]/255,m[1]/255,m[2]/255];}}a.remove();b.remove();}}catch(e){}
1184
+ var bd=document.body;if(bd&&bd.classList){bd.classList.toggle('lq-light',th.light);bd.classList.toggle('lq-contrast',th.contrast);bd.classList.toggle('lq-solid',th.solid||th.contrast);bd.classList.toggle('lq-forced',th.forced);}
1185
+ LQ.theme=th;return th;}
1186
+ function lqSize(){var el=$('liquid');var w=(el&&el.clientWidth)||window.innerWidth||1280,h=(el&&el.clientHeight)||window.innerHeight||800;return {w:w,h:h,s:Math.max(0.55,Math.min(1.15,Math.min(w,h)/860))};}
1187
+ function lqLoad(commit){Promise.all([api('/api/adapt?from='+encodeURIComponent(LQ.last)+(commit?'&commit=1':'')+(LQ.touch?'&touch=1':'')),api('/api/home')]).then(function(r){LQ.adapt=r[0]||{};LQ.home=r[1]||{};lqBuild();}).catch(function(){});}
1188
+ function lqBuild(){var a=LQ.adapt||{},h=LQ.home||{},S=lqSize(),cx=S.w/2,cy=S.h*0.47,list=[];LQ.bw=S.w;LQ.bh=S.h;
1189
+ var you=(h.you||[]).slice(0,3),feeds=(h.feeds||[]).slice(0,2),lay=(a.layout&&a.layout.items)||[],more=((a.layout&&a.layout.more)||[]).slice();
1190
+ var vx=Math.max(0.5,Math.min(S.s*1.3,(S.w/2-90)/400)),vy=Math.max(0.35,Math.min(S.s*0.8,(S.h-cy-230)/400,(cy-160)/400));var rs=Math.min(S.s,(vx+vy)/1.5);var room=Math.max(3,LQ_MAX-you.length-feeds.length-1);lay.slice(room).forEach(function(it){more.push(it.id);});
1191
+ lay.slice(0,room).forEach(function(it){list.push({id:'shape:'+it.id,kind:'shape',shape:it.id,title:LQNAMES[it.id]||it.id,sub:'',r:it.r*rs,tx:cx+Math.cos(it.angle)*it.d*vx,ty:cy+Math.sin(it.angle)*it.d*vy});});
1192
+ you.forEach(function(y,i){var ang=-Math.PI/2+(i-(you.length-1)/2)*0.6;list.push({id:y.id,kind:'you',shape:y.shape,title:y.title,sub:y.sub,r:50*rs,tx:cx+Math.cos(ang)*230*vx,ty:cy+Math.sin(ang)*230*vy*1.05,tendril:true});});
1193
+ feeds.forEach(function(f,i){var ang=Math.PI/2+(i-(feeds.length-1)/2)*0.7;list.push({id:f.id,kind:'feed',shape:f.shape,title:f.title,sub:f.sub,r:(28+5*Math.min(f.count||1,5))*rs,tx:cx+Math.cos(ang)*300*vx,ty:cy+Math.sin(ang)*300*vy});});
1194
+ if(more.length)list.push({id:'more',kind:'more',title:'more',sub:more.map(function(m){return LQNAMES[m]||m;}).join(' · '),more:more,r:24*S.s,tx:S.w-Math.max(70,S.w*0.08),ty:150});
1195
+ lqRelax(list,S,cx,cy,92*rs);var old={};LQ.drops.forEach(function(d){old[d.id]=d;});
1196
+ LQ.drops=list.map(function(d){var o=old[d.id];d.x=o?o.x:cx;d.y=o?o.y:cy;d.vx=o?o.vx:0;d.vy=o?o.vy:0;d.cr=o?o.cr:0;return d;});
1197
+ var n=(h.you||[]).length,w=h.working||0;
1198
+ LQ.coreText=n?(n+(n>1?' things need':' thing needs')+' only you'+(w?' · '+w+' agent'+(w>1?'s':'')+' working':'')):(w?w+' agent'+(w>1?'s':'')+' working · nothing needs you':'All handled');
1199
+ var b=document.body;if(b&&b.classList){b.classList.toggle('lq-touch',!!(a.modes&&a.modes.touch)||LQ.touch);b.classList.toggle('lq-keys',!!(a.modes&&a.modes.keyboard));}
1200
+ LQ.talkWeight=(a.modes&&a.modes.talkWeight)||0.35;
1201
+ lqLabels();}
1202
+ // Force-directed layout, solved once per change: push overlapping targets apart
1203
+ // (each droplet's circle plus its label below or above), and off the core, within
1204
+ // the screen. Deterministic, so the same data gives the same layout.
1205
+ function lqRelax(L,S,cx,cy,coreR){var n=L.length;for(var it=0;it<160;it++){var moved=0;for(var i=0;i<n;i++){var a=L[i];
1206
+ for(var j=i+1;j<n;j++){var b=L[j],dx=a.tx-b.tx,dy=a.ty-b.ty,dd=Math.sqrt(dx*dx+dy*dy)||0.01,mn=a.r+b.r+70+(Math.abs(dx)<150?48:0);if(dd<mn){var f=(mn-dd)/2/dd;a.tx+=dx*f;a.ty+=dy*f;b.tx-=dx*f;b.ty-=dy*f;moved++;}}
1207
+ var cdx=a.tx-cx,cdy=a.ty-cy,cd=Math.sqrt(cdx*cdx+cdy*cdy)||0.01,cm=a.r+coreR+70;if(cd<cm){a.tx+=cdx*(cm-cd)/cd;a.ty+=cdy*(cm-cd)/cd;moved++;}
1208
+ a.tx=Math.max(a.r+60,Math.min(S.w-a.r-60,a.tx));a.ty=Math.max(140+a.r,Math.min(S.h-200-a.r,a.ty));}if(!moved)break;}}
1209
+ function lqLabels(){var el=$('lqdrops');if(!el)return;
1210
+ el.innerHTML=LQ.drops.map(function(d,i){return "<button type='button' class='lqd lq-"+d.kind+"' data-i='"+i+"'><span>"+esc(d.title)+"</span>"+(d.sub?"<small>"+esc(d.sub)+"</small>":"")+"</button>";}).join('');
1211
+ LQ.btns=[];el.querySelectorAll('.lqd').forEach(function(b){LQ.btns.push(b);b.addEventListener('click',function(ev){lqOpen(LQ.drops[+b.getAttribute('data-i')],ev);});});
1212
+ var c=$('lqcore');if(c)c.textContent=LQ.coreText||'';}
1213
+ function lqVia(ev){return LQ.talking?'talk':ev&&ev.pointerType==='touch'?'touch':ev&&ev.detail===0?'key':'click';}
1214
+ function lqUse(shape,ev,via){api('/api/adapt/use',{shape:shape,from:LQ.last,via:via||lqVia(ev)});LQ.last=shape;}
1215
+ function lqOpen(d,ev){if(!d)return;lqAct();
1216
+ if(d.kind==='more'){LQ.moreOpen=!LQ.moreOpen;lqMore(d);return;}
1217
+ var S=lqSize();LQ.ripple=[d.x/S.w,d.y/S.h,LQ.t];lqUse(d.shape||'board',ev);lqPool(d.shape||'board');}
1218
+ function lqMore(d){var el=$('lqmore');if(!el)return;if(!LQ.moreOpen||!d){el.innerHTML='';return;}
1219
+ el.innerHTML=(d.more||[]).map(function(s){return "<button type='button' class='lqd lqm' style='position:static' data-s='"+s+"'><span>"+esc(LQNAMES[s]||s)+"</span></button>";}).join('');
1220
+ el.querySelectorAll('.lqm').forEach(function(b){b.addEventListener('click',function(ev){var s=b.getAttribute('data-s');LQ.moreOpen=false;el.innerHTML='';lqUse(s,ev);lqPool(s);});});}
1221
+ function lqPool(shape){tabPicked=true;setTab(shape);var b=document.body;if(b&&b.classList)b.classList.add('lq-pooled');LQ.mode='pool';var m=document.querySelector('main');if(m&&m.focus)m.focus();}
1222
+ function lqSink(){var b=document.body;if(b&&b.classList)b.classList.remove('lq-pooled');LQ.mode='aware';lqAct();lqLoad(false);}
1223
+ function lqAct(){LQ.lastAct=Date.now();if(LQ.mode==='rest'){LQ.mode='aware';lqLoad(true);}}
1224
+ function lqTalkMode(on){LQ.talking=on;var b=document.body;if(b&&b.classList)b.classList.toggle('lq-talking',on);}
1225
+ function lqTalkShow(wait){var el=$('lqtalk');if(!el)return;el.innerHTML=LQ.talk.map(function(m){return "<div class='lqmsg"+(m.me?' me':'')+"'>"+esc(m.text)+"</div>";}).join('')+(wait?"<div class='lqmsg'>&hellip;</div>":'');if(el.scrollHeight)el.scrollTop=el.scrollHeight;}
1226
+ function lqSay(){var i=$('lqask');var q=((i&&i.value)||'').trim();if(!q)return;i.value='';lqAct();
1227
+ var low=q.toLowerCase().replace(/^(please |can you |could you )/,''),hit='';
1228
+ 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;});
1229
+ if(hit){lqTalkMode(true);lqUse(hit,null,'talk');lqPool(hit);return;}
1230
+ LQ.talk.push({me:true,text:q});LQ.talk=LQ.talk.slice(-8);lqTalkShow(true);
1231
+ api('/api/home/ask',{question:q}).then(function(r){LQ.talk.push({me:false,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);});}
1232
+ // The physics: each droplet a critically damped spring to its place (no wobble,
1233
+ // no overshoot), pushed off its neighbours and the core where they'd overlap.
1234
+ function lqStep(){var S0=lqSize();if(LQ.adapt&&(S0.w!==LQ.bw||S0.h!==LQ.bh))lqBuild();var S=S0,cx=S.w/2,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;
1235
+ var core=LQ.core,ctx=cx,cty=talk?S.h-44:cy,ctr=(rest?140:pool?50:talk?54+30*(LQ.talkWeight||0.35):92)*S.s;
1236
+ if(!core.x){core.x=cx;core.y=cy;}core.x+=(ctx-core.x)*(still?1:0.06);core.y+=(cty-core.y)*(still?1:0.06);core.cr+=(ctr-core.cr)*(still?1:0.05);
1237
+ var D=LQ.drops,N=D.length;
1238
+ for(var i=0;i<N;i++){var d=D[i],tx=d.tx,ty=d.ty,tr=d.r;
1239
+ if(rest){tx=cx+Math.cos(i*1.3)*16;ty=cy+Math.sin(i*1.3)*16;tr=d.r*0.6;}
1240
+ else if(pool){tx=cx+(i-(N-1)/2)*40*S.s;ty=S.h-40;tr=13*S.s;}
1241
+ else if(talk){ty=S.h*0.12+(d.ty-S.h*0.12)*0.55;}
1242
+ var ax=k*(tx-d.x)-c*d.vx,ay=k*(ty-d.y)-c*d.vy;
1243
+ if(!rest&&!pool){for(var j=0;j<N;j++){if(i===j)continue;var e=D[j],dx=d.x-e.x,dy=d.y-e.y,dd=Math.sqrt(dx*dx+dy*dy)||1,mn=d.cr+e.cr+78+(Math.abs(dx)<((d.lw||110)+(e.lw||110))/2?52:0);if(dd<mn){var f=0.06*(mn-dd)/dd;ax+=dx*f;ay+=dy*f;}}
1244
+ var dx0=d.x-core.x,dy0=d.y-core.y,d0=Math.sqrt(dx0*dx0+dy0*dy0)||1,m0=d.cr+core.cr+80;if(d0<m0){var f0=0.06*(m0-d0)/d0;ax+=dx0*f0;ay+=dy0*f0;}}
1245
+ if(still){d.x=tx;d.y=ty;d.vx=0;d.vy=0;}else{d.vx+=ax;d.vy+=ay;d.x+=d.vx;d.y+=d.vy;}
1246
+ if(!rest&&!pool){d.x=Math.max(d.cr+12,Math.min(S.w-d.cr-12,d.x));d.y=Math.max(140+d.cr,Math.min(S.h-150-d.cr-50,d.y));}
1247
+ d.cr+=(tr-d.cr)*(still?1:0.06);
1248
+ var b=LQ.btns[i];if(b&&b.offsetWidth)d.lw=b.offsetWidth;if(b&&b.style){var up=d.y<core.y-core.cr*0.3;b.style.transform='translate('+Math.round(d.x)+'px,'+Math.round(up?d.y-d.cr-10:d.y+d.cr+10)+'px) translateX(-50%)'+(up?' translateY(-100%)':'');var show=!rest&&!pool;b.style.opacity=show?'1':'0';b.style.pointerEvents=show?'auto':'none';b.tabIndex=show?0:-1;}}
1249
+ var cb=$('lqcore');if(cb&&cb.style){cb.style.transform='translate('+Math.round(cx)+'px,'+(rest?Math.round(core.y+core.cr+18):56)+'px) translateX(-50%)';var ct=rest?(LQ.coreText||'All handled'):talk||pool?'':(LQ.coreText||'');cb.textContent=ct;cb.style.opacity=ct?'1':'0';cb.style.pointerEvents=ct?'auto':'none';}}
1250
+ // The liquid: a metaball surface (Σ r²/d² = 1) shaded as chrome, on the GPU.
1251
+ var LQ_FS=['precision highp float;',
1252
+ 'uniform vec2 uRes;uniform float uT;uniform float uDpr;uniform vec3 uB[16];uniform vec3 uRip;uniform float uLight;uniform float uContrast;uniform vec4 uAccent;uniform float uExposure;',
1253
+ 'float field(vec2 p){float f=0.0;for(int i=0;i<16;i++){vec3 b=uB[i];float r=b.z*uDpr;vec2 d=p-b.xy*uRes;f+=r*r/(dot(d,d)+1.0);}vec2 q=p/uDpr;return f*(1.0+0.045*sin(q.x*0.011+uT*0.7)*cos(q.y*0.009-uT*0.55)+0.025*sin(q.x*0.031-q.y*0.027+uT*1.3));}',
1254
+ 'float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
1255
+ 'vec3 env(vec3 r){float y=r.y;vec3 c=mix(mix(vec3(0.02,0.022,0.03),vec3(0.36,0.38,0.43),uLight),vec3(0.82,0.84,0.90),smoothstep(-0.35,0.55,y));c+=vec3(1.0)*pow(max(0.0,1.0-abs(y-0.18+0.04*sin(uT*0.3))*5.0),4.0)*1.1;c+=vec3(0.75,0.82,1.0)*pow(max(0.0,r.x),10.0)*0.9;c+=vec3(1.0,0.93,0.86)*pow(max(0.0,-r.x),14.0)*0.5;c*=0.92+0.08*sin(r.x*4.0+r.y*3.0+uT*0.4);return c;}',
1256
+ 'void main(){vec2 p=vec2(gl_FragCoord.x,uRes.y-gl_FragCoord.y);float f=field(p);float e=2.0*uDpr;',
1257
+ 'float fx=field(p+vec2(e,0.0))-field(p-vec2(e,0.0));float fy=field(p+vec2(0.0,e))-field(p-vec2(0.0,e));',
1258
+ 'vec2 rc=uRip.xy*uRes;float rd=distance(p,rc)/uDpr;float age=uT-uRip.z;float ring=0.0;if(age>0.0&&age<4.0){float w=rd-age*300.0;ring=sin(w*0.07)*exp(-abs(w)*0.02)*(1.0-age/4.0);}',
1259
+ 'vec2 rdir=normalize(p-rc+0.001);fx+=ring*0.22*rdir.x;fy+=ring*0.22*rdir.y;',
1260
+ 'vec3 n=normalize(vec3(-fx*6.0,fy*6.0,1.0));vec3 r=reflect(vec3(0.0,0.0,-1.0),n);vec3 metal=env(r);',
1261
+ 'float fres=pow(1.0-n.z,2.0);metal=mix(metal,vec3(1.0),fres*0.25);metal*=0.86+0.14*smoothstep(1.0,4.0,f);',
1262
+ 'metal=mix(metal,metal*(0.55+0.9*uAccent.rgb),uAccent.a*0.3);',
1263
+ 'metal=mix(metal,metal*(1.0-0.5*fres)+0.04,uLight);metal*=uExposure;',
1264
+ 'vec2 uv=p/uRes;vec2 cell=floor(p/(2.0*uDpr));float s=step(0.9975,hash(cell))*(0.35+0.25*sin(uT*1.5+hash(cell+3.1)*6.28))*(1.0-uLight);',
1265
+ 'vec3 dark=vec3(0.012,0.014,0.02)+vec3(s);dark*=1.0-0.55*length(uv-0.5);dark+=vec3(0.05,0.055,0.07)*smoothstep(0.25,1.0,f);',
1266
+ 'vec3 pearl=vec3(0.93,0.94,0.955)-0.07*length(uv-0.5);pearl-=vec3(0.11)*smoothstep(0.3,1.0,f);',
1267
+ 'vec3 bg=mix(dark,pearl,uLight)*mix(1.0,uExposure,0.5);bg+=mix(vec3(0.55,0.6,0.7),vec3(-0.3),uLight)*abs(ring)*0.09;',
1268
+ 'float edge=mix(0.07,0.02,uContrast);float m=smoothstep(1.0-edge,1.0+edge,f);',
1269
+ 'float line=(smoothstep(0.86,0.97,f)-smoothstep(0.97,1.08,f))*uContrast;',
1270
+ 'vec3 col=mix(bg,metal,m);col=mix(col,mix(vec3(1.0),vec3(0.0),uLight),line);',
1271
+ 'gl_FragColor=vec4(col,1.0);}'].join('');
1272
+ function lqGL(){var c=$('lq');if(!c||!c.getContext)return null;var gl=null;try{gl=c.getContext('webgl',{antialias:false,alpha:false});}catch(e){}if(!gl)return null;
1273
+ function sh(t,src){var o=gl.createShader(t);gl.shaderSource(o,src);gl.compileShader(o);return o;}
1274
+ var pr=gl.createProgram();gl.attachShader(pr,sh(gl.VERTEX_SHADER,'attribute vec2 a;void main(){gl_Position=vec4(a,0.0,1.0);}'));gl.attachShader(pr,sh(gl.FRAGMENT_SHADER,LQ_FS));gl.linkProgram(pr);
1275
+ if(!gl.getProgramParameter(pr,gl.LINK_STATUS))return null;gl.useProgram(pr);
1276
+ var bf=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,bf);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),gl.STATIC_DRAW);
1277
+ var al=gl.getAttribLocation(pr,'a');gl.enableVertexAttribArray(al);gl.vertexAttribPointer(al,2,gl.FLOAT,false,0,0);
1278
+ var U={};['uRes','uT','uDpr','uB','uRip','uLight','uContrast','uAccent','uExposure'].forEach(function(n){U[n]=gl.getUniformLocation(pr,n);});
1279
+ return {gl:gl,c:c,U:U,out:new Float32Array(48),t0:(window.performance&&performance.now)?performance.now():Date.now()};}
1280
+ function lqDraw(G){var gl=G.gl,c=G.c,S=lqSize(),d=Math.min(window.devicePixelRatio||1,1.5),W=Math.max(1,Math.floor(S.w*d)),H=Math.max(1,Math.floor(S.h*d));
1281
+ if(c.width!==W||c.height!==H){c.width=W;c.height=H;gl.viewport(0,0,W,H);}
1282
+ var now=(window.performance&&performance.now)?performance.now():Date.now(),t=(now-G.t0)/1000,th=LQ.theme,still=th.still,o=G.out;LQ.t=t;
1283
+ for(var i=0;i<48;i++)o[i]=0;
1284
+ var put=function(k,x,y,r){if(k>15)return;var w=still?0:1;o[k*3]=x/S.w+Math.sin(t*1.3+k*1.7)*0.004*w;o[k*3+1]=y/S.h+Math.cos(t*1.1+k*2.3)*0.005*w;o[k*3+2]=Math.max(0,r)*(1+0.035*Math.sin(t*2.0+k)*w);};
1285
+ put(0,LQ.core.x,LQ.core.y,LQ.core.cr);var k=1,tend=[];
1286
+ LQ.drops.forEach(function(dr){put(k++,dr.x,dr.y,dr.cr);if(dr.tendril&&LQ.mode==='aware'&&!LQ.talking)tend.push(dr);});
1287
+ tend.forEach(function(dr){var dx=dr.x-LQ.core.x,dy=dr.y-LQ.core.y,len=Math.sqrt(dx*dx+dy*dy)||1,gap=len-LQ.core.cr-dr.cr;if(k<15&&gap>8){var at=LQ.core.cr+gap/2;put(k++,LQ.core.x+dx/len*at,LQ.core.y+dy/len*at,Math.min(20*S.s,gap*0.45));}});
1288
+ var P=LQ.pointer;if(P&&LQ.mode==='aware'&&!LQ.talking){var best=null,bd=1e9;[LQ.core].concat(LQ.drops).forEach(function(g){if(g.cr<18)return;var dd=Math.hypot(P[0]-g.x,P[1]-g.y)-g.cr;if(dd<bd){bd=dd;best=g;}});
1289
+ if(best&&bd<230){var dx=P[0]-best.x,dy=P[1]-best.y,dl=Math.hypot(dx,dy)||1,reach=Math.min(dl,best.cr+70);put(15,best.x+dx/dl*reach,best.y+dy/dl*reach,8+20*(1-Math.max(0,bd)/230));}}
1290
+ var U=G.U;gl.uniform2f(U.uRes,W,H);gl.uniform1f(U.uT,t);gl.uniform1f(U.uDpr,d);gl.uniform3fv(U.uB,o);gl.uniform3f(U.uRip,LQ.ripple[0],LQ.ripple[1],still?-10:LQ.ripple[2]);
1291
+ gl.uniform1f(U.uLight,th.light?1:0);gl.uniform1f(U.uContrast,th.contrast?1:0);var ac=th.accent;gl.uniform4f(U.uAccent,ac?ac[0]:1,ac?ac[1]:1,ac?ac[2]:1,ac?1:0);gl.uniform1f(U.uExposure,th.night?0.82:1);
1292
+ gl.drawArrays(gl.TRIANGLES,0,3);}
1293
+ function lqInit(){var bd=document.body;if(!bd||!bd.classList)return;lqTheme();bd.classList.add('lq-liquid');
1294
+ ['(prefers-color-scheme: light)','(prefers-contrast: more)','(forced-colors: active)','(prefers-reduced-transparency: reduce)','(prefers-reduced-motion: reduce)'].forEach(function(q){try{var mq=window.matchMedia&&window.matchMedia(q);if(mq&&mq.addEventListener)mq.addEventListener('change',lqTheme);else if(mq&&mq.addListener)mq.addListener(lqTheme);}catch(e){}});
1295
+ var L=$('liquid');if(L&&L.addEventListener){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();});}
1296
+ var f=$('lqform');if(f&&f.addEventListener)f.addEventListener('submit',function(ev){if(ev&&ev.preventDefault)ev.preventDefault();lqSay();});
1297
+ 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;}ia.value='';LQ.talk=[];lqTalkShow(false);lqTalkMode(false);if(ia.blur)ia.blur();}});}
1298
+ var co=$('lqcore');if(co&&co.addEventListener)co.addEventListener('click',function(){var i=$('lqask');if(i&&i.focus)i.focus();});
1299
+ var sk=$('lqsink');if(sk&&sk.addEventListener)sk.addEventListener('click',lqSink);
1300
+ var mn=document.querySelector('main');if(mn&&mn.addEventListener)mn.addEventListener('keydown',function(ev){if(ev&&ev.key==='Escape')lqSink();});
1301
+ lqLoad(true);
1302
+ // the only clocks: rest when left alone, fresh data now and then, the hour (night)
1303
+ setInterval(function(){if(LQ.mode==='aware'&&!LQ.talking&&Date.now()-LQ.lastAct>LQ_REST)LQ.mode='rest';if(LQ.mode!=='pool')lqLoad(false);var h=new Date().getHours(),n=h>=22||h<6;if(n!==LQ.theme.night)lqTheme();},30000);
1304
+ var G=lqGL();if(typeof window.requestAnimationFrame!=='function')return;var raf=function(f){return window.requestAnimationFrame(f);};
1305
+ function frame(){if(!document.hidden){LQ.frame++;if(LQ.mode!=='pool'||LQ.frame%3===0){lqStep();if(G)lqDraw(G);}}raf(frame);}
1306
+ raf(frame);}
1307
+
1308
+ initGraphEvents();syncP();refresh();loadMap();firstTab();lqInit();loadWhatsNew();loadAgentCfg();loadScanRoots();loadPhone();loadLinks();loadMind();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);loadPhoneLink();
1087
1309
  </script></body></html>`;