symbiot 0.41.2 → 0.41.3
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 +17 -2
- package/agents.mjs +1 -1
- package/desktop.mjs +8 -5
- package/index.mjs +14 -4
- package/package.json +1 -1
- package/server.mjs +27 -18
- package/ui.mjs +51 -5
- package/watch.mjs +19 -1
package/README.md
CHANGED
|
@@ -55,6 +55,11 @@ Tabs:
|
|
|
55
55
|
**no AI key**; reviews and suggestions use your chosen model. While the scan runs
|
|
56
56
|
the Map shows its progress. Under the graph is the [experimental](#screens-blueprints-for-screen-automation)
|
|
57
57
|
**Screens** view.
|
|
58
|
+
- **Dashboard** — everything you [watch](#screens-blueprints-for-screen-automation)
|
|
59
|
+
side by side: a card for each page (your inbox, GitHub, WhatsApp, any other) with
|
|
60
|
+
how many new since yesterday (or in the last 3 or 7 days), its latest brief, the
|
|
61
|
+
newest items, and **Open**, **Draft a reply** and **Check now** on them. The tab
|
|
62
|
+
shows the count, so you see what's waiting from any tab.
|
|
58
63
|
- **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
|
|
59
64
|
a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
|
|
60
65
|
- **Week / Standup / Todo** — the write-ups (these use your chosen AI).
|
|
@@ -113,6 +118,12 @@ repo is now archived), without Electron:
|
|
|
113
118
|
then opens that copy's window. Switch it off to remove the entry. It needs an
|
|
114
119
|
installed Symbiot (`npm install -g symbiot`), not `npx`.
|
|
115
120
|
|
|
121
|
+
For a script or tray of your own: `isAppRunningWeekly()` (from
|
|
122
|
+
`symbiot/index.mjs`) says whether a running `symbiot app` already writes the
|
|
123
|
+
week, with no port or token to know; and `runWeekly(produce, { notify: false })`
|
|
124
|
+
(from `symbiot/desktop.mjs`) writes and saves the week in the same `weeks/`
|
|
125
|
+
format without the notification, for a caller that tells you itself.
|
|
126
|
+
|
|
116
127
|
### On your phone (Android, in Termux)
|
|
117
128
|
|
|
118
129
|
Experimental, and tried on one phone so far (a Galaxy A26, Android 16), where it
|
|
@@ -620,7 +631,9 @@ there with what it has seen. It only reads: no screenshot is saved and nothing
|
|
|
620
631
|
on the page is pressed or typed. The first read just learns what's there, so it
|
|
621
632
|
never announces your whole inbox. After that, anything new (a new email, a new
|
|
622
633
|
reply in a thread) gets a desktop notification and goes under **Watching** in
|
|
623
|
-
Screens, newest first
|
|
634
|
+
Screens, newest first, and on the page's card in the **Dashboard** tab. A chat
|
|
635
|
+
works the same way: map `web.whatsapp.com` once you've signed in, watch it, and new
|
|
636
|
+
messages (counted as WhatsApp messages in Standup) arrive with your mail and GitHub. A row is the same row whether its time reads `9:05 AM`,
|
|
624
637
|
`Oct 5` or `2 hours ago`, or you've read it since. On a page with no rows it
|
|
625
638
|
watches the links instead. If the site has signed you out, the watch says so,
|
|
626
639
|
and notifies you once. With
|
|
@@ -633,6 +646,7 @@ symbiot watch add <screen id> --every 15 # watch a mapped page (or: symbiot wa
|
|
|
633
646
|
symbiot watch add github # your GitHub notifications (see below)
|
|
634
647
|
symbiot watch new --hours 24 # what's new, newest first (JSON)
|
|
635
648
|
symbiot watch check # read them all now (JSON)
|
|
649
|
+
symbiot watch draft <id> # Draft a reply to a new email (see below)
|
|
636
650
|
symbiot watch brief on # your AI says what needs you (off to stop)
|
|
637
651
|
symbiot watch # what you watch, and what's new
|
|
638
652
|
symbiot watch remove <id>
|
|
@@ -666,7 +680,8 @@ your mail's site (`mail.google.com`) under Trusted sites. Each email gets a fold
|
|
|
666
680
|
of its own in `~/.config/symbiot/drafts` (yours only) with the agent's brief, and
|
|
667
681
|
the run shows in the Agents tab like any other: what it asks (a date only you
|
|
668
682
|
know, say) is answered there. The agent sees the email's text, so it reaches
|
|
669
|
-
whatever AI your agent uses.
|
|
683
|
+
whatever AI your agent uses. From a terminal, `symbiot watch new` marks the
|
|
684
|
+
emails `"mail": true`, and `symbiot watch draft <id>` does the same as the button.
|
|
670
685
|
|
|
671
686
|
**In Standup.** Standup ends with what's waiting on you since yesterday, counted
|
|
672
687
|
from what Watch found: `Waiting on you: 3 emails, 2 GitHub notifications`. Symbiot
|
package/agents.mjs
CHANGED
|
@@ -292,7 +292,7 @@ const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbio
|
|
|
292
292
|
// bullet under Questions is a question with no options.
|
|
293
293
|
function parseQuestions(md) {
|
|
294
294
|
const questions = [], suggestions = []; let sec = "q", cur = null;
|
|
295
|
-
const clip = (s) => String(s).trim().slice(0,
|
|
295
|
+
const clip = (s) => String(s).trim().slice(0, 1000); // as long as a task holds (addTask), so an idea added in one click arrives whole
|
|
296
296
|
for (const raw of String(md || "").split(/\r?\n/)) {
|
|
297
297
|
const l = raw.trim(); if (!l) continue;
|
|
298
298
|
let m;
|
package/desktop.mjs
CHANGED
|
@@ -55,16 +55,19 @@ function latestWeek() {
|
|
|
55
55
|
}
|
|
56
56
|
function weeklyState() { return { ...weeklyCfg(), latest: latestWeek() }; }
|
|
57
57
|
// Write the week with `produce` (index.mjs), save it and notify. `last` is set
|
|
58
|
-
// first, so a slow or failing run is never retried every minute.
|
|
59
|
-
|
|
58
|
+
// first, so a slow or failing run is never retried every minute. notify: false
|
|
59
|
+
// skips the notification, for a caller that tells you itself (the saved file
|
|
60
|
+
// and what's returned are the same either way).
|
|
61
|
+
async function runWeekly(produce, { notify: on = true } = {}) {
|
|
62
|
+
const notify = (body) => on && desktopNotify("Symbiot", body);
|
|
60
63
|
const cfg = loadConfig(); cfg.weekly = { ...weeklyCfg(cfg), last: Date.now() }; saveConfig(cfg);
|
|
61
64
|
const r = await produce("week");
|
|
62
|
-
if (r.error === "not-connected") {
|
|
63
|
-
if (/^\(?couldn't reach the model/i.test(String(r.text || ""))) {
|
|
65
|
+
if (r.error === "not-connected") { notify("Time for your weekly update. Connect an AI in Symbiot's Settings and it writes it for you."); return { error: r.error }; }
|
|
66
|
+
if (/^\(?couldn't reach the model/i.test(String(r.text || ""))) { notify("Couldn't write your week: the model didn't answer. Try the Week tab."); return { error: r.text }; }
|
|
64
67
|
const file = join(WEEKS_DIR, localDay() + ".md");
|
|
65
68
|
try { mkdirSync(WEEKS_DIR, { recursive: true }); writeFileSync(file, String(r.text || "").trim() + (r.footer ? FOOT + r.footer : "") + "\n"); }
|
|
66
69
|
catch (e) { return { error: "Couldn't save the write-up: " + ((e && e.message) || e) }; }
|
|
67
|
-
|
|
70
|
+
notify("Your week is written. Open the Week tab in Symbiot, or " + file);
|
|
68
71
|
return { ok: true, file, text: r.text };
|
|
69
72
|
}
|
|
70
73
|
// Checks once a minute while the app runs. Returns a stop function.
|
package/index.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
28
29
|
import { realpathSync } from "node:fs";
|
|
29
30
|
import { createInterface } from "node:readline";
|
|
30
31
|
import { fileURLToPath } from "node:url";
|
|
@@ -38,9 +39,9 @@ import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm,
|
|
|
38
39
|
import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
|
|
39
40
|
import { loadScreens, screenImage, blueprint } from "./screens.mjs";
|
|
40
41
|
import { mapPage, pressRegion, typeRegion, signIn, isTrusted } from "./headless.mjs";
|
|
41
|
-
import { watchState, addWatch, removeWatch, newsSince, checkWatch, setBrief } from "./watch.mjs";
|
|
42
|
+
import { watchState, addWatch, removeWatch, newsSince, checkWatch, setBrief, isMail, draftReply } from "./watch.mjs";
|
|
42
43
|
import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
|
|
43
|
-
import { startApp, updateCmd } from "./server.mjs";
|
|
44
|
+
import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
|
|
44
45
|
|
|
45
46
|
// ---- tiny arg parse --------------------------------------------------------
|
|
46
47
|
const argv = process.argv.slice(2);
|
|
@@ -369,7 +370,13 @@ async function cmdWatch() {
|
|
|
369
370
|
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))}`);
|
|
370
371
|
return;
|
|
371
372
|
}
|
|
372
|
-
if (sub === "new") return out(newsSince(hours));
|
|
373
|
+
if (sub === "new") { const mail = new Set(watchState().watches.filter((w) => isMail(w.url)).map((w) => w.id)); return out(newsSince(hours).map((n) => (mail.has(n.watch) ? { ...n, mail: true } : n))); }
|
|
374
|
+
// the same as the app's Draft a reply button: through the app when it runs, so its Agents tab tracks the run
|
|
375
|
+
if (sub === "draft") {
|
|
376
|
+
if (!a1) return out({ error: "Give the email's id: symbiot watch new lists them, and the ones marked \"mail\": true can get a reply." });
|
|
377
|
+
const r = (await viaApp("/api/watch/draft", { id: a1 })) || draftReply(a1);
|
|
378
|
+
return out(r.ok ? { ...r, next: "Your agent is writing the reply and leaves it in Drafts, never sent. Its log is in " + join(r.dir, ".symbiot", "agent.log") + " (and the app's Agents tab)." } : r);
|
|
379
|
+
}
|
|
373
380
|
if (sub === "add") { const screen = loadScreens().some((s) => s.id === a1) ? a1 : ""; return out(addWatch({ screen, site: screen ? "" : a1, every: flag("every", 15) })); }
|
|
374
381
|
if (sub === "remove") return out(removeWatch(String(a1 || "")));
|
|
375
382
|
if (sub === "brief") { if (a1 === "on" || a1 === "off") return out(setBrief(a1 === "on")); return out({ brief: watchState().brief, briefs: watchState().briefs }); }
|
|
@@ -387,6 +394,9 @@ async function cmdWatch() {
|
|
|
387
394
|
failed CI runs (through gh when it's signed in)
|
|
388
395
|
symbiot watch new [--hours 24] what's new, newest first (JSON)
|
|
389
396
|
symbiot watch check [id] read them now (JSON)
|
|
397
|
+
symbiot watch draft <id> your agent drafts a reply to that new email
|
|
398
|
+
("mail": true in watch new) and leaves it in
|
|
399
|
+
Drafts, never sent, like Draft a reply in the app
|
|
390
400
|
symbiot watch brief [on|off] your AI says what needs you, and what can wait
|
|
391
401
|
symbiot watch remove <id> stop watching it
|
|
392
402
|
While the app runs it reads each page every few minutes in its hidden browser
|
|
@@ -548,5 +558,5 @@ const isMain = (() => {
|
|
|
548
558
|
})();
|
|
549
559
|
if (isMain) main();
|
|
550
560
|
|
|
551
|
-
export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions };
|
|
561
|
+
export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks, semverGt, updateCmd, parseQuestions, agentQuestions, isAppRunningWeekly };
|
|
552
562
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.3",
|
|
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": {
|
package/server.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
|
|
|
19
19
|
import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
|
|
20
20
|
import { mapPage, pressRegion, typeRegion, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
|
|
21
21
|
import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
|
|
22
|
-
import { watchState, addWatch, setEvery, removeWatch, clearNews, checkWatch, startWatches, setBrief, draftReply } from "./watch.mjs";
|
|
22
|
+
import { watchState, addWatch, setEvery, removeWatch, clearNews, checkWatch, startWatches, setBrief, draftReply, watchBoard } from "./watch.mjs";
|
|
23
23
|
import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
|
|
24
24
|
|
|
25
25
|
// The in-app update installs the EXACT newest version (not the `latest` tag, which
|
|
@@ -69,6 +69,21 @@ function openApp(url) {
|
|
|
69
69
|
return "browser tab";
|
|
70
70
|
} catch { return null; }
|
|
71
71
|
}
|
|
72
|
+
// What the `symbiot app` already serving on this computer says at /api/<path>,
|
|
73
|
+
// or null when none answers. Knows the app's port and token, so a caller (a
|
|
74
|
+
// tray, a script, startApp's single-instance check) doesn't have to.
|
|
75
|
+
async function askRunningApp(path, { port, token, ms = 800 } = {}) {
|
|
76
|
+
const cfg = loadConfig(); token = token || cfg.appToken; if (!token) return null;
|
|
77
|
+
port = port || Number(process.env.SYMBIOT_PORT || cfg.appPort) || 7391;
|
|
78
|
+
const ctrl = new AbortController(), to = setTimeout(() => ctrl.abort(), ms);
|
|
79
|
+
try {
|
|
80
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/${path}`, { headers: { "x-symbiot-token": token }, signal: ctrl.signal });
|
|
81
|
+
return r.ok ? await r.json() : null;
|
|
82
|
+
} catch { return null; } finally { clearTimeout(to); }
|
|
83
|
+
}
|
|
84
|
+
// True when a `symbiot app` is running with the weekly write-up on, so it
|
|
85
|
+
// writes the week (and notifies) itself and nothing else needs to.
|
|
86
|
+
async function isAppRunningWeekly() { const d = await askRunningApp("desktop"); return !!(d && d.weekly && d.weekly.on); }
|
|
72
87
|
// How long the app keeps Screens' hidden browser open after an action.
|
|
73
88
|
const BROWSER_KEEP = 5 * 60 * 1000;
|
|
74
89
|
const PLAIN_COLOURS = { g: (s) => s, d: (s) => s, b: (s) => s, y: (s) => s };
|
|
@@ -92,21 +107,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
92
107
|
// open a second window and exit, leaving nothing serving either window.
|
|
93
108
|
const RELAUNCH = process.env.SYMBIOT_RELAUNCH === "1"; delete process.env.SYMBIOT_RELAUNCH;
|
|
94
109
|
if (!process.env.SYMBIOT_FORCE_NEW && !RELAUNCH) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
|
|
104
|
-
console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
|
|
105
|
-
console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
} catch {}
|
|
110
|
+
const p = await askRunningApp("ping", { port: PORT, token: TOKEN });
|
|
111
|
+
if (p && p.version) {
|
|
112
|
+
const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url);
|
|
113
|
+
console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
|
|
114
|
+
console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
|
|
115
|
+
console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
110
118
|
}
|
|
111
119
|
// Screens' hidden browser stays open between map, press and type (headless.mjs),
|
|
112
120
|
// so an agent can type into a field and then press a separate button there.
|
|
@@ -201,6 +209,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
201
209
|
if (u.pathname === "/api/screens/signin" && req.method === "POST") { const b = await readBody(req); return json(res, await signIn(b.site)); }
|
|
202
210
|
// Watch (watch.mjs): a mapped page read again every few minutes, and what's new on it.
|
|
203
211
|
if (u.pathname === "/api/watch") return json(res, watchState());
|
|
212
|
+
if (u.pathname === "/api/watch/board") return json(res, watchBoard(Math.min(168, Math.max(1, Number(u.searchParams.get("hours")) || 24))));
|
|
204
213
|
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 })); }
|
|
205
214
|
if (u.pathname === "/api/watch/every" && req.method === "POST") { const b = await readBody(req); return json(res, setEvery(String(b.id || ""), b.every)); }
|
|
206
215
|
if (u.pathname === "/api/watch/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeWatch(String(b.id || ""))); }
|
|
@@ -222,7 +231,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
222
231
|
// What symbiot-desktop added (desktop.mjs): the weekly write-up, start at login.
|
|
223
232
|
if (u.pathname === "/api/desktop") return json(res, { weekly: weeklyState(), autostart: autostartState() });
|
|
224
233
|
if (u.pathname === "/api/desktop/weekly" && req.method === "POST") { const b = await readBody(req); return json(res, setWeekly(b)); }
|
|
225
|
-
if (u.pathname === "/api/desktop/weekly/run" && req.method === "POST") return json(res, await runWeekly(writeup));
|
|
234
|
+
if (u.pathname === "/api/desktop/weekly/run" && req.method === "POST") { const b = await readBody(req); return json(res, await runWeekly(writeup, { notify: b.notify !== false })); }
|
|
226
235
|
if (u.pathname === "/api/desktop/autostart" && req.method === "POST") { const b = await readBody(req); return json(res, setAutostart(!!b.on, bin)); }
|
|
227
236
|
if (u.pathname === "/api/run" && req.method === "POST") { const b = await readBody(req); const cmd = ["week", "standup", "todo"].includes(b.cmd) ? b.cmd : "week"; return json(res, await writeup(cmd)); }
|
|
228
237
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
@@ -269,4 +278,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
269
278
|
startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
|
|
270
279
|
}
|
|
271
280
|
|
|
272
|
-
export { updateCmd, BROWSER_KEEP, startApp };
|
|
281
|
+
export { updateCmd, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly };
|
package/ui.mjs
CHANGED
|
@@ -133,6 +133,15 @@ label.check input{width:auto}
|
|
|
133
133
|
.scrbox{position:absolute;border:2px solid var(--green);background:rgba(61,220,151,.12);pointer-events:none}
|
|
134
134
|
.scrbox span{position:absolute;left:0;top:0;font-size:11px;background:var(--green);color:var(--ink);padding:1px 6px;white-space:nowrap}
|
|
135
135
|
.scrbox.draw{border:2px dashed var(--amber);background:rgba(242,165,65,.14)}
|
|
136
|
+
.board{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px;margin-top:12px}
|
|
137
|
+
.bcard{border:1px solid var(--line);border-radius:12px;padding:12px 14px;background:var(--ink2);min-width:0}
|
|
138
|
+
.bcard.has{border-color:#2a6b52}
|
|
139
|
+
.bcard .bh{display:flex;gap:8px;align-items:center}.bcard .bn{color:var(--bone);font-weight:600;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
140
|
+
.bcard .bc{font-size:26px;font-weight:700;color:var(--faint);margin-top:6px}.bcard.has .bc{color:var(--green)}
|
|
141
|
+
.bcard .bl{font-size:12px;color:var(--faint)}
|
|
142
|
+
.bcard .bi{display:flex;gap:8px;align-items:flex-start;padding:7px 0;border-top:1px solid var(--line);font-size:13px;color:var(--text)}
|
|
143
|
+
.bcard .bi .t{flex:1;min-width:0;overflow-wrap:anywhere}.bcard .bi button{padding:4px 9px;font-size:12px}
|
|
144
|
+
.bcard .bbrief{white-space:pre-line;margin:8px 0;padding:8px 10px;border-left:3px solid var(--green);background:var(--ink3);border-radius:0 8px 8px 0;font-size:13px;color:var(--bone)}
|
|
136
145
|
@media(max-width:760px){.maprow{flex-direction:column}.detail{width:auto;max-height:none}}
|
|
137
146
|
@media(max-width:600px){#panel-tasks .row>#newtask,#screenname,#pagesite{flex:1 1 100%!important}.tabs{overflow-x:auto;scrollbar-width:none;padding:0 8px;gap:0}.tabs::-webkit-scrollbar{display:none}.tab{flex:none;padding:9px 11px}header{padding:12px 14px 8px}main{padding:12px}}
|
|
138
147
|
</style></head><body>
|
|
@@ -141,6 +150,7 @@ label.check input{width:auto}
|
|
|
141
150
|
<header><span class="dot"></span><span class="brand">Symbiot</span><span class="ver" id="ver"></span><span class="status" id="status">...</span></header>
|
|
142
151
|
<div class="tabs">
|
|
143
152
|
<button class="tab active" data-tab="map">Map</button>
|
|
153
|
+
<button class="tab" data-tab="board" id="boardtab">Dashboard</button>
|
|
144
154
|
<button class="tab" data-tab="drift">Drift</button>
|
|
145
155
|
<button class="tab" data-tab="week">Week</button>
|
|
146
156
|
<button class="tab" data-tab="standup">Standup</button>
|
|
@@ -180,6 +190,11 @@ label.check input{width:auto}
|
|
|
180
190
|
<div id="screenview"></div>
|
|
181
191
|
</div>
|
|
182
192
|
</section>
|
|
193
|
+
<section id="panel-board" class="hidden">
|
|
194
|
+
<div class="row"><span class="muted" id="boardsum" style="flex:1">Everything you watch, side by side.</span><select id="boardhours" title="how far back" style="flex:0 0 auto;width:auto"><option value="24">last 24 hours</option><option value="72">last 3 days</option><option value="168">last 7 days</option></select><button class="ghost" id="boardrefresh">Refresh</button></div>
|
|
195
|
+
<div id="boardmsg"></div>
|
|
196
|
+
<div id="board" class="board"></div>
|
|
197
|
+
</section>
|
|
183
198
|
<section id="panel-run" class="hidden">
|
|
184
199
|
<div class="row"><button class="act" id="write">Write my <span id="what">week</span></button>
|
|
185
200
|
<button class="ghost hidden" id="copy">Copy</button>
|
|
@@ -293,8 +308,9 @@ var current='map';var mapLoaded=false;var driftLoaded=false;
|
|
|
293
308
|
var COLORS={person:'#3DDC97',repo:'#F4F1EA',lang:'#F2A541',tool:'#6bb3ff',agent:'#c58af9',ai:'#5fe3b0',folder:'#b7a98c'};
|
|
294
309
|
function tabs(){return document.querySelectorAll('.tab');}
|
|
295
310
|
function setTab(tab){current=tab;tabs().forEach(function(t){t.classList.toggle('active',t.dataset.tab===tab);});
|
|
296
|
-
var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isRun=(tab==='week'||tab==='standup'||tab==='todo');
|
|
311
|
+
var isMap=tab==='map',isSet=tab==='settings',isTasks=tab==='tasks',isDrift=tab==='drift',isAgents=tab==='agents',isBoard=tab==='board',isRun=(tab==='week'||tab==='standup'||tab==='todo');
|
|
297
312
|
$('panel-map').classList.toggle('hidden',!isMap);
|
|
313
|
+
$('panel-board').classList.toggle('hidden',!isBoard);if(isBoard)loadBoard();
|
|
298
314
|
$('panel-run').classList.toggle('hidden',!isRun);
|
|
299
315
|
$('panel-settings').classList.toggle('hidden',!isSet);
|
|
300
316
|
$('panel-tasks').classList.toggle('hidden',!isTasks);
|
|
@@ -734,7 +750,7 @@ var wb=$('scrwatch');if(wb)wb.addEventListener('click',function(){watchUI(s,wb);
|
|
|
734
750
|
var WATCH={watches:[],news:[],every:[5,15,30,60]};
|
|
735
751
|
function watchOf(s){var w=null;(WATCH.watches||[]).forEach(function(x){if(s&&s.page&&x.url===s.page.url)w=x;});return w;}
|
|
736
752
|
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';}
|
|
737
|
-
function loadWatchUI(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();});}
|
|
753
|
+
function loadWatchUI(){api('/api/watch').then(function(d){if(d&&d.watches)WATCH=d;renderWatch();});loadBoard();}
|
|
738
754
|
function watchUI(s,b){var w=watchOf(s);
|
|
739
755
|
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;}
|
|
740
756
|
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;}
|
|
@@ -758,14 +774,44 @@ var wbr=$('wbrief');if(wbr)wbr.addEventListener('change',function(){api('/api/wa
|
|
|
758
774
|
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);});});
|
|
759
775
|
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();});});});});
|
|
760
776
|
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…';
|
|
761
|
-
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;}
|
|
762
|
-
$('screenmsg').innerHTML="<div class='note "+(x["new"]&&x["new"].length?"ok":"muted")+"'>"+
|
|
777
|
+
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;}
|
|
778
|
+
$('screenmsg').innerHTML="<div class='note "+(x["new"]&&x["new"].length?"ok":"muted")+"'>"+watchCheckMsg(w,x)+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;b.textContent='Check now';screenErr(String((e&&e.message)||e));});});});
|
|
763
779
|
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');});});
|
|
764
780
|
// Draft a reply (watch.mjs draftReply): handed to your agent, which never sends it
|
|
765
781
|
box.querySelectorAll('.wdraft').forEach(function(b){b.addEventListener('click',function(){var n=ns[+b.closest('.task').getAttribute('data-i')];b.disabled=true;
|
|
766
782
|
api('/api/watch/draft',{id:n.id}).then(function(x){b.disabled=false;if(!x||x.error){screenErr((x&&x.error)||'failed');return;}
|
|
767
|
-
$('screenmsg').innerHTML="<div class='note ok'
|
|
783
|
+
$('screenmsg').innerHTML="<div class='note ok'>"+DRAFTING+"</div>";loadWatchUI();}).catch(function(e){b.disabled=false;screenErr(String((e&&e.message)||e));});});});
|
|
768
784
|
var wc=$('wclear');if(wc)wc.addEventListener('click',function(){api('/api/watch/clear',{}).then(loadWatchUI);});}
|
|
785
|
+
// what Check now found, for Watching and the Dashboard
|
|
786
|
+
function watchCheckMsg(w,x){return 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.";}
|
|
787
|
+
var DRAFTING="✓ Your agent is drafting a reply to it. It opens the email in your inbox through Screens, writes the reply and leaves it in Drafts for you to read and send: it never presses Send. Follow it, and answer anything it asks, in the Agents tab.";
|
|
788
|
+
// ---- Dashboard: a card per page you watch (your inbox, GitHub, WhatsApp…), what's new on each ----
|
|
789
|
+
var BOARD=null;var BOARDICON={mail:'✉️',github:'🔔',chat:'💬',page:'🌐'};
|
|
790
|
+
function loadBoard(){api('/api/watch/board?hours='+(+$('boardhours').value||24)).then(function(b){if(b&&b.cards){BOARD=b;renderBoard();}});}
|
|
791
|
+
function boardMsg(cls,html){$('boardmsg').innerHTML="<div class='note "+cls+"'>"+html+"</div>";}
|
|
792
|
+
function renderBoard(){var b=BOARD,cs=b.cards,el=$('board');
|
|
793
|
+
var tab=$('boardtab');if(tab)tab.textContent='Dashboard'+(b.total?' · '+b.total:'');
|
|
794
|
+
if(!cs.length){$('boardsum').textContent='Everything you watch, side by side.';
|
|
795
|
+
el.innerHTML="<div style='grid-column:1/-1'><div class='note muted' style='margin-top:0'>Nothing watched yet. Map your inbox, a chat (web.whatsapp.com) or any page under <b>Screens</b> on the Map tab and click <b>Watch</b> on it, or click <b>Watch GitHub</b> there. Each one gets a card here with what's new on it.</div><div class='row' style='margin-top:8px'><button class='ghost' id='boardgo'>Go to Screens</button></div></div>";
|
|
796
|
+
$('boardgo').addEventListener('click',function(){setTab('map');var w=$('watchbox');if(w&&w.scrollIntoView)w.scrollIntoView({block:'center'});});return;}
|
|
797
|
+
var when=b.hours===24?'since yesterday':b.hours===72?'in the last 3 days':'in the last 7 days';
|
|
798
|
+
var waiting=cs.filter(function(c){return c.count;}).map(function(c){return c.label+(c.source==='page'?' on '+c.name:'');});
|
|
799
|
+
$('boardsum').innerHTML=waiting.length?"<b style='color:var(--bone)'>Waiting on you "+when+":</b> "+esc(waiting.join(', ')):"Nothing new "+when+" on the "+cs.length+" thing"+(cs.length===1?"":"s")+" you watch.";
|
|
800
|
+
el.innerHTML=cs.map(function(c,i){
|
|
801
|
+
return "<div class='bcard"+(c.count?" has":"")+"' data-i='"+i+"'><div class='bh'><span>"+(BOARDICON[c.source]||BOARDICON.page)+"</span><span class='bn' title='"+esc(c.url)+"'>"+esc(c.name)+"</span><button class='ghost bcheck' title='read it now' style='padding:4px 9px;font-size:12px'>Check now</button></div>"
|
|
802
|
+
+"<div class='bc'>"+c.count+"</div><div class='bl'>"+esc(c.count?c.label.replace(/^\\d+ /,''):'nothing new')+" · "+(c.checked?"read "+agoTxt(c.checked):c.last?"tried "+agoTxt(c.last):"first read within a minute")+(c.via==='gh'?" · through gh":"")+"</div>"
|
|
803
|
+
+(c.error?"<div class='note err'>"+esc(c.error)+"</div>":"")
|
|
804
|
+
+(c.brief?"<div class='bbrief'>"+esc(c.brief.text)+"</div>":"")
|
|
805
|
+
+c.items.map(function(n,j){return "<div class='bi' data-j='"+j+"'><span class='t'>"+esc(n.text)+" <span class='muted' style='font-size:11px'>"+agoTxt(n.ts)+"</span></span>"+(n.href?"<button class='ghost bopen' title='open it in your browser'>Open</button>":"")+(n.mail?"<button class='ghost bdraft' title='your coding agent writes a reply and leaves it in Drafts. It never presses Send'>"+(n.drafted?"Drafted · again":"Draft a reply")+"</button>":"")+"</div>";}).join('')
|
|
806
|
+
+(c.count>c.items.length?"<div class='bl' style='margin-top:6px'>…and "+(c.count-c.items.length)+" more</div>":"")+"</div>";}).join('');
|
|
807
|
+
function card(btn){return cs[+btn.closest('.bcard').getAttribute('data-i')];}
|
|
808
|
+
function item(btn){return card(btn).items[+btn.closest('.bi').getAttribute('data-j')];}
|
|
809
|
+
el.querySelectorAll('.bcheck').forEach(function(btn){btn.addEventListener('click',function(){var c=card(btn);btn.disabled=true;btn.textContent='Reading…';
|
|
810
|
+
api('/api/watch/check',{id:c.id}).then(function(x){if(!x||x.error)boardMsg('err',esc((x&&x.error)||'failed'));else boardMsg(x["new"]&&x["new"].length?'ok':'muted',watchCheckMsg(c,x));loadWatchUI();}).catch(function(e){btn.disabled=false;btn.textContent='Check now';boardMsg('err',esc(String((e&&e.message)||e)));});});});
|
|
811
|
+
el.querySelectorAll('.bopen').forEach(function(btn){btn.addEventListener('click',function(){var n=item(btn);if(n&&/^https?:/.test(n.href))window.open(n.href,'_blank','noopener');});});
|
|
812
|
+
el.querySelectorAll('.bdraft').forEach(function(btn){btn.addEventListener('click',function(){var n=item(btn);btn.disabled=true;
|
|
813
|
+
api('/api/watch/draft',{id:n.id}).then(function(x){btn.disabled=false;if(!x||x.error){boardMsg('err',esc((x&&x.error)||'failed'));return;}boardMsg('ok',DRAFTING);loadWatchUI();}).catch(function(e){btn.disabled=false;boardMsg('err',esc(String((e&&e.message)||e)));});});});}
|
|
814
|
+
$('boardrefresh').addEventListener('click',loadBoard);$('boardhours').addEventListener('change',loadBoard);
|
|
769
815
|
// A web page, mapped by itself in the hidden browser (headless.mjs): no capture,
|
|
770
816
|
// no dragging. It takes a few seconds, so the button says so meanwhile.
|
|
771
817
|
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;
|
package/watch.mjs
CHANGED
|
@@ -187,6 +187,7 @@ function kindOf(url) {
|
|
|
187
187
|
const h = hostOf(url);
|
|
188
188
|
if (isGitHubInbox(url)) return ["GitHub notification", "GitHub notifications"];
|
|
189
189
|
if (/^(mail|webmail)\.|^outlook\.(live|office|office365)\.com$/.test(h)) return ["email", "emails"];
|
|
190
|
+
if (h === "web.whatsapp.com") return ["WhatsApp message", "WhatsApp messages"];
|
|
190
191
|
return null;
|
|
191
192
|
}
|
|
192
193
|
// What's new on each page you watch in the last `hours`: [{ name, count, label,
|
|
@@ -203,6 +204,23 @@ function waitingOn(hours = 24, now = Date.now()) {
|
|
|
203
204
|
});
|
|
204
205
|
}
|
|
205
206
|
|
|
207
|
+
// ---- the Dashboard: one card per page you watch ----------------------------------
|
|
208
|
+
// Each watch with what it found in the last `hours`: its source (mail, github,
|
|
209
|
+
// chat, page), how many ("3 emails"), the newest few and its latest brief. Read
|
|
210
|
+
// from all of watch.json, so a busy GitHub can't push your mail off the board the
|
|
211
|
+
// way it can off the 50 newest under Watching.
|
|
212
|
+
const sourceOf = (url) => (isGitHubInbox(url) ? "github" : isMail(url) ? "mail" : hostOf(url) === "web.whatsapp.com" ? "chat" : "page");
|
|
213
|
+
function watchBoard(hours = 24, now = Date.now()) {
|
|
214
|
+
const d = loadWatch(), since = now - hours * 3600000;
|
|
215
|
+
const cards = d.watches.map((w) => {
|
|
216
|
+
const recent = d.news.filter((n) => n.watch === w.id && n.ts >= since), k = kindOf(w.url), mail = isMail(w.url), count = recent.length;
|
|
217
|
+
const b = d.briefs.find((x) => x.watch === w.id && x.ts >= since);
|
|
218
|
+
return { ...view(w), source: sourceOf(w.url), count, label: k ? `${count} ${k[count === 1 ? 0 : 1]}` : `${count} new`,
|
|
219
|
+
items: recent.slice(0, 8).map((n) => (mail ? { ...n, mail: true } : n)), ...(b ? { brief: { text: b.text, ts: b.ts, count: b.count } } : {}) };
|
|
220
|
+
});
|
|
221
|
+
return { hours, total: cards.reduce((s, c) => s + c.count, 0), cards, brief: briefOn() };
|
|
222
|
+
}
|
|
223
|
+
|
|
206
224
|
// A batch of news as one notification: how many and where, then the brief if
|
|
207
225
|
// there is one, else the first few. The phone (phone.mjs) shows the same.
|
|
208
226
|
function newsNotice(news, name, brief = "") {
|
|
@@ -349,4 +367,4 @@ function startWatches(opts = {}) {
|
|
|
349
367
|
return () => { clearTimeout(first); clearInterval(every); };
|
|
350
368
|
}
|
|
351
369
|
|
|
352
|
-
export { WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, newsAfter, waitingOn, draftsUrl, draftBrief, draftReply, checkWatch, dueWatches, startWatches };
|
|
370
|
+
export { WATCH_FILE, EVERY, GITHUB_INBOX, DRAFTS_DIR, itemsOf, itemKey, newItems, remember, isGitHubInbox, githubItems, readGitHub, setBrief, briefOf, newsNotice, watchState, addWatch, setEvery, removeWatch, clearNews, newsSince, newsAfter, waitingOn, watchBoard, isMail, draftsUrl, draftBrief, draftReply, checkWatch, dueWatches, startWatches };
|