symbiot 0.40.2 → 0.40.4

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
@@ -136,6 +136,9 @@ symbiot app # opens in your phone's browser
136
136
  `proot-distro`. To reach
137
137
  the phone's shared storage, run `termux-setup-storage` and add `~/storage/shared`
138
138
  (or a folder in it) under Settings → Folders to scan.
139
+ - Claude Code's history is read from Termux's home and from those Linuxes'
140
+ homes, so projects your agent worked on inside `proot-distro login debian` get
141
+ their "agent" badge on the Map, as they do on a computer.
139
142
  - **Write my week and notify me** sends an Android notification when the
140
143
  `termux-api` package and the Termux:API app are installed.
141
144
  - **Start Symbiot in the background when the phone starts** writes a
@@ -555,6 +558,29 @@ While the app is running, these commands use its hidden browser, so it stays
555
558
  open between them as it does in the app. Without the app, each command opens the
556
559
  browser and closes it when done.
557
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
+
558
584
  **More than one display?** Symbiot reads how your displays are laid out
559
585
  (`cosmic-randr`, `wlr-randr`, `kscreen-doctor` or `xrandr` on Linux, PowerShell on
560
586
  Windows, AppKit on macOS) and a picker appears next to Capture: **each display**
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 {}
@@ -732,16 +733,22 @@ function storageBlocked() {
732
733
  // home folders in $PREFIX/var/lib/proot-distro/installed-rootfs/<distro>, outside
733
734
  // Termux's home, and that's where projects worked on in it live. Symbiot in
734
735
  // Termux reads them there: /root and /home/<user> of each installed distro.
735
- function prootHomes() {
736
+ const subdirs = (p) => { try { return readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join(p, d.name)); } catch { return []; } };
737
+ // The folder each installed distro sees as "/".
738
+ function prootDistros() {
736
739
  if (!process.env.PREFIX) return [];
737
- const base = join(process.env.PREFIX, "var", "lib", "proot-distro", "installed-rootfs"), out = [];
738
- const dirs = (p) => { try { return readdirSync(p, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => join(p, d.name)); } catch { return []; } };
739
- for (const distro of dirs(base)) {
740
- if (existsSync(join(distro, "root"))) out.push(join(distro, "root"));
741
- out.push(...dirs(join(distro, "home")));
740
+ return subdirs(join(process.env.PREFIX, "var", "lib", "proot-distro", "installed-rootfs"));
741
+ }
742
+ // Each distro's home folders, with the distro they're in.
743
+ function prootHomeDirs() {
744
+ const out = [];
745
+ for (const rootfs of prootDistros()) {
746
+ if (existsSync(join(rootfs, "root"))) out.push({ rootfs, home: join(rootfs, "root") });
747
+ for (const home of subdirs(join(rootfs, "home"))) out.push({ rootfs, home });
742
748
  }
743
749
  return out;
744
750
  }
751
+ function prootHomes() { return prootHomeDirs().map((h) => h.home); }
745
752
  // Where to look when you haven't picked folders: your home folder (and in Termux,
746
753
  // the homes of its proot-distro Linuxes).
747
754
  function scanDefaults() { return [scanHome(), ...prootHomes()]; }
@@ -1118,9 +1125,13 @@ function detectFolder(path) {
1118
1125
  // Projects you've run an AI coding agent on. Claude Code stores one dir per
1119
1126
  // project under ~/.claude/projects, named by the project path with every
1120
1127
  // non-alphanumeric char → "-" (so "/" "." "_" "-" all collide). Returns
1121
- // { "<encoded path>": { agent, last, path } } — last = newest session mtime
1128
+ // { "<key>": { agent, last, path, rootfs, enc } } — last = newest session mtime
1122
1129
  // (epoch secs); path = the real project dir, read from the "cwd" a session
1123
- // records (empty when no session says, then callers fall back to decoding).
1130
+ // records (empty when no session says, then callers fall back to decoding enc).
1131
+ // In Termux it also reads Claude Code's history inside each proot-distro (in
1132
+ // the distro's /root and /home/<user>). Paths there are the distro's own
1133
+ // ("/root/work/proj"), so rootfs is the distro's folder ("" outside one), path
1134
+ // is prefixed with it, and key = agentKey(rootfs, enc).
1124
1135
  // (Gemini's dir is global config, Codex's is empty, so no reliable per-project
1125
1136
  // signal there yet; add them here when there is.)
1126
1137
  const claudeEnc = (p) => String(p || "").replace(/[^a-zA-Z0-9]/g, "-");
@@ -1130,9 +1141,21 @@ function sessionCwd(file) {
1130
1141
  if (!m) return "";
1131
1142
  try { return JSON.parse(`"${m[1]}"`); } catch { return ""; }
1132
1143
  }
1144
+ const agentKey = (rootfs, enc) => (rootfs ? rootfs + "\0" : "") + enc;
1145
+ // The keys a project folder's history could be under: its path, and its path as
1146
+ // seen from inside the proot-distro it's in (if any), each encoded both ways.
1147
+ function agentKeys(path, distros) {
1148
+ const enc = (p) => [claudeEnc(p), p.replace(/\//g, "-")]; // older Claude Code mapped only "/"
1149
+ const rootfs = distros.find((r) => path.startsWith(r + "/"));
1150
+ return [...enc(path), ...(rootfs ? enc(path.slice(rootfs.length)).map((e) => agentKey(rootfs, e)) : [])];
1151
+ }
1133
1152
  function claudeProjects() {
1134
- const base = join(homedir(), ".claude", "projects");
1135
1153
  const out = {};
1154
+ readClaudeProjects(join(homedir(), ".claude", "projects"), "", out);
1155
+ for (const { rootfs, home } of prootHomeDirs()) readClaudeProjects(join(home, ".claude", "projects"), rootfs, out);
1156
+ return out;
1157
+ }
1158
+ function readClaudeProjects(base, rootfs, out) {
1136
1159
  let dirs = [];
1137
1160
  try { dirs = sh(`ls -1 ${JSON.stringify(base)} 2>/dev/null`).split("\n").filter(Boolean); } catch {}
1138
1161
  for (const enc of dirs) {
@@ -1145,9 +1168,10 @@ function claudeProjects() {
1145
1168
  if (cwd && (claudeEnc(cwd) === enc || cwd.replace(/\//g, "-") === enc)) path = cwd; // only trust a cwd that matches this dir
1146
1169
  }
1147
1170
  if (!last) { try { last = Math.floor(statSync(join(base, enc)).mtimeMs / 1000); } catch {} }
1148
- out[enc] = { agent: "Claude Code", last, path };
1171
+ const key = agentKey(rootfs, enc);
1172
+ if (out[key] && out[key].last >= last) continue; // one project worked on from two of the distro's homes: keep the newest
1173
+ out[key] = { agent: "Claude Code", last, path: path && rootfs + path, rootfs, enc };
1149
1174
  }
1150
- return out;
1151
1175
  }
1152
1176
  // Async only to YIELD between repos, so the app server can answer /api/scan
1153
1177
  // (progress) mid-scan; the git calls themselves stay synchronous. Concurrent
@@ -1212,10 +1236,10 @@ async function buildMapScan() {
1212
1236
  // Agent projects: badge the repo/folder nodes an AI coding agent has worked,
1213
1237
  // and surface agent-worked projects the scan missed as their own nodes.
1214
1238
  try {
1215
- const ap = claudeProjects(); const used = new Set();
1239
+ const ap = claudeProjects(), distros = prootDistros(); const used = new Set();
1216
1240
  for (const n of nodes) {
1217
1241
  if ((n.type === "repo" || n.type === "folder") && n.meta && n.meta.path) {
1218
- const e = [claudeEnc(n.meta.path), n.meta.path.replace(/\//g, "-")].find((k) => ap[k]); // older Claude Code mapped only "/"
1242
+ const e = agentKeys(n.meta.path, distros).find((k) => ap[k]);
1219
1243
  if (e) { n.meta.agents = [{ agent: ap[e].agent, last: ap[e].last }]; used.add(e); }
1220
1244
  }
1221
1245
  }
@@ -1223,7 +1247,7 @@ async function buildMapScan() {
1223
1247
  for (const e of Object.keys(ap)) {
1224
1248
  if (used.has(e) || extra >= 20) continue;
1225
1249
  // the session's recorded cwd; else best-effort decode (names with - . _ won't resolve and are skipped)
1226
- const decoded = ap[e].path || e.replace(/-/g, "/");
1250
+ const decoded = ap[e].path || ap[e].rootfs + ap[e].enc.replace(/-/g, "/");
1227
1251
  if (!existsSync(decoded) || !statSync(decoded).isDirectory()) continue;
1228
1252
  if (nodes.some((n) => n.meta && n.meta.path === decoded)) continue;
1229
1253
  const isGit = existsSync(join(decoded, ".git"));
@@ -1773,6 +1797,13 @@ async function cmdApp() {
1773
1797
  if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
1774
1798
  if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
1775
1799
  if (u.pathname === "/api/screens/signin" && req.method === "POST") { const b = await readBody(req); return json(res, await signIn(b.site)); }
1800
+ // Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
1801
+ if (u.pathname === "/api/watch") return json(res, watchState());
1802
+ 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 })); }
1803
+ if (u.pathname === "/api/watch/every" && req.method === "POST") { const b = await readBody(req); return json(res, setEvery(String(b.id || ""), b.every)); }
1804
+ if (u.pathname === "/api/watch/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeWatch(String(b.id || ""))); }
1805
+ if (u.pathname === "/api/watch/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkWatch(String(b.id || ""))); }
1806
+ if (u.pathname === "/api/watch/clear" && req.method === "POST") return json(res, clearNews());
1776
1807
  // What symbiot-desktop added (desktop.mjs): the weekly write-up, start at login.
1777
1808
  if (u.pathname === "/api/desktop") return json(res, { weekly: weeklyState(), autostart: autostartState() });
1778
1809
  if (u.pathname === "/api/desktop/weekly" && req.method === "POST") { const b = await readBody(req); return json(res, setWeekly(b)); }
@@ -1819,6 +1850,7 @@ async function cmdApp() {
1819
1850
  server.listen(PORT, "127.0.0.1");
1820
1851
  checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
1821
1852
  startWeekly(produce); // the weekly write-up + notification, when switched on in Settings
1853
+ startWatches(); // pages you watch (Screens → Watch), read every few minutes
1822
1854
  }
1823
1855
 
1824
1856
  // `symbiot mail [--on|--off] [--add <path>]`: what mail it can read, and a preview.
@@ -1903,7 +1935,43 @@ async function cmdScreens() {
1903
1935
  if (sub !== "help") process.exitCode = 1;
1904
1936
  }
1905
1937
 
1906
- const HELP = `${c.b("symbiot")} — your week, written from your real work.
1938
+ // ---- `symbiot watch`: pages Symbiot keeps track of, and what's new on them ----
1939
+ // new, add, remove and check print JSON, for you or an agent.
1940
+ async function cmdWatch() {
1941
+ const [sub = "list", a1] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !["--every", "--hours"].includes(all[i - 1]));
1942
+ const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
1943
+ const hours = Number(flag("hours", 24)) || 24;
1944
+ if (sub === "list") {
1945
+ const st = watchState();
1946
+ 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."));
1947
+ 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 : ""}`)}`);
1948
+ const news = newsSince(hours);
1949
+ 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)")));
1950
+ 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))}`);
1951
+ return;
1952
+ }
1953
+ if (sub === "new") return out(newsSince(hours));
1954
+ if (sub === "add") { const screen = loadScreens().some((s) => s.id === a1) ? a1 : ""; return out(addWatch({ screen, site: screen ? "" : a1, every: flag("every", 15) })); }
1955
+ if (sub === "remove") return out(removeWatch(String(a1 || "")));
1956
+ if (sub === "check") {
1957
+ // through the app when it runs: its hidden browser may be open, and one profile takes one browser
1958
+ const ids = a1 ? [a1] : watchState().watches.map((w) => w.id), done = [];
1959
+ for (const id of ids) done.push((await viaApp("/api/watch/check", { id })) || await checkWatch(id));
1960
+ return out(a1 ? done[0] : done);
1961
+ }
1962
+ console.log(`${c.b("symbiot watch")} ${c.d("— experimental")}
1963
+ symbiot watch the pages you watch, and what's new
1964
+ symbiot watch add <screen id | site> [--every 5|15|30|60]
1965
+ watch a mapped page (or a site) for new rows
1966
+ symbiot watch new [--hours 24] what's new, newest first (JSON)
1967
+ symbiot watch check [id] read them now (JSON)
1968
+ symbiot watch remove <id> stop watching it
1969
+ While the app runs it reads each page every few minutes in its hidden browser
1970
+ (only reads: nothing is pressed or typed), and notifies you of what's new.`);
1971
+ if (sub !== "help") process.exitCode = 1;
1972
+ }
1973
+
1974
+ const HELP =`${c.b("symbiot")} — your week, written from your real work.
1907
1975
 
1908
1976
  ${c.b("Usage")}
1909
1977
  symbiot ${c.d("(or)")} symbiot week write up your last ${SINCE_WEEK} days
@@ -1924,6 +1992,8 @@ ${c.b("Experimental")}
1924
1992
  symbiot setup-local [--model X] install/run a free local model (Ollama)
1925
1993
  symbiot screens map <site> map a web page's buttons in a hidden browser
1926
1994
  (symbiot screens help for more)
1995
+ symbiot watch add <screen id> keep track of a mapped page: what's new on it
1996
+ (symbiot watch help for more)
1927
1997
 
1928
1998
  ${c.b("Options")}
1929
1999
  --dir <path> where your repos are (default: ${homedir()})
@@ -1955,6 +2025,7 @@ async function main() {
1955
2025
  if (cmd === "push") return cmdPush();
1956
2026
  if (cmd === "mail" || cmd === "email") return cmdMail();
1957
2027
  if (cmd === "screens" || cmd === "screen") return cmdScreens();
2028
+ if (cmd === "watch") return cmdWatch();
1958
2029
  if (cmd === "week") return cmdRun("week");
1959
2030
  if (cmd === "standup") return cmdRun("standup");
1960
2031
  if (cmd === "todo") return cmdRun("todo");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.40.2",
3
+ "version": "0.40.4",
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
@@ -169,10 +169,11 @@ label.check input{width:auto}
169
169
  <div id="review" class="review hidden"></div>
170
170
  <div style="margin-top:22px;border-top:1px solid var(--line);padding-top:4px">
171
171
  <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>
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. <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
173
  <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
174
  <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
175
  <div id="screenmsg"></div>
176
+ <div id="watchbox"></div>
176
177
  <div id="screenlist" class="taskfilter"></div>
177
178
  <div id="screenview"></div>
178
179
  </div>
@@ -658,7 +659,7 @@ box.querySelectorAll('.fchip').forEach(function(b){b.addEventListener('click',fu
658
659
  function scrPct(v,of){return (v/of*100).toFixed(3)+'%';}
659
660
  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
661
  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>";
662
+ 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
663
  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
664
  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
665
  h+="<div class='mapbar' id='scrbar'>Drag on the screenshot to mark a region &middot; coordinates are screenshot pixels</div>";
@@ -693,7 +694,32 @@ $('scrregions').querySelectorAll('.scrclick').forEach(function(b){b.addEventList
693
694
  $('scrregions').querySelectorAll('.scrpress').forEach(function(b){b.addEventListener('click',function(){pressUI(s,+b.closest('.task').getAttribute('data-i'),b);});});
694
695
  $('scrregions').querySelectorAll('.scrtype').forEach(function(b){b.addEventListener('click',function(){typeUI(s,+b.closest('.task').getAttribute('data-i'),b);});});
695
696
  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);});}
697
+ var rm=$('scrremap');if(rm)rm.addEventListener('click',function(){mapUI(s.page.url,rm);});
698
+ var wb=$('scrwatch');if(wb)wb.addEventListener('click',function(){watchUI(s,wb);});}
699
+ // Watch (watch.mjs): a mapped page read again every few minutes while Symbiot
700
+ // runs, and what's new on it. Only reads: nothing there is pressed or typed.
701
+ var WATCH={watches:[],news:[],every:[5,15,30,60]};
702
+ function watchOf(s){var w=null;(WATCH.watches||[]).forEach(function(x){if(s&&s.page&&x.url===s.page.url)w=x;});return w;}
703
+ 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';}
704
+ function loadWatchUI(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();});}
705
+ function watchUI(s,b){var w=watchOf(s);
706
+ 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;}
707
+ 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;}
708
+ $('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>";
709
+ api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();renderScreen();});});}
710
+ function renderWatch(){var box=$('watchbox'),ws=WATCH.watches||[],ns=WATCH.news||[];if(!ws.length){box.innerHTML='';return;}
711
+ var h="<div class='tgroup'>Watching <span class='tcount'>read again while Symbiot runs &middot; only reads, never presses</span></div>";
712
+ 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('');
713
+ 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('')
714
+ :"<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>";
715
+ box.innerHTML=h;
716
+ 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);});});
717
+ 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();});});});});
718
+ 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…';
719
+ 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.";
720
+ $('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));});});});
721
+ 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');});});
722
+ var wc=$('wclear');if(wc)wc.addEventListener('click',function(){api('/api/watch/clear',{}).then(loadWatchUI);});}
697
723
  // A web page, mapped by itself in the hidden browser (headless.mjs): no capture,
698
724
  // no dragging. It takes a few seconds, so the button says so meanwhile.
699
725
  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 +861,5 @@ function appBar(p){var b=$('appbar');if(!b||!p.termux||window.SymbiotAndroid||ty
835
861
  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
862
  setInterval(heartbeat,4000);heartbeat(true);
837
863
  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();
864
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadPhone();loadTrusted();loadMail();loadScreensUI();loadMonitorsUI();loadDesktop();loadWatchUI();setInterval(loadWatchUI,60000);
839
865
  </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 };