symbiot 0.31.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.
- package/README.md +69 -6
- package/index.mjs +170 -13
- package/mail.mjs +155 -0
- package/package.json +2 -1
- 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
|
-
|
|
72
|
-
|
|
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`.
|
|
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.
|
|
273
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -1666,6 +1778,26 @@ async function cmdApp() {
|
|
|
1666
1778
|
let TOKEN = cfg0.appToken;
|
|
1667
1779
|
if (!TOKEN) { TOKEN = randomBytes(16).toString("hex"); try { saveConfig({ ...loadConfig(), appToken: TOKEN }); } catch {} }
|
|
1668
1780
|
const PORT = Number(process.env.SYMBIOT_PORT || cfg0.appPort) || 7391;
|
|
1781
|
+
// Single instance: if a Symbiot app is already serving this port, don't start
|
|
1782
|
+
// a second one (multiple instances race the config and split the open tabs) —
|
|
1783
|
+
// just open the one that's running. SYMBIOT_FORCE_NEW overrides (e.g. tests).
|
|
1784
|
+
if (!process.env.SYMBIOT_FORCE_NEW) {
|
|
1785
|
+
try {
|
|
1786
|
+
const ctrl = new AbortController(); const to = setTimeout(() => ctrl.abort(), 800);
|
|
1787
|
+
const r = await fetch(`http://127.0.0.1:${PORT}/api/ping`, { headers: { "x-symbiot-token": TOKEN }, signal: ctrl.signal }).catch(() => null);
|
|
1788
|
+
clearTimeout(to);
|
|
1789
|
+
if (r && r.ok) {
|
|
1790
|
+
const p = await r.json().catch(() => ({}));
|
|
1791
|
+
if (p && p.version) {
|
|
1792
|
+
const url = `http://127.0.0.1:${PORT}/?t=${TOKEN}`; const how = openApp(url);
|
|
1793
|
+
console.log(`\n${c.g("●")} ${c.b("Symbiot")} is already running (v${p.version}) at ${c.b(url)}`);
|
|
1794
|
+
console.log(how ? c.d(` Opened the existing window (a ${how}).`) : c.d(" Open that URL in your browser."));
|
|
1795
|
+
console.log(c.d(" (Not starting a second copy. Set SYMBIOT_FORCE_NEW=1 to force one.)"));
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
} catch {}
|
|
1800
|
+
}
|
|
1669
1801
|
const json = (res, obj) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
1670
1802
|
const server = createServer(async (req, res) => {
|
|
1671
1803
|
const u = new URL(req.url, "http://127.0.0.1");
|
|
@@ -1711,7 +1843,11 @@ async function cmdApp() {
|
|
|
1711
1843
|
const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
|
|
1712
1844
|
return json(res, { started: true, model, id: e ? e.id : "" });
|
|
1713
1845
|
}
|
|
1714
|
-
if (u.pathname === "/api/agents") return json(res,
|
|
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) }); }
|
|
1715
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)); }
|
|
1716
1852
|
if (u.pathname === "/api/connect" && req.method === "POST") { return json(res, await connectProvider(await readBody(req))); }
|
|
1717
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) }); }
|
|
@@ -1754,6 +1890,24 @@ async function cmdApp() {
|
|
|
1754
1890
|
checkLatest(); setInterval(checkLatest, 2 * 60 * 1000).unref(); // background update check (every 2 min)
|
|
1755
1891
|
}
|
|
1756
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
|
+
|
|
1757
1911
|
const HELP = `${c.b("symbiot")} — your week, written from your real work.
|
|
1758
1912
|
|
|
1759
1913
|
${c.b("Usage")}
|
|
@@ -1763,6 +1917,7 @@ ${c.b("Usage")}
|
|
|
1763
1917
|
symbiot app open the visual app in your browser
|
|
1764
1918
|
symbiot drift what's out of sync / at risk across repos
|
|
1765
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)
|
|
1766
1921
|
symbiot models recommend AI models for your hardware
|
|
1767
1922
|
symbiot setup-local [--model X] install/run a free local model (Ollama)
|
|
1768
1923
|
symbiot login connect it to an AI (once)
|
|
@@ -1782,9 +1937,10 @@ ${c.b("Setup")} pick any AI to write with:
|
|
|
1782
1937
|
${c.d("Env keys also work: ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY.")}
|
|
1783
1938
|
${c.d("Override the model per run with SYMBIOT_MODEL.")}
|
|
1784
1939
|
|
|
1785
|
-
${c.d("Reads only your local git
|
|
1786
|
-
${c.d("
|
|
1787
|
-
${c.d("
|
|
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).")}`;
|
|
1788
1944
|
|
|
1789
1945
|
// ---- main -----------------------------------------------------------------
|
|
1790
1946
|
async function main() {
|
|
@@ -1797,6 +1953,7 @@ async function main() {
|
|
|
1797
1953
|
if (cmd === "setup-local" || cmd === "setup-ollama") return cmdSetupLocal();
|
|
1798
1954
|
if (cmd === "drift") return cmdDrift();
|
|
1799
1955
|
if (cmd === "push") return cmdPush();
|
|
1956
|
+
if (cmd === "mail" || cmd === "email") return cmdMail();
|
|
1800
1957
|
if (cmd === "week") return cmdRun("week");
|
|
1801
1958
|
if (cmd === "standup") return cmdRun("standup");
|
|
1802
1959
|
if (cmd === "todo") return cmdRun("todo");
|
|
@@ -1812,4 +1969,4 @@ const isMain = (() => {
|
|
|
1812
1969
|
})();
|
|
1813
1970
|
if (isMain) main();
|
|
1814
1971
|
|
|
1815
|
-
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.
|
|
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 — add what you sent to Week and Standup</label>
|
|
220
|
+
<label class="check"><input type="checkbox" id="mailon"> Use my sent email (subjects & 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…) already keeps on this computer, or an exported .mbox — 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>❓ "+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 & continue</button><button class='ghost qsave' title='save the answers for the next run'>Save only</button></div>";}
|
|
418
|
+
if(ss.length){h+="<h4>💡 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'>✓ Saved "+r.saved+" answer"+(r.saved>1?"s":"")+" for <b>"+esc(a.name)+"</b> in .symbiot/ANSWERS.md"+(r.rerun?" · 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) — 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) — 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&¤t==='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 · '+fmtE(a.elapsed)):(esc(a.status)+' · '+fmtE(a.elapsed)+(a.exitCode!=null?' · exit '+a.exitCode:''));
|
|
444
|
+
if(nQs(a))st+=" · <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
|
-
|
|
461
|
+
restoreDrafts(el);wireAsks(el);
|
|
403
462
|
stopAgentsPoll();if(anyRunning&¤t==='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…</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'>×</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…</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 — what a write-up would see:<br>"+items.map(function(x){return esc(x.date)+" "+esc(x.subject)+" → "+esc((x.to||[]).join(', ')||'?');}).join("<br>")+"</div>";});}
|
|
441
517
|
function setupLocalUI(){var o=document.getElementById('setupout');o.innerHTML="Setting up a local model…";
|
|
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> — 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>`;
|