symbiot 0.32.0 → 0.33.0

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.
Files changed (5) hide show
  1. package/README.md +69 -6
  2. package/index.mjs +150 -13
  3. package/mail.mjs +155 -0
  4. package/package.json +2 -1
  5. package/ui.mjs +85 -4
package/README.md CHANGED
@@ -14,6 +14,7 @@ symbiot todo what's still on your plate
14
14
  symbiot app open the visual app in your browser
15
15
  symbiot drift what's out of sync / at risk across your repos
16
16
  symbiot push write tasks into each repo for your coding agent
17
+ symbiot mail add the mail you sent to your write-ups (local, no API)
17
18
  symbiot models recommend AI models for your hardware
18
19
  symbiot setup-local set up a free local model (Ollama), one command
19
20
  symbiot login connect it to an AI (once)
@@ -68,8 +69,11 @@ Tabs:
68
69
  - **Agents** — every agent run Symbiot has started (and local-model downloads):
69
70
  live status, elapsed time, exit code, the tail of its output, and **what it
70
71
  did** — files changed and commits made, read from git, whichever agent it was.
71
- - **Settings** — your AI, the folders to scan, your agent command, model
72
- recommendations and a one-click local model.
72
+ When an agent leaves **questions, options or ideas** for you, they show up on its
73
+ block (see [Questions from your agent](#questions-from-your-agent)).
74
+ - **Settings** — your AI, the folders to scan, your agent command, your
75
+ [email](#your-email-without-an-api-symbiot-mail), model recommendations and a
76
+ one-click local model.
73
77
 
74
78
  **Stays current by itself.** The app checks npm for a newer Symbiot every couple of
75
79
  minutes (and whenever you come back to the window); the version you're on is shown
@@ -147,6 +151,34 @@ Each run is logged to `.symbiot/agent.log` in the repo and shown live in the
147
151
  `~/.config/symbiot/config.json` — it's your command, Symbiot only fills in
148
152
  `{dir}` and `{prompt}`. A command saved from an older preset keeps working as-is.
149
153
 
154
+ ### Questions from your agent
155
+
156
+ An agent working in your terminal or IDE stops to ask you things: which way to go,
157
+ whether to do something risky, ideas it had along the way. A handed-off run can't
158
+ do that, because it runs unattended. So the brief gives **every** agent (Claude,
159
+ Codex, Aider, Gemini, Cursor, whatever you run) a way to ask anyway. It writes
160
+ **`.symbiot/QUESTIONS.md`**:
161
+
162
+ ```markdown
163
+ ## Questions
164
+ ### Keep the old config format working?
165
+ Reading both costs about 40 lines.
166
+ - Yes, read both (recommended)
167
+ - No, migrate once and drop it
168
+
169
+ ## Suggestions
170
+ - Add a --json flag to drift
171
+ ```
172
+
173
+ Up to five questions show on that agent's block in the **Agents** tab, each with
174
+ its options and room for your own answer, alongside its ideas. **Send answers &
175
+ continue** appends your answers to `.symbiot/ANSWERS.md` and runs your agent
176
+ command again so the agent carries on with them. **Save only** keeps them for the
177
+ next run. **+ task** adds an idea to your Tasks for that repo. The agent is told to
178
+ keep going with everything that doesn't depend on an answer, and to ask instead of
179
+ doing anything destructive. Both files live in `.symbiot/`, so they're never
180
+ committed.
181
+
150
182
  ## What's out of sync? `symbiot drift`
151
183
 
152
184
  ```bash
@@ -238,6 +270,34 @@ It summarises the commits **you** authored — matching all your identities in e
238
270
  repo (per-repo and global email, your GitHub noreply address, your name), and
239
271
  counting everyone if that filter would drop almost all of an active repo's history.
240
272
 
273
+ ## Your email, without an API: `symbiot mail`
274
+
275
+ ```bash
276
+ symbiot mail # what mail it can read, and what you sent this week
277
+ symbiot mail --on # use it in week / standup (--off to stop)
278
+ symbiot mail --add ~/Takeout/Mail/All\ mail.mbox # an export, or any mail folder
279
+ ```
280
+
281
+ Much of a week's work happens over email, so Week and Standup can include **what
282
+ you sent**. There's no Gmail or Outlook API, no OAuth, no app to register and no
283
+ password. Symbiot reads the mail a desktop client already keeps on your computer,
284
+ so anyone can link theirs the same way:
285
+
286
+ - **Thunderbird** (Linux, macOS, Windows, including Snap and Flatpak installs),
287
+ **Apple Mail**, **Evolution**, **KMail**, and **mutt/neomutt** or any
288
+ `~/Maildir` / `~/mail` are found automatically. Symbiot only looks at their
289
+ **Sent** folders.
290
+ - **Webmail only?** Add an export. Google Takeout gives you an `.mbox`, and Symbiot
291
+ keeps just the messages with Gmail's *Sent* label (or from one of your
292
+ addresses). You can also point it at any `.mbox` file or a folder of `.eml`
293
+ files.
294
+
295
+ It reads **headers only** (date, recipients, subject), never a message body. It's
296
+ **off until you switch it on**, in Settings (*Email*) or with `symbiot mail --on`.
297
+ **Preview** shows exactly what a write-up would see. On macOS, reading Apple Mail
298
+ needs Full Disk Access for your terminal. Outlook for Windows keeps mail in `.pst`
299
+ files, which Symbiot can't read, so export to `.mbox` or use Thunderbird.
300
+
241
301
  ## Where it looks
242
302
 
243
303
  By default Symbiot scans your **home folder**. To point it at where your work
@@ -266,11 +326,14 @@ instead of hanging. The CLI shows scan progress on one line as it goes.
266
326
  ## What it reads, and what it doesn't
267
327
 
268
328
  - **Reads:** your local git — commit messages and changed-file names, plus
269
- `TODO`/`FIXME` markers and uncommitted changes for `todo`. All local.
329
+ `TODO`/`FIXME` markers and uncommitted changes for `todo`. If you switch
330
+ [email](#your-email-without-an-api-symbiot-mail) on, it also reads the headers
331
+ of mail you sent (subject, recipients, date). All local.
270
332
  - **Sends to the AI:** commit messages and dates, changed-file **names**, the
271
333
  folder structure, `TODO`/`FIXME` lines, and (for a repo review) an excerpt of
272
- the README and any `CLAUDE.md`/`AGENTS.md` conventions. It does **not** send
273
- whole source files. With a **local Ollama model, nothing leaves your machine.**
334
+ the README and any `CLAUDE.md`/`AGENTS.md` conventions. With email on, it also
335
+ sends the subjects and recipient names of mail you sent. It does **not** send
336
+ whole source files or any email body. With a **local Ollama model, nothing leaves your machine.**
274
337
  - **Shows its work:** every report ends with a footer — path, branch, how many
275
338
  commits matched you (e.g. "1090 of 1101"), the README's age, and the
276
339
  working-tree state — so you can see exactly what it read.
@@ -281,7 +344,7 @@ instead of hanging. The CLI shows scan progress on one line as it goes.
281
344
  ## Config
282
345
 
283
346
  Everything lives in `~/.config/symbiot/`: `config.json` (your AI, `scanRoots`,
284
- `agentCmd`; readable only by you), `tasks.json` (your tasks),
347
+ `agentCmd`, `mail`; readable only by you), `tasks.json` (your tasks),
285
348
  `deploys.json` (optional, for drift's production check) and `rules.md` (optional
286
349
  conventions every repo review must respect, alongside each repo's own
287
350
  `CLAUDE.md` / `AGENTS.md` / `CONTRIBUTING.md`).
package/index.mjs CHANGED
@@ -9,7 +9,8 @@
9
9
  // symbiot week your week, written up (default)
10
10
  // symbiot standup yesterday + today, for standup
11
11
  // symbiot todo what's still on your plate
12
- // symbiot login connect it to an AI (once)
12
+ // symbiot mail add the mail you sent to write-ups (local, no API)
13
+ // symbiot login connect it to an AI (once)
13
14
  // symbiot whoami show how it's connected
14
15
  // symbiot help
15
16
  //
@@ -27,6 +28,7 @@ import { createServer } from "node:http";
27
28
  import { randomBytes } from "node:crypto";
28
29
  import { fileURLToPath, pathToFileURL } from "node:url";
29
30
  import { EMBEDDED_UI } from "./ui.mjs";
31
+ import { detectMailSources, mailActivity } from "./mail.mjs";
30
32
 
31
33
  const HERE = fileURLToPath(new URL(".", import.meta.url));
32
34
  let VERSION = "0"; try { VERSION = JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch {}
@@ -267,6 +269,10 @@ function buildTasksMd(name, ctx, list) {
267
269
  const keys = Object.keys(byType).sort((a, b) => TASK_ORDER.indexOf(a) - TASK_ORDER.indexOf(b));
268
270
  for (const ty of keys) { L.push(`### ${ty}`); for (const t of byType[ty]) L.push(`- [ ] ${t.text}`); L.push(""); }
269
271
  L.push("## When you finish an item", "- Tick it here (`- [x]`) as soon as it's done — that's how it reaches review. Ticking doesn't archive it: the user approves it in Symbiot, which commits it on a branch and opens a PR.", "- Leave your changes **uncommitted**, and don't tick anything you didn't finish or couldn't verify.", "");
272
+ L.push("## If you need a decision, or have ideas", "You may be running unattended, so you can't ask in chat. Write `.symbiot/QUESTIONS.md` instead: Symbiot shows it to the user on your block in its Agents tab, and their answers come back in `.symbiot/ANSWERS.md` (read that first if it exists).",
273
+ `- At most ${QUESTIONS_MAX} questions, under a \`## Questions\` heading. Each is a \`### \` heading, then an optional line of context, then 2–4 options as \`- \` bullets (put the one you recommend first).`,
274
+ "- Ideas, options or follow-ups outside these tasks go under `## Suggestions` as `- ` bullets — the user can add them to their tasks in one click.",
275
+ "- Carry on with everything that doesn't depend on an answer, and don't tick an item that does. Ask there rather than doing anything destructive.", "");
270
276
  L.push("---", 'To action these, tell your coding agent: "Read `.symbiot/TASKS.md` and implement the unchecked items in this repo, using the context above. Tick each item as you finish it and leave changes uncommitted for review. Confirm with me before anything destructive."');
271
277
  return L.join("\n") + "\n";
272
278
  }
@@ -746,7 +752,7 @@ const shSingle = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'";
746
752
  const escDq = (s) => String(s).replace(/[\\"$`]/g, "\\$&");
747
753
  // Plain words only: in the Orca preset this passes through two shells, so no
748
754
  // backticks or $ (escDq covers one level).
749
- const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. Confirm before anything destructive.";
755
+ const HANDOFF_PROMPT = "Read .symbiot/TASKS.md and implement the unchecked items in this repo. Tick each item [x] in that file as you finish it and leave your changes uncommitted, so they can be reviewed and approved. If you need a decision, or have ideas or options for the user, write them to .symbiot/QUESTIONS.md as TASKS.md explains, and read .symbiot/ANSWERS.md first if it exists. Confirm before anything destructive.";
750
756
  function handoffCmd() { const cfg = loadConfig(); return migrateOrcaCmd(cfg.agentCmd) || (cfg.ide ? `${cfg.ide} {dir}` : ""); } // ide = legacy
751
757
  // Save the template ("" clears it). Either way the legacy `ide` key goes.
752
758
  function setHandoffCmd(cmd) {
@@ -887,6 +893,81 @@ function agentChanges(path, startedAt) {
887
893
  return { dirty, stat, commits };
888
894
  } catch { return { dirty: 0, stat: "", commits: [] }; }
889
895
  }
896
+ // ---- agent questions: decisions, options and ideas, from ANY agent ---------
897
+ // A handoff runs unattended (claude -p, codex exec, aider --message, …), so the
898
+ // agent can't stop and ask in chat. TASKS.md tells it to write
899
+ // .symbiot/QUESTIONS.md instead — questions with options, plus ideas — which
900
+ // the Agents tab shows on that agent's block. The user's answers are appended to
901
+ // .symbiot/ANSWERS.md for the next run to read. Plain files, so it works the
902
+ // same whichever model or tool the agent is.
903
+ const QUESTIONS_MAX = 5;
904
+ const qKey = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
905
+ const readSymbiot = (path, f) => { try { return readFileSync(join(path, ".symbiot", f), "utf8"); } catch { return ""; } };
906
+ // "## Questions" → "### question", context lines, "- option" bullets;
907
+ // "## Suggestions" (or Ideas / Follow-ups) → "- idea" bullets. Forgiving: a bare
908
+ // bullet under Questions is a question with no options.
909
+ function parseQuestions(md) {
910
+ const questions = [], suggestions = []; let sec = "q", cur = null;
911
+ const clip = (s) => String(s).trim().slice(0, 300);
912
+ for (const raw of String(md || "").split(/\r?\n/)) {
913
+ const l = raw.trim(); if (!l) continue;
914
+ let m;
915
+ if ((m = l.match(/^###\s+(.+)$/))) {
916
+ if (sec === "s") { suggestions.push(clip(m[1])); cur = null; }
917
+ else { cur = { q: clip(m[1]), context: "", options: [] }; questions.push(cur); }
918
+ continue;
919
+ }
920
+ if ((m = l.match(/^##\s+(.+)$/))) { sec = /suggest|idea|follow|option/i.test(m[1]) ? "s" : "q"; cur = null; continue; }
921
+ if (/^#\s/.test(l)) continue;
922
+ const b = l.match(/^(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.+)$/);
923
+ if (sec === "s") { if (b) suggestions.push(clip(b[1])); continue; }
924
+ if (b) { if (cur) cur.options.push(clip(b[1])); else questions.push({ q: clip(b[1]), context: "", options: [] }); continue; }
925
+ if (cur) cur.context = clip((cur.context ? cur.context + " " : "") + l);
926
+ }
927
+ return { questions: questions.filter((x) => x.q).slice(0, 20).map((x) => ({ ...x, options: x.options.slice(0, 6) })), suggestions: suggestions.filter(Boolean).slice(0, 10) };
928
+ }
929
+ // The OPEN questions (not yet in ANSWERS.md) and the agent's ideas, each marked
930
+ // if it's already on the task list for this repo.
931
+ function agentQuestions(path, repo) {
932
+ const p = parseQuestions(readSymbiot(path, "QUESTIONS.md"));
933
+ const done = new Set([...readSymbiot(path, "ANSWERS.md").matchAll(/^###\s+(.+)$/gm)].map((m) => qKey(m[1])));
934
+ const open = p.questions.filter((x) => !done.has(qKey(x.q)));
935
+ const tasks = p.suggestions.length ? loadTasks() : [];
936
+ return {
937
+ questions: open.slice(0, QUESTIONS_MAX), answered: p.questions.length - open.length,
938
+ suggestions: p.suggestions.map((text) => ({ text, added: tasks.some((t) => t.repo === repo && qKey(t.text) === qKey(text)) })),
939
+ };
940
+ }
941
+ // Save answers to .symbiot/ANSWERS.md; opts.rerun hands the repo back to the
942
+ // agent (same saved command) so it carries on with them.
943
+ function answerQuestions(path, answers, opts = {}) {
944
+ path = String(path || "");
945
+ if (!path || !HANDOFFS.some((e) => e.path === path)) return { error: "No agent has run in that folder." };
946
+ const open = new Map(agentQuestions(path, "").questions.map((x) => [qKey(x.q), x.q]));
947
+ const rows = (Array.isArray(answers) ? answers : [])
948
+ .map((x) => ({ q: open.get(qKey(x && x.q)), a: String((x && x.a) || "").replace(/^\s*#+/gm, "").trim().slice(0, 2000) }))
949
+ .filter((x) => x.q && x.a);
950
+ if (!rows.length) return { error: "Pick or type at least one answer." };
951
+ const prev = readSymbiot(path, "ANSWERS.md") || "# Answers from the user\nAnswers to the questions in QUESTIONS.md, newest last. Follow them; ask again in QUESTIONS.md if one is unclear.\n";
952
+ const day = new Date().toISOString().slice(0, 10);
953
+ try {
954
+ mkdirSync(join(path, ".symbiot"), { recursive: true });
955
+ writeFileSync(join(path, ".symbiot", "ANSWERS.md"), prev.replace(/\s*$/, "\n") + rows.map((x) => `\n### ${x.q}\n${x.a}\n_answered ${day}_\n`).join(""));
956
+ } catch (e) { return { error: "Couldn't write ANSWERS.md: " + ((e && e.message) || e) }; }
957
+ const out = { ok: true, saved: rows.length };
958
+ if (opts.rerun) { const e = runHandoff(path); 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."; }
959
+ return out;
960
+ }
961
+ // The Agents tab: every tracked job, its log tail, what it changed, and — on the
962
+ // newest job per folder — the questions/ideas it left.
963
+ function agentsList() {
964
+ const seen = new Set();
965
+ return HANDOFFS.map((e) => {
966
+ let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {}
967
+ const first = !seen.has(e.path); seen.add(e.path);
968
+ return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null };
969
+ });
970
+ }
890
971
  // ---- local model one-command setup (Ollama), OS-aware ---------------------
891
972
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
892
973
  function hasOllama() { return !!sh("ollama --version").trim(); } // cross-platform (not POSIX command -v)
@@ -1469,6 +1550,33 @@ function discoveredRepos() {
1469
1550
  if (LAST_MAP && LAST_MAP.nodes) return LAST_MAP.nodes.filter((n) => n.type === "repo" && n.meta && n.meta.path).map((n) => ({ path: n.meta.path, name: n.label, recency: 0 }));
1470
1551
  return findAllRepos();
1471
1552
  }
1553
+ // ---- email: opt-in, local, no API (see mail.mjs) ---------------------------
1554
+ // config.mail = { enabled, sources: [extra folders/.mbox files], addresses: [your
1555
+ // mail addresses, to pick your mail out of a whole-mailbox export] }.
1556
+ function mailCfg() {
1557
+ const m = loadConfig().mail || {};
1558
+ return { enabled: !!m.enabled, sources: Array.isArray(m.sources) ? m.sources : [], addresses: Array.isArray(m.addresses) ? m.addresses : [] };
1559
+ }
1560
+ function mailState() { return { ...mailCfg(), detected: detectMailSources() }; }
1561
+ function setMail(b = {}) {
1562
+ const m = mailCfg();
1563
+ if (typeof b.enabled === "boolean") m.enabled = b.enabled;
1564
+ if (b.add) {
1565
+ const p = expandRoot(b.add);
1566
+ if (!p || !existsSync(p)) return { ...mailState(), error: "not found: " + (p || "(empty)") };
1567
+ if (!m.sources.includes(p)) m.sources.push(p);
1568
+ }
1569
+ if (b.remove) m.sources = m.sources.filter((x) => x !== b.remove);
1570
+ if (typeof b.addresses === "string") m.addresses = b.addresses.split(/[\s,;]+/).map((s) => s.trim().toLowerCase()).filter((s) => s.includes("@"));
1571
+ const cfg = loadConfig(); cfg.mail = m; saveConfig(cfg);
1572
+ return mailState();
1573
+ }
1574
+ // Mail you sent in the window — [] unless it's switched on (preview ignores that).
1575
+ function sentMail(days, preview = false) {
1576
+ const m = mailCfg(); if (!m.enabled && !preview) return [];
1577
+ return mailActivity({ days, sources: m.sources, addresses: [...m.addresses, me().email].filter(Boolean) });
1578
+ }
1579
+ const renderMail = (list) => list.slice(0, 60).map((x) => `- ${x.date} · to ${x.to.join(", ") || "?"} · ${x.subject}`).join("\n");
1472
1580
  async function produce(cmd) {
1473
1581
  if (!resolveProvider()) return { error: "not-connected" };
1474
1582
  if (cmd === "todo") {
@@ -1488,26 +1596,30 @@ async function produce(cmd) {
1488
1596
  // Discover the Map's repo set, then keep only those with commits in the window.
1489
1597
  const all = discoveredRepos();
1490
1598
  const repos = all.filter((r) => sh(`git -C ${JSON.stringify(r.path)} log --since="${days} days ago" --oneline -1 2>/dev/null`).trim());
1491
- if (!repos.length) return { text: `No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, sub: "no activity" };
1599
+ const mail = sentMail(days); // [] unless email is switched on
1600
+ if (!repos.length && !mail.length) return { text: `No commits in the last ${days} days across your ${all.length} repos.\nAdd folders to scan in Settings, or check your git identity.`, sub: "no activity" };
1492
1601
  let cs = commits(repos, `${days} days ago`, !has("all"));
1493
1602
  if (!cs.length) cs = commits(repos, `${days} days ago`, false); // fall back to all if none matched you
1494
1603
  const open = label === "week" ? openWork(repos) : [];
1495
- if (!cs.length) return { text: "Found repos, but no commits in the window.", sub: "no commits" };
1604
+ if (!cs.length && !mail.length) return { text: "Found repos, but no commits in the window.", sub: "no commits" };
1496
1605
 
1497
1606
  const system =
1498
- `You write a short, first-person work update from a person's git commits. ` +
1607
+ `You write a short, first-person work update from a person's git commits${mail.length ? " and the emails they sent" : ""}. ` +
1499
1608
  `Write as them ("I"), plainly and specifically, grouped by theme or project, most important first. ` +
1500
1609
  `Turn commit messages into outcomes a manager or teammate would understand — not a raw commit list. ` +
1610
+ (mail.length ? `Fold the emails into those themes (a decision, a hand-off, who they worked with); skip routine ones (receipts, scheduling, one-line replies). You only have their subjects and recipients — don't guess at what they said. ` : "") +
1501
1611
  `${label === "standup" ? "Keep it to 3-5 bullets: done, and what's next." : "A short paragraph or a few grouped bullets; end with a one-line 'In progress / next' if there are open items."} ` +
1502
- `No preamble, no sign-off, no invented work — only what the commits and open items show.`;
1612
+ `No preamble, no sign-off, no invented work — only what the commits${mail.length ? ", emails" : ""} and open items show.`;
1503
1613
  const prompt =
1504
1614
  `Person: ${who.name || "me"}. Window: ${label === "standup" ? "since yesterday" : `last ${days} days`}.\n\n` +
1505
- `Commits:\n${renderCommits(cs)}\n\n` +
1615
+ `Commits:\n${cs.length ? renderCommits(cs) : "(none)"}\n\n` +
1616
+ (mail.length ? `Emails I sent (date · to · subject):\n${renderMail(mail)}\n\n` : "") +
1506
1617
  (open.length ? `Open / in progress:\n${open.map((o) => `- ${o}`).join("\n")}\n\n` : "") +
1507
1618
  `Write the ${label === "standup" ? "standup" : "update"}.`;
1508
1619
 
1509
1620
  const text = await write(system, prompt);
1510
- return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d` };
1621
+ const mailNote = mail.length ? ` · ${mail.length} sent email${mail.length === 1 ? "" : "s"}` : "";
1622
+ return { text: text || "(couldn't reach the model)", sub: `${cs.length} commits across ${new Set(cs.map((x) => x.repo)).size} repos${mailNote} · ${label}`, footer: `symbiot ${VERSION} · ${all.length} repos (same as the Map) · ${repos.length} active · ${cs.length} commits in last ${days}d${mailNote}` };
1511
1623
  }
1512
1624
 
1513
1625
  async function cmdRun(cmd) {
@@ -1731,7 +1843,11 @@ async function cmdApp() {
1731
1843
  const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
1732
1844
  return json(res, { started: true, model, id: e ? e.id : "" });
1733
1845
  }
1734
- if (u.pathname === "/api/agents") return json(res, HANDOFFS.map((e) => { let tail = ""; try { tail = readFileSync(e.log, "utf8").slice(-1200); } catch {} return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, changed: agentChanges(e.path, e.startedAt) }; }));
1846
+ if (u.pathname === "/api/agents") return json(res, agentsList());
1847
+ if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req); return json(res, answerQuestions(String(b.path || ""), b.answers, { rerun: !!b.rerun })); }
1848
+ if (u.pathname === "/api/mail") return json(res, mailState());
1849
+ if (u.pathname === "/api/mail/set" && req.method === "POST") { const b = await readBody(req); return json(res, setMail(b)); }
1850
+ if (u.pathname === "/api/mail/preview") { const items = sentMail(Number(u.searchParams.get("days")) || SINCE_WEEK, true); return json(res, { count: items.length, items: items.slice(0, 20) }); }
1735
1851
  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 produce(cmd)); }
1736
1852
  if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
1737
1853
  if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1") await checkLatest(); return json(res, { version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: semverGt(LATEST_VERSION, VERSION) }); }
@@ -1774,6 +1890,24 @@ async function cmdApp() {
1774
1890
  checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
1775
1891
  }
1776
1892
 
1893
+ // `symbiot mail [--on|--off] [--add <path>]`: what mail it can read, and a preview.
1894
+ function cmdMail() {
1895
+ if (has("on") || has("off")) setMail({ enabled: has("on") });
1896
+ const add = flag("add", null);
1897
+ if (add) { const r = setMail({ add }); if (r.error) console.log(c.y(r.error)); }
1898
+ const m = mailState();
1899
+ console.log(`\n${c.g("●")} ${c.b("Symbiot mail")} ${c.d("· " + (m.enabled ? "on — what you sent feeds week and standup" : "off"))}\n`);
1900
+ const srcs = [...m.detected.map((d) => [d.path, d.kind]), ...m.sources.map((p) => [p, "added"])];
1901
+ if (!srcs.length) console.log(c.y("No mail found on this computer.") + c.d(" Use a desktop mail app (Thunderbird, Apple Mail, Evolution…), or add an export: symbiot mail --add ~/Takeout/Mail/All.mbox"));
1902
+ for (const [p, k] of srcs) console.log(` ${c.g("·")} ${p} ${c.d(k)}`);
1903
+ if (srcs.length) {
1904
+ const items = sentMail(SINCE_WEEK, true);
1905
+ console.log("\n" + c.b(`${items.length} sent in the last ${SINCE_WEEK} days`) + c.d(" (headers only)"));
1906
+ for (const x of items.slice(0, 12)) console.log(` ${c.d(x.date)} ${x.subject} ${c.d("→ " + (x.to.join(", ") || "?"))}`);
1907
+ }
1908
+ if (!m.enabled) console.log("\n" + c.d("Switch it on with symbiot mail --on (or in the app's Settings)."));
1909
+ }
1910
+
1777
1911
  const HELP = `${c.b("symbiot")} — your week, written from your real work.
1778
1912
 
1779
1913
  ${c.b("Usage")}
@@ -1783,6 +1917,7 @@ ${c.b("Usage")}
1783
1917
  symbiot app open the visual app in your browser
1784
1918
  symbiot drift what's out of sync / at risk across repos
1785
1919
  symbiot push [--open] write tasks into each repo (and run your agent)
1920
+ symbiot mail [--on|--off] use the mail you sent in write-ups (local, no API)
1786
1921
  symbiot models recommend AI models for your hardware
1787
1922
  symbiot setup-local [--model X] install/run a free local model (Ollama)
1788
1923
  symbiot login connect it to an AI (once)
@@ -1802,9 +1937,10 @@ ${c.b("Setup")} pick any AI to write with:
1802
1937
  ${c.d("Env keys also work: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY.")}
1803
1938
  ${c.d("Override the model per run with SYMBIOT_MODEL.")}
1804
1939
 
1805
- ${c.d("Reads only your local git. No accounts, no OAuth, no data leaves except the")}
1806
- ${c.d("commit summaries sent to the AI to write your update (nothing leaves at all")}
1807
- ${c.d("with a local Ollama model).")}`;
1940
+ ${c.d("Reads only your local git (and, if you switch it on, the headers of mail you")}
1941
+ ${c.d("sent). No accounts, no OAuth, no data leaves except the commit and email")}
1942
+ ${c.d("subjects sent to the AI to write your update (nothing leaves at all with a")}
1943
+ ${c.d("local Ollama model).")}`;
1808
1944
 
1809
1945
  // ---- main -----------------------------------------------------------------
1810
1946
  async function main() {
@@ -1817,6 +1953,7 @@ async function main() {
1817
1953
  if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
1818
1954
  if (cmd === "drift") return cmdDrift();
1819
1955
  if (cmd === "push") return cmdPush();
1956
+ if (cmd === "mail" || cmd === "email") return cmdMail();
1820
1957
  if (cmd === "week") return cmdRun("week");
1821
1958
  if (cmd === "standup") return cmdRun("standup");
1822
1959
  if (cmd === "todo") return cmdRun("todo");
@@ -1832,4 +1969,4 @@ const isMain = (() => {
1832
1969
  })();
1833
1970
  if (isMain) main();
1834
1971
 
1835
- export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, approveRepo, sendBack, pushTasks, semverGt };
1972
+ export { authorship, repoState, readmeInfo, repoShape, houseRules, findAllRepos, buildMap, reportFooter, detectHardware, recommendModels, computeDrift, driftRepo, gitDefaultBranch, buildTasksMd, taskType, EMBEDDED_UI, orcaHandoffCmd, migrateOrcaCmd, fillHandoff, handoffCmd, setHandoffCmd, ORCA_CLAUDE_CMD, HANDOFF_PROMPT, shipChanges, syncTasks, pendingReview, approveRepo, sendBack, pushTasks, semverGt, parseQuestions, agentQuestions };
package/mail.mjs ADDED
@@ -0,0 +1,155 @@
1
+ // symbiot — your email, without an API.
2
+ //
3
+ // Reads the mail a desktop client already keeps on this computer (Thunderbird,
4
+ // Apple Mail, Evolution, KMail, mutt/neomutt — any Maildir or mbox) or an
5
+ // exported .mbox (e.g. Google Takeout). No OAuth, no app registration, no
6
+ // password, no server: it works the same for anyone with mail on disk, which is
7
+ // how everyone else can link theirs too. Only headers are read (Date, From, To,
8
+ // Subject, Message-ID), never a message body — and only mail YOU sent, so a
9
+ // write-up gets "what I did over email", not your inbox.
10
+ import { homedir } from "node:os";
11
+ import { join, basename, dirname } from "node:path";
12
+ import { readdirSync, statSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
13
+
14
+ const TAIL_BYTES = 24 * 1024 * 1024; // an mbox appends: the newest mail is at the end
15
+ const HEAD_BYTES = 64 * 1024; // enough for one message's headers
16
+ const MAX_FILES = 600; // message files read per folder
17
+ const MAX_ITEMS = 80;
18
+ const WALK_MS = 8000; // one folder walk
19
+ const READ_MS = 15000; // everything, all sources
20
+ const SKIP_DIRS = new Set(["node_modules", ".git", "Attachments", "attachments", "cache2", "startupCache", "crashes", "minidumps", "storage", "datareporting", "extensions"]);
21
+ const NOT_MAIL = /\.(msf|dat|sqlite|sqlite-wal|sqlite-shm|json|plist|db|summary|cmeta|ibex|index|lock|log|txt|js|html|css|png|jpg|gif)$/i;
22
+ const SENTNAME = /^\.?sent\b/i; // Sent, Sent Mail, Sent Items, Sent Messages(.mbox), .Sent
23
+
24
+ // Bounded directory walk in plain JS (no `find`, so it also works on Windows).
25
+ function walk(root, { depth = 6, limit = 200, dirs = false, match, deadline = Date.now() + WALK_MS }) {
26
+ const out = [];
27
+ const go = (dir, d) => {
28
+ if (out.length >= limit || Date.now() > deadline) return;
29
+ let ents; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
30
+ for (const e of ents) {
31
+ if (out.length >= limit || Date.now() > deadline) return;
32
+ const p = join(dir, e.name);
33
+ if (e.isDirectory()) {
34
+ if (dirs && match(e.name, p)) { out.push(p); continue; }
35
+ if (d < depth && !SKIP_DIRS.has(e.name)) go(p, d + 1);
36
+ } else if (!dirs && e.isFile() && match(e.name, p)) out.push(p);
37
+ }
38
+ };
39
+ go(root, 0);
40
+ return out;
41
+ }
42
+
43
+ // Where desktop mail clients keep sent mail, per OS. Returns [{ path, kind }].
44
+ export function detectMailSources() {
45
+ const H = homedir(), out = [];
46
+ const add = (kind, paths) => { for (const p of paths) if (!out.some((x) => x.path === p)) out.push({ path: p, kind }); };
47
+ const sentFile = (n) => SENTNAME.test(n) && !NOT_MAIL.test(n);
48
+ const tb = process.platform === "darwin" ? [join(H, "Library/Thunderbird/Profiles")]
49
+ : process.platform === "win32" ? [join(process.env.APPDATA || join(H, "AppData/Roaming"), "Thunderbird/Profiles")]
50
+ : [join(H, ".thunderbird"), join(H, "snap/thunderbird/common/.thunderbird"), join(H, ".var/app/org.mozilla.Thunderbird/.thunderbird")];
51
+ for (const r of tb) add("Thunderbird", walk(r, { depth: 6, limit: 12, match: sentFile })); // mbox files: Mail/…/Sent, ImapMail/…/Sent Mail
52
+ if (process.platform === "darwin") add("Apple Mail", walk(join(H, "Library/Mail"), { depth: 4, limit: 12, dirs: true, match: (n) => SENTNAME.test(n) && /\.mbox$/i.test(n) }));
53
+ if (process.platform === "linux") {
54
+ for (const r of [join(H, ".local/share/evolution/mail"), join(H, ".cache/evolution/mail")]) add("Evolution", walk(r, { depth: 5, limit: 8, dirs: true, match: (n) => SENTNAME.test(n) }));
55
+ add("KMail", walk(join(H, ".local/share/local-mail"), { depth: 3, limit: 4, dirs: true, match: (n) => SENTNAME.test(n) }));
56
+ }
57
+ for (const r of [join(H, "Maildir"), join(H, "mail"), join(H, "Mail"), join(H, ".mail")]) {
58
+ add("Maildir", walk(r, { depth: 3, limit: 6, dirs: true, match: (n) => SENTNAME.test(n) }));
59
+ add("mbox", walk(r, { depth: 3, limit: 6, match: sentFile }));
60
+ }
61
+ return out.slice(0, 24);
62
+ }
63
+
64
+ function readChunk(file, bytes, fromEnd) {
65
+ let fd;
66
+ try {
67
+ fd = openSync(file, "r");
68
+ const size = fstatSync(fd).size, len = Math.min(bytes, size), buf = Buffer.alloc(len);
69
+ readSync(fd, buf, 0, len, fromEnd ? size - len : 0);
70
+ return { text: buf.toString("utf8"), partial: fromEnd && size > len };
71
+ } catch { return { text: "", partial: false }; }
72
+ finally { if (fd !== undefined) try { closeSync(fd); } catch {} }
73
+ }
74
+ // RFC 5322 header block -> { lowercased name: first value }, folded lines joined.
75
+ export function parseHeaders(raw) {
76
+ const s = String(raw || ""), end = s.search(/\r?\n\r?\n/);
77
+ const head = (end >= 0 ? s.slice(0, end) : s).replace(/\r?\n[ \t]+/g, " ");
78
+ const h = {};
79
+ for (const line of head.split(/\r?\n/)) {
80
+ const i = line.indexOf(":"); if (i <= 0) continue;
81
+ const k = line.slice(0, i).trim().toLowerCase(); if (!(k in h)) h[k] = line.slice(i + 1).trim();
82
+ }
83
+ return h;
84
+ }
85
+ // RFC 2047 encoded words: =?UTF-8?B?…?= / =?iso-8859-1?Q?…?=
86
+ export function decodeWords(s) {
87
+ return String(s || "").replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=(?:\s+(?==\?))?/g, (all, cs, enc, txt) => {
88
+ try {
89
+ const buf = /b/i.test(enc) ? Buffer.from(txt, "base64")
90
+ : Buffer.from(txt.replace(/_/g, " ").replace(/=([0-9A-Fa-f]{2})/g, (x, hx) => String.fromCharCode(parseInt(hx, 16))), "latin1");
91
+ return new TextDecoder(/^(utf-?8|us-ascii)$/i.test(cs) ? "utf-8" : cs.replace(/\*.*$/, "").toLowerCase()).decode(buf);
92
+ } catch { return all; }
93
+ });
94
+ }
95
+ // "Ann <a@x.com>, b@y.com" -> [{ name, addr }]
96
+ export function people(v) {
97
+ const out = [];
98
+ for (const part of decodeWords(v).split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)) {
99
+ const m = part.match(/^\s*"?([^"<]*?)"?\s*<([^>]+)>/);
100
+ const addr = (m ? m[2] : part).trim().toLowerCase();
101
+ if (addr.includes("@")) out.push({ name: m ? m[1].trim() : "", addr });
102
+ }
103
+ return out;
104
+ }
105
+ function toItem(h, fallbackTs) {
106
+ if (parseInt(h["x-mozilla-status"] || "0", 16) & 0x0008) return null; // Thunderbird: deleted, not yet compacted away
107
+ const ts = Date.parse(String(h.date || "").replace(/\s*\([^)]*\)\s*$/, "")) || fallbackTs || 0;
108
+ return {
109
+ ts, id: String(h["message-id"] || "").trim(), labels: String(h["x-gmail-labels"] || ""),
110
+ from: people(h.from || "")[0] || null, to: people([h.to, h.cc].filter(Boolean).join(", ")),
111
+ subject: decodeWords(h.subject || "").replace(/\s+/g, " ").trim().slice(0, 200) || "(no subject)",
112
+ };
113
+ }
114
+ // One mbox file: read its tail, split on the "From " separator lines.
115
+ function readMbox(file) {
116
+ const { text, partial } = readChunk(file, TAIL_BYTES, true);
117
+ const parts = text.split(/\r?\n(?=From )/);
118
+ if (partial) parts.shift(); // starts mid-message
119
+ return parts.map((p) => toItem(parseHeaders(p.replace(/^From [^\n]*\n/, "")), 0)).filter(Boolean);
120
+ }
121
+ // A folder of one-file-per-message mail: Maildir cur/new, .eml, Apple Mail .emlx.
122
+ function readMailDir(dir, since, deadline) {
123
+ const msg = (n, p) => !n.startsWith(".") && !NOT_MAIL.test(n) && (/\.(eml|emlx)$/i.test(n) || /^(cur|new)$/.test(basename(dirname(p))) || /[\\/](cur|new)[\\/]/.test(p.slice(dir.length)));
124
+ const recent = (p) => { try { return statSync(p).mtimeMs >= since - 86400000; } catch { return false; } };
125
+ const files = walk(dir, { depth: 8, limit: MAX_FILES, deadline: Math.min(deadline, Date.now() + WALK_MS), match: (n, p) => msg(n, p) && recent(p) });
126
+ return files.map((f) => {
127
+ const t = readChunk(f, HEAD_BYTES, false).text.replace(/^\d+[ \t]*\r?\n/, ""); // .emlx starts with a byte count
128
+ let mt = 0; try { mt = statSync(f).mtimeMs; } catch {}
129
+ return toItem(parseHeaders(t), mt);
130
+ }).filter(Boolean);
131
+ }
132
+ const sentLabel = (l) => /(^|,)\s*"?sent"?\s*(,|$)/i.test(l); // Google Takeout's X-Gmail-Labels
133
+
134
+ // Mail you sent in the last `days`: from the detected sources plus any you added.
135
+ // Mail in a Sent folder is yours; elsewhere (e.g. a whole-mailbox export) it
136
+ // counts only with Gmail's Sent label or a From matching one of `addresses`.
137
+ export function mailActivity({ days = 7, sources = [], addresses = [], auto = true } = {}) {
138
+ const now = Date.now(), since = now - days * 86400000, deadline = now + READ_MS;
139
+ const me = new Set(addresses.map((a) => String(a).trim().toLowerCase()).filter(Boolean));
140
+ const paths = [...new Set([...(auto ? detectMailSources().map((s) => s.path) : []), ...sources])];
141
+ const seen = new Set(), items = [];
142
+ for (const p of paths) {
143
+ if (Date.now() > deadline) break;
144
+ let st; try { st = statSync(p); } catch { continue; }
145
+ if (!st.isDirectory() && st.mtimeMs < since) continue; // nothing appended in the window
146
+ const sentSrc = SENTNAME.test(basename(p));
147
+ for (const m of st.isDirectory() ? readMailDir(p, since, deadline) : readMbox(p)) {
148
+ if (m.ts < since || m.ts > now + 86400000) continue;
149
+ if (!(sentSrc || sentLabel(m.labels) || (m.from && me.has(m.from.addr)))) continue;
150
+ const k = m.id || m.ts + "|" + m.subject; if (seen.has(k)) continue; seen.add(k);
151
+ items.push({ ts: m.ts, date: new Date(m.ts).toISOString().slice(0, 10), to: m.to.slice(0, 3).map((x) => x.name || x.addr), subject: m.subject, source: p });
152
+ }
153
+ }
154
+ return items.sort((a, b) => b.ts - a.ts).slice(0, MAX_ITEMS);
155
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
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": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "index.mjs",
11
11
  "ui.mjs",
12
+ "mail.mjs",
12
13
  "README.md",
13
14
  "LICENSE"
14
15
  ],
package/ui.mjs CHANGED
@@ -105,6 +105,17 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
105
105
  .commits{margin-top:6px;font-size:12px;color:var(--text);font-family:ui-monospace,Menlo,Consolas,monospace}
106
106
  .commits code{color:var(--amber);margin-right:6px}
107
107
  @keyframes pulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(242,165,65,.5)}50%{opacity:.4;box-shadow:0 0 0 5px rgba(242,165,65,0)}}
108
+ .aq{margin-top:10px;border:1px solid var(--amber);border-radius:9px;padding:10px 12px;background:var(--ink)}
109
+ .aq h4{margin:4px 0 6px;color:var(--amber);font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:700}
110
+ .aq .q{padding:8px 0;border-bottom:1px solid var(--line)}
111
+ .aq .qt{color:var(--bone);font-weight:600;font-size:13.5px}
112
+ .aq .qc{color:var(--faint);font-size:12.5px;margin-top:3px;line-height:1.5}
113
+ .aq label.opt{display:flex;gap:8px;align-items:flex-start;margin:6px 0 0;font-size:13px;color:var(--text);cursor:pointer}
114
+ .aq .opt input{width:auto;margin-top:3px;flex:none}
115
+ .aq .qother{margin-top:7px;padding:7px 10px;font-size:13px}
116
+ .aq .row{margin:10px 0 2px}
117
+ label.check{display:flex;gap:8px;align-items:center;font-size:13px;color:var(--text);margin:6px 0}
118
+ label.check input{width:auto}
108
119
  .alogout{white-space:pre-wrap;background:var(--ink);border:1px solid var(--line);border-radius:8px;padding:10px;margin-top:10px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:12px;line-height:1.5;color:var(--text);max-height:260px;overflow:auto}
109
120
  .bar{height:3px;background:var(--green-dim);border-radius:2px;overflow:hidden;margin-top:8px}
110
121
  .bar > i{display:block;height:100%;width:35%;background:var(--green);border-radius:2px;animation:slide 1.3s ease-in-out infinite}
@@ -165,7 +176,8 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
165
176
  <div id="tasklist"></div>
166
177
  </section>
167
178
  <section id="panel-agents" class="hidden">
168
- <div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output.</span><button class="ghost" id="agentsrefresh" style="margin-left:auto">Refresh</button></div>
179
+ <div class="row"><span class="muted">Agents Symbiot has handed work to — live status and output. Questions, options and ideas an agent leaves for you show up on its block, whichever model it runs.</span><button class="ghost" id="agentsrefresh" style="margin-left:auto">Refresh</button></div>
180
+ <div id="agentsmsg"></div>
169
181
  <div id="agentslist"></div>
170
182
  </section>
171
183
  <section id="panel-drift" class="hidden">
@@ -204,6 +216,15 @@ footer{padding:10px 18px;border-top:1px solid var(--line);display:flex}
204
216
  <div id="agentpresets" style="margin-top:8px"></div>
205
217
  </div>
206
218
  <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
219
+ <label>Email &mdash; add what you sent to Week and Standup</label>
220
+ <label class="check"><input type="checkbox" id="mailon"> Use my sent email (subjects &amp; recipients only)</label>
221
+ <div id="mailsources"></div>
222
+ <div class="row" style="margin-top:6px"><input id="newmail" placeholder="add a mail folder or .mbox file (e.g. a Google Takeout export)" style="flex:1"><button class="ghost" id="addmail">Add</button><button class="ghost" id="mailpreview">Preview</button></div>
223
+ <input id="mailaddrs" type="text" placeholder="your email addresses, comma-separated (only needed for a whole-mailbox export)">
224
+ <div class="note muted" id="mailnote">No API, no OAuth, no password: Symbiot reads the mail your desktop mail app (Thunderbird, Apple Mail, Evolution, mutt&hellip;) already keeps on this computer, or an exported .mbox &mdash; so anyone can link theirs. Headers only, never a message body; off until you tick it.</div>
225
+ <div id="mailout"></div>
226
+ </div>
227
+ <div style="margin-top:20px;border-top:1px solid var(--line);padding-top:16px">
207
228
  <button class="ghost" id="recbtn">Recommend models for my machine</button>
208
229
  <button class="ghost" id="setuplocal" style="margin-left:8px">Set up a free local model</button>
209
230
  <div id="setupout" class="note muted" style="margin-top:10px"></div>
@@ -380,10 +401,47 @@ var h="<div class='tgroup'>Archived <span class='tcount'>"+list.length+"</span><
380
401
  var agentsTimer=null;
381
402
  function stopAgentsPoll(){if(agentsTimer){clearTimeout(agentsTimer);agentsTimer=null;}}
382
403
  function fmtE(ms){var s=Math.floor(ms/1000);if(s<60)return s+'s';var m=Math.floor(s/60);return m+'m '+(s%60)+'s';}
404
+ // Questions, options and ideas an agent left in .symbiot/QUESTIONS.md (any
405
+ // agent: it's a file). Answers go to .symbiot/ANSWERS.md; "Send & continue"
406
+ // re-runs the agent so it picks them up. Text stays out of attributes (esc()
407
+ // doesn't escape quotes) — rows carry indexes into AGENTLIST instead.
408
+ var AGENTLIST=[],QDRAFT={};
409
+ function agentById(id){for(var i=0;i<AGENTLIST.length;i++)if(AGENTLIST[i].id===id)return AGENTLIST[i];return null;}
410
+ function nQs(a){return (a.ask&&a.ask.questions)?a.ask.questions.length:0;}
411
+ function askHtml(a){var k=a.ask;if(!k)return '';var qs=k.questions||[],ss=k.suggestions||[];if(!qs.length&&!ss.length)return '';
412
+ var h="<div class='aq' data-id='"+esc(a.id)+"'>";
413
+ if(qs.length){h+="<h4>&#10067; "+qs.length+" question"+(qs.length>1?"s":"")+" for you</h4>";
414
+ qs.forEach(function(q,i){h+="<div class='q' data-i='"+i+"'><div class='qt'>"+esc(q.q)+"</div>"+(q.context?"<div class='qc'>"+esc(q.context)+"</div>":"");
415
+ (q.options||[]).forEach(function(o,j){h+="<label class='opt'><input type='radio' name='q_"+esc(a.id)+"_"+i+"' value='"+j+"'><span>"+esc(o)+"</span></label>";});
416
+ h+="<input class='qother' placeholder='"+((q.options&&q.options.length)?"or answer in your own words":"your answer")+"'></div>";});
417
+ h+="<div class='row'><button class='act qsend' title='save the answers and hand the repo back to your agent'>Send answers &amp; continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
418
+ if(ss.length){h+="<h4>&#128161; Ideas from the agent</h4>";ss.forEach(function(s,i){h+="<div class='idea'><span style='flex:1'>"+esc(s.text)+"</span>"+(s.added?"<span class='tag'>in Tasks</span>":"<button class='ghost qidea' data-i='"+i+"' style='padding:3px 9px;font-size:12px'>+ task</button>")+"</div>";});}
419
+ return h+"</div>";}
420
+ function qKeyOf(qe){var box=qe.closest('.aq');var a=box&&agentById(box.getAttribute('data-id'));var q=a&&a.ask.questions[+qe.getAttribute('data-i')];return q?a.path+'|'+q.q:'';}
421
+ function saveDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var k=qKeyOf(qe);if(!k)return;var pick=qe.querySelector('input[type=radio]:checked');QDRAFT[k]={o:pick?pick.value:'',t:qe.querySelector('.qother').value};});}
422
+ function restoreDrafts(el){el.querySelectorAll('.aq .q').forEach(function(qe){var d=QDRAFT[qKeyOf(qe)];if(!d)return;qe.querySelectorAll('input[type=radio]').forEach(function(r){r.checked=r.value===d.o;});qe.querySelector('.qother').value=d.t||'';});}
423
+ function collectAnswers(box,a){var out=[];box.querySelectorAll('.q').forEach(function(qe){var q=a.ask.questions[+qe.getAttribute('data-i')];if(!q)return;
424
+ var other=(qe.querySelector('.qother').value||'').trim();var pick=qe.querySelector('input[type=radio]:checked');var ans=other||(pick?q.options[+pick.value]:'');if(ans)out.push({q:q.q,a:ans});});return out;}
425
+ function wireAsks(el){el.querySelectorAll('.aq').forEach(function(box){var a=agentById(box.getAttribute('data-id'));if(!a||!a.ask)return;
426
+ function send(rerun){var msg=document.getElementById('agentsmsg');var ans=collectAnswers(box,a);
427
+ if(!ans.length){msg.innerHTML="<div class='note err'>Pick an option or type an answer first.</div>";return;}
428
+ box.querySelectorAll('button').forEach(function(b){b.disabled=true;});
429
+ api('/api/agents/answer',{path:a.path,answers:ans,rerun:rerun}).then(function(r){
430
+ if(!r||r.error){box.querySelectorAll('button').forEach(function(b){b.disabled=false;});msg.innerHTML="<div class='note err'>"+esc((r&&r.error)||'failed')+"</div>";return;}
431
+ msg.innerHTML="<div class='note ok'>&#10003; Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" &middot; your agent is picking them up now.":".")+(r.note?"<div class='muted'>"+esc(r.note)+"</div>":"")+"</div>";loadAgents();});}
432
+ var s1=box.querySelector('.qsend'),s2=box.querySelector('.qsave');
433
+ if(s1)s1.addEventListener('click',function(){send(true);});if(s2)s2.addEventListener('click',function(){send(false);});
434
+ box.querySelectorAll('.qidea').forEach(function(btn){btn.addEventListener('click',function(){var s=a.ask.suggestions[+btn.getAttribute('data-i')];if(!s)return;btn.disabled=true;
435
+ api('/api/tasks/add',{text:s.text,repo:a.name}).then(function(){s.added=true;btn.outerHTML="<span class='tag'>in Tasks</span>";});});});});}
436
+ function answering(){var f=document.activeElement;return !!(f&&f.closest&&f.closest('.aq'));}
383
437
  function loadAgents(){api('/api/agents').then(function(list){var el=document.getElementById('agentslist');
384
- if(!list||!list.length){el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
438
+ if(!list||!list.length){AGENTLIST=[];el.innerHTML="<div class='muted' style='margin-top:12px'>No agents yet. In <b>Tasks</b>, tick ideas and hit <b>Send to repos</b> (with an agent command set in Settings) &mdash; you'll watch it work here.</div>";stopAgentsPoll();return;}
439
+ var anyRunning=list.some(function(a){return a.status==='running';});
440
+ if(answering()){stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);return;} // don't re-render under someone typing an answer
441
+ saveDrafts(el);AGENTLIST=list;
385
442
  el.innerHTML=list.map(function(a){var cls=a.status==='running'?'run':(a.status==='done'?'ok':'fail');
386
443
  var st=a.status==='running'?('working &middot; '+fmtE(a.elapsed)):(esc(a.status)+' &middot; '+fmtE(a.elapsed)+(a.exitCode!=null?' &middot; exit '+a.exitCode:''));
444
+ if(nQs(a))st+=" &middot; <span style='color:var(--amber)'>needs your answers</span>";
387
445
  var b="<div class='dh'><span class='adot "+cls+"'></span><span class='dn'>"+esc(a.name)+"</span><span class='dd'>"+st+"</span></div>";
388
446
  if(a.status==='running')b+="<div class='bar'><i></i></div>";
389
447
  var ch=a.changed;
@@ -396,10 +454,11 @@ if(ch&&(ch.dirty||ch.stat||(ch.commits&&ch.commits.length))){
396
454
  }else if(a.status==='done'){
397
455
  b+="<div class='changed muted'>No file changes detected (the agent may have only planned or asked).</div>";
398
456
  }
457
+ b+=askHtml(a);
399
458
  b+="<pre class='alogout'>"+esc((a.tail&&a.tail.trim())||'(waiting for output…)')+"</pre>";
400
459
  return "<div class='agent'>"+b+"</div>";}).join("");
401
460
  el.querySelectorAll('.alogout').forEach(function(p){p.scrollTop=p.scrollHeight;});
402
- var anyRunning=list.some(function(a){return a.status==='running';});
461
+ restoreDrafts(el);wireAsks(el);
403
462
  stopAgentsPoll();if(anyRunning&&current==='agents')agentsTimer=setTimeout(loadAgents,2000);});}
404
463
  function loadDrift(){var out=document.getElementById('driftout');out.innerHTML="<div class='muted' style='margin-top:12px'>Reading your repos&hellip;</div>";
405
464
  var ci=document.getElementById('driftci').checked?'1':'0';var ft=document.getElementById('driftfetch').checked?'1':'0';
@@ -438,6 +497,23 @@ box.innerHTML=roots.map(function(r){var custom=(d.roots||[]).indexOf(r)>=0;retur
438
497
  box.querySelectorAll('.rmroot').forEach(function(btn){btn.addEventListener('click',function(){api('/api/scanroots/remove',{path:btn.closest('.task').getAttribute('data-p')}).then(function(){loadScanRoots();mapLoaded=false;driftLoaded=false;});});});});}
439
498
  function addRootUI(){var i=document.getElementById('newroot');var v=(i.value||'').trim();if(!v)return;var n=document.getElementById('scanrootnote');
440
499
  api('/api/scanroots/add',{path:v}).then(function(r){if(r.error){n.innerHTML="<span class='err'>"+esc(r.error)+"</span>";return;}i.value='';n.textContent='Added — the Map/Drift will rescan.';loadScanRoots();mapLoaded=false;driftLoaded=false;});}
500
+ // Email: opt-in, read from mail already on this computer (no API) — see mail.mjs.
501
+ function renderMail(d){if(!d)return;document.getElementById('mailon').checked=!!d.enabled;document.getElementById('mailaddrs').value=(d.addresses||[]).join(', ');
502
+ var rows=(d.detected||[]).map(function(s){return "<div class='task'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(s.path)+"</span><span class='rp'>"+esc(s.kind)+"</span></div>";});
503
+ (d.sources||[]).forEach(function(p,i){rows.push("<div class='task' data-i='"+i+"'><span class='t' style='font-family:ui-monospace,monospace;font-size:12px'>"+esc(p)+"</span><span class='rp'>added</span><button class='rm rmmail' title='remove'>&times;</button></div>");});
504
+ var box=document.getElementById('mailsources');
505
+ box.innerHTML=rows.length?rows.join(''):"<div class='muted' style='font-size:12px;margin-top:6px'>No mail app data found on this computer. Use a desktop mail app, or add an export (Google Takeout gives you an .mbox).</div>";
506
+ box.querySelectorAll('.rmmail').forEach(function(btn){btn.addEventListener('click',function(){var p=(d.sources||[])[+btn.closest('.task').getAttribute('data-i')];if(p)api('/api/mail/set',{remove:p}).then(renderMail);});});
507
+ if(d.error)document.getElementById('mailout').innerHTML="<div class='note err'>"+esc(d.error)+"</div>";}
508
+ function loadMail(){api('/api/mail').then(renderMail);}
509
+ function setMailOn(){api('/api/mail/set',{enabled:document.getElementById('mailon').checked}).then(renderMail);}
510
+ function saveMailAddrs(){api('/api/mail/set',{addresses:document.getElementById('mailaddrs').value}).then(renderMail);}
511
+ function addMailUI(){var i=document.getElementById('newmail');var v=(i.value||'').trim();if(!v)return;document.getElementById('mailout').innerHTML='';
512
+ api('/api/mail/set',{add:v}).then(function(d){if(d&&!d.error)i.value='';renderMail(d);});}
513
+ function previewMail(){var o=document.getElementById('mailout');o.innerHTML="<div class='muted' style='margin-top:8px'>Reading your sent mail&hellip;</div>";
514
+ api('/api/mail/preview?days=7').then(function(r){var items=(r&&r.items)||[];
515
+ if(!items.length){o.innerHTML="<div class='muted' style='margin-top:8px'>Nothing sent in the last 7 days found in these sources.</div>";return;}
516
+ o.innerHTML="<div class='out2'><b>"+r.count+"</b> sent in the last 7 days &mdash; what a write-up would see:<br>"+items.map(function(x){return esc(x.date)+" &nbsp;"+esc(x.subject)+" &nbsp;&rarr; "+esc((x.to||[]).join(', ')||'?');}).join("<br>")+"</div>";});}
441
517
  function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model&hellip;";
442
518
  api('/api/setup-local',{}).then(function(r){if(r.error==='not-installed'){var i=r.install||{};o.innerHTML="Ollama isn't installed. Run this once, then click again:<div class='out2'>"+esc(i.cmd||'')+"</div><div class='muted' style='font-size:11px'>or download: "+esc(i.alt||'https://ollama.com/download')+"</div>";return;}
443
519
  o.innerHTML="Pulling <b>"+esc(r.model)+"</b> &mdash; watch it in the <b>Agents</b> tab. Symbiot switches to it automatically when the download finishes.";setTimeout(function(){setTab('agents');},700);}).catch(function(e){o.innerHTML="<span class='err'>Setup failed: "+esc(String((e&&e.message)||e))+"</span>";});}
@@ -481,6 +557,11 @@ document.getElementById('agentsrefresh').addEventListener('click',loadAgents);
481
557
  document.getElementById('addtask').addEventListener('click',addTaskUI);
482
558
  document.getElementById('pushtasks').addEventListener('click',pushTasksUI);
483
559
  document.getElementById('agentcmd').addEventListener('change',saveAgent);
560
+ document.getElementById('mailon').addEventListener('change',setMailOn);
561
+ document.getElementById('mailaddrs').addEventListener('change',saveMailAddrs);
562
+ document.getElementById('addmail').addEventListener('click',addMailUI);
563
+ document.getElementById('newmail').addEventListener('keydown',function(e){if(e.key==='Enter')addMailUI();});
564
+ document.getElementById('mailpreview').addEventListener('click',previewMail);
484
565
  document.getElementById('newtask').addEventListener('keydown',function(e){if(e.key==='Enter')addTaskUI();});
485
566
  var SRV_STARTED=null,srvDown=false,updBusy=false;
486
567
  function ubar(){return document.getElementById('updatebar');}
@@ -514,5 +595,5 @@ function heartbeat(fresh){
514
595
  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',{});}
515
596
  setInterval(heartbeat,4000);heartbeat(true);
516
597
  window.addEventListener('focus',function(){heartbeat(true);}); // re-check for updates when you come back to the window
517
- initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();
598
+ initGraphEvents();syncP();refresh();loadMap();loadAgentCfg();loadScanRoots();loadMail();
518
599
  </script></body></html>`;