symbiot 0.40.4 → 0.41.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.40.4",
3
+ "version": "0.41.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,11 @@
9
9
  "files": [
10
10
  "index.mjs",
11
11
  "core.mjs",
12
+ "ai.mjs",
13
+ "scan.mjs",
14
+ "tasks.mjs",
15
+ "writeups.mjs",
16
+ "server.mjs",
12
17
  "agents.mjs",
13
18
  "drift.mjs",
14
19
  "ui.mjs",
@@ -16,6 +21,7 @@
16
21
  "screens.mjs",
17
22
  "headless.mjs",
18
23
  "watch.mjs",
24
+ "phone.mjs",
19
25
  "desktop.mjs",
20
26
  "README.md",
21
27
  "LICENSE"
@@ -51,6 +57,6 @@
51
57
  "@anthropic-ai/sdk": "^0.128.0"
52
58
  },
53
59
  "scripts": {
54
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs"
60
+ "test": "node test/load.mjs && node test/fixtures.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs"
55
61
  }
56
62
  }
package/phone.mjs ADDED
@@ -0,0 +1,167 @@
1
+ // symbiot — Watch on your phone: what Symbiot on your computer finds (Watch,
2
+ // watch.mjs) shows up as a notification on your phone, through the Symbiot there
3
+ // (the Android app, or Symbiot in Termux).
4
+ //
5
+ // The computer: switched on in Settings, it listens on your network (port 7392)
6
+ // for just two things: pairing, with a 6-digit code it shows for 10 minutes,
7
+ // and "what's new since …" for a phone that paired. Nothing else is served there,
8
+ // and nothing can be changed from it. The app itself stays on 127.0.0.1.
9
+ // The phone: paired once with the computer's address and that code, it asks every
10
+ // 2 minutes while it runs and notifies what's new, with the brief if there is one.
11
+ //
12
+ // config.phoneLink = { on, port?, phones: [{ id, name, token, added }] } (computer)
13
+ // config.computer = { url, token, name, since } (phone; since: the computer's
14
+ // clock, so only what's newer comes back)
15
+ import { createServer } from "node:http";
16
+ import { hostname, networkInterfaces } from "node:os";
17
+ import { randomBytes, randomInt, timingSafeEqual } from "node:crypto";
18
+ import { loadConfig, saveConfig, hasCmd } from "./core.mjs";
19
+ import { newsAfter, newsNotice } from "./watch.mjs";
20
+ import { desktopNotify } from "./desktop.mjs";
21
+
22
+ const PORT = 7392;
23
+ const CODE_MS = 10 * 60 * 1000, CODE_TRIES = 5;
24
+ const POLL_MS = 2 * 60 * 1000;
25
+ const PHONE = process.platform === "android"; // the Android app, or Termux
26
+ const same = (a, b) => { const x = Buffer.from(String(a || "")), y = Buffer.from(String(b || "")); return x.length === y.length && x.length > 0 && timingSafeEqual(x, y); };
27
+
28
+ // ---- the computer ----------------------------------------------------------------
29
+ function linkCfg(cfg = loadConfig()) {
30
+ const l = cfg.phoneLink || {};
31
+ return { on: !!l.on, port: Number(l.port) || PORT, phones: Array.isArray(l.phones) ? l.phones.filter((p) => p && p.token) : [] };
32
+ }
33
+ function saveLink(l) { const cfg = loadConfig(); cfg.phoneLink = l; return saveConfig(cfg); }
34
+ // This computer's addresses on your network, the likeliest first (not Docker's,
35
+ // a VM's or a VPN's bridges).
36
+ function lanAddresses(ifs = networkInterfaces()) {
37
+ const out = [];
38
+ for (const [name, list] of Object.entries(ifs || {})) {
39
+ if (/^(docker|br-|veth|virbr|vmnet|vboxnet|lxc|lxd|podman|cni|flannel)/i.test(name)) continue;
40
+ for (const a of list || []) if ((a.family === "IPv4" || a.family === 4) && !a.internal) out.push(a.address);
41
+ }
42
+ const rank = (ip) => (/^192\.168\./.test(ip) ? 0 : /^10\./.test(ip) ? 1 : /^172\.(1[6-9]|2\d|3[01])\./.test(ip) ? 2 : 3);
43
+ return out.sort((a, b) => rank(a) - rank(b));
44
+ }
45
+
46
+ let listener = null, listenErr = "", pairing = null; // pairing: { code, until, tries }
47
+ const lastSeen = new Map(); // phone id -> when it last asked
48
+ function linkState() {
49
+ const l = linkCfg();
50
+ if (pairing && Date.now() > pairing.until) pairing = null;
51
+ return { role: "computer", on: l.on, port: l.port, listening: !!listener, ...(listenErr ? { error: listenErr } : {}), addresses: lanAddresses(),
52
+ phones: l.phones.map((p) => ({ id: p.id, name: p.name, added: p.added, ...(lastSeen.has(p.id) ? { seen: lastSeen.get(p.id) } : {}) })),
53
+ ...(pairing ? { code: pairing.code, until: pairing.until } : {}) };
54
+ }
55
+ // A new pairing code, good for 10 minutes and 5 tries.
56
+ function newCode() { pairing = { code: String(randomInt(0, 1e6)).padStart(6, "0"), until: Date.now() + CODE_MS, tries: 0 }; return linkState(); }
57
+ function unpairPhone(id) { const l = linkCfg(); l.phones = l.phones.filter((p) => p.id !== id); lastSeen.delete(id); saveLink(l); return linkState(); }
58
+
59
+ function reply(res, code, obj) { res.writeHead(code, { "content-type": "application/json", "cache-control": "no-store" }); res.end(JSON.stringify(obj)); }
60
+ function body(req) {
61
+ return new Promise((resolve) => {
62
+ let d = ""; req.on("data", (ch) => { d += ch; if (d.length > 4096) req.destroy(); });
63
+ req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
64
+ });
65
+ }
66
+ async function handle(req, res) {
67
+ const u = new URL(req.url, "http://phone");
68
+ if (u.pathname === "/phone/pair" && req.method === "POST") {
69
+ const b = await body(req);
70
+ if (!pairing || Date.now() > pairing.until) { pairing = null; return reply(res, 403, { error: "No pairing code is open. In Symbiot on your computer, open Settings → Watch on your phone and click New code." }); }
71
+ if (!same(String(b.code || "").replace(/\D/g, ""), pairing.code)) {
72
+ if (++pairing.tries >= CODE_TRIES) pairing = null;
73
+ return reply(res, 403, { error: pairing ? "That code isn't right. Check it on your computer." : "That code was wrong too many times. Click New code on your computer." });
74
+ }
75
+ pairing = null;
76
+ const l = linkCfg(), p = { id: randomBytes(4).toString("hex"), name: String(b.name || "Phone").replace(/\s+/g, " ").trim().slice(0, 40) || "Phone", token: randomBytes(24).toString("hex"), added: Date.now() };
77
+ l.phones = [...l.phones, p].slice(-5); saveLink(l);
78
+ return reply(res, 200, { token: p.token, id: p.id, name: hostname(), now: Date.now() });
79
+ }
80
+ if (u.pathname === "/phone/news" && req.method === "GET") {
81
+ const p = linkCfg().phones.find((x) => same(x.token, req.headers["x-symbiot-phone"]));
82
+ if (!p) return reply(res, 403, { error: "This phone isn't paired any more. Pair it again." });
83
+ lastSeen.set(p.id, Date.now());
84
+ const { news, briefs } = newsAfter(u.searchParams.get("since"));
85
+ return reply(res, 200, { name: hostname(), now: Date.now(), news: news.slice(0, 100), briefs: briefs.slice(0, 20) });
86
+ }
87
+ reply(res, 404, { error: "not found" });
88
+ }
89
+ // Listen on the network while it's switched on. Gives the state.
90
+ function startPhoneLink() {
91
+ const l = linkCfg();
92
+ if (!l.on || listener) return Promise.resolve(linkState());
93
+ return new Promise((resolve) => {
94
+ const srv = createServer((req, res) => { handle(req, res).catch(() => { try { reply(res, 500, { error: "failed" }); } catch {} }); });
95
+ srv.on("error", (e) => {
96
+ listenErr = e && e.code === "EADDRINUSE" ? `Port ${l.port} is in use by something else on this computer.` : `Couldn't listen on port ${l.port}: ${(e && e.message) || e}`;
97
+ listener = null; resolve(linkState());
98
+ });
99
+ srv.listen(l.port, "0.0.0.0", () => { listener = srv; listenErr = ""; srv.unref(); resolve(linkState()); });
100
+ });
101
+ }
102
+ function stopPhoneLink() { if (listener) { listener.close(); if (listener.closeAllConnections) listener.closeAllConnections(); } listener = null; pairing = null; }
103
+ async function setPhoneLink(on) {
104
+ const l = linkCfg(); l.on = !!on; saveLink(l);
105
+ if (!on) { stopPhoneLink(); listenErr = ""; return linkState(); }
106
+ await startPhoneLink();
107
+ return listener && !pairing ? newCode() : linkState();
108
+ }
109
+
110
+ // ---- the phone -------------------------------------------------------------------
111
+ // What the user typed as the computer's address: "192.168.8.50", ":7392" optional.
112
+ function computerUrl(input) {
113
+ let s = String(input || "").trim().replace(/\/+$/, "");
114
+ if (!s) return "";
115
+ if (!/^https?:\/\//i.test(s)) s = "http://" + s;
116
+ try { const u = new URL(s); if (!u.hostname) return ""; if (!u.port) u.port = String(PORT); return `${u.protocol}//${u.host}`; } catch { return ""; }
117
+ }
118
+ const canNotify = () => process.env.SYMBIOT_ANDROID_APP === "1" || !PHONE || hasCmd("termux-notification");
119
+ let pollLast = 0, pollErr = "", pollTimer = null;
120
+ function computerState() {
121
+ const c = loadConfig().computer;
122
+ return { role: "phone", paired: !!(c && c.token), ...(c && c.token ? { name: c.name, url: c.url } : {}), ...(pollLast ? { last: pollLast } : {}), ...(pollErr ? { error: pollErr } : {}), notify: canNotify(), termux: PHONE && process.env.SYMBIOT_ANDROID_APP !== "1" };
123
+ }
124
+ async function pairComputer(address, code, { fetchFn = fetch, name = hostname() } = {}) {
125
+ const url = computerUrl(address);
126
+ if (!url) return { ...computerState(), error: "Type your computer's address, as Symbiot shows it there (like 192.168.8.50:7392)." };
127
+ if (!/^\d{6}$/.test(String(code || "").replace(/\s/g, ""))) return { ...computerState(), error: "Type the 6-digit code your computer shows." };
128
+ let r;
129
+ try { r = await fetchFn(url + "/phone/pair", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ code: String(code).replace(/\s/g, ""), name: name === "localhost" ? "Phone" : name }), signal: AbortSignal.timeout(8000) }); }
130
+ catch { return { ...computerState(), error: `Couldn't reach ${url}. Is Symbiot running there, with Watch on your phone switched on, and is this phone on the same Wi-Fi? A firewall on the computer may need to allow port ${new URL(url).port}.` }; }
131
+ const j = await r.json().catch(() => ({}));
132
+ if (!r.ok || !j.token) return { ...computerState(), error: j.error || `Your computer answered ${r.status}.` };
133
+ const cfg = loadConfig(); cfg.computer = { url, token: j.token, name: String(j.name || "your computer").slice(0, 60), since: Number(j.now) || Date.now() }; saveConfig(cfg);
134
+ pollErr = ""; startComputerPoll();
135
+ return computerState();
136
+ }
137
+ function forgetComputer() { const cfg = loadConfig(); delete cfg.computer; saveConfig(cfg); pollErr = ""; pollLast = 0; return computerState(); }
138
+ // Ask the computer what's new and notify it here, one notification per watch
139
+ // per read, as the computer did. Gives { state, shown }.
140
+ async function pollComputer({ fetchFn = fetch, notify = desktopNotify } = {}) {
141
+ const c = loadConfig().computer; if (!c || !c.token) return { ...computerState(), shown: 0 };
142
+ let r;
143
+ try { r = await fetchFn(`${c.url}/phone/news?since=${encodeURIComponent(c.since || 0)}`, { headers: { "x-symbiot-phone": c.token }, signal: AbortSignal.timeout(10000) }); }
144
+ catch { pollErr = `Couldn't reach ${c.name} at ${c.url}. It's asked again in a few minutes (the computer may be off or asleep, or on another network).`; return { ...computerState(), shown: 0 }; }
145
+ const j = await r.json().catch(() => ({}));
146
+ if (!r.ok) { pollErr = j.error || `${c.name} answered ${r.status}.`; return { ...computerState(), shown: 0 }; }
147
+ pollErr = ""; pollLast = Date.now();
148
+ const news = Array.isArray(j.news) ? j.news : [], briefs = Array.isArray(j.briefs) ? j.briefs : [];
149
+ const batches = new Map();
150
+ for (const n of news) { const k = n.watch + " " + n.ts; if (!batches.has(k)) batches.set(k, []); batches.get(k).push(n); }
151
+ for (const [k, list] of batches) { const b = briefs.find((x) => x.watch + " " + x.ts === k); notify(...newsNotice(list, list[0].name, b ? b.text : "")); }
152
+ if (news.length) { const cfg = loadConfig(); if (cfg.computer) { cfg.computer.since = Math.max(Number(cfg.computer.since) || 0, ...news.map((n) => Number(n.ts) || 0)); saveConfig(cfg); } }
153
+ return { ...computerState(), shown: batches.size };
154
+ }
155
+ function startComputerPoll(opts) {
156
+ if (pollTimer || !(loadConfig().computer || {}).token) return;
157
+ const tick = () => { if (!(loadConfig().computer || {}).token) { clearInterval(pollTimer); pollTimer = null; return; } pollComputer(opts).catch(() => {}); };
158
+ setTimeout(tick, 15000).unref();
159
+ pollTimer = setInterval(tick, POLL_MS); pollTimer.unref();
160
+ }
161
+
162
+ // ---- both ------------------------------------------------------------------------
163
+ const phoneState = () => (PHONE ? computerState() : linkState());
164
+ // While the app runs: the computer listens if it's switched on; the phone asks if it's paired.
165
+ function startPhone() { if (PHONE) startComputerPoll(); else startPhoneLink(); }
166
+
167
+ export { PORT, lanAddresses, linkState, newCode, unpairPhone, startPhoneLink, stopPhoneLink, setPhoneLink, computerUrl, computerState, pairComputer, forgetComputer, pollComputer, startComputerPoll, phoneState, startPhone };