symbiot 0.40.3 → 0.40.5

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/README.md CHANGED
@@ -558,6 +558,29 @@ While the app is running, these commands use its hidden browser, so it stays
558
558
  open between them as it does in the app. Without the app, each command opens the
559
559
  browser and closes it when done.
560
560
 
561
+ **Watch a page for what's new.** On a mapped page (your Gmail inbox, GitHub's
562
+ notifications), **Watch** has Symbiot read it again every 15 minutes (or 5, 30,
563
+ 60) while the app runs, in the same hidden browser, and compare what's listed
564
+ there with what it has seen. It only reads: no screenshot is saved and nothing
565
+ on the page is pressed or typed. The first read just learns what's there, so it
566
+ never announces your whole inbox. After that, anything new (a new email, a new
567
+ reply in a thread) gets a desktop notification and goes under **Watching** in
568
+ Screens, newest first. A row is the same row whether its time reads `9:05 AM`,
569
+ `Oct 5` or `2 hours ago`, or you've read it since. On a page with no rows it
570
+ watches the links instead. If the site has signed you out, the watch says so,
571
+ and notifies you once. With
572
+ **Start at login** on (Settings), this keeps going on days you never open
573
+ Symbiot. What's new is saved in `~/.config/symbiot/watch.json` (yours only).
574
+ Your coding agent reads it too, so it can act on it with `symbiot screens`:
575
+
576
+ ```bash
577
+ symbiot watch add <screen id> --every 15 # watch a mapped page (or: symbiot watch add gmail)
578
+ symbiot watch new --hours 24 # what's new, newest first (JSON)
579
+ symbiot watch check # read them all now (JSON)
580
+ symbiot watch # what you watch, and what's new
581
+ symbiot watch remove <id>
582
+ ```
583
+
561
584
  **More than one display?** Symbiot reads how your displays are laid out
562
585
  (`cosmic-randr`, `wlr-randr`, `kscreen-doctor` or `xrandr` on Linux, PowerShell on
563
586
  Windows, AppKit on macOS) and a picker appears next to Capture: **each display**
package/agents.mjs CHANGED
@@ -37,12 +37,22 @@ function setHandoffCmd(cmd) {
37
37
  // `tool` (a command like "python3" → Bash(python3:*), or a full "Tool(spec)")
38
38
  // joins --allowedTools; `dir` joins --add-dir. Claude only (that's where these
39
39
  // flags live); for another agent we say to edit the command directly.
40
+ // The --allowedTools rule for what was typed: a rule already in Tool(spec) form
41
+ // is kept as it is, even pasted with its quotes as it reads in the command;
42
+ // only a bare command is wrapped, once (npm install -> Bash(npm install:*)).
43
+ // Stripping quotes first is the point: a quoted "Bash(npm install:*)" once
44
+ // looked bare and became Bash(Bashnpm install:*:*).
45
+ function grantRule(tool) {
46
+ let t = String(tool || "").trim().replace(/^["'`]+|["'`]+$/g, "").trim();
47
+ if (/^[A-Za-z]+\(.*\)$/.test(t)) return t;
48
+ t = t.replace(/[()"'`]/g, "").replace(/:\*$/, "").trim();
49
+ return t ? `Bash(${t}:*)` : "";
50
+ }
40
51
  function grantAgent({ tool, dir } = {}) {
41
52
  const cfg = loadConfig(); let cmd = (cfg.agentCmd || CLAUDE_CMD).trim();
42
53
  if (!/^\s*claude\b/.test(cmd)) return { error: "Grants apply to the Claude agent command. Pick a Claude preset first, or edit the command directly." };
43
54
  if (tool) {
44
- let t = String(tool).trim();
45
- if (t && !/^[A-Za-z]+\(/.test(t)) t = `Bash(${t.replace(/[()"]/g, "")}:*)`; // bare word -> Bash(word:*)
55
+ const t = grantRule(tool);
46
56
  if (t && !cmd.includes(`"${t}"`)) {
47
57
  if (/--allowedTools\b/.test(cmd)) cmd = cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
48
58
  else cmd += ` --allowedTools "${t}"`;
@@ -343,4 +353,4 @@ function agentsList() {
343
353
  });
344
354
  }
345
355
 
346
- export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };
356
+ export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, agentQuestions, answerQuestions, agentsList };
package/headless.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // site. On a site you trust (Settings), that goes ahead without asking. In the
8
8
  // app the browser stays open for a few minutes after each action, so the next
9
9
  // one carries on from the page as it is (type into a field, then press Send).
10
+ // readPage reads a page without saving a screen, for Watch (watch.mjs).
10
11
  //
11
12
  // It drives Chrome / Chromium / Edge / Brave over the DevTools protocol on a pipe
12
13
  // (--remote-debugging-pipe: commands in on fd 3, replies out on fd 4, each JSON
@@ -154,7 +155,7 @@ function closeBrowser() { return shut(live); }
154
155
  // Give fn the hidden browser's page (the open one, else a new one), and what it
155
156
  // shows (b.shown). After: kept open for KEEP ms, or closed; closed on an error,
156
157
  // so the next action starts afresh.
157
- async function withPage(fn) {
158
+ async function withPage(fn, keep = true) {
158
159
  if (live && live.c.closed()) await shut(live);
159
160
  let b = live;
160
161
  if (b) clearTimeout(b.timer); else b = live = await launch();
@@ -167,7 +168,7 @@ async function withPage(fn) {
167
168
  } catch (e) { await shut(b); throw e; }
168
169
  finally {
169
170
  clearTimeout(kill); b.busy = false;
170
- if (live === b && KEEP && !b.c.closed()) b.timer = setTimeout(() => oneAtATime(() => live === b && !b.busy ? shut(b) : null), KEEP);
171
+ if (live === b && KEEP && keep && !b.c.closed()) b.timer = setTimeout(() => oneAtATime(() => live === b && !b.busy ? shut(b) : null), KEEP);
171
172
  else await shut(b);
172
173
  }
173
174
  }
@@ -239,25 +240,36 @@ const COLLECT = `(() => {
239
240
  const top = document.elementFromPoint((x + x2) / 2, (y + y2) / 2);
240
241
  if (!top || !(top === e || e.contains(top) || top.contains(e))) continue;
241
242
  kinds.set(e, kind);
242
- const r = { label: labelOf(e).slice(0, 80) || kind, kind, x: Math.round(x), y: Math.round(y), w: Math.round(x2 - x), h: Math.round(y2 - y), selector: cssPath(e) };
243
+ const full = labelOf(e);
244
+ const r = { label: full.slice(0, 80) || kind, kind, x: Math.round(x), y: Math.round(y), w: Math.round(x2 - x), h: Math.round(y2 - y), selector: cssPath(e) };
245
+ if (full.length > 80) r.text = full.slice(0, 400); // all of a long one (an inbox row), for Watch
243
246
  if (e.href && /^https?:/.test(e.href)) r.href = String(e.href).slice(0, 500);
244
247
  out.push(r);
245
248
  }
246
249
  return { url: location.href, title: document.title, items: out };
247
250
  })()`;
248
251
 
249
- // The page as it is now, saved as a screen with its regions.
250
- async function snapshot(page, name) {
252
+ // What's on the page now: { url, title, items }.
253
+ async function collect(page) {
251
254
  const { result, exceptionDetails } = await page.send("Runtime.evaluate", { expression: COLLECT, returnByValue: true });
252
255
  if (exceptionDetails) throw new Error("Couldn't read the page: " + ((exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text));
253
- const info = (result && result.value) || { url: "", title: "", items: [] };
256
+ return (result && result.value) || { url: "", title: "", items: [] };
257
+ }
258
+ // A sign-in page instead of the site.
259
+ function signInPage(url) {
260
+ const host = hostOf(url);
261
+ return /(^|\.)(accounts\.google|login\.(microsoftonline|live)|signin\.aws|auth0|okta)\./i.test(host + ".") || /\/(log-?in|sign-?in|auth|sso)\b/i.test(String(url || ""));
262
+ }
263
+
264
+ // The page as it is now, saved as a screen with its regions.
265
+ async function snapshot(page, name) {
266
+ const info = await collect(page);
254
267
  const { data } = await page.send("Page.captureScreenshot", { format: "png" });
255
- let host = ""; try { host = new URL(info.url).hostname.replace(/^www\./, ""); } catch {}
268
+ const host = hostOf(info.url);
256
269
  const s = addPageScreen(String(name || "").trim() || info.title || host || "Page", Buffer.from(data, "base64"), info, info.items);
257
270
  if (s.error) return s;
258
271
  // a sign-in page instead of the site: say how to get past it once
259
- const login = /(^|\.)(accounts\.google|login\.(microsoftonline|live)|signin\.aws|auth0|okta)\./i.test(host + ".") || /\/(log-?in|sign-?in|auth|sso)\b/i.test(info.url);
260
- return login ? { ...s, note: `This looks like a sign-in page (${host}). Click Sign in, sign in once in the window that opens, close it, then map again: the hidden browser keeps that sign-in.` } : s;
272
+ return signInPage(info.url) ? { ...s, note: `This looks like a sign-in page (${host}). Click Sign in, sign in once in the window that opens, close it, then map again: the hidden browser keeps that sign-in.` } : s;
261
273
  }
262
274
 
263
275
  // Map a site: open it in the hidden browser and save what's on it as a screen.
@@ -267,6 +279,21 @@ function mapPage(input, name) {
267
279
  return oneAtATime(() => withPage(async (page) => { await open(page, url); return snapshot(page, name); })).catch((e) => ({ error: String((e && e.message) || e) }));
268
280
  }
269
281
 
282
+ // Read a site as it is now, without saving a screen: { url, title, items, login }.
283
+ // For Watch (watch.mjs), which only looks: nothing is pressed or typed. While the
284
+ // browser is open for you or an agent (type, then press), it leaves it alone
285
+ // ({ busy }) rather than take its page somewhere else; and it doesn't keep the
286
+ // browser open after.
287
+ function readPage(input) {
288
+ const url = siteUrl(input);
289
+ if (!url) return Promise.resolve({ error: "Give a site to read: a name (gmail), a host or a web address." });
290
+ return oneAtATime(() => browserOpen() ? { busy: true } : withPage(async (page) => {
291
+ await open(page, url);
292
+ const info = await collect(page);
293
+ return { ...info, login: signInPage(info.url) };
294
+ }, false)).catch((e) => ({ error: String((e && e.message) || e) }));
295
+ }
296
+
270
297
  // Trusted sites (Settings → Screens): on a page from one of these, press and type
271
298
  // go ahead without asking, for you and for agents. Anywhere else each one asks.
272
299
  // A host covers its subdomains (google.com covers mail.google.com). Only the app's
@@ -359,4 +386,4 @@ async function signIn(input) {
359
386
  return { ok: true, url };
360
387
  }
361
388
 
362
- export { siteUrl, browserArgs, mapPage, pressRegion, typeRegion, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
389
+ export { siteUrl, browserArgs, mapPage, readPage, pressRegion, typeRegion, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
package/index.mjs CHANGED
@@ -35,6 +35,7 @@ import { gitDefaultBranch, loadDeploys, driftRepo } from "./drift.mjs";
35
35
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
36
36
  import { mapPage, pressRegion, typeRegion, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
37
37
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
38
+ import { watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, checkWatch, startWatches } from "./watch.mjs";
38
39
 
39
40
  const HERE = fileURLToPath(new URL(".", import.meta.url));
40
41
  let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch {}
@@ -484,6 +485,7 @@ function buildTasksMd(name, ctx, list) {
484
485
  L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
485
486
  L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
486
487
  `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
488
+ "- Start every option with who acts, because the user reads it as something they'd be agreeing to do: `👤 You:` when they have to do something (a setting, a click, a command), `🤖 Agent:` when picking it is enough and the next run does the work. If it takes both, put their step first: `👤 You: allow npm install in Settings. 🤖 Agent: the next run adds ESLint`.",
487
489
  "- 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.",
488
490
  "- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
489
491
  "- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
@@ -1796,6 +1798,13 @@ async function cmdApp() {
1796
1798
  if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
1797
1799
  if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
1798
1800
  if (u.pathname === "/api/screens/signin" && req.method === "POST") { const b = await readBody(req); return json(res, await signIn(b.site)); }
1801
+ // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
1802
+ if (u.pathname === "/api/watch") return json(res, watchState());
1803
+ if (u.pathname === "/api/watch/add" && req.method === "POST") { const b = await readBody(req); return json(res, addWatch({ screen: b.screen ? String(b.screen) : "", site: b.site, every: b.every })); }
1804
+ if (u.pathname === "/api/watch/every" && req.method === "POST") { const b = await readBody(req); return json(res, setEvery(String(b.id || ""), b.every)); }
1805
+ if (u.pathname === "/api/watch/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeWatch(String(b.id || ""))); }
1806
+ if (u.pathname === "/api/watch/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkWatch(String(b.id || ""))); }
1807
+ if (u.pathname === "/api/watch/clear" && req.method === "POST") return json(res, clearNews());
1799
1808
  // What symbiot-desktop added (desktop.mjs): the weekly write-up, start at login.
1800
1809
  if (u.pathname === "/api/desktop") return json(res, { weekly: weeklyState(), autostart: autostartState() });
1801
1810
  if (u.pathname === "/api/desktop/weekly" && req.method === "POST") { const b = await readBody(req); return json(res, setWeekly(b)); }
@@ -1842,6 +1851,7 @@ async function cmdApp() {
1842
1851
  server.listen(PORT, "127.0.0.1");
1843
1852
  checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
1844
1853
  startWeekly(produce); // the weekly write-up + notification, when switched on in Settings
1854
+ startWatches(); // pages you watch (Screens → Watch), read every few minutes
1845
1855
  }
1846
1856
 
1847
1857
  // `symbiot mail [--on|--off] [--add <path>]`: what mail it can read, and a preview.
@@ -1926,7 +1936,43 @@ async function cmdScreens() {
1926
1936
  if (sub !== "help") process.exitCode = 1;
1927
1937
  }
1928
1938
 
1929
- const HELP = `${c.b("symbiot")} — your week, written from your real work.
1939
+ // ---- `symbiot watch`: pages Symbiot keeps track of, and what's new on them ----
1940
+ // new, add, remove and check print JSON, for you or an agent.
1941
+ async function cmdWatch() {
1942
+ const [sub = "list", a1] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !["--every", "--hours"].includes(all[i - 1]));
1943
+ const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
1944
+ const hours = Number(flag("hours", 24)) || 24;
1945
+ if (sub === "list") {
1946
+ const st = watchState();
1947
+ if (!st.watches.length) console.log(c.d("Not watching anything yet. Map a page (symbiot screens map gmail), then symbiot watch add <screen id>, or click Watch on it in the app."));
1948
+ for (const w of st.watches) console.log(`${w.id} ${w.name} ${c.d(`every ${w.every} min · ${w.checked ? "last read " + new Date(w.checked).toLocaleString() : "not read yet"}${w.error ? " · " + w.error : ""}`)}`);
1949
+ const news = newsSince(hours);
1950
+ if (st.watches.length) console.log("\n" + c.b(`${news.length} new in the last ${hours} hours`) + (news.length ? "" : c.d(" (the app reads each page every few minutes while it runs)")));
1951
+ for (const n of news.slice(0, 20)) console.log(` ${c.d(new Date(n.ts).toLocaleString())} ${n.text.slice(0, 110)} ${c.d(n.name.slice(0, 30))}`);
1952
+ return;
1953
+ }
1954
+ if (sub === "new") return out(newsSince(hours));
1955
+ if (sub === "add") { const screen = loadScreens().some((s) => s.id === a1) ? a1 : ""; return out(addWatch({ screen, site: screen ? "" : a1, every: flag("every", 15) })); }
1956
+ if (sub === "remove") return out(removeWatch(String(a1 || "")));
1957
+ if (sub === "check") {
1958
+ // through the app when it runs: its hidden browser may be open, and one profile takes one browser
1959
+ const ids = a1 ? [a1] : watchState().watches.map((w) => w.id), done = [];
1960
+ for (const id of ids) done.push((await viaApp("/api/watch/check", { id })) || await checkWatch(id));
1961
+ return out(a1 ? done[0] : done);
1962
+ }
1963
+ console.log(`${c.b("symbiot watch")} ${c.d("— experimental")}
1964
+ symbiot watch the pages you watch, and what's new
1965
+ symbiot watch add <screen id | site> [--every 5|15|30|60]
1966
+ watch a mapped page (or a site) for new rows
1967
+ symbiot watch new [--hours 24] what's new, newest first (JSON)
1968
+ symbiot watch check [id] read them now (JSON)
1969
+ symbiot watch remove <id> stop watching it
1970
+ While the app runs it reads each page every few minutes in its hidden browser
1971
+ (only reads: nothing is pressed or typed), and notifies you of what's new.`);
1972
+ if (sub !== "help") process.exitCode = 1;
1973
+ }
1974
+
1975
+ const HELP =`${c.b("symbiot")} — your week, written from your real work.
1930
1976
 
1931
1977
  ${c.b("Usage")}
1932
1978
  symbiot ${c.d("(or)")} symbiot week write up your last ${SINCE_WEEK} days
@@ -1947,6 +1993,8 @@ ${c.b("Experimental")}
1947
1993
  symbiot setup-local [--model X] install/run a free local model (Ollama)
1948
1994
  symbiot screens map <site> map a web page's buttons in a hidden browser
1949
1995
  (symbiot screens help for more)
1996
+ symbiot watch add <screen id> keep track of a mapped page: what's new on it
1997
+ (symbiot watch help for more)
1950
1998
 
1951
1999
  ${c.b("Options")}
1952
2000
  --dir <path> where your repos are (default: ${homedir()})
@@ -1978,6 +2026,7 @@ async function main() {
1978
2026
  if (cmd === "push") return cmdPush();
1979
2027
  if (cmd === "mail" || cmd === "email") return cmdMail();
1980
2028
  if (cmd === "screens" || cmd === "screen") return cmdScreens();
2029
+ if (cmd === "watch") return cmdWatch();
1981
2030
  if (cmd === "week") return cmdRun("week");
1982
2031
  if (cmd === "standup") return cmdRun("standup");
1983
2032
  if (cmd === "todo") return cmdRun("todo");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.40.3",
3
+ "version": "0.40.5",
4
4
  "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "mail.mjs",
16
16
  "screens.mjs",
17
17
  "headless.mjs",
18
+ "watch.mjs",
18
19
  "desktop.mjs",
19
20
  "README.md",
20
21
  "LICENSE"
package/ui.mjs CHANGED
@@ -112,6 +112,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
112
112
  .aq .qc{color:var(--faint);font-size:12.5px;margin-top:3px;line-height:1.5}
113
113
  .aq label.opt{display:flex;gap:8px;align-items:flex-start;margin:6px 0 0;font-size:13px;color:var(--text);cursor:pointer}
114
114
  .aq .opt input{width:auto;margin-top:3px;flex:none}
115
+ .who{display:inline-block;font-size:11px;font-weight:700;border-radius:999px;padding:0 7px;margin-right:2px;white-space:nowrap}
116
+ .who.you{color:var(--amber);border:1px solid var(--amber)}.who.agent{color:var(--green);border:1px solid var(--green)}
115
117
  .aq .qrel{font-size:12px;margin-top:4px;color:var(--green)}.aq .qrel.wait{color:var(--amber)}
116
118
  .aq label.opt.off{color:var(--faint);cursor:default}.aq .opt.off i{font-size:12px}
117
119
  .aq .qother{margin-top:7px;padding:7px 10px;font-size:13px}
@@ -169,10 +171,11 @@ label.check input{width:auto}
169
171
  <div id="review" class="review hidden"></div>
170
172
  <div style="margin-top:22px;border-top:1px solid var(--line);padding-top:4px">
171
173
  <div class="tgroup">Screens <span class="tcount">experimental &middot; blueprints for screen automation</span></div>
172
- <div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Or let Symbiot do it all for a web page: <b>Map page</b> opens it in a hidden browser, takes its screenshot and marks every button, link and field by itself, and <b>Press</b> on one of them follows it there and maps the next page; <b>Type</b> on a field types into it there. Only a mapped page is typed into: nothing types on your real screen.</div>
174
+ <div class="note muted" style="margin-top:2px">Capture a screen, then drag a box over each part that matters (a button, a field, a menu) and name it. Each region keeps its pixel coordinates and its centre, ready for automation to aim at. With more than one display, pick which one to capture, or one screen per display. <b>Click here</b> on a region clicks its centre on your real screen, after you confirm. Or let Symbiot do it all for a web page: <b>Map page</b> opens it in a hidden browser, takes its screenshot and marks every button, link and field by itself, and <b>Press</b> on one of them follows it there and maps the next page; <b>Type</b> on a field types into it there. Only a mapped page is typed into: nothing types on your real screen. <b>Watch</b> on a mapped page (your inbox, say) has Symbiot read it again every few minutes while it runs and tell you what's new there.</div>
173
175
  <div class="row" style="margin-top:10px"><input id="pagesite" placeholder="a web page to map by itself: gmail, github.com/pulls or a web address" style="flex:1"><button class="act" id="mappage">Map page</button><button class="ghost" id="pagesignin" title="open this site in Symbiot's own browser as a window, to sign in once; close it when you're done">Sign in</button></div>
174
176
  <div class="row" style="margin-top:10px"><input id="screenname" placeholder="name the screen, e.g. GitHub PR page" style="flex:1"><select id="screenwhich" class="hidden" title="which display to capture" style="flex:0 0 auto;width:auto"></select><select id="screendelay" title="wait first, so you can bring the right window to the front" style="flex:0 0 auto;width:auto"><option value="0">now</option><option value="3">in 3s</option><option value="5">in 5s</option><option value="10">in 10s</option></select><button class="ghost" id="capture">Capture screen</button><button class="ghost" id="screenload" title="use a PNG screenshot you already have">Load image</button><input type="file" id="screenfile" accept="image/png" class="hidden"></div>
175
177
  <div id="screenmsg"></div>
178
+ <div id="watchbox"></div>
176
179
  <div id="screenlist" class="taskfilter"></div>
177
180
  <div id="screenview"></div>
178
181
  </div>
@@ -316,6 +319,9 @@ api('/api/connect',cfg).then(function(r){$('saveMsg').textContent=r.message||(r.
316
319
  $('quit').addEventListener('click',function(){api('/api/quit');document.body.innerHTML='<div style=\\'padding:40px;color:#7E9690;font-family:sans-serif\\'>Symbiot stopped. You can close this window.</div>';});
317
320
  var GRAPH=null,sel=null,view={k:1,x:0,y:0},GW=960,GH=620;
318
321
  function esc(s){return String(s).replace(/[&<>]/g,function(ch){return ch==='&'?'&amp;':ch==='<'?'&lt;':'&gt;';});}
322
+ // An agent's option, with its who-acts markers as badges: 👤 You (you do this
323
+ // step) and 🤖 Agent (picking it is enough; the next run does it). Escaped first.
324
+ function whoHtml(o){return esc(o).replace(/👤[ ]*(You:|You(?=[ ]))?/g,"<span class='who you'>&#128100; You</span> ").replace(/🤖[ ]*(Agent:|Agent(?=[ ]))?/g,"<span class='who agent'>&#129302; Agent</span> ");}
319
325
  function layout(nodes,edges){var idx={};nodes.forEach(function(n){n.x=GW/2+(Math.random()-0.5)*GW*0.8;n.y=GH/2+(Math.random()-0.5)*GH*0.8;n.vx=0;n.vy=0;idx[n.id]=n;});
320
326
  for(var it=0;it<340;it++){for(var i=0;i<nodes.length;i++)for(var j=i+1;j<nodes.length;j++){var a=nodes[i],b=nodes[j];var dx=a.x-b.x,dy=a.y-b.y;var d2=dx*dx+dy*dy+0.01;var d=Math.sqrt(d2);var f=4600/d2;a.vx+=f*dx/d;a.vy+=f*dy/d;b.vx-=f*dx/d;b.vy-=f*dy/d;}
321
327
  edges.forEach(function(e){var a=idx[e.source],b=idx[e.target];if(!a||!b)return;var dx=b.x-a.x,dy=b.y-a.y;var d=Math.sqrt(dx*dx+dy*dy)+0.01;var f=(d-115)*0.03;a.vx+=f*dx/d;a.vy+=f*dy/d;b.vx-=f*dx/d;b.vy-=f*dy/d;});
@@ -484,7 +490,7 @@ function askHtml(a){var k=a.ask;if(!k)return '';var qs=k.questions||[],ss=k.sugg
484
490
  var h="<div class='aq' data-id='"+esc(a.id)+"'>";
485
491
  if(qs.length){h+="<h4>&#10067; "+qs.length+" question"+(qs.length>1?"s":"")+" for you</h4>";
486
492
  qs.forEach(function(q,i){var rl=q.release;h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"")+(rl?relHtml(rl):"");
487
- (q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+esc(o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
493
+ (q.options||[]).forEach(function(o,j){var off=rl&&rl.waiting&&DONEOPT.test(o);h+="<label class='opt"+(off?" off":"")+"'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'"+(off?" disabled":"")+"><span>"+whoHtml(o)+(off?" <i>(once "+esc(rl.name+" "+rl.needs)+" is installed)</i>":"")+"</span></label>";});
488
494
  h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
489
495
  h+="<div class='row'><button class='act qsend' title='save the answers and hand the repo back to your agent'>Send answers &amp; continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
490
496
  if(ss.length){h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button>")+"</div>";});}
@@ -658,7 +664,7 @@ box.querySelectorAll('.fchip').forEach(function(b){b.addEventListener('click',fu
658
664
  function scrPct(v,of){return (v/of*100).toFixed(3)+'%';}
659
665
  function scrBox(r,s,cls){return "<div class='scrbox"+(cls?" "+cls:"")+"' style='left:"+scrPct(r.x,s.w)+";top:"+scrPct(r.y,s.h)+";width:"+scrPct(r.w,s.w)+";height:"+scrPct(r.h,s.h)+"'>"+(r.label?"<span>"+esc(r.label)+"</span>":"")+"</div>";}
660
666
  function renderScreen(){var v=$('screenview'),s=SCREEN;if(!s){v.innerHTML='';return;}
661
- var h="<div class='row' style='margin-top:10px'><input id='scrname' title='rename this screen' style='flex:1'><span class='muted'>"+(s.monitor?esc(s.monitor.name)+(s.monitor.where?" ("+esc(s.monitor.where)+")":"")+" &middot; ":"")+s.w+" &times; "+s.h+" px</span>"+(s.page?"<button class='ghost' id='scrremap' title='open this page in the hidden browser again and map it as it is now'>Map again</button>":"")+(!s.monitor&&!s.page&&MONITORS.length>1?"<button class='ghost' id='scrsplit' title='cut this screenshot into one screen per display, regions included (this one stays)'>Split by display</button>":"")+"<button class='ghost' id='scrcopy' title='copy the regions and their coordinates as JSON'>Copy blueprint</button><button class='ghost' id='scrdel'>Delete</button></div>";
667
+ var h="<div class='row' style='margin-top:10px'><input id='scrname' title='rename this screen' style='flex:1'><span class='muted'>"+(s.monitor?esc(s.monitor.name)+(s.monitor.where?" ("+esc(s.monitor.where)+")":"")+" &middot; ":"")+s.w+" &times; "+s.h+" px</span>"+(s.page?"<button class='ghost' id='scrremap' title='open this page in the hidden browser again and map it as it is now'>Map again</button>"+(watchOf(s)?"<button class='ghost' id='scrwatch' title='Symbiot reads this page every few minutes and tells you what&#39;s new: click to stop'>Watching &#10003;</button>":"<button class='ghost' id='scrwatch' title='read this page again every few minutes while Symbiot runs, and tell me what&#39;s new on it'>Watch</button>"):"")+(!s.monitor&&!s.page&&MONITORS.length>1?"<button class='ghost' id='scrsplit' title='cut this screenshot into one screen per display, regions included (this one stays)'>Split by display</button>":"")+"<button class='ghost' id='scrcopy' title='copy the regions and their coordinates as JSON'>Copy blueprint</button><button class='ghost' id='scrdel'>Delete</button></div>";
662
668
  if(s.page)h+="<div class='muted' style='font-size:12px;margin-top:6px;overflow-wrap:anywhere'>Mapped in the hidden browser: "+esc(s.page.url)+"</div>";
663
669
  h+="<div class='scrwrap' id='scrwrap'><img src='/api/screens/image?id="+encodeURIComponent(s.id)+"&t="+encodeURIComponent(T)+"' alt='' draggable='false'>"+(s.regions||[]).map(function(r){return scrBox(r,s,'');}).join('')+(SPEND?scrBox(SPEND,s,'draw'):"")+"<div class='scrbox draw hidden' id='scrdraw'></div></div>";
664
670
  h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region &middot; coordinates are screenshot pixels</div>";
@@ -693,7 +699,32 @@ $('scrregions').querySelectorAll('.scrclick').forEach(function(b){b.addEventList
693
699
  $('scrregions').querySelectorAll('.scrpress').forEach(function(b){b.addEventListener('click',function(){pressUI(s,+b.closest('.task').getAttribute('data-i'),b);});});
694
700
  $('scrregions').querySelectorAll('.scrtype').forEach(function(b){b.addEventListener('click',function(){typeUI(s,+b.closest('.task').getAttribute('data-i'),b);});});
695
701
  var te=$('screnter');if(te)te.addEventListener('change',function(){STYPEENTER=!!te.checked;});
696
- var rm=$('scrremap');if(rm)rm.addEventListener('click',function(){mapUI(s.page.url,rm);});}
702
+ var rm=$('scrremap');if(rm)rm.addEventListener('click',function(){mapUI(s.page.url,rm);});
703
+ var wb=$('scrwatch');if(wb)wb.addEventListener('click',function(){watchUI(s,wb);});}
704
+ // Watch (watch.mjs): a mapped page read again every few minutes while Symbiot
705
+ // runs, and what's new on it. Only reads: nothing there is pressed or typed.
706
+ var WATCH={watches:[],news:[],every:[5,15,30,60]};
707
+ function watchOf(s){var w=null;(WATCH.watches||[]).forEach(function(x){if(s&&s.page&&x.url===s.page.url)w=x;});return w;}
708
+ function agoTxt(t){var m=Math.max(0,(Date.now()-t)/60000);return m<1.5?'just now':m<90?Math.round(m)+' min ago':m<2160?Math.round(m/60)+' h ago':Math.round(m/1440)+' days ago';}
709
+ function loadWatchUI(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();});}
710
+ function watchUI(s,b){var w=watchOf(s);
711
+ if(w){if(typeof confirm==='function'&&!confirm('Stop watching '+w.name+'? What it found goes too.'))return;api('/api/watch/remove',{id:w.id}).then(function(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();renderScreen();});});return;}
712
+ b.disabled=true;api('/api/watch/add',{screen:s.id,every:15}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
713
+ $('screenmsg').innerHTML="<div class='note ok'>&#10003; Watching "+esc(x.name)+". Symbiot reads it every "+x.every+" minutes while it runs (first within a minute, to learn what's there) and notifies you of anything new. It's listed under Watching, just below.</div>";
714
+ api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();renderScreen();});});}
715
+ function renderWatch(){var box=$('watchbox'),ws=WATCH.watches||[],ns=WATCH.news||[];if(!ws.length){box.innerHTML='';return;}
716
+ var h="<div class='tgroup'>Watching <span class='tcount'>read again while Symbiot runs &middot; only reads, never presses</span></div>";
717
+ h+=ws.map(function(w,i){return "<div class='task' data-i='"+i+"'><span class='t'><b>"+esc(w.name)+"</b> <span class='muted' style='font-size:12px'>"+(w.checked?"read "+agoTxt(w.checked):w.last?"tried "+agoTxt(w.last):"first read within a minute")+"</span>"+(w.error?"<br><span class='err' style='font-size:12px'>"+esc(w.error)+"</span>":"")+"</span><select class='wevery' title='how often to read it' style='flex:0 0 auto;width:auto'>"+(WATCH.every||[]).map(function(m){return "<option value='"+m+"'"+(m===w.every?" selected":"")+">every "+m+" min</option>";}).join('')+"</select><button class='ghost wcheck' title='read it now'>Check now</button><button class='rm wrm' title='stop watching it'>&times;</button></div>";}).join('');
718
+ h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New &middot; "+ns.length+"</span><button class='ghost' id='wclear' title='clear this list (Symbiot still remembers what it has seen)'>Clear</button></div>"+ns.slice(0,20).map(function(n,i){return "<div class='task' data-i='"+i+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:12px'>"+esc(n.name)+" &middot; "+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost wopen' title='open it in your browser'>Open</button>":"")+"</div>";}).join('')
719
+ :"<div class='muted' style='font-size:12px;margin-top:6px'>Nothing new yet. New rows show up here, and as a notification. Your agent reads them with <b>symbiot watch new</b>.</div>";
720
+ box.innerHTML=h;
721
+ box.querySelectorAll('.wevery').forEach(function(sel){sel.addEventListener('change',function(){var w=ws[+sel.closest('.task').getAttribute('data-i')];api('/api/watch/every',{id:w.id,every:+sel.value}).then(loadWatchUI);});});
722
+ box.querySelectorAll('.wrm').forEach(function(b){b.addEventListener('click',function(){var w=ws[+b.closest('.task').getAttribute('data-i')];if(typeof confirm==='function'&&!confirm('Stop watching '+w.name+'? What it found goes too.'))return;api('/api/watch/remove',{id:w.id}).then(function(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();renderScreen();});});});});
723
+ box.querySelectorAll('.wcheck').forEach(function(b){b.addEventListener('click',function(){var w=ws[+b.closest('.task').getAttribute('data-i')];b.disabled=true;b.textContent='Reading…';
724
+ api('/api/watch/check',{id:w.id}).then(function(x){b.disabled=false;b.textContent='Check now';if(!x||x.error){screenErr((x&&x.error)||'failed');loadWatchUI();return;}var m=x.busy?"The hidden browser is busy with a map, press or type. It reads "+esc(w.name)+" once that's done.":x.learned!=null?"Read "+esc(x.name)+": learned the "+x.learned+" things listed there now. From here on, anything new is noted.":x["new"]&&x["new"].length?"&#10003; "+x["new"].length+" new on "+esc(x.name)+".":"Read "+esc(x.name)+": nothing new.";
725
+ $('screenmsg').innerHTML="<div class='note "+(x["new"]&&x["new"].length?"ok":"muted")+"'>"+m+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;b.textContent='Check now';screenErr(String((e&&e.message)||e));});});});
726
+ box.querySelectorAll('.wopen').forEach(function(b){b.addEventListener('click',function(){var n=ns[+b.closest('.task').getAttribute('data-i')];if(n&&/^https?:/.test(n.href))window.open(n.href,'_blank','noopener');});});
727
+ var wc=$('wclear');if(wc)wc.addEventListener('click',function(){api('/api/watch/clear',{}).then(loadWatchUI);});}
697
728
  // A web page, mapped by itself in the hidden browser (headless.mjs): no capture,
698
729
  // no dragging. It takes a few seconds, so the button says so meanwhile.
699
730
  function mapUI(site,b){site=(site||'').trim();if(!site){screenErr('Type a site to map first: gmail, github.com/pulls or a web address.');return;}b.disabled=true;
@@ -835,5 +866,5 @@ function appBar(p){var b=$('appbar');if(!b||!p.termux||window.SymbiotAndroid||ty
835
866
  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',{});}
836
867
  setInterval(heartbeat,4000);heartbeat(true);
837
868
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
838
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();
869
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);
839
870
  </script></body></html>`;
package/watch.mjs ADDED
@@ -0,0 +1,160 @@
1
+ // symbiot — Watch: keep track of a mapped web page (your Gmail inbox, GitHub's
2
+ // notifications) while the app runs. Every few minutes Symbiot reads the page
3
+ // again in its hidden browser (headless.mjs readPage: no screenshot, nothing
4
+ // pressed or typed) and compares what's listed there with what it has seen
5
+ // before. What's new is kept, newest first, and you get a notification. The
6
+ // first read only learns what's there, so it never announces your whole inbox.
7
+ // An agent reads what's new with `symbiot watch new`, and can act on it with
8
+ // `symbiot screens press` / `type`.
9
+ //
10
+ // Stored in ~/.config/symbiot/watch.json, readable by you only:
11
+ // { watches: [{ id, name, url, every (minutes), added, last, checked?, error?, seen: [key…] }],
12
+ // news: [{ id, watch, name, ts, text, href? }] }
13
+ // `last` is when it was last read, `checked` when a read last worked; `seen`
14
+ // holds what's been listed, as itemKey()s.
15
+ import { join } from "node:path";
16
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
17
+ import { randomBytes } from "node:crypto";
18
+ import { CONFIG_DIR } from "./core.mjs";
19
+ import { readPage, siteUrl } from "./headless.mjs";
20
+ import { loadScreens } from "./screens.mjs";
21
+ import { desktopNotify } from "./desktop.mjs";
22
+
23
+ const WATCH_FILE = join(CONFIG_DIR, "watch.json");
24
+ const EVERY = [5, 15, 30, 60]; // minutes between reads
25
+ const MAX_WATCHES = 12, MAX_SEEN = 1000, MAX_NEWS = 200, MAX_PER_READ = 25;
26
+
27
+ function loadWatch() {
28
+ try { const d = JSON.parse(readFileSync(WATCH_FILE, "utf8")); return { watches: Array.isArray(d.watches) ? d.watches : [], news: Array.isArray(d.news) ? d.news : [] }; }
29
+ catch { return { watches: [], news: [] }; }
30
+ }
31
+ // What's new can be your mail's senders and subjects, so it's yours only (0600).
32
+ function saveWatch(d) {
33
+ try { mkdirSync(CONFIG_DIR, { recursive: true }); writeFileSync(WATCH_FILE, JSON.stringify(d, null, 2), { mode: 0o600 }); try { chmodSync(WATCH_FILE, 0o600); } catch {} return true; }
34
+ catch { return false; }
35
+ }
36
+ const hostOf = (url) => { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return ""; } };
37
+ const everyOf = (v, def) => (EVERY.includes(Number(v)) ? Number(v) : def);
38
+
39
+ // ---- what's on the page, and what's new ----------------------------------------
40
+ // What a page lists: its rows (an inbox, a table of notifications), else its
41
+ // links. Each { text, href? }, the whole text of a long row (headless.mjs keeps it).
42
+ const tidy = (s) => String(s || "").replace(/^\s*unread\s*,?\s*/i, "").replace(/\s+/g, " ").trim();
43
+ function itemsOf(page) {
44
+ const all = (page && page.items) || [], rows = all.filter((r) => r.kind === "row");
45
+ return (rows.length ? rows : all.filter((r) => r.kind === "link"))
46
+ .filter((r) => r.label && r.label !== r.kind)
47
+ .map((r) => ({ text: tidy(r.text || r.label), ...(r.href ? { href: r.href } : {}) }))
48
+ .filter((x) => x.text.length > 2);
49
+ }
50
+ // The same item from one read to the next, though its time reads differently
51
+ // ("9:05 AM" today, "Oct 5" tomorrow, "2 hours ago") or it's been read or starred.
52
+ const MONTH = "(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|june?|july?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\\.?";
53
+ const WHEN = [
54
+ /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s*[ap]\.?m\b\.?)?/gi, // 9:05 AM, 08:24
55
+ new RegExp(`\\b${MONTH}\\s+\\d{1,2}\\b(?:,?\\s+\\d{4}\\b)?`, "gi"), // Oct 5, Oct 5, 2026
56
+ new RegExp(`\\b\\d{1,2}\\s+${MONTH}(?=\\W|$)(?:\\s+\\d{4}\\b)?`, "gi"), // 5 Oct
57
+ /\b\d{1,4}[/-]\d{1,2}[/-]\d{1,4}\b/g, // 10/5/26, 2026-10-05
58
+ /\b(?:\d+|an?)\s+(?:seconds?|secs?|minutes?|mins?|hours?|hrs?|days?|weeks?|months?|years?)\s+ago\b/gi,
59
+ /\b(?:just now|yesterday|today)\b/gi,
60
+ /\b(?:unread|not starred|starred)\b/gi,
61
+ ];
62
+ function itemKey(text) {
63
+ let s = String(text || "").toLowerCase();
64
+ for (const re of WHEN) s = s.replace(re, " ");
65
+ return s.replace(/[\s,;·•|]+/g, " ").trim().slice(0, 240);
66
+ }
67
+ // The items not seen before, top first, and the keys of everything listed now.
68
+ function newItems(seen, items) {
69
+ const known = new Set(seen), keys = new Set(), fresh = [];
70
+ for (const it of items) {
71
+ const k = itemKey(it.text); if (!k || keys.has(k)) continue;
72
+ keys.add(k); if (!known.has(k)) fresh.push(it);
73
+ }
74
+ return { fresh, keys: [...keys] };
75
+ }
76
+ // Remember what's listed now as the most recent, and forget the oldest.
77
+ function remember(seen, keys) { const now = new Set(keys); return [...(seen || []).filter((k) => !now.has(k)), ...keys].slice(-MAX_SEEN); }
78
+
79
+ // ---- watches -------------------------------------------------------------------
80
+ const view = (w) => ({ id: w.id, name: w.name, url: w.url, every: w.every, added: w.added, last: w.last || 0, ...(w.checked ? { checked: w.checked } : {}), ...(w.error ? { error: w.error } : {}), known: (w.seen || []).length });
81
+ function watchState() { const d = loadWatch(); return { watches: d.watches.map(view), news: d.news.slice(0, 50), every: EVERY }; }
82
+
83
+ // Watch a mapped page (`screen`: its id) or a site (`site`). Watching the same
84
+ // address again just changes how often.
85
+ function addWatch({ screen, site, every, name } = {}) {
86
+ const s = screen ? loadScreens().find((x) => x.id === screen) : null;
87
+ if (screen && !s) return { error: `No screen ${screen}.` };
88
+ if (s && !s.page) return { error: "Watch reads a web page you mapped (Map page). A screenshot of your screen can't be read again by itself." };
89
+ const url = s ? s.page.url : siteUrl(site);
90
+ if (!url) return { error: "Give a mapped page or a site to watch: gmail, github.com/notifications or a web address." };
91
+ const d = loadWatch(), had = d.watches.find((w) => w.url === url);
92
+ if (had) { had.every = everyOf(every, had.every); return saveWatch(d) ? view(had) : { error: "Couldn't write " + WATCH_FILE + "." }; }
93
+ if (d.watches.length >= MAX_WATCHES) return { error: `You're watching ${MAX_WATCHES} pages already. Stop watching one first.` };
94
+ const w = { id: randomBytes(4).toString("hex"), name: String(name || (s && s.name) || hostOf(url)).trim().slice(0, 80) || hostOf(url), url, every: everyOf(every, 15), added: Date.now(), last: 0, seen: [] };
95
+ d.watches.push(w);
96
+ return saveWatch(d) ? view(w) : { error: "Couldn't write " + WATCH_FILE + "." };
97
+ }
98
+ function setEvery(id, every) {
99
+ const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: `No watch ${id}.` };
100
+ if (!EVERY.includes(Number(every))) return { error: `Every ${EVERY.join(", ")} minutes.` };
101
+ w.every = Number(every); saveWatch(d); return view(w);
102
+ }
103
+ // Stop watching, and forget what it found.
104
+ function removeWatch(id) {
105
+ const d = loadWatch(); if (!d.watches.some((x) => x.id === id)) return { error: `No watch ${id}.` };
106
+ d.watches = d.watches.filter((x) => x.id !== id); d.news = d.news.filter((n) => n.watch !== id);
107
+ saveWatch(d); return { ok: true };
108
+ }
109
+ function clearNews() { const d = loadWatch(); d.news = []; saveWatch(d); return { ok: true }; }
110
+ // What's new in the last `hours`, newest first (what an agent reads).
111
+ function newsSince(hours = 24, now = Date.now()) { return loadWatch().news.filter((n) => n.ts >= now - hours * 3600000); }
112
+
113
+ // Read a watched page now and note what's new. `read` and `notify` are
114
+ // headless.mjs's readPage and a desktop notification (the tests pass their own).
115
+ // Gives the watch, plus `new` (what was new) or `learned` (the first read), or
116
+ // `busy` when the hidden browser is in use (nothing changes: it's read later).
117
+ async function checkWatch(id, { read = readPage, notify = desktopNotify } = {}) {
118
+ const w0 = loadWatch().watches.find((x) => x.id === id); if (!w0) return { error: `No watch ${id}.` };
119
+ const page = (await read(w0.url)) || { error: "Nothing came back from the page." };
120
+ if (page.busy) return { ...view(w0), busy: true };
121
+ // read again: the file may have changed while the page loaded
122
+ const d = loadWatch(), w = d.watches.find((x) => x.id === id); if (!w) return { error: "That watch was removed." };
123
+ w.last = Date.now();
124
+ const items = page.error || page.login ? [] : itemsOf(page), before = w.error || "";
125
+ if (page.login) w.error = `Signed out of ${hostOf(page.url) || hostOf(w.url)}. Under Screens, type ${hostOf(w.url)} and click Sign in, sign in once and close the window.`;
126
+ else if (page.error) w.error = page.error;
127
+ else if (!items.length) w.error = "Found nothing listed on the page this time.";
128
+ if (page.error || page.login || !items.length) {
129
+ saveWatch(d);
130
+ // signed out needs you; the rest (the browser busy with Sign in, a slow page) usually passes by itself
131
+ if (page.login && w.error !== before) notify("Symbiot", `${w.name}: ${w.error}`);
132
+ return view(w);
133
+ }
134
+ delete w.error;
135
+ const first = !w.checked, { fresh, keys } = newItems(w.seen, items);
136
+ w.seen = remember(w.seen, keys); w.checked = w.last;
137
+ const news = first ? [] : fresh.slice(0, MAX_PER_READ).map((it) => ({ id: randomBytes(4).toString("hex"), watch: w.id, name: w.name, ts: w.last, text: it.text.slice(0, 300), ...(it.href ? { href: it.href } : {}) }));
138
+ d.news = [...news, ...d.news].slice(0, MAX_NEWS);
139
+ saveWatch(d);
140
+ if (news.length) notify(`${news.length} new · ${w.name.slice(0, 50)}`, news.slice(0, 3).map((n) => n.text.slice(0, 90)).join("\n") + (news.length > 3 ? `\n…and ${news.length - 3} more in Symbiot` : ""));
141
+ return { ...view(w), ...(first ? { learned: keys.length } : { new: news }) };
142
+ }
143
+ // The watches that are due, longest-waiting first.
144
+ function dueWatches(now = Date.now()) { return loadWatch().watches.filter((w) => now - (w.last || 0) >= w.every * 60000).sort((a, b) => (a.last || 0) - (b.last || 0)); }
145
+
146
+ // While the app runs: once a minute, read the watches that are due, one at a
147
+ // time. Returns a stop function.
148
+ function startWatches(opts = {}) {
149
+ let busy = false;
150
+ const tick = async () => {
151
+ if (busy) return; busy = true;
152
+ try { for (const w of dueWatches()) { const r = await checkWatch(w.id, opts); if (r && r.busy) break; } }
153
+ catch {} finally { busy = false; }
154
+ };
155
+ const first = setTimeout(tick, 20000), every = setInterval(tick, 60 * 1000);
156
+ first.unref(); every.unref();
157
+ return () => { clearTimeout(first); clearInterval(every); };
158
+ }
159
+
160
+ export { WATCH_FILE, EVERY, itemsOf, itemKey, newItems, remember, watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, checkWatch, dueWatches, startWatches };