symbiot 0.41.4 → 0.41.6

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
@@ -58,8 +58,11 @@ Tabs:
58
58
  - **Dashboard** — everything you [watch](#screens-blueprints-for-screen-automation)
59
59
  side by side: a card for each page (your inbox, GitHub, WhatsApp, any other) with
60
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.
61
+ newest items, and **Open**, **Draft a reply** and **Check now** on them. **Seen**
62
+ on a card sets its count back to 0 (what it found stays under Watching, and the
63
+ other cards keep theirs). The tab shows the count, so you see what's waiting from
64
+ any tab, and once you watch something the app opens on the Dashboard instead of
65
+ the Map. `symbiot watch board` prints the same cards as JSON.
63
66
  - **Drift** — the [`symbiot drift`](#whats-out-of-sync-symbiot-drift) report, with
64
67
  a "fetch latest" toggle (and an [experimental](#experimental) "check CI").
65
68
  - **Week / Standup / Todo** — the write-ups (these use your chosen AI).
@@ -108,7 +111,8 @@ repo is now archived), without Electron:
108
111
  default). The app writes your week with your chosen AI, saves it to
109
112
  `~/.config/symbiot/weeks/<date>.md`, and sends a desktop notification
110
113
  (`notify-send` on Linux, Notification Center on macOS, a tray balloon on
111
- Windows). The Week tab shows it until you write a new one. It runs while
114
+ Windows). The Week tab shows it until you write a new one, and a week you
115
+ write there with its button is saved to `weeks/` the same way. It runs while
112
116
  `symbiot app` is running. A week missed while the computer was off is written
113
117
  when the app next starts, once. **Write it now** checks that it works.
114
118
  - **Start Symbiot in the background when I log in** adds an autostart entry
@@ -263,6 +267,16 @@ Tasks waiting for review aren't re-sent to the agent, and while the repo's agent
263
267
  is still running, Approve waits ("agent still working"), so half-done work isn't
264
268
  committed.
265
269
 
270
+ **Tasks about tasks.** An agent can't edit your task list, so a task like
271
+ `Drop the "gosolr's own WhatsApp number" task` or ``Merge the two `WA_WABA_ID`
272
+ tasks into one`` is done once you approve it. The tasks it names then leave your
273
+ list, so a later **Send to repos** doesn't bring them back: a dropped one is
274
+ archived, and of merged ones the newest stays and the rest are archived. Symbiot
275
+ finds them by the quoted name before the word "task": tasks in that repo, from
276
+ before the approval, with all of its words ("'s" aside). If it names more than
277
+ four, it's too vague, and nothing is removed. In the archived view they're marked
278
+ **dropped** or **merged**, and one you restore stays.
279
+
266
280
  **Releases.** The review card warns about merged work that isn't released yet,
267
281
  measured the way the repo releases: from its last `v*` tag, or, for a repo whose
268
282
  GitHub workflow runs `npm publish` on every push to its default branch, from the
@@ -311,6 +325,18 @@ preset only fills in the command box; the saved command is what runs.
311
325
  isn't on your PATH). They just open the repo. Nothing comes back for review unless
312
326
  you run an agent there yourself, and Neovim needs a terminal to open in.
313
327
 
328
+ **Connectors.** The connectors you've linked to Claude (claude.ai's Google Drive,
329
+ Gmail, Notion…, and servers added with `claude mcp add`) are only usable by an
330
+ unattended run when `--allowedTools` names them, so a run asked to check your
331
+ Drive or mail used to be refused. Symbiot now adds each one's rule
332
+ (`mcp__claude_ai_Google_Drive` covers `mcp__claude_ai_Google_Drive__search_files`
333
+ and the rest of its tools) to a Claude command on every run. It reads them from
334
+ `~/.claude.json`, so linking one takes effect on the next run, and your saved
335
+ command stays as you typed it. A connector still waiting to be authorized at
336
+ claude.ai isn't added. Settings → Handoff lists them, and says so when your
337
+ command isn't Claude and its runs can't use them. Claude in Orca's tab asks you
338
+ before using one.
339
+
314
340
  Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
315
341
  **Agents** tab. The command is saved as `agentCmd` in
316
342
  `~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
@@ -365,6 +391,16 @@ keep going with everything that doesn't depend on an answer, and to ask instead
365
391
  doing anything destructive. Both files live in `.symbiot/`, so they're never
366
392
  committed.
367
393
 
394
+ **A run that stopped on questions isn't repeated for nothing.** When a run ends
395
+ having asked questions you haven't answered, with tasks still unticked, Symbiot
396
+ keeps what it ran with in `.symbiot/blocked.json`. The next send to that repo
397
+ starts no agent until something it could act on changes: `.env` (or another
398
+ `.env.*` file there), `ANSWERS.md`, the agent command (a command you allowed, a
399
+ connector), or a task it didn't have. Otherwise the run would only ask the same
400
+ questions again. The send says so, with **Start it anyway** for when what changed
401
+ is somewhere else (`symbiot push --open --force` from the terminal). **Draft a
402
+ reply** always starts its run.
403
+
368
404
  ## What's out of sync? `symbiot drift`
369
405
 
370
406
  ```bash
@@ -589,7 +625,13 @@ the window, so a map only has the part that fits: when there's more, the screen
589
625
  says so and **Scroll down** (and **Scroll up**) scrolls the page in the hidden
590
626
  browser and maps what's in the window then, as a new screen. It scrolls what your
591
627
  mouse wheel would: the page, or the part of it that scrolls on its own (Gmail's
592
- list of mail). Scrolling only looks, so it never asks first. **Press** on a region clicks it
628
+ list of mail). Scrolling only looks, so it never asks first. Or **Whole page**
629
+ maps all of it at once: one tall screenshot (up to 16,000 pixels) with every
630
+ button, link and field on it marked where it is on the page. It's for a page that
631
+ scrolls as a whole; a list that scrolls inside the page, like Gmail's, still takes
632
+ Scroll down. The hidden browser scrolls down a window at a time and puts the
633
+ screenshots together, so a menu bar that stays at the top shows once, at the top,
634
+ and a sticky side menu shows in the first window only. **Press** on a region clicks it
593
635
  in that hidden browser and maps the page it leads to as a new screen, so map,
594
636
  press, map is how an agent finds its way around a site. **Type** on a field types
595
637
  your text into it there (replacing what was in it) and, with **Type, then Enter**
@@ -621,6 +663,7 @@ symbiot screens type <id> "Search mail" "invoice" --enter --yes # type into a
621
663
  symbiot screens type <id> To "sam@example.com" --yes # without --enter: the text stays in the field...
622
664
  symbiot screens press <new id> Send --yes # ...for a press on the screen that just printed
623
665
  symbiot screens scroll <id> # "more": "below"? scroll down and map the next part (or up, top, bottom)
666
+ symbiot screens whole <id> # all of that page in one tall screen (or: symbiot screens map <site> --whole)
624
667
  symbiot screens show <id> # a saved screen's blueprint
625
668
  symbiot screens signin gmail # sign in once, in Symbiot's browser window
626
669
  symbiot screens # list them
@@ -651,8 +694,10 @@ Your coding agent reads it too, so it can act on it with `symbiot screens`:
651
694
  symbiot watch add <screen id> --every 15 # watch a mapped page (or: symbiot watch add gmail)
652
695
  symbiot watch add github # your GitHub notifications (see below)
653
696
  symbiot watch new --hours 24 # what's new, newest first (JSON)
697
+ symbiot watch board # the Dashboard's cards (JSON; .total is the count, for a status bar)
698
+ symbiot watch seen <watch id> # set a card back to 0, like its Seen button
654
699
  symbiot watch check # read them all now (JSON)
655
- symbiot watch draft <id> # Draft a reply to a new email (see below)
700
+ symbiot watch draft <id> # Draft a reply to a new email or chat message (see below)
656
701
  symbiot watch brief on # your AI says what needs you (off to stop)
657
702
  symbiot watch # what you watch, and what's new
658
703
  symbiot watch remove <id>
@@ -689,6 +734,15 @@ know, say) is answered there. The agent sees the email's text, so it reaches
689
734
  whatever AI your agent uses. From a terminal, `symbiot watch new` marks the
690
735
  emails `"mail": true`, and `symbiot watch draft <id>` does the same as the button.
691
736
 
737
+ A new WhatsApp message (a watched `web.whatsapp.com`) has **Draft a reply** too.
738
+ There, the agent opens the chat in the hidden browser and types the reply into
739
+ its message box, and leaves it there unsent. WhatsApp keeps it as that chat's
740
+ draft in Symbiot's browser: type `web.whatsapp.com` under Screens and click
741
+ **Sign in** to read it and send it. The same guard holds, plus one for chats: a
742
+ draft's run can't press Enter (in a chat, Enter sends), and in WhatsApp its line
743
+ breaks are typed as spaces. Add `web.whatsapp.com` under Trusted sites for it.
744
+ `symbiot watch new` marks those `"chat": true`.
745
+
692
746
  **In Standup.** Standup ends with what's waiting on you since yesterday, counted
693
747
  from what Watch found: `Waiting on you: 3 emails, 2 GitHub notifications`. Symbiot
694
748
  counts them itself, so the numbers are right; the AI only sees them to know
package/agents.mjs CHANGED
@@ -4,9 +4,9 @@
4
4
  import { spawn } from "node:child_process";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync } from "node:fs";
8
- import { randomBytes } from "node:crypto";
9
- import { loadConfig, saveConfig, loadTasks, sameTask, sh, hasCmd } from "./core.mjs";
7
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, openSync, writeSync, unlinkSync, readdirSync, statSync } from "node:fs";
8
+ import { randomBytes, createHash } from "node:crypto";
9
+ import { loadConfig, saveConfig, loadTasks, sameTask, taskWords, sh, hasCmd } from "./core.mjs";
10
10
 
11
11
  const HANDOFFS = []; // live registry of agents Symbiot has handed work to
12
12
  // ---- hand a repo (+ its tasks) to the user's agent — generic, settings-based
@@ -48,21 +48,50 @@ function grantRule(tool) {
48
48
  t = t.replace(/[()"'`]/g, "").replace(/:\*$/, "").trim();
49
49
  return t ? `Bash(${t}:*)` : "";
50
50
  }
51
+ // Add a rule to a Claude command's --allowedTools (or start the flag), once.
52
+ function allowTool(cmd, t) {
53
+ if (!t || cmd.includes(`"${t}"`)) return cmd;
54
+ if (/--allowedTools\b/.test(cmd)) return cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
55
+ return cmd + ` --allowedTools "${t}"`;
56
+ }
57
+ const isClaudeCmd = (cmd) => /^\s*claude\b/.test(String(cmd || ""));
51
58
  function grantAgent({ tool, dir } = {}) {
52
59
  const cfg = loadConfig(); let cmd = (cfg.agentCmd || CLAUDE_CMD).trim();
53
- if (!/^\s*claude\b/.test(cmd)) return { error: "Grants apply to the Claude agent command. Pick a Claude preset first, or edit the command directly." };
54
- if (tool) {
55
- const t = grantRule(tool);
56
- if (t && !cmd.includes(`"${t}"`)) {
57
- if (/--allowedTools\b/.test(cmd)) cmd = cmd.replace(/(--allowedTools\s+(?:"[^"]*"\s*)+)/, (m) => m.trimEnd() + ` "${t}" `);
58
- else cmd += ` --allowedTools "${t}"`;
59
- }
60
- }
60
+ if (!isClaudeCmd(cmd)) return { error: "Grants apply to the Claude agent command. Pick a Claude preset first, or edit the command directly." };
61
+ if (tool) cmd = allowTool(cmd, grantRule(tool));
61
62
  if (dir) { const d = String(dir).trim(); if (d && !cmd.includes(`--add-dir "${d}"`)) cmd += ` --add-dir "${d}"`; }
62
63
  cmd = cmd.replace(/\s+/g, " ").trim();
63
64
  cfg.agentCmd = cmd; saveConfig(cfg);
64
65
  return { ok: true, cmd };
65
66
  }
67
+ // ---- connectors: what the user linked to Claude, for its runs -------------
68
+ // Connectors linked to Claude (claude.ai's Google Drive, Gmail, Notion…, and
69
+ // servers added with `claude mcp add`) are tools to an unattended run only when
70
+ // --allowedTools names them: `claude -p` can't ask, so a run told to check your
71
+ // Drive or mail was refused and said it had no access. From ~/.claude.json: the
72
+ // claude.ai connectors that have connected (claudeAiMcpEverConnected), the
73
+ // user's own servers and this folder's. One waiting to be authorized
74
+ // (mcpNeedsAuthNoticed) is listed but not ready. Its rule, mcp__<name>, allows
75
+ // all its tools; Claude names them with anything but letters, digits, _ and -
76
+ // made _ ("claude.ai Google Drive" -> mcp__claude_ai_Google_Drive__search_files).
77
+ function claudeConnectors(dir = "", file = join(homedir(), ".claude.json")) {
78
+ let j = {}; try { j = JSON.parse(readFileSync(file, "utf8")) || {}; } catch {}
79
+ const names = (x) => Array.isArray(x) ? x.filter((s) => typeof s === "string" && s) : [];
80
+ const servers = (o) => o && typeof o === "object" && !Array.isArray(o) ? Object.keys(o) : [];
81
+ const proj = dir && j.projects && typeof j.projects === "object" ? j.projects[dir] : null;
82
+ const needsAuth = new Set(names(j.mcpNeedsAuthNoticed));
83
+ return [...new Set([...names(j.claudeAiMcpEverConnected), ...servers(j.mcpServers), ...servers(proj && proj.mcpServers)])]
84
+ .map((name) => ({ name, rule: "mcp__" + name.replace(/[^A-Za-z0-9_-]/g, "_"), ready: !needsAuth.has(name) }));
85
+ }
86
+ // A Claude command with the ready connectors' rules added, for this run only:
87
+ // the saved command stays as typed, so linking or unlinking one takes effect on
88
+ // the next run. Any other agent's command runs as-is (Settings says so).
89
+ function withConnectors(tmpl, dir, file) {
90
+ if (!isClaudeCmd(tmpl)) return tmpl;
91
+ return claudeConnectors(dir, file).filter((c) => c.ready).reduce((cmd, c) => allowTool(cmd, c.rule), tmpl).replace(/\s+$/, "");
92
+ }
93
+ // For Settings → Handoff: the connectors, and whether this command's runs get them.
94
+ function connectorsInfo(file) { return { claude: isClaudeCmd(handoffCmd()), list: claudeConnectors("", file).map(({ name, ready }) => ({ name, ready })) }; }
66
95
  const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPath)).replace(/\{prompt\}/g, escDq(HANDOFF_PROMPT));
67
96
  // One agent per folder: two identical runs once started on the same repo 6s
68
97
  // apart and raced each other. The registry catches a second click in this
@@ -74,15 +103,21 @@ const fillHandoff = (tmpl, repoPath) => tmpl.replace(/\{dir\}/g, shSingle(repoPa
74
103
  // A folder's .symbiot/handoff.json ({ name, env }) names its job and joins its
75
104
  // environment, on every run there, a rerun with answers too: a draft reply's
76
105
  // folder (watch.mjs) carries SYMBIOT_DRAFT, which refuses a press on Send.
77
- function runHandoff(repoPath) {
78
- const tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
106
+ // A folder whose last run stopped on questions, with nothing changed since
107
+ // that could answer them, gets no run: { blocked, questions, note }
108
+ // (blockedAgain). force starts one anyway (something changed elsewhere).
109
+ function runHandoff(repoPath, { force = false } = {}) {
110
+ let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
79
111
  let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
80
112
  const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
81
113
  releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
114
+ tmpl = withConnectors(tmpl, repoPath);
115
+ const blocked = !force && blockedAgain(repoPath, tmpl); if (blocked) return blocked;
82
116
  const lock = join(repoPath, ".symbiot", LOCK);
83
117
  const env = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null;
84
- const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, () => {
118
+ const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(tmpl, repoPath), repoPath, (code) => {
85
119
  try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
120
+ noteBlocked(repoPath, tmpl, e.startedAt, code);
86
121
  startHeldTasks(repoPath); // tasks sent while it ran land now; start on them as that Send would have
87
122
  }, env);
88
123
  if (!e) return null;
@@ -90,6 +125,38 @@ function runHandoff(repoPath) {
90
125
  if (e.pid) try { writeFileSync(lock, JSON.stringify({ pid: e.pid, id: e.id, startedAt: e.startedAt, owner: process.pid })); } catch {}
91
126
  return e;
92
127
  }
128
+ // ---- a run that would only report the same blockers again -------------------
129
+ // A run that ends having asked questions (QUESTIONS.md written during it, some
130
+ // still unanswered) with tasks left unticked is blocked on the user: noteBlocked
131
+ // keeps what it ran with in .symbiot/blocked.json. Another run on the same
132
+ // inputs could only say the same things again, so blockedAgain refuses one
133
+ // until something it could act on changes: .env (the values it asked for),
134
+ // ANSWERS.md, the agent command (a grant, a connector) or a task it didn't have.
135
+ const BLOCKED = "blocked.json";
136
+ const digest = (s) => createHash("sha1").update(String(s)).digest("hex").slice(0, 16);
137
+ const openTasks = (path) => readSymbiot(path, "TASKS.md").split("\n").filter((l) => /^\s*-\s*\[ \]/.test(l)).map((l) => taskWords(l.replace(/^\s*-\s*\[ \]\s*/, ""))).filter(Boolean);
138
+ function runInputs(path, cmd) {
139
+ let files = [], env = ""; try { files = readdirSync(path).filter((f) => /^\.env(\..+)?$/.test(f) && !/\.(example|sample|template)$/.test(f)).sort(); } catch {}
140
+ for (const f of files) { try { env += f + "\0" + readFileSync(join(path, f), "utf8") + "\0"; } catch {} }
141
+ return { env: digest(env), answers: digest(readSymbiot(path, "ANSWERS.md")), cmd: digest(cmd), open: openTasks(path) };
142
+ }
143
+ function noteBlocked(path, cmd, startedAt, code) {
144
+ const f = join(path, ".symbiot", BLOCKED);
145
+ let asked = false; try { asked = statSync(join(path, ".symbiot", "QUESTIONS.md")).mtimeMs >= startedAt; } catch {}
146
+ const questions = asked ? agentQuestions(path, "").questions.length : 0, inputs = runInputs(path, cmd);
147
+ try {
148
+ if (code === 0 && questions && inputs.open.length) writeFileSync(f, JSON.stringify({ at: Date.now(), questions, ...inputs }));
149
+ else unlinkSync(f);
150
+ } catch {}
151
+ }
152
+ function blockedAgain(path, cmd) {
153
+ let b = null; try { b = JSON.parse(readSymbiot(path, BLOCKED)); } catch {}
154
+ if (!b || !Array.isArray(b.open)) return null;
155
+ const now = runInputs(path, cmd), was = new Set(b.open);
156
+ if (now.env !== b.env || now.answers !== b.answers || now.cmd !== b.cmd || !now.open.length || now.open.some((t) => !was.has(t))) return null;
157
+ const n = b.questions || 0, them = n === 1 ? "it" : "them";
158
+ return { blocked: true, questions: n, note: `Not started: the last run here stopped on ${n} question${n === 1 ? "" : "s"}, and .env and ANSWERS.md haven't changed since, so another run would only ask again. Answer ${them} or change .env, then send again.` };
159
+ }
93
160
  const LOCK = "agent.pid";
94
161
  const LOCK_MAX_AGE = 12 * 3600 * 1000; // older than this, the pid has probably been reused
95
162
  const pidAlive = (pid) => { try { process.kill(pid, 0); return true; } catch (err) { return !!err && err.code === "EPERM"; } };
@@ -132,7 +199,7 @@ function releaseHeldTasks(path) {
132
199
  // once, so this can't loop. Returns the new job, or null.
133
200
  function startHeldTasks(path) {
134
201
  if (!releaseHeldTasks(path) || !/^\s*-\s*\[ \]/m.test(readSymbiot(path, "TASKS.md"))) return null;
135
- const n = runHandoff(path); if (!n || n.busy) return null;
202
+ const n = runHandoff(path); if (!n || n.busy || n.blocked) return null;
136
203
  n.fromHeld = true; return n;
137
204
  }
138
205
  // Presets. [cmd, label, macAppName] — macApp used to launch GUI editors on macOS
@@ -348,7 +415,7 @@ function answerQuestions(path, answers, opts = {}) {
348
415
  writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
349
416
  } catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
350
417
  const out = { ok: true, saved: rows.length };
351
- if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
418
+ if (opts.rerun) { const e = runHandoff(path); if (e && e.busy) out.note = "Answers saved. An agent is still running in that folder, so another wasn't started. Send them again once it finishes."; else if (e && e.blocked) out.note = "Answers saved. " + e.note; else if (e) out.rerun = e.id; else out.note = "Answers saved. Set an agent command in Settings to have the agent pick them up automatically."; }
352
419
  return out;
353
420
  }
354
421
  // The open tasks' titles in a held brief (TASKS.next.md), or null if nothing is held.
@@ -367,4 +434,4 @@ function agentsList() {
367
434
  });
368
435
  }
369
436
 
370
- export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, fillHandoff, runHandoff, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
437
+ export { HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, connectorsInfo, fillHandoff, runHandoff, blockedAgain, runningHandoff, writeTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, agentQuestions, answerQuestions, agentsList };
package/desktop.mjs CHANGED
@@ -57,18 +57,20 @@ function weeklyState() { return { ...weeklyCfg(), latest: latestWeek() }; }
57
57
  // Write the week with `produce` (index.mjs), save it and notify. `last` is set
58
58
  // first, so a slow or failing run is never retried every minute. notify: false
59
59
  // skips the notification, for a caller that tells you itself (the saved file
60
- // and what's returned are the same either way).
60
+ // and what's returned are the same either way): the Week tab's button does, so
61
+ // a week written there is saved too. What's returned is produce's answer (text,
62
+ // footer), plus the file it was saved to, or an error.
61
63
  async function runWeekly(produce, { notify: on = true } = {}) {
62
64
  const notify = (body) => on && desktopNotify("Symbiot", body);
63
65
  const cfg = loadConfig(); cfg.weekly = { ...weeklyCfg(cfg), last: Date.now() }; saveConfig(cfg);
64
66
  const r = await produce("week");
65
67
  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 }; }
68
+ 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 { ...r, error: r.text }; }
67
69
  const file = join(WEEKS_DIR, localDay() + ".md");
68
70
  try { mkdirSync(WEEKS_DIR, { recursive: true }); writeFileSync(file, String(r.text || "").trim() + (r.footer ? FOOT + r.footer : "") + "\n"); }
69
- catch (e) { return { error: "Couldn't save the write-up: " + ((e && e.message) || e) }; }
71
+ catch (e) { return { ...r, error: "Couldn't save the write-up: " + ((e && e.message) || e) }; }
70
72
  notify("Your week is written. Open the Week tab in Symbiot, or " + file);
71
- return { ok: true, file, text: r.text };
73
+ return { ...r, ok: true, file };
72
74
  }
73
75
  // Checks once a minute while the app runs. Returns a stop function.
74
76
  function startWeekly(produce) {
package/headless.mjs CHANGED
@@ -7,6 +7,8 @@
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
+ // A map has what fits in the window: Scroll down maps the next part, and Whole
11
+ // page maps all of a page that scrolls as a whole in one tall screenshot.
10
12
  // readPage reads a page without saving a screen, for Watch (watch.mjs).
11
13
  //
12
14
  // It drives Chrome / Chromium / Edge / Brave over the DevTools protocol on a pipe
@@ -19,7 +21,7 @@ import { spawn } from "node:child_process";
19
21
  import { join } from "node:path";
20
22
  import { mkdirSync, existsSync } from "node:fs";
21
23
  import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
22
- import { loadScreens, addPageScreen, center } from "./screens.mjs";
24
+ import { loadScreens, addPageScreen, center, stitchPng } from "./screens.mjs";
23
25
 
24
26
  const PROFILE = join(CONFIG_DIR, "browser");
25
27
  const VIEW = { w: 1280, h: 800 }; // the page's size: a laptop-sized window
@@ -207,10 +209,14 @@ const SCROLLER = `function scroller() {
207
209
  // link, a part of the same kind is the same thing, so only the outer one counts.
208
210
  // And how far down it's scrolled: { y, max, selector? } (no selector: the page
209
211
  // itself), when there's more than the window shows.
210
- const COLLECT = `(() => { ${SCROLLER}
212
+ // whole: called again a window further down each time (snapshotWhole), it adds
213
+ // what's new on screen to what it found before (kept in the page) and gives all
214
+ // of it, each where it is on the page, not the window.
215
+ const collectJs = (whole) => `(() => { ${SCROLLER}
211
216
  const SEL = 'a[href],button,input:not([type=hidden]),select,textarea,summary,[contenteditable=""],[contenteditable=true],[onclick],' +
212
217
  ['button','link','tab','menuitem','menuitemcheckbox','menuitemradio','option','checkbox','radio','switch','combobox','textbox','searchbox','row','treeitem'].map((r) => '[role=' + r + ']').join(',');
213
- const W = innerWidth, H = innerHeight, out = [], kinds = new Map();
218
+ const W = innerWidth, H = innerHeight, X = ${whole ? "scrollX" : "0"}, Y = ${whole ? "scrollY" : "0"};
219
+ const kept = ${whole ? "(window.__symbiotWhole = window.__symbiotWhole || { out: [], kinds: new Map() })" : "{ out: [], kinds: new Map() }"}, out = kept.out, kinds = kept.kinds;
214
220
  const clean = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
215
221
  const kindOf = (e) => {
216
222
  const t = e.tagName.toLowerCase(), r = (e.getAttribute('role') || '').toLowerCase(), ty = (e.getAttribute('type') || 'text').toLowerCase();
@@ -236,6 +242,7 @@ const COLLECT = `(() => { ${SCROLLER}
236
242
  const img = e.querySelector('img[alt],svg title,[aria-label]');
237
243
  return img ? clean(img.getAttribute('alt') || img.getAttribute('aria-label') || img.textContent) : '';
238
244
  };
245
+ const stuck = (e) => { for (let n = e; n && n !== document.body; n = n.parentElement) if (/^(fixed|sticky)$/.test(getComputedStyle(n).position)) return true; return false; };
239
246
  const unique = (sel) => { try { return document.querySelectorAll(sel).length === 1; } catch (x) { return false; } };
240
247
  const cssPath = (e) => {
241
248
  const parts = [];
@@ -250,7 +257,7 @@ const COLLECT = `(() => { ${SCROLLER}
250
257
  };
251
258
  for (const e of document.querySelectorAll(SEL)) {
252
259
  if (out.length >= 200) break;
253
- if (e.disabled || e.closest('[aria-hidden=true],[inert]')) continue;
260
+ if (kinds.has(e) || e.disabled || e.closest('[aria-hidden=true],[inert]')) continue;
254
261
  const kind = kindOf(e), up = e.parentElement && e.parentElement.closest(SEL);
255
262
  if (up && kinds.get(up) === kind && kind !== 'field') continue;
256
263
  const b = e.getBoundingClientRect();
@@ -261,9 +268,11 @@ const COLLECT = `(() => { ${SCROLLER}
261
268
  // on top: what's at its centre is it, or inside it (not a banner over it)
262
269
  const top = document.elementFromPoint((x + x2) / 2, (y + y2) / 2);
263
270
  if (!top || !(top === e || e.contains(top) || top.contains(e))) continue;
271
+ ${whole ? "if (y >= H - 200 && stuck(e)) continue; // stuck to the window's bottom: left out of the whole page's picture" : ""}
264
272
  kinds.set(e, kind);
265
273
  const full = labelOf(e);
266
- 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) };
274
+ const box = ${whole ? "{ x: Math.max(0, b.left + X), y: Math.max(0, b.top + Y), w: b.width, h: b.height }" : "{ x, y, w: x2 - x, h: y2 - y }"};
275
+ const r = { label: full.slice(0, 80) || kind, kind, x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.w), h: Math.round(box.h), selector: cssPath(e) };
267
276
  if (full.length > 80) r.text = full.slice(0, 400); // all of a long one (an inbox row), for Watch
268
277
  if (e.href && /^https?:/.test(e.href)) r.href = String(e.href).slice(0, 500);
269
278
  out.push(r);
@@ -272,10 +281,11 @@ const COLLECT = `(() => { ${SCROLLER}
272
281
  const scroll = max > 0 ? { y: Math.round(sc.scrollTop), max, ...(sc === (document.scrollingElement || document.documentElement) ? {} : { selector: cssPath(sc) }) } : null;
273
282
  return { url: location.href, title: document.title, items: out, ...(scroll ? { scroll } : {}) };
274
283
  })()`;
284
+ const COLLECT = collectJs(false), COLLECT_WHOLE = collectJs(true);
275
285
 
276
286
  // What's on the page now: { url, title, items }.
277
- async function collect(page) {
278
- const { result, exceptionDetails } = await page.send("Runtime.evaluate", { expression: COLLECT, returnByValue: true });
287
+ async function collect(page, expression = COLLECT) {
288
+ const { result, exceptionDetails } = await page.send("Runtime.evaluate", { expression, returnByValue: true });
279
289
  if (exceptionDetails) throw new Error("Couldn't read the page: " + ((exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text));
280
290
  return (result && result.value) || { url: "", title: "", items: [] };
281
291
  }
@@ -285,22 +295,106 @@ function signInPage(url) {
285
295
  return /(^|\.)(accounts\.google|login\.(microsoftonline|live)|signin\.aws|auth0|okta)\./i.test(host + ".") || /\/(log-?in|sign-?in|auth|sso)\b/i.test(String(url || ""));
286
296
  }
287
297
 
298
+ // A screenshot and what's on it, saved as a screen named `name`, else for the page.
299
+ function saveScreen(name, data, info, suffix = "") {
300
+ const host = hostOf(info.url), base = String(name || "").trim() || info.title || host || "Page";
301
+ const s = addPageScreen(suffix ? base.slice(0, 80 - suffix.length) + suffix : base, Buffer.isBuffer(data) ? data : Buffer.from(data, "base64"), info, info.items);
302
+ if (s.error) return s;
303
+ // a sign-in page instead of the site: say how to get past it once
304
+ 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;
305
+ }
288
306
  // The page as it is now, saved as a screen with its regions.
289
307
  async function snapshot(page, name) {
290
308
  const info = await collect(page);
291
309
  const { data } = await page.send("Page.captureScreenshot", { format: "png" });
292
- const host = hostOf(info.url);
293
- const s = addPageScreen(String(name || "").trim() || info.title || host || "Page", Buffer.from(data, "base64"), info, info.items);
294
- if (s.error) return s;
295
- // a sign-in page instead of the site: say how to get past it once
296
- 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;
310
+ return saveScreen(name, data, info);
311
+ }
312
+
313
+ // The whole page at once, in one screenshot as tall as the page (up to MAX_TALL),
314
+ // with every button, link and field on it as a region, where it is on the page.
315
+ // The window stays the laptop size (so a part as tall as the window stays that
316
+ // tall): it scrolls down a window at a time, finds what's on each part and takes
317
+ // its screenshot, and the screenshots are put together (screens.mjs stitchPng).
318
+ // What's fixed or sticky (a menu bar, a cookie banner) is hidden once it's in one,
319
+ // so it shows once, where it was, not on every part. The parts overlap by
320
+ // OVERLAP pixels, and the later one's are kept, so what stuck to the bottom of the
321
+ // window is left out (and not marked: collectJs). Only for a page that scrolls
322
+ // as a whole: a list that scrolls inside the page (Gmail's) wouldn't show more, so
323
+ // that gives { error, inside } and is left to Scroll down.
324
+ const MAX_TALL = 16000, OVERLAP = 200; // pixels
325
+ const PAGE_HEIGHT = `(() => { ${SCROLLER}
326
+ const d = document.scrollingElement || document.documentElement, sc = scroller();
327
+ return { h: Math.max(d.scrollHeight, document.body ? document.body.scrollHeight : 0), inside: !!sc && sc !== d };
328
+ })()`;
329
+ const HIDE_STUCK = `(() => { const H = innerHeight;
330
+ if (!document.getElementById('symbiot-hide')) { const st = document.createElement('style'); st.id = 'symbiot-hide'; st.textContent = '[data-symbiot-hide]{visibility:hidden!important}'; document.documentElement.appendChild(st); }
331
+ for (const e of document.querySelectorAll('body *')) {
332
+ const p = getComputedStyle(e).position; if (p !== 'fixed' && p !== 'sticky') continue;
333
+ const b = e.getBoundingClientRect(); if (b.bottom > 0 && b.top < H) e.setAttribute('data-symbiot-hide', '');
334
+ }
335
+ })()`;
336
+ const UNHIDE = `document.querySelectorAll('[data-symbiot-hide]').forEach((e) => e.removeAttribute('data-symbiot-hide')); const st = document.getElementById('symbiot-hide'); if (st) st.remove(); window.__symbiotWhole = null; scrollTo({ top: 0, behavior: 'instant' });`;
337
+ async function evaluate(page, expression) {
338
+ const { result, exceptionDetails } = await page.send("Runtime.evaluate", { expression, returnByValue: true });
339
+ if (exceptionDetails) throw new Error("Couldn't read the page: " + ((exceptionDetails.exception && exceptionDetails.exception.description) || exceptionDetails.text));
340
+ return result && result.value;
341
+ }
342
+ async function snapshotWhole(page, name) {
343
+ const first = await evaluate(page, PAGE_HEIGHT);
344
+ if (first.inside) return { error: "This page doesn't scroll as a whole: a list inside it does (like Gmail's mail), and a tall screenshot wouldn't show more of that. Use Scroll down to map the next part of the list.", inside: true };
345
+ if (first.h <= VIEW.h + 1) { const s = await snapshot(page, name); return s.error || s.note ? s : { ...s, note: "It all fits in the window, so this map is the whole page." }; }
346
+ // a window at a time, overlapping a little; the page can load more as it goes (up to MAX_TALL)
347
+ let info = null, h = first.h, end = 0; const shots = [];
348
+ await evaluate(page, "window.__symbiotWhole = null");
349
+ try {
350
+ for (let y = 0, i = 0; i < 60; i++) {
351
+ await evaluate(page, `scrollTo({ top: ${y}, behavior: 'instant' })`); await sleep(150);
352
+ const at = Number(await evaluate(page, "scrollY")) || 0;
353
+ if (!info || info.items.length < 200) info = await collect(page, COLLECT_WHOLE);
354
+ const { data } = await page.send("Page.captureScreenshot", { format: "png" });
355
+ shots.push({ png: Buffer.from(data, "base64"), y: at }); end = at + VIEW.h;
356
+ await evaluate(page, HIDE_STUCK);
357
+ h = (await evaluate(page, PAGE_HEIGHT)).h;
358
+ if (end >= Math.min(MAX_TALL, h) || at < y) break; // the bottom (or it wouldn't scroll further)
359
+ y = Math.min(at + VIEW.h - OVERLAP, Math.min(MAX_TALL, h) - VIEW.h);
360
+ }
361
+ } finally { await evaluate(page, UNHIDE).catch(() => {}); }
362
+ const tall = Math.min(MAX_TALL, end), png = stitchPng(shots, tall);
363
+ if (!png) return { error: "Couldn't put the page's screenshots together." };
364
+ const items = info.items.filter((r) => r.y < tall);
365
+ const s = saveScreen(name, png, { url: info.url, title: info.title, full: true, items }, " (whole page)");
366
+ if (s.error || s.note) return s;
367
+ const note = [h > tall + 1 && `The page goes on past ${tall} pixels, so this map stops there.`, items.length >= 200 && "It marks the first 200 buttons, links and fields, from the top."].filter(Boolean).join(" ");
368
+ return note ? { ...s, note } : s;
297
369
  }
298
370
 
299
371
  // Map a site: open it in the hidden browser and save what's on it as a screen.
300
- function mapPage(input, name) {
372
+ // whole: all of the page in one tall screen (snapshotWhole); a page whose list
373
+ // scrolls inside it gets the usual map, with a note that says why.
374
+ function mapPage(input, name, { whole = false } = {}) {
301
375
  const url = siteUrl(input);
302
376
  if (!url) return Promise.resolve({ error: "Give a site to map: a name (gmail), a host (github.com/pulls) or a web address." });
303
- return oneAtATime(() => withPage(async (page) => { await open(page, url); return snapshot(page, name); })).catch((e) => ({ error: String((e && e.message) || e) }));
377
+ return oneAtATime(() => withPage(async (page) => {
378
+ await open(page, url);
379
+ if (!whole) return snapshot(page, name);
380
+ const s = await snapshotWhole(page, name);
381
+ if (!s.inside) return s;
382
+ const one = await snapshot(page, name);
383
+ return one.error || one.note ? one : { ...one, note: s.error };
384
+ })).catch((e) => ({ error: String((e && e.message) || e) }));
385
+ }
386
+
387
+ // The whole of a page you've mapped, as one tall screen (snapshotWhole). Only
388
+ // looks, so it never asks first.
389
+ function wholePage(id) {
390
+ const s = loadScreens().find((x) => x.id === id); if (!s) return Promise.resolve({ error: "not found" });
391
+ if (!s.page || !s.page.url) return Promise.resolve({ error: "Whole page works on a mapped page." });
392
+ return oneAtATime(() => withPage(async (page, b) => {
393
+ const here = await showScreen(page, b, s);
394
+ const next = await snapshotWhole(page, s.name.replace(/ ↓ \d+%$/, "").replace(/ \(whole page\)$/, ""));
395
+ if (next.error) { delete next.inside; return next; }
396
+ return { ...next, kept: here };
397
+ })).catch((e) => ({ error: String((e && e.message) || e) }));
304
398
  }
305
399
 
306
400
  // Read a site as it is now, without saving a screen: { url, title, items, login }.
@@ -408,7 +502,11 @@ function actOnRegion(id, regionId, verb, confirmed, check, act) {
408
502
  e.scrollIntoView({ block: 'center', inline: 'center' }); const b = e.getBoundingClientRect();
409
503
  return b.width && b.height ? { x: b.left + b.width / 2, y: b.top + b.height / 2 } : null; })()`;
410
504
  const { result } = await page.send("Runtime.evaluate", { expression: find, returnByValue: true });
411
- const at = (result && result.value) || center(r), found = !!(result && result.value);
505
+ const found = !!(result && result.value);
506
+ // not found by its selector: the same spot. On a whole-page screen that spot is
507
+ // on the page, not the window: scroll it into the window first.
508
+ let at = found ? result.value : center(r);
509
+ if (!found && s.page.full) { const y = await evaluate(page, `(scrollTo(0, ${at.y} - innerHeight / 2), scrollY)`); at = { x: at.x, y: at.y - (Number(y) || 0) }; }
412
510
  const navigating = page.until("Page.frameStartedLoading", 1500);
413
511
  const extra = await act(page, at, r);
414
512
  if (await navigating) await page.until("Page.loadEventFired", 30000);
@@ -432,10 +530,15 @@ function pressRegion(id, regionId, { confirmed = false, noSend = false } = {}) {
432
530
  // asked (which is how a search or a one-line form is sent). Without Enter, press
433
531
  // the form's button next, on the screen this maps, while the browser is still
434
532
  // open (the app's): what's typed is still there. Otherwise it's gone by then.
533
+ // noSend (a draft reply's run) refuses Enter, which sends in a chat, and in a
534
+ // WhatsApp chat types line breaks as spaces, in case a new line sends there too.
435
535
  const MAX_TEXT = 2000;
436
- function typeRegion(id, regionId, text, { enter = false, confirmed = false } = {}) {
536
+ const CHAT_HOSTS = new Set(["web.whatsapp.com"]);
537
+ function typeRegion(id, regionId, text, { enter = false, confirmed = false, noSend = false } = {}) {
437
538
  text = String(text == null ? "" : text);
539
+ if (noSend) { const s = loadScreens().find((x) => x.id === id); if (s && s.page && CHAT_HOSTS.has(hostOf(s.page.url))) text = text.replace(/\s*[\r\n]+\s*/g, " ").trim(); }
438
540
  return actOnRegion(id, regionId, "Type", confirmed, (r) => {
541
+ if (noSend && enter) return "Enter sends in a chat, and this run only drafts: type without --enter, and the reply stays unsent for the user to send.";
439
542
  if (r.kind !== "field") return `"${r.label}" isn't a field (it's a ${r.kind || "region"}). Type works on a field; use Press for the rest.`;
440
543
  if (!text && !enter) return "Give the text to type.";
441
544
  if (text.length > MAX_TEXT) return `That's more than ${MAX_TEXT} characters.`;
@@ -463,4 +566,4 @@ async function signIn(input) {
463
566
  return { ok: true, url };
464
567
  }
465
568
 
466
- export { siteUrl, browserArgs, mapPage, readPage, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
569
+ export { siteUrl, browserArgs, mapPage, wholePage, readPage, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };