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 +26 -0
- package/headless.mjs +37 -10
- package/index.mjs +86 -15
- package/package.json +2 -1
- package/ui.mjs +30 -4
- package/watch.mjs +160 -0
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
|
|
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
|
-
//
|
|
250
|
-
async function
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
// { "<
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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 · 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)+")":"")+" · ":"")+s.w+" × "+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)+")":"")+" · ":"")+s.w+" × "+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's new: click to stop'>Watching ✓</button>":"<button class='ghost' id='scrwatch' title='read this page again every few minutes while Symbiot runs, and tell me what'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 · 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'>✓ 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 · 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'>×</button></div>";}).join('');
|
|
713
|
+
h+=ns.length?"<div class='row' style='margin-top:8px'><span class='fl' style='margin-left:0;flex:1'>New · "+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)+" · "+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?"✓ "+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 };
|