symbiot 0.63.6 → 0.63.7
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/CHANGELOG.md +36 -0
- package/README.md +39 -5
- package/agents.mjs +43 -10
- package/browser.html +21 -3
- package/capture.mjs +761 -0
- package/demo.mjs +171 -0
- package/guard.mjs +23 -5
- package/handback.mjs +22 -8
- package/handover.mjs +1 -1
- package/headless.mjs +281 -80
- package/home.mjs +26 -5
- package/index.mjs +65 -11
- package/lanes.mjs +99 -9
- package/links.mjs +52 -2
- package/marketing.mjs +40 -7
- package/mdns.mjs +6 -4
- package/package.json +5 -3
- package/phone.mjs +78 -11
- package/reports.mjs +15 -1
- package/server.mjs +89 -42
- package/tray.mjs +44 -4
- package/ui.mjs +311 -41
package/mdns.mjs
CHANGED
|
@@ -67,15 +67,17 @@ function decMessage(buf) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// ---- the computer: answer while the link is on ------------------------------------------
|
|
70
|
-
// opts: { id (a few hex characters, from the fingerprint), port, fp, addresses: () => [...] }.
|
|
70
|
+
// opts: { id (a few hex characters, from the fingerprint), port, fp, addresses: (asker) => [...] }.
|
|
71
|
+
// addresses gets the asking device's address (none for an announcement), so the answer
|
|
72
|
+
// carries only what that device can reach: RFC 6762 6.2's "the interface it came in on".
|
|
71
73
|
// Gives { stop() } (a goodbye, so phones forget it at once), or null where nothing
|
|
72
74
|
// can listen on 5353 (it's in use without sharing, or there's no network).
|
|
73
75
|
// (socket: a dgram-like factory, for the tests)
|
|
74
76
|
const udp = (o) => createSocket(o);
|
|
75
77
|
function announce({ id, port, fp, addresses }, { onError = () => {}, socket = udp } = {}) {
|
|
76
78
|
const inst = `Symbiot-${id}.${SERVICE}`, host = `symbiot-${id}.local`;
|
|
77
|
-
const records = (ttl = TTL) => {
|
|
78
|
-
const ips = addresses();
|
|
79
|
+
const records = (ttl = TTL, asker) => {
|
|
80
|
+
const ips = addresses(asker);
|
|
79
81
|
return { answers: [{ name: SERVICE, type: T.PTR, ttl, data: inst }],
|
|
80
82
|
additionals: [{ name: inst, type: T.SRV, ttl, flush: true, data: { port, target: host } }, { name: inst, type: T.TXT, ttl, flush: true, data: { v: 1, fp } },
|
|
81
83
|
...ips.map((ip) => ({ name: host, type: T.A, ttl, flush: true, data: ip }))] };
|
|
@@ -89,7 +91,7 @@ function announce({ id, port, fp, addresses }, { onError = () => {}, socket = ud
|
|
|
89
91
|
let m; try { m = decMessage(buf); } catch { return; }
|
|
90
92
|
if (m.response || !m.questions.some(ours)) return;
|
|
91
93
|
const legacy = from.port !== PORT; // a one-shot query from any port: answered to it directly (RFC 6762 6.7)
|
|
92
|
-
const msg = encMessage({ id: legacy ? m.id : 0, response: true, questions: legacy ? m.questions.filter(ours) : [], ...records(legacy ? 10 : TTL) });
|
|
94
|
+
const msg = encMessage({ id: legacy ? m.id : 0, response: true, questions: legacy ? m.questions.filter(ours) : [], ...records(legacy ? 10 : TTL, from.address) });
|
|
93
95
|
if (legacy || m.questions.some((q) => q.unicast)) send(msg, from.port, from.address); else send(msg);
|
|
94
96
|
});
|
|
95
97
|
sock.bind(PORT, () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.63.
|
|
3
|
+
"version": "0.63.7",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"away.mjs",
|
|
47
47
|
"marketing.mjs",
|
|
48
48
|
"tray.mjs",
|
|
49
|
+
"capture.mjs",
|
|
50
|
+
"demo.mjs",
|
|
49
51
|
"postinstall.mjs",
|
|
50
52
|
"icon.svg",
|
|
51
53
|
"scanworker.mjs",
|
|
@@ -77,7 +79,7 @@
|
|
|
77
79
|
"developer-tools",
|
|
78
80
|
"cli"
|
|
79
81
|
],
|
|
80
|
-
"author": "Garth White <
|
|
82
|
+
"author": "Garth White <garth@symbiot.co.za>",
|
|
81
83
|
"license": "Elastic-2.0",
|
|
82
84
|
"repository": {
|
|
83
85
|
"type": "git",
|
|
@@ -91,7 +93,7 @@
|
|
|
91
93
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
92
94
|
},
|
|
93
95
|
"scripts": {
|
|
94
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/signin.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/lang.mjs && node test/browser.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/phone.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
96
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/signin.mjs && node test/choose.mjs && node test/replace.mjs && node test/tabs.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/licence.mjs && node test/lang.mjs && node test/browser.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/phone.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/capture.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
95
97
|
"lint": "eslint .",
|
|
96
98
|
"postinstall": "node postinstall.mjs"
|
|
97
99
|
},
|
package/phone.mjs
CHANGED
|
@@ -126,6 +126,26 @@ function lanAddresses(ifs = networkInterfaces()) {
|
|
|
126
126
|
const rank = (ip) => (/^192\.168\./.test(ip) ? 0 : /^10\./.test(ip) ? 1 : /^172\.(1[6-9]|2\d|3[01])\./.test(ip) ? 2 : 3);
|
|
127
127
|
return out.sort((a, b) => rank(a) - rank(b));
|
|
128
128
|
}
|
|
129
|
+
// What the network announcement (mdns.mjs) gives a device that asks: the address on
|
|
130
|
+
// its own subnet, as it came in on that interface. With no asker (an announcement), or
|
|
131
|
+
// none on its subnet, every address but a VPN's: Tailscale, WireGuard and other tunnels
|
|
132
|
+
// (100.64.0.0/10 is Tailscale's), which a phone on the Wi-Fi can't reach.
|
|
133
|
+
const overlay = (name, ip) => /^(tailscale|ts\d|wg|tun|utun|tap|zt|nordlynx|proton|mullvad)/i.test(name) || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip);
|
|
134
|
+
const ip4 = (ip) => String(ip).split(".").reduce((n, x) => ((n << 8) | (Number(x) & 255)) >>> 0, 0);
|
|
135
|
+
function mdnsAddresses(asker, ifs = networkInterfaces()) {
|
|
136
|
+
const all = [];
|
|
137
|
+
for (const [name, list] of Object.entries(ifs || {})) {
|
|
138
|
+
if (/^(docker|br-|veth|virbr|vmnet|vboxnet|lxc|lxd|podman|cni|flannel)/i.test(name)) continue;
|
|
139
|
+
for (const a of list || []) if ((a.family === "IPv4" || a.family === 4) && !a.internal) all.push({ name, ip: a.address, mask: a.netmask || "255.255.255.0" });
|
|
140
|
+
}
|
|
141
|
+
const from = String(asker || "").replace(/^::ffff:/i, "");
|
|
142
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(from)) {
|
|
143
|
+
const same = all.filter((a) => (ip4(a.ip) & ip4(a.mask)) === (ip4(from) & ip4(a.mask)));
|
|
144
|
+
if (same.length) return same.map((a) => a.ip);
|
|
145
|
+
}
|
|
146
|
+
const lan = all.filter((a) => !overlay(a.name, a.ip));
|
|
147
|
+
return (lan.length ? lan : all).map((a) => a.ip);
|
|
148
|
+
}
|
|
129
149
|
|
|
130
150
|
let listener = null, listenErr = "", pairing = null; // pairing: { code, until, tries }
|
|
131
151
|
const lastSeen = new Map(); // phone id -> { at, via }
|
|
@@ -137,7 +157,7 @@ function linkState() {
|
|
|
137
157
|
if (pairing && Date.now() > pairing.until) pairing = null;
|
|
138
158
|
const link = pairing && listener ? pairLink(pairing.code, l) : "";
|
|
139
159
|
return { role: "computer", on: l.on, port: l.port, listening: !!listener, ...(listenErr ? { error: listenErr } : {}), addresses: lanAddresses(),
|
|
140
|
-
...(l.pub ? { fp: fingerprint(l.pub) } : {}), relay: { on: l.relay, ...relayState() }, found: !!mdns,
|
|
160
|
+
...(l.pub ? { fp: fingerprint(l.pub) } : {}), relay: { on: l.relay && relayAllowed(), ...(relayAllowed() ? {} : { off: "SYMBIOT_NO_RELAY" }), ...relayState() }, found: !!mdns,
|
|
141
161
|
phones: l.phones.map((p) => ({ id: p.id, name: p.name, added: p.added, ...(p.legacy || !p.pub ? { old: true } : {}), ...(lastSeen.has(p.id) ? { seen: lastSeen.get(p.id).at, via: lastSeen.get(p.id).via } : p.seen ? { seen: p.seen } : {}) })),
|
|
142
162
|
...(pairing ? { code: pairing.code, until: pairing.until } : {}), ...(link ? { link, qr: qrSvg(link) } : {}) };
|
|
143
163
|
}
|
|
@@ -225,12 +245,33 @@ const OPS = {
|
|
|
225
245
|
// ---- the copy a phone keeps ---------------------------------------------------------------
|
|
226
246
|
// Small and in plain words: your open tasks, what needs you, agents' questions, the
|
|
227
247
|
// work waiting for your Approve (with a sum of exactly what's waiting, which an
|
|
228
|
-
// approve from the phone has to match), what Watch found,
|
|
229
|
-
//
|
|
248
|
+
// approve from the phone has to match), what Watch found, what was approved from a
|
|
249
|
+
// phone lately, and the newest reports (reportsForPhone). v changes only when something
|
|
250
|
+
// in it does.
|
|
230
251
|
let snapCache = null;
|
|
231
252
|
const plainText = (s, n = 280) => { const t = String(s || "").replace(/\s+/g, " ").trim(); return t.length > n ? t.slice(0, n - 1) + "…" : t; };
|
|
232
253
|
const approveSum = (r) => sha(JSON.stringify([(r.tasks || []).map((t) => t.id).sort(), (r.files || []).map((f) => (typeof f === "string" ? f : f.path || f.file || JSON.stringify(f))).sort(), r.stat || ""])).slice(0, 16);
|
|
233
254
|
const askId = (path) => "ask:" + sha(String(path)).slice(0, 12);
|
|
255
|
+
// The newest few of what this computer's agents wrote up (reports.mjs), so the phone's
|
|
256
|
+
// Reports has them too, to read an audit or a plan away from the desk, offline as well.
|
|
257
|
+
// Each is cut at a paragraph past REPORT_CAP: the copy goes through the relay, which
|
|
258
|
+
// takes 256 KB at most, and the rest is here to read.
|
|
259
|
+
const PHONE_REPORTS = 5, REPORT_CAP = 12000;
|
|
260
|
+
function cutReport(text, cap = REPORT_CAP) {
|
|
261
|
+
const t = String(text || ""); if (t.length <= cap) return { text: t, cut: false };
|
|
262
|
+
const head = t.slice(0, cap), k = head.lastIndexOf("\n\n");
|
|
263
|
+
return { text: (k > cap * 0.5 ? head.slice(0, k) : head).replace(/\s+$/, ""), cut: true };
|
|
264
|
+
}
|
|
265
|
+
async function reportsForPhone({ list } = {}) {
|
|
266
|
+
if (!list) list = (await import("./reports.mjs")).listReports();
|
|
267
|
+
return list.slice(0, PHONE_REPORTS).map((r) => {
|
|
268
|
+
let t = ""; try { t = readFileSync(r.file, "utf8"); } catch { return null; }
|
|
269
|
+
const c = cutReport(t);
|
|
270
|
+
return { id: r.id, title: plainText(r.title, 140), name: r.name, lane: r.lane || "", run: r.run || "", mtime: r.mtime, size: r.size, text: c.text, ...(c.cut ? { cut: true } : {}) };
|
|
271
|
+
}).filter(Boolean);
|
|
272
|
+
}
|
|
273
|
+
// On the phone: the copy, and the computer it's from, for its Reports (server.mjs). null on a computer.
|
|
274
|
+
function computerCopy() { const c = loadConfig().computer, copy = c ? loadCopy() : null; return copy ? { copy, from: copy.name || c.name || "your computer" } : null; }
|
|
234
275
|
async function snapshot({ now = Date.now(), fresh = false } = {}) {
|
|
235
276
|
if (!fresh && snapCache && now - snapCache.at < 5000) return snapCache.snap;
|
|
236
277
|
const tryOr = async (f, d) => { try { return await f(); } catch { return d; } };
|
|
@@ -249,7 +290,8 @@ async function snapshot({ now = Date.now(), fresh = false } = {}) {
|
|
|
249
290
|
const board = watch ? await tryOr(() => watch.watchBoard(24, now), { cards: [] }) : { cards: [] };
|
|
250
291
|
const seen = (board.cards || []).filter((c) => c.count > 0).slice(0, 8).map((c) => ({ name: plainText(c.name, 60), label: plainText(c.label, 60), count: c.count, items: (c.items || []).filter((i) => i.need !== false).slice(0, 3).map((i) => ({ text: plainText(i.text, 140), ts: i.ts })) }));
|
|
251
292
|
const recent = (readJson(PATHS.applied, {}).approvals || []).filter((a) => now - a.at < 24 * 3600000).slice(-10);
|
|
252
|
-
const
|
|
293
|
+
const reports = await tryOr(() => reportsForPhone(), []);
|
|
294
|
+
const body = { tasks, needs, asks, approves, watch: seen, recent, reports, relay: linkCfg().relay && relayAllowed() };
|
|
253
295
|
const snap = { v: sha(JSON.stringify(body)).slice(0, 16), name: hostname(), at: now, ...body };
|
|
254
296
|
snapCache = { at: now, snap };
|
|
255
297
|
return snap;
|
|
@@ -326,7 +368,7 @@ let mdns = null;
|
|
|
326
368
|
function startMdns() {
|
|
327
369
|
if (mdns || process.env.SYMBIOT_NO_MDNS === "1") return;
|
|
328
370
|
const l = linkCfg(), k = linkKeys();
|
|
329
|
-
mdns = announce({ id: sha(Buffer.from(k.pub, "base64url")).slice(0, 4), port: l.port, fp: fingerprint(k.pub), addresses: () =>
|
|
371
|
+
mdns = announce({ id: sha(Buffer.from(k.pub, "base64url")).slice(0, 4), port: l.port, fp: fingerprint(k.pub), addresses: (asker) => mdnsAddresses(asker) }, { onError: () => {} });
|
|
330
372
|
}
|
|
331
373
|
function stopMdns() { if (mdns) mdns.stop(); mdns = null; }
|
|
332
374
|
|
|
@@ -526,7 +568,7 @@ function computerView() {
|
|
|
526
568
|
const st = computerState(), copy = loadCopy(), q = loadQueue();
|
|
527
569
|
if (!copy) return { ...st, copy: null };
|
|
528
570
|
const v = JSON.parse(JSON.stringify(copy));
|
|
529
|
-
for (const k of ["tasks", "needs", "asks", "approves", "watch", "recent", "notes"]) if (!Array.isArray(v[k])) v[k] = [];
|
|
571
|
+
for (const k of ["tasks", "needs", "asks", "approves", "watch", "recent", "notes", "reports"]) if (!Array.isArray(v[k])) v[k] = [];
|
|
530
572
|
for (const o of q) {
|
|
531
573
|
const a = o.args || {};
|
|
532
574
|
if (o.op === "task.add") v.tasks.unshift({ id: "q:" + o.id, text: plainText(a.text), repo: a.repo || "", done: false, waiting: true });
|
|
@@ -552,14 +594,37 @@ function queueChange(op, args = {}) {
|
|
|
552
594
|
if (process.env.SYMBIOT_NO_POLL !== "1") setTimeout(() => { pollComputer().catch(() => {}); }, 30).unref();
|
|
553
595
|
return computerView();
|
|
554
596
|
}
|
|
597
|
+
// What waits on you there, each by a key that holds while it waits: an agent's
|
|
598
|
+
// questions (by what they ask, so a new question tells you again), work waiting for
|
|
599
|
+
// your Approve (by its tasks: a file more isn't news, a task more is), and Home's
|
|
600
|
+
// other needs. Watch's news is told on its own (newsNotice), so it isn't here.
|
|
601
|
+
function waitingOn(s) {
|
|
602
|
+
const out = new Map();
|
|
603
|
+
for (const a of (s && s.asks) || []) { const qs = (a.questions || []).map((q) => q.q); out.set(a.id + ":" + sha(JSON.stringify(qs)).slice(0, 8), `${a.name} asks: ${qs[0] || "a question"}`); }
|
|
604
|
+
for (const r of (s && s.approves) || []) { const ids = (r.tasks || []).map((t) => t.id).sort(), n = ids.length;
|
|
605
|
+
out.set("approve:" + r.repo + ":" + sha(JSON.stringify(ids)).slice(0, 8), `${r.repo}: ${n ? `${n} task${n > 1 ? "s" : ""} done` : "changes without a task"}, waiting for your Approve`); }
|
|
606
|
+
for (const n of (s && s.needs) || []) out.set("need:" + n.id, n.sub ? `${n.title}: ${n.sub}` : n.title);
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
// What's newly waiting since the copy before, as one notification (a poll that finds
|
|
610
|
+
// three things buzzes once), or null. Nothing for the first copy after pairing: what
|
|
611
|
+
// was already waiting isn't news. What went (answered, approved) says nothing.
|
|
612
|
+
function waitingNotice(was, now, name = "your computer") {
|
|
613
|
+
if (!was || !now) return null;
|
|
614
|
+
const before = waitingOn(was), fresh = [...waitingOn(now)].filter(([k]) => !before.has(k)).map(([, t]) => plainText(t, 160));
|
|
615
|
+
if (!fresh.length) return null;
|
|
616
|
+
const more = fresh.length > 4 ? `\nand ${fresh.length - 4} more` : "";
|
|
617
|
+
return [`${fresh.length === 1 ? "1 thing needs" : `${fresh.length} things need`} you · ${name}`, fresh.slice(0, 4).join("\n") + more];
|
|
618
|
+
}
|
|
555
619
|
function dismissNote(id) { const c = loadCopy(); if (c) saveCopy(c, { notes: (c.notes || []).filter((n) => n.id !== id) }); return computerView(); }
|
|
556
620
|
// Ask the computer: send what was queued here, then what's new (notified, one
|
|
557
|
-
// notification per watch per read), then the copy (unchanged costs nothing)
|
|
621
|
+
// notification per watch per read), then the copy (unchanged costs nothing), and
|
|
622
|
+
// tell what newly waits on you in it (waitingNotice).
|
|
558
623
|
async function pollComputer(opts = {}) { if (polling) return polling; polling = pollOnce(opts).finally(() => { polling = null; schedulePoll(); }); return polling; }
|
|
559
624
|
async function pollOnce({ fetchFn = fetch, notify = desktopNotify, discover } = {}) {
|
|
560
625
|
const c = loadConfig().computer; if (!c) return { ...computerState(), shown: 0 };
|
|
561
626
|
if (!readSecret()) return { ...computerState(), shown: 0 };
|
|
562
|
-
const via = { fetchFn, ...(discover ? { discover } : {}) };
|
|
627
|
+
const via = { fetchFn, ...(discover ? { discover } : {}) }, was = loadCopy();
|
|
563
628
|
const away = () => { fails++; pollErr = `Couldn't reach ${c.name}${fails > 1 ? "" : `. It's asked again in ${BACKOFF[0] / 1000} seconds`} (it may be off or asleep${c.relay ? "" : ", or on another network"}).`; return { ...computerState(), shown: 0 }; };
|
|
564
629
|
const refused = (r) => {
|
|
565
630
|
pollErr = (r.body && r.body.error) || `${c.name} answered ${r.status}.`; fails = 0;
|
|
@@ -592,7 +657,9 @@ async function pollOnce({ fetchFn = fetch, notify = desktopNotify, discover } =
|
|
|
592
657
|
const copy = loadCopy(), st = await ask("state", { etag: (copy && copy.v) || "" }, via);
|
|
593
658
|
if (st.status === 200 && st.body && st.body.v) { saveCopy(st.body); const cfg = loadConfig(); if (cfg.computer && !!cfg.computer.relayOff !== (st.body.relay === false)) { cfg.computer.relayOff = st.body.relay === false; saveConfig(cfg); } }
|
|
594
659
|
else if (st.status === 304 && copy) saveCopy(copy);
|
|
595
|
-
|
|
660
|
+
const waiting = waitingNotice(was, loadCopy(), c.name);
|
|
661
|
+
if (waiting) notify(...waiting);
|
|
662
|
+
return { ...computerState(), shown: batches.size + (waiting ? 1 : 0) };
|
|
596
663
|
}
|
|
597
664
|
// Every 2 minutes; after a failed ask sooner (15 s, 30 s, a minute), so a phone
|
|
598
665
|
// that changed networks or a computer that woke is back without a wait.
|
|
@@ -609,6 +676,6 @@ const phoneState = () => (PHONE ? computerView() : linkState());
|
|
|
609
676
|
// While the app runs: the computer listens if it's switched on; the phone asks if it's paired.
|
|
610
677
|
function startPhone() { if (PHONE) startComputerPoll(); else startPhoneLink(); }
|
|
611
678
|
|
|
612
|
-
export { PORT, PHONE, ANDROID_APP, PHONE_OPS, lanAddresses, linkState, newCode, unpairPhone, setRelay, startPhoneLink, stopPhoneLink, setPhoneLink, setPhoneApprove, phoneApprovals, snapshot,
|
|
613
|
-
computerUrl, parsePairLink, pairLink, computerState, computerView, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, startComputerPoll, phoneState, startPhone,
|
|
679
|
+
export { computerCopy, reportsForPhone, cutReport, PHONE_REPORTS, REPORT_CAP, PORT, PHONE, ANDROID_APP, PHONE_OPS, lanAddresses, mdnsAddresses, linkState, newCode, unpairPhone, setRelay, startPhoneLink, stopPhoneLink, setPhoneLink, setPhoneApprove, phoneApprovals, snapshot,
|
|
680
|
+
computerUrl, parsePairLink, pairLink, computerState, computerView, waitingNotice, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, startComputerPoll, phoneState, startPhone,
|
|
614
681
|
newKeys, linkKey, fingerprint, seal, unseal, relayOf, answer };
|
package/reports.mjs
CHANGED
|
@@ -68,6 +68,20 @@ function readReport(id, { list, mark = true, seenFile = SEEN_FILE } = {}) {
|
|
|
68
68
|
const draft = isDraftReport(r.title, r.name);
|
|
69
69
|
return { ...r, new: false, text, html: mdHtml(text, { id: r.id, base: dirname(r.file), roots: imageRoots(r) }) + (cut ? "<p class='muted'>(cut at 512 KB: open the file for the rest)</p>" : ""), draft, ...(draft ? { decided: decidedOn(r, seenFile) } : { ideas: reportIdeas(text) }) };
|
|
70
70
|
}
|
|
71
|
+
// On a phone: its computer's newest reports, from the copy it sent (phone.mjs
|
|
72
|
+
// computerCopy, reportsForPhone), beside the phone's own. Their ids start "pc:", they
|
|
73
|
+
// say where they're from, and one is read from the copy, so it opens offline too.
|
|
74
|
+
// It can't be asked about or ticked into ideas here: that's the computer's (remote).
|
|
75
|
+
function phoneReports(pc) {
|
|
76
|
+
if (!pc || !pc.copy || !Array.isArray(pc.copy.reports)) return [];
|
|
77
|
+
return pc.copy.reports.map((r) => ({ id: "pc:" + r.id, title: r.title, name: r.name, lane: r.lane, run: r.run, file: `${pc.from}: ${r.lane ? r.lane + " · " : ""}${r.name}`, folder: "", size: r.size, mtime: r.mtime, new: false, running: false, computer: pc.from }));
|
|
78
|
+
}
|
|
79
|
+
function phoneReport(pc, id) {
|
|
80
|
+
const r = phoneReports(pc).find((x) => x.id === String(id || "")), raw = r && pc.copy.reports.find((x) => "pc:" + x.id === r.id);
|
|
81
|
+
if (!r || !raw) return { error: "That report isn't in the copy from your computer any more: it sends its newest few." };
|
|
82
|
+
const text = String(raw.text || "");
|
|
83
|
+
return { ...r, remote: true, text, html: mdHtml(text) + (raw.cut ? mdHtml(`_That's its first part: the rest is on ${pc.from}._`) : ""), draft: false, ideas: [] };
|
|
84
|
+
}
|
|
71
85
|
// Mark them all read (the list's "Mark all read").
|
|
72
86
|
function markAllRead({ list, seenFile = SEEN_FILE } = {}) {
|
|
73
87
|
const d = loadSeen(seenFile); for (const r of list || listReports()) d.seen[r.id] = r.mtime;
|
|
@@ -215,4 +229,4 @@ function decide(r, status, file = SEEN_FILE) { const d = loadSeen(file); d.decid
|
|
|
215
229
|
// How many reports are new, and the newest one's title (Home's droplet).
|
|
216
230
|
function reportsNews(opts) { const l = listReports(opts).filter((r) => r.new); return { count: l.length, latest: l[0] ? l[0].title : "" }; }
|
|
217
231
|
|
|
218
|
-
export { SEEN_FILE, NOT_REPORTS, titleOf, listReports, readReport, reportImage, imagePath, markAllRead, reportsNews, mdHtml, reportIdeas, isDraftReport, decidedOn, decide, IDEAS_MAX };
|
|
232
|
+
export { SEEN_FILE, NOT_REPORTS, titleOf, listReports, readReport, phoneReports, phoneReport, reportImage, imagePath, markAllRead, reportsNews, mdHtml, reportIdeas, isDraftReport, decidedOn, decide, IDEAS_MAX };
|
package/server.mjs
CHANGED
|
@@ -25,20 +25,21 @@ import { computeDrift } from "./drift.mjs";
|
|
|
25
25
|
import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
|
|
26
26
|
import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
|
|
27
27
|
import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
|
|
28
|
-
import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub, siteUrl, browserHub } from "./headless.mjs";
|
|
28
|
+
import { mapPage, wholePage, pressRegion, typeRegion, chooseRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, onBrowserClosed, setBrowserHub, siteUrl, browserHub } from "./headless.mjs";
|
|
29
29
|
import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
|
|
30
30
|
import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
31
|
-
import { linksState, linkSite, checkLink, unlinkSite, addSite, signInAsked } from "./links.mjs";
|
|
31
|
+
import { linksState, linkSite, checkLink, unlinkSite, addSite, signInAsked, sitesCheck } from "./links.mjs";
|
|
32
32
|
import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
|
|
33
33
|
import { mindState, forget } from "./mind.mjs";
|
|
34
|
-
import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
|
|
34
|
+
import { lanesTick, lanesState, partlyDone, handingOf, loadLedger, orcaRelink } from "./lanes.mjs";
|
|
35
35
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
36
36
|
import { adaptState, noteUse } from "./adapt.mjs";
|
|
37
37
|
import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding, startOnboarding, phoneSetupFirst } from "./home.mjs";
|
|
38
38
|
import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName, setDraftMedia } from "./marketing.mjs";
|
|
39
|
-
import { trays, trayMedia, setBlur, renderCapture } from "./tray.mjs";
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
39
|
+
import { trays, trayMedia, setBlur, addBlur, removeBlur, setReviewed, renderCapture } from "./tray.mjs";
|
|
40
|
+
import { captureProduct, captureProducts } from "./capture.mjs";
|
|
41
|
+
import { listReports, readReport, reportImage, markAllRead, phoneReports, phoneReport } from "./reports.mjs";
|
|
42
|
+
import { computerCopy, phoneState, setPhoneLink, newCode, unpairPhone, setRelay, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, setPhoneApprove, startPhone } from "./phone.mjs";
|
|
42
43
|
import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
|
|
43
44
|
import { runChecks, checksState, markClashesSeen } from "./checks.mjs";
|
|
44
45
|
|
|
@@ -50,6 +51,24 @@ function updateCmd(latest, current, platform = process.platform) {
|
|
|
50
51
|
const target = latest && semverGt(latest, current) ? latest : "latest";
|
|
51
52
|
return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
|
|
52
53
|
}
|
|
54
|
+
// npm lists a new version minutes before its download is there: 1 to 5 minutes for
|
|
55
|
+
// most releases, 11 for 0.59.0 (~/.symbiot/agent.log, 0.52.0 to 0.63.3). Five tries 30s
|
|
56
|
+
// apart gave up on 0.59.0, 0.62.0 and 0.63.3 and sent you to a terminal. So the update
|
|
57
|
+
// looks for the download itself before it runs npm, and keeps looking (and trying
|
|
58
|
+
// again on a 404) for up to 20 minutes. Only a 404 waits: anything else (offline, say)
|
|
59
|
+
// goes on to npm, whose own error says what's wrong.
|
|
60
|
+
const UPDATE_EVERY = Number(process.env.SYMBIOT_UPDATE_EVERY) || 30000, UPDATE_WAIT = 20 * 60 * 1000;
|
|
61
|
+
async function downloadReady(version, registry = REGISTRY, fetchFn = fetch) {
|
|
62
|
+
if (!/^\d+\.\d+\.\d+/.test(String(version))) return true; // "latest": npm picks it, nothing to look at
|
|
63
|
+
try { return (await fetchFn(`${registry}/symbiot/-/symbiot-${encodeURIComponent(version)}.tgz`, { method: "HEAD", signal: AbortSignal.timeout(10000) })).status !== 404; } catch { return true; }
|
|
64
|
+
}
|
|
65
|
+
// This update's own lines in agent.log (an earlier run's 404 isn't this one's), and
|
|
66
|
+
// the line that says what went wrong.
|
|
67
|
+
function updateOutcome(log) {
|
|
68
|
+
const mine = String(log || "").slice(String(log || "").lastIndexOf("=== symbiot update"));
|
|
69
|
+
const why = (mine.match(/npm (?:error|ERR!) (?!code\b|syscall\b|errno\b|path\b|dest\b|404\s*$|A complete log)([^\n]+)/m) || [])[1] || "";
|
|
70
|
+
return { notYet: /E404|ETARGET|notarget|No matching version|is not in this registry/i.test(mine), why: why.trim().slice(0, 200) };
|
|
71
|
+
}
|
|
53
72
|
|
|
54
73
|
// ---- what's new: the changelog's releases between two versions -------------------
|
|
55
74
|
// CHANGELOG.md's "## 0.45.0 — 2026-10-06" sections newer than `from` and up to
|
|
@@ -247,8 +266,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
247
266
|
setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
|
|
248
267
|
if (!SANDBOX) { refreshRevoked().catch(() => {}); setInterval(() => refreshRevoked().catch(() => {}), 6 * 3600 * 1000).unref(); } // cancelled Pro keys, about daily
|
|
249
268
|
{ const t0 = Date.now(); refreshRepos().then((l) => console.log(`Found ${l.length} project${l.length === 1 ? "" : "s"} in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)); }
|
|
250
|
-
let UPDATING = null; // an update in flight: { target, attempt, retrying? }
|
|
251
|
-
|
|
269
|
+
let UPDATING = null; // an update in flight: { target, attempt, since, retrying? }
|
|
270
|
+
let UPDATE_FAILED = null; // the last one that didn't go through: { target, error, at }
|
|
271
|
+
let HUB_SEEN = 0; // when the Symbiot Browser's own page last asked for the sites (browser.html)
|
|
272
|
+
onBrowserClosed(() => { sitesCheck(); }); // its last window closed by its X: checked, as Done does
|
|
252
273
|
let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
|
|
253
274
|
process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
|
|
254
275
|
const server = createServer(async (req, res) => {
|
|
@@ -333,7 +354,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
333
354
|
const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
|
|
334
355
|
return json(res, { started: true, model, id: e ? e.id : "" });
|
|
335
356
|
}
|
|
336
|
-
if (u.pathname === "/api/agents")
|
|
357
|
+
if (u.pathname === "/api/agents") { // handing: what it handed to other lanes, on the newest block per folder: where each stands, and how far the run on it has got
|
|
358
|
+
const seen = new Set(), map = laneMap(), ledger = loadLedger(), list = agentsList().map((a) => { if (!a.path || seen.has(a.path)) return a; seen.add(a.path); let h = []; try { h = handingOf(a.path, { map, ledger }); } catch {} return h.length ? { ...a, handing: h } : a; });
|
|
359
|
+
return json(res, await withReleases(list));
|
|
360
|
+
}
|
|
337
361
|
// the answer, saved in the folder that asked; it says which lane that is, so "Sent to …"
|
|
338
362
|
// names where it went (a Marketing answer said "Sent to Paperclip Steve")
|
|
339
363
|
if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req), path = String(b.path || ""), r = answerQuestions(path, b.answers, { rerun: !!b.rerun }); return json(res, r && r.ok ? { ...r, lane: laneOfFolder(path) } : r); }
|
|
@@ -366,15 +390,19 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
366
390
|
// A web page in the hidden browser (headless.mjs): mapped by itself, no
|
|
367
391
|
// screen needed. Press acts on the real site, signed in as you, so it's
|
|
368
392
|
// confirmed like a click; Sign in opens a window the user signs in with.
|
|
369
|
-
if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name, { whole: b.whole === true }))); }
|
|
393
|
+
if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name, { whole: b.whole === true, who: String(b.who || "") }))); }
|
|
370
394
|
// press / type: refused unless confirmed, or the page's site is one you trust
|
|
371
|
-
if (u.pathname === "/api/screens/press" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await pressRegion(String(b.id || ""), String(b.region || ""), { confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
|
|
395
|
+
if (u.pathname === "/api/screens/press" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await pressRegion(String(b.id || ""), String(b.region || ""), { confirmed: b.confirmed === true, noSend: b.noSend === true, who: String(b.who || "") }))); }
|
|
372
396
|
// scroll and whole page: they only look, so they never ask
|
|
373
|
-
if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || "")))); }
|
|
374
|
-
if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down")))); }
|
|
375
|
-
if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
|
|
397
|
+
if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || ""), { who: String(b.who || "") }))); }
|
|
398
|
+
if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down"), { who: String(b.who || "") }))); }
|
|
399
|
+
if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true, who: String(b.who || "") }))); }
|
|
400
|
+
// its own route, so an app from before it answers 404 instead of retyping the whole field
|
|
401
|
+
if (u.pathname === "/api/screens/replace" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true, replace: String(b.replace == null ? "" : b.replace), who: String(b.who || "") }))); }
|
|
402
|
+
// choose: an option in a menu (a <select>, or the site's own), asked like press and type
|
|
403
|
+
if (u.pathname === "/api/screens/choose" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await chooseRegion(String(b.id || ""), String(b.region || ""), b.option, { confirmed: b.confirmed === true, who: String(b.who || "") }))); }
|
|
376
404
|
// upload: a file of yours to the page's file box, asked like press and type
|
|
377
|
-
if (u.pathname === "/api/screens/upload" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await uploadRegion(String(b.id || ""), String(b.region || ""), Array.isArray(b.files) ? b.files.map(String) : String(b.files || ""), { confirmed: b.confirmed === true }))); }
|
|
405
|
+
if (u.pathname === "/api/screens/upload" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await uploadRegion(String(b.id || ""), String(b.region || ""), Array.isArray(b.files) ? b.files.map(String) : String(b.files || ""), { confirmed: b.confirmed === true, who: String(b.who || "") }))); }
|
|
378
406
|
if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
|
|
379
407
|
if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
|
|
380
408
|
if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
|
|
@@ -383,10 +411,13 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
383
411
|
if (u.pathname === "/api/mind") return json(res, mindState());
|
|
384
412
|
if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
|
|
385
413
|
// Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
|
|
386
|
-
if (u.pathname === "/api/links") { let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
|
|
414
|
+
if (u.pathname === "/api/links") { if (u.searchParams.get("hub") === "1") HUB_SEEN = Date.now(); let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
|
|
387
415
|
if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""), b.here ? { open: async (url) => ({ ok: true, url }) } : undefined)); } // here: from the Symbiot Browser, which opens the site itself
|
|
388
416
|
if (u.pathname === "/api/browser/open" && req.method === "POST") { const b = await readBody(req); if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { ok: false, note: "not opened (SYMBIOT_NO_OPEN)", page: browserHub() }); return json(res, await openSymbiotBrowser(b.url ? siteUrl(String(b.url)) : "")); }
|
|
389
|
-
|
|
417
|
+
// Done: it closes, and every site is checked (links.mjs sitesCheck). Its page's X sends
|
|
418
|
+
// closed: the same, unless the page is back within a moment (a reload, not a close).
|
|
419
|
+
if (u.pathname === "/api/browser/done" && req.method === "POST") { if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { closed: 0 }); const r = await closeSymbiotBrowser(); sitesCheck(); return json(res, { ...r, checking: true }); }
|
|
420
|
+
if (u.pathname === "/api/browser/closed" && req.method === "POST") { const at = Date.now(); if (process.env.SYMBIOT_NO_OPEN !== "1") setTimeout(() => { if (HUB_SEEN > at) return; closeSymbiotBrowser().catch(() => {}).then(() => sitesCheck()); }, 3000).unref(); return json(res, { ok: true }); }
|
|
390
421
|
// any site, added in Settings → Connections or the Symbiot Browser's address bar, or a card's / reply's "Sign in to <site>": linked and opened to sign in
|
|
391
422
|
if (u.pathname === "/api/links/site" && req.method === "POST") { const b = await readBody(req), here = b.here ? { open: async (url) => ({ ok: true, url }) } : undefined; return json(res, b.id ? await linkSite(String(b.id), here) : await addSite(String(b.site || ""), here)); }
|
|
392
423
|
if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
|
|
@@ -416,10 +447,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
416
447
|
if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
|
|
417
448
|
// the pick tray (tray.mjs): its captures and their blur boxes, a capture (only from inside
|
|
418
449
|
// a tray), a box switched on or off and the capture blurred again, a capture as a post's media
|
|
419
|
-
if (u.pathname === "/api/marketing/tray") return json(res, { trays: trays() });
|
|
450
|
+
if (u.pathname === "/api/marketing/tray") { let products = []; try { products = captureProducts(); } catch {} return json(res, { trays: trays(), products }); }
|
|
420
451
|
if (u.pathname === "/api/marketing/tray/media") { const m = trayMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=86400" }); res.end(readFileSync(m.file)); return; }
|
|
421
452
|
if (u.pathname === "/api/marketing/tray/blur" && req.method === "POST") { const b = await readBody(req), r = setBlur(String(b.tray || ""), String(b.name || ""), b.id, !!b.on); if (r.error) return json(res, r); const d = await renderCapture(String(b.tray), String(b.name)); return json(res, { ...r, rendered: !!d.ok, ...(d.error ? { note: d.error } : {}) }); }
|
|
422
|
-
if (u.pathname === "/api/marketing/tray/use" && req.method === "POST") { const b = await readBody(req); return json(res, setDraftMedia(String(b.rel || ""), [].concat(b.file || []).map(String))); }
|
|
453
|
+
if (u.pathname === "/api/marketing/tray/use" && req.method === "POST") { const b = await readBody(req); return json(res, setDraftMedia(String(b.rel || ""), [].concat(b.file || []).map(String))); } // one to look over first is refused there (marketing.mjs lookOver)
|
|
454
|
+
// a box of your own (drawn on it), one gone, and a capture looked over (tray.mjs); then it's blurred again
|
|
455
|
+
if (u.pathname === "/api/marketing/tray/box" && req.method === "POST") { const b = await readBody(req), r = b.remove != null ? removeBlur(String(b.tray || ""), String(b.name || ""), b.remove) : addBlur(String(b.tray || ""), String(b.name || ""), b.box); if (r.error) return json(res, r); const d = await renderCapture(String(b.tray), String(b.name)); return json(res, { ...r, rendered: !!d.ok, ...(d.error ? { note: d.error } : {}) }); }
|
|
456
|
+
if (u.pathname === "/api/marketing/tray/reviewed" && req.method === "POST") { const b = await readBody(req); return json(res, setReviewed(String(b.tray || ""), String(b.name || ""))); }
|
|
457
|
+
// Symbiot's own capture of a product, for its pick tray (capture.mjs): `symbiot capture`, the lane's first run, or the page's Capture
|
|
458
|
+
if (u.pathname === "/api/marketing/capture" && req.method === "POST") { const b = await readBody(req); return json(res, await captureProduct({ product: String(b.product || ""), url: String(b.url || ""), window: String(b.window || ""), screens: [].concat(b.screens || []).map(String), clips: b.clips !== false, ...(Number(b.seconds) > 0 ? { seconds: Math.min(20, Number(b.seconds)) } : {}) })); }
|
|
423
459
|
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
424
460
|
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""))); }
|
|
425
461
|
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
|
@@ -433,8 +469,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
433
469
|
// reads your LinkedIn only on your click (confirmed), never by itself
|
|
434
470
|
if (u.pathname === "/api/posts/voice" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Reading your LinkedIn posts needs your click." }); return json(res, await voiceFromLinkedIn()); }
|
|
435
471
|
// Reports (reports.mjs): what runs wrote up in their .symbiot/; read by id, never by path.
|
|
436
|
-
|
|
437
|
-
if (u.pathname === "/api/reports
|
|
472
|
+
// on a phone, its computer's newest reports too (from the copy it keeps)
|
|
473
|
+
if (u.pathname === "/api/reports") return json(res, { reports: [...listReports(), ...phoneReports(computerCopy())].sort((a, b) => b.mtime - a.mtime) });
|
|
474
|
+
if (u.pathname === "/api/reports/read") { const id = u.searchParams.get("id") || ""; return json(res, id.startsWith("pc:") ? phoneReport(computerCopy(), id) : readReport(id)); }
|
|
438
475
|
// A report's image (an <img> src, so its token is in ?t=): by the report's id and a src it shows, from its allowed folders only.
|
|
439
476
|
if (u.pathname === "/api/reports/image") { const f = reportImage(u.searchParams.get("id") || "", u.searchParams.get("src") || ""); if (f.error) { res.writeHead(404, { "content-type": "text/plain" }); res.end(f.error); return; } res.writeHead(200, { "content-type": f.type, "cache-control": "private, no-cache", "x-content-type-options": "nosniff" }); res.end(readFileSync(f.file)); return; }
|
|
440
477
|
if (u.pathname === "/api/reports/seen" && req.method === "POST") return json(res, markAllRead());
|
|
@@ -516,7 +553,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
516
553
|
// a sandbox (symbiot app --fresh) never offers an update: it would replace your real install.
|
|
517
554
|
// A headless browser (an agent's screenshot of Home) never becomes the newest window:
|
|
518
555
|
// that closed the user's real one, again and again (2026-10-08, "symbiot keeps crashing").
|
|
519
|
-
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING
|
|
556
|
+
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING ? { updating: { ...UPDATING, every: UPDATE_EVERY, wait: UPDATE_WAIT } } : {}), ...(UPDATE_FAILED ? { updateFailed: UPDATE_FAILED } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
|
|
520
557
|
// What's new: after an update, since the version you last saw (until you click Got it);
|
|
521
558
|
// ?latest=1, what the update on offer brings, from its package on npm
|
|
522
559
|
if (u.pathname === "/api/whatsnew") {
|
|
@@ -550,26 +587,36 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
550
587
|
// relaunch, so it takes over instead of finding us and bowing out.
|
|
551
588
|
// SYMBIOT_UPDATE_CMD replaces the install (the tests use a no-op).
|
|
552
589
|
if (SANDBOX) return json(res, { error: "This is a sandbox (symbiot app --fresh): update your real Symbiot instead." });
|
|
590
|
+
// One at a time: a second click (another window, or one while it waits) started a
|
|
591
|
+
// second npm install over the first, and they collided (ENOTEMPTY, 0.61.1).
|
|
592
|
+
// (an install still running after 10 minutes has hung: a click starts it afresh)
|
|
593
|
+
if (UPDATING && (UPDATING.retrying || Date.now() - UPDATING.at < 10 * 60 * 1000)) return json(res, { started: true, already: true, target: UPDATING.target });
|
|
553
594
|
const { target, cmd } = updateCmd(LATEST_VERSION, VERSION);
|
|
554
|
-
const inst = process.env.SYMBIOT_UPDATE_CMD || cmd;
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
595
|
+
const inst = process.env.SYMBIOT_UPDATE_CMD || cmd, since = Date.now();
|
|
596
|
+
// not downloadable yet (see downloadReady): look again in 30s, for up to 20 minutes, and say so
|
|
597
|
+
const again = (n, error) => {
|
|
598
|
+
if (Date.now() - since + UPDATE_EVERY > UPDATE_WAIT) { UPDATING = null; UPDATE_FAILED = { target, error, at: Date.now() }; return null; }
|
|
599
|
+
UPDATING = { target, retrying: true, attempt: n + 1, since, at: Date.now() }; setTimeout(() => attempt(n + 1), UPDATE_EVERY).unref(); return null;
|
|
600
|
+
};
|
|
601
|
+
const attempt = async (n) => {
|
|
602
|
+
if (!(await downloadReady(target))) return again(n, `npm lists ${target}, but its download still wasn't there after 20 minutes.`);
|
|
603
|
+
const e = track("symbiot update", inst, homedir(), (code) => {
|
|
604
|
+
if (code !== 0) {
|
|
605
|
+
const o = updateOutcome(readRunLog(join(homedir(), ".symbiot", "agent.log")).slice(-6000));
|
|
606
|
+
if (o.notYet) again(n, o.why || `npm couldn't find ${target}'s download.`);
|
|
607
|
+
else { UPDATING = null; UPDATE_FAILED = { target, error: o.why || `npm stopped (exit code ${code}).`, at: Date.now() }; }
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
server.close(); if (server.closeAllConnections) server.closeAllConnections();
|
|
611
|
+
try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" }, cwd: homedir() }); ch.unref(); } catch {}
|
|
612
|
+
setTimeout(() => process.exit(0), 1200);
|
|
613
|
+
});
|
|
614
|
+
if (!e) { UPDATING = null; UPDATE_FAILED = { target, error: "Symbiot couldn't start npm.", at: Date.now() }; }
|
|
615
|
+
return e;
|
|
616
|
+
};
|
|
617
|
+
UPDATING = { target, attempt: 1, since, at: Date.now() }; UPDATE_FAILED = null;
|
|
618
|
+
const e = await attempt(1);
|
|
619
|
+
return json(res, { started: true, id: e ? e.id : "", target, ...(UPDATING && UPDATING.retrying ? { waiting: true } : {}) });
|
|
573
620
|
}
|
|
574
621
|
if (u.pathname === "/api/quit") { stopVoices(); res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
|
|
575
622
|
} catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
|
|
@@ -629,4 +676,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
629
676
|
setInterval(() => { try { awaitTick(); } catch {} }, 60000).unref(); // a reply an agent's email waits on: found, and handed on
|
|
630
677
|
}
|
|
631
678
|
|
|
632
|
-
export { headlessAgent, updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
|
|
679
|
+
export { headlessAgent, updateCmd, downloadReady, updateOutcome, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
|
package/tray.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { join, dirname, basename } from "node:path";
|
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, realpathSync, renameSync, rmSync, mkdtempSync } from "node:fs";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
-
import { MARKETING_DIR, MEDIA_TYPES, draftFiles, parseDraft, inLane } from "./marketing.mjs";
|
|
16
|
+
import { MARKETING_DIR, MEDIA_TYPES, draftFiles, parseDraft, inLane, reviewOf } from "./marketing.mjs";
|
|
17
17
|
|
|
18
18
|
const NAME = /^\w[\w.-]{0,100}$/; // a capture's file: no slashes, no dot first
|
|
19
19
|
const IMG = /\.(?:png|jpe?g|gif|webp)$/i, VID = /\.(?:mp4|webm|mov)$/i;
|
|
@@ -44,12 +44,15 @@ function trayOf(dir, rel) {
|
|
|
44
44
|
const use = (it.kind === "still" ? files.find((f) => IMG.test(f)) : files.find((f) => VID.test(f))) || show;
|
|
45
45
|
const spec = NAME.test(`${it.name}.blur.json`) ? readJson(join(root, `${it.name}.blur.json`)) : null;
|
|
46
46
|
const [w, h] = spec && Array.isArray(spec.size) ? spec.size.map(Number) : [0, 0];
|
|
47
|
-
const regions = spec && Array.isArray(spec.regions) && w > 0 && h > 0 ? spec.regions.filter((r) => r && r.id != null).map((r) => ({ id: r.id, kind: String(r.kind || "box"), hint: String(r.hint || ""), on: !!r.on,
|
|
47
|
+
const regions = spec && Array.isArray(spec.regions) && w > 0 && h > 0 ? spec.regions.filter((r) => r && r.id != null).map((r) => ({ id: r.id, kind: String(r.kind || "box"), hint: String(r.hint || ""), on: !!r.on, ...(r.hand ? { hand: true } : {}),
|
|
48
48
|
x: r.x / w, y: r.y / h, w: r.w / w, h: r.h / h, ...(r.from != null || r.to != null ? { from: Number(r.from) || 0, to: Number(r.to) || 0 } : {}) })) : [];
|
|
49
49
|
let at = 0; try { at = show ? statSync(join(root, show)).mtimeMs : 0; } catch {}
|
|
50
50
|
return { name: it.name, kind: String(it.kind || (spec && spec.kind) || "still"), caption: String(it.caption || (spec && spec.caption) || ""), screen: String(it.screen || ""), for: (Array.isArray(it.for) ? it.for : []).map(String),
|
|
51
|
-
show: show ? `${rel}/${show}` : "", showKind: VID.test(show) ? "video" : "image", use: use ? `${rel}/${use}` : "", blur: !!spec, regions, parts: Array.isArray(it.parts) ? it.parts.map(String) : [], at
|
|
51
|
+
show: show ? `${rel}/${show}` : "", showKind: VID.test(show) ? "video" : "image", use: use ? `${rel}/${use}` : "", blur: !!spec, regions, parts: Array.isArray(it.parts) ? it.parts.map(String) : [], at,
|
|
52
|
+
...(spec && spec.desktop ? { desktop: true } : {}), ...(spec && Array.isArray(spec.size) ? { size: spec.size.map(Number) } : {}) };
|
|
52
53
|
});
|
|
54
|
+
// to look over before it's used: its own reason, or a reel's from the clips in it
|
|
55
|
+
for (const it of items) { const why = lookFirst(root, it); if (why) it.review = why; }
|
|
53
56
|
return { rel, folder: dirname(rel), items };
|
|
54
57
|
}
|
|
55
58
|
// Every tray in the lane, each with its product's drafts (not those in the tray) to use a
|
|
@@ -75,6 +78,43 @@ function setBlur(tray, name, id, on, { dir = MARKETING_DIR } = {}) {
|
|
|
75
78
|
try { writeFileSync(f, JSON.stringify(spec, null, 2)); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
|
|
76
79
|
return { ok: true, on: !!on, ids };
|
|
77
80
|
}
|
|
81
|
+
// ---- yours: a box drawn, and a capture looked over --------------------------------------
|
|
82
|
+
// A box of your own over a capture (x, y, w, h as fractions of it, as the page draws it),
|
|
83
|
+
// blurred: on a clip, all the way through. { ok, id } or { error }.
|
|
84
|
+
function specOf(dir, tray, name) {
|
|
85
|
+
if (!isTray(dir, tray) || !NAME.test(String(name || ""))) return { error: "That capture isn't in a tray any more." };
|
|
86
|
+
const f = join(dir, tray, `${name}.blur.json`), spec = readJson(f);
|
|
87
|
+
if (!spec || !Array.isArray(spec.regions)) return { error: "That capture has no blur boxes to add to." };
|
|
88
|
+
return { f, spec };
|
|
89
|
+
}
|
|
90
|
+
const saveSpec = (f, spec) => { try { writeFileSync(f, JSON.stringify(spec, null, 2)); return null; } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; } };
|
|
91
|
+
function addBlur(tray, name, box, { dir = MARKETING_DIR } = {}) {
|
|
92
|
+
const o = specOf(dir, tray, name); if (o.error) return o;
|
|
93
|
+
const [W, H] = Array.isArray(o.spec.size) ? o.spec.size.map(Number) : [0, 0];
|
|
94
|
+
if (!(W > 0 && H > 0)) return { error: "Couldn't tell how big the capture is." };
|
|
95
|
+
const n = (v) => Math.min(1, Math.max(0, Number(v)));
|
|
96
|
+
const b = box || {}, x = n(b.x), y = n(b.y), w = Math.min(1 - x, n(b.w)), h = Math.min(1 - y, n(b.h));
|
|
97
|
+
if (![b.x, b.y, b.w, b.h].every((v) => Number.isFinite(Number(v))) || w * W < 4 || h * H < 4) return { error: "Draw the box over what to blur: drag across it." };
|
|
98
|
+
const id = Math.max(0, ...o.spec.regions.map((r) => Number(r && r.id) || 0)) + 1;
|
|
99
|
+
o.spec.regions.push({ id, kind: "yours", x: Math.round(x * W), y: Math.round(y * H), w: Math.round(w * W), h: Math.round(h * H), hint: "", on: true, hand: true });
|
|
100
|
+
return saveSpec(o.f, o.spec) || { ok: true, id };
|
|
101
|
+
}
|
|
102
|
+
// A box you drew, gone. One Symbiot found stays (switch it off instead), so it can go back on.
|
|
103
|
+
function removeBlur(tray, name, id, { dir = MARKETING_DIR } = {}) {
|
|
104
|
+
const o = specOf(dir, tray, name); if (o.error) return o;
|
|
105
|
+
const r = o.spec.regions.find((x) => x && String(x.id) === String(id));
|
|
106
|
+
if (!r) return { error: "That blur box isn't there any more." };
|
|
107
|
+
if (!r.hand) return { error: "Symbiot found that one: switch it off instead, so it can go back on." };
|
|
108
|
+
o.spec.regions = o.spec.regions.filter((x) => x !== r);
|
|
109
|
+
return saveSpec(o.f, o.spec) || { ok: true };
|
|
110
|
+
}
|
|
111
|
+
// A capture to look over before it's used (marketing.mjs reviewOf): its own reason, or a reel's from its clips.
|
|
112
|
+
const lookFirst = (root, it) => reviewOf(root, it.name, it.kind, it.parts);
|
|
113
|
+
function setReviewed(tray, name, { dir = MARKETING_DIR, now = Date.now() } = {}) {
|
|
114
|
+
const o = specOf(dir, tray, name); if (o.error) return o;
|
|
115
|
+
o.spec.reviewed = now;
|
|
116
|
+
return saveSpec(o.f, o.spec) || { ok: true };
|
|
117
|
+
}
|
|
78
118
|
// The capture rendered again from its original with its boxes as they are now, and every
|
|
79
119
|
// reel it's in joined again (a box blurred in a clip is blurred in its reel too). One at a
|
|
80
120
|
// time a capture: two clicks don't write the same files at once. { ok, files } or { error }.
|
|
@@ -151,4 +191,4 @@ function ffmpeg(args, { timeout = 180000 } = {}) {
|
|
|
151
191
|
});
|
|
152
192
|
}
|
|
153
193
|
|
|
154
|
-
export { trayDirs, trayMedia, trayOf, trays, setBlur, renderCapture };
|
|
194
|
+
export { trayDirs, trayMedia, trayOf, trays, setBlur, addBlur, removeBlur, setReviewed, renderCapture };
|