symbiot 0.61.1 → 0.62.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/index.mjs CHANGED
@@ -40,7 +40,8 @@ import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
40
40
  import { loadScreens, screenImage, blueprint } from "./screens.mjs";
41
41
  import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, uploadFiles, scrollPage, signIn, isTrusted } from "./headless.mjs";
42
42
  import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
43
- import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
43
+ import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer, parsePairLink } from "./phone.mjs";
44
+ import { qrMatrix } from "./qr.mjs";
44
45
  import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
45
46
  import { startApp, updateCmd, isAppRunningWeekly, askRunningApp, openApp } from "./server.mjs";
46
47
  import { listReports, readReport } from "./reports.mjs";
@@ -617,26 +618,35 @@ function cmdKnowledge() {
617
618
  if (sub !== "help") process.exitCode = 1;
618
619
  }
619
620
 
620
- // ---- `symbiot phone`: Watch on your phone, from a terminal ----------------------
621
+ // ---- `symbiot phone`: Your phone, from a terminal --------------------------------
621
622
  // On the phone (Termux), pair with your computer without the app window; on the
622
- // computer, the addresses and a code to pair with. Through the app when it runs:
623
+ // computer, a code (and its QR) to pair with. Through the app when it runs:
623
624
  // that's where the computer listens and the phone asks.
625
+ // A QR in the terminal: two rows of modules per line, in half blocks, with the
626
+ // quiet zone a camera needs (light on dark terminals too: it draws the light parts).
627
+ function terminalQr(text) {
628
+ const { modules, size } = qrMatrix(text), q = 2, n = size + 2 * q, dark = (r, c) => r >= q && c >= q && r < size + q && c < size + q && modules[r - q][c - q];
629
+ const lines = [];
630
+ for (let r = 0; r < n; r += 2) { let l = ""; for (let c = 0; c < n; c++) { const a = !dark(r, c), b = r + 1 < n && !dark(r + 1, c); l += a && b ? "█" : a ? "▀" : b ? "▄" : " "; } lines.push(l); }
631
+ return lines.join("\n");
632
+ }
624
633
  async function cmdPhone() {
625
634
  const [sub = "status", ...rest] = argv.slice(1).filter((x) => !x.startsWith("--"));
626
635
  const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
627
636
  const app = await viaApp("/api/phone", {}), st = app && !app.error ? app : phoneState();
628
637
  const phone = st.role === "phone", ask = "symbiot phone pair <address> <code>";
629
- const notPaired = `Not paired with a computer. On the computer, switch on Settings → Watch on your phone in Symbiot (or run symbiot phone code there), then here: ${ask}`;
638
+ const notPaired = `Not paired with a computer. On the computer, switch on Settings → Your phone in Symbiot (or run symbiot phone code there), then here: ${ask}`;
630
639
  const onlyPhone = () => fail(`That's for the phone. In Termux there: ${ask}, with an address and the code this computer shows (symbiot phone code).`);
631
640
  const onlyComputer = () => fail("That's for the computer your phone pairs with: run it there.");
632
- const noApp = "Symbiot's app isn't running here, so nothing listens for your phone. Start it with symbiot app, switch on Settings → Watch on your phone, then run this again.";
641
+ const noApp = "Symbiot's app isn't running here, so nothing listens for your phone. Start it with symbiot app, switch on Settings → Your phone, then run this again.";
633
642
  const asks = (r) => (app ? `Symbiot here asks ${r.name} what's new every 2 minutes and notifies you.` : `Symbiot here asks ${r.name} what's new every 2 minutes while symbiot app runs: start it to be notified.`) + (r.notify === false ? c.d(" For notifications in Termux: pkg install termux-api, and the Termux:API app.") : "");
634
643
  if (sub === "pair") {
635
644
  if (!phone) return onlyPhone();
636
- // the code may be typed as shown, in two halves: 123 456
637
- const [address, ...code] = rest;
638
- if (!address || !code.length) return fail(`Give the computer's address and the 6-digit code it shows: ${ask} (like 192.168.1.21:7392 123456)`);
639
- const r = (app && await viaApp("/api/phone/pair", { address, code: code.join("") })) || await pairComputer(address, code.join(""));
645
+ // the code may be typed as shown, in two halves: 123 456; or the whole link the QR holds
646
+ const [address, ...code] = rest, link = parsePairLink(address) ? address : "";
647
+ if (!link && (!address || !code.length)) return fail(`Give the computer's address and the 6-digit code it shows: ${ask} (like 192.168.1.21:7392 123456)`);
648
+ const body = link ? { link } : { address, code: code.join("") };
649
+ const r = (app && await viaApp("/api/phone/pair", body)) || await pairComputer(body.address, body.code, { link });
640
650
  if (r.error || !r.paired) return fail(r.error || "Pairing didn't work.");
641
651
  console.log(`${c.g("✓")} Paired with ${c.b(r.name)} ${c.d(r.url)}\n ${asks(r)}`);
642
652
  return;
@@ -658,27 +668,30 @@ async function cmdPhone() {
658
668
  if (sub === "code") {
659
669
  if (phone) return onlyComputer();
660
670
  if (!app) return fail(noApp);
661
- if (!st.on) return fail("Watch on your phone is off. Switch it on in Symbiot: Settings → Watch on your phone.");
671
+ if (!st.on) return fail("Your phone's link is off. Switch it on in Symbiot: Settings → Your phone.");
662
672
  const r = await viaApp("/api/phone/code", {});
663
673
  if (!r || r.error || !r.code) return fail((r && r.error) || "No code came back.");
664
- console.log(`Code ${c.b(r.code)} ${c.d(`for ${Math.max(1, Math.round((r.until - Date.now()) / 60000))} minutes, 5 tries`)}\nOn the phone, in Termux, with this computer's address:`);
674
+ if (r.link && process.stdout.isTTY) console.log(terminalQr(r.link) + "\n" + c.d("Scan it with your phone's camera: Symbiot's app pairs with this computer.") + "\n");
675
+ console.log(`Code ${c.b(r.code)} ${c.d(`for ${Math.max(1, Math.round((r.until - Date.now()) / 60000))} minutes, 5 tries`)}\nOr on the phone, in Termux, with this computer's address:`);
665
676
  for (const a of r.addresses || []) console.log(` symbiot phone pair ${a}:${r.port || PHONE_PORT} ${r.code}`);
666
677
  return;
667
678
  }
668
679
  if (sub === "status") {
669
680
  if (phone) {
670
681
  if (!st.paired) return console.log(notPaired);
671
- console.log(`Paired with ${c.b(st.name)} ${c.d(st.url)}` + (st.last ? c.d(` · asked ${new Date(st.last).toLocaleString()}`) : ""));
682
+ console.log(`Paired with ${c.b(st.name)} ${c.d(st.url)}` + (st.last ? c.d(` · asked ${new Date(st.last).toLocaleString()}${st.via === "relay" ? " through the relay" : ""}`) : ""));
672
683
  if (st.error) console.log(c.y(st.error));
684
+ if (st.queued) console.log(c.d(` ${st.queued} change${st.queued > 1 ? "s" : ""} waiting to send`));
673
685
  console.log(" " + asks(st));
674
686
  return;
675
687
  }
676
688
  if (!app) return console.log(c.y(noApp));
677
- if (!st.on) return console.log(c.y("Watch on your phone is off.") + c.d(" Switch it on in Symbiot: Settings → Watch on your phone."));
689
+ if (!st.on) return console.log(c.y("Your phone's link is off.") + c.d(" Switch it on in Symbiot: Settings → Your phone."));
678
690
  if (st.error) console.log(c.y(st.error));
679
691
  if (st.listening) console.log(`Listening for your phone at ${(st.addresses || []).map((a) => c.b(`${a}:${st.port || PHONE_PORT}`)).join(", ") || c.y("(no network address found)")}`);
692
+ if (st.relay && st.relay.on && (st.phones || []).length) console.log(c.d(st.relay.connected ? "Away from home, your phone reaches it through relay.symbiot.co.za (sealed: the relay can't read it)." : `Away from home: not connected to the relay yet${st.relay.error ? ` (${st.relay.error})` : ""}.`));
680
693
  console.log(st.code ? `Code ${c.b(st.code)} ${c.d(`until ${new Date(st.until).toLocaleTimeString()}`)}` : c.d("No pairing code open: symbiot phone code for one."));
681
- for (const p of st.phones || []) console.log(` ${c.g("·")} ${p.name} ${c.d("paired " + new Date(p.added).toLocaleDateString() + (p.seen ? ", asked " + new Date(p.seen).toLocaleString() : ""))}`);
694
+ for (const p of st.phones || []) console.log(` ${c.g("·")} ${p.name} ${c.d("paired " + new Date(p.added).toLocaleDateString() + (p.seen ? ", asked " + new Date(p.seen).toLocaleString() : "") + (p.old ? " (paired before the link was sealed: pair it again)" : ""))}`);
682
695
  console.log(c.d(`On the phone, in Termux: ${ask}`));
683
696
  return;
684
697
  }
@@ -687,11 +700,12 @@ async function cmdPhone() {
687
700
  symbiot phone the computer it's paired with
688
701
  symbiot phone pair <address> <code> pair with your computer, with its address
689
702
  (like 192.168.1.21:7392) and the code it shows
703
+ symbiot phone pair <link> or with the link its QR holds
690
704
  symbiot phone check ask it what's new now
691
705
  symbiot phone forget stop asking it
692
- On the computer (while symbiot app runs, Watch on your phone switched on):
706
+ On the computer (while symbiot app runs, Your phone switched on):
693
707
  symbiot phone where it listens, and the phones paired
694
- symbiot phone code a new 6-digit code to pair a phone with`);
708
+ symbiot phone code a new code (and its QR) to pair a phone with`);
695
709
  if (sub !== "help") process.exitCode = 1;
696
710
  }
697
711
 
@@ -903,7 +917,7 @@ ${c.b("Experimental")}
903
917
  (symbiot watch help for more)
904
918
  symbiot phone pair <address> <code>
905
919
  in Termux: pair with your computer for
906
- Watch on your phone (symbiot phone help)
920
+ Your phone (symbiot phone help)
907
921
  symbiot knowledge add <folder> documents chats quote and cite (Markdown,
908
922
  CSV, text); waiting / owner from case files
909
923
  (symbiot knowledge help for more)
package/links.mjs CHANGED
@@ -50,6 +50,9 @@ const CATALOG = [
50
50
  { id: "linkedin", name: "LinkedIn", group: "Social", url: "https://www.linkedin.com/notifications/", hosts: ["linkedin.com"], watch: true },
51
51
  ];
52
52
  const GROUPS = ["Mail", "Calendar", "Code", "Chat", "Work", "Docs", "Sales", "Social"];
53
+ // Any other site, added by you (Settings → Connections → Add a site): domains.co.za, a
54
+ // supplier's portal. config.sites: [{ id, name, url, hosts }], linked like the buttons.
55
+ const OWN = "Your sites";
53
56
 
54
57
  const hostOf = (url) => { try { return new URL(url).hostname.replace(/^www\./, "").toLowerCase(); } catch { return ""; } };
55
58
  const linksFile = () => process.env.SYMBIOT_LINKS || join(CONFIG_DIR, "links.json");
@@ -69,11 +72,13 @@ function catalog() {
69
72
  try { file = JSON.parse(readFileSync(linksFile(), "utf8")); } catch (e) { if (e && e.code !== "ENOENT") error = `${linksFile()} isn't valid JSON, so it was skipped.`; }
70
73
  const extra = ((file && Array.isArray(file.links) && file.links) || []).map(cleanEntry).filter(Boolean);
71
74
  const hide = new Set(((file && Array.isArray(file.hide) && file.hide) || []).map((x) => String(x).toLowerCase()));
72
- const byId = new Map(); for (const e of [...CATALOG, ...extra]) byId.set(e.id, e);
75
+ const own = ownSites().map((e) => ({ ...cleanEntry(e), group: OWN, own: true, company: false })).filter((e) => e.id);
76
+ const byId = new Map(); for (const e of [...CATALOG, ...extra, ...own]) if (!(e.own && byId.has(e.id))) byId.set(e.id, e);
73
77
  const list = [...byId.values()].filter((e) => !hide.has(e.id));
74
78
  return { list, error, file: file ? linksFile() : "" };
75
79
  }
76
80
 
81
+ function ownSites() { const s = loadConfig().sites; return Array.isArray(s) ? s.filter((x) => x && typeof x === "object" && x.id && x.url) : []; }
77
82
  function linked() { const l = loadConfig().linked; return l && typeof l === "object" && !Array.isArray(l) ? l : {}; }
78
83
  function saveLinked(l) { const cfg = loadConfig(); if (Object.keys(l).length) cfg.linked = l; else delete cfg.linked; return saveConfig(cfg); }
79
84
 
@@ -93,8 +98,8 @@ function stateOf(e, l, watches) {
93
98
  }
94
99
  function linksState() {
95
100
  const c = catalog(), l = linked(), watches = watchState().watches;
96
- const items = c.list.map((e) => ({ id: e.id, name: e.name, group: e.group, url: e.url, ...(e.company ? { company: true } : {}), ...stateOf(e, l[e.id], watches) }));
97
- const groups = [...new Set([...GROUPS, ...items.map((x) => x.group)])].filter((g) => items.some((x) => x.group === g));
101
+ const items = c.list.map((e) => ({ id: e.id, name: e.name, group: e.group, url: e.url, ...(e.company ? { company: true } : {}), ...(e.own ? { own: true } : {}), ...stateOf(e, l[e.id], watches) }));
102
+ const groups = [...new Set([...GROUPS, ...items.map((x) => x.group).filter((g) => g !== OWN), OWN])].filter((g) => items.some((x) => x.group === g));
98
103
  return { items, groups, linked: items.filter((x) => x.state !== "off").length, ...(c.file ? { file: c.file } : {}), ...(c.error ? { error: c.error } : {}) };
99
104
  }
100
105
 
@@ -130,7 +135,7 @@ async function checkLink(id, { check = checkWatch, read = readPage } = {}) {
130
135
  if (!e || !l[id]) return { error: `${e ? e.name : id} isn't linked.` };
131
136
  if (l[id].watch) { const r = await check(l[id].watch); if (r && r.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) }; }
132
137
  else {
133
- const p = await read(e.url);
138
+ const p = await read(e.url, { password: true });
134
139
  if (p && p.busy) return { busy: true, item: linksState().items.find((x) => x.id === id) };
135
140
  const l2 = linked(); if (!l2[id]) return { error: `${e.name} isn't linked.` };
136
141
  l2[id] = { ...l2[id], ok: !!(p && !p.error && !p.login), checked: Date.now() }; saveLinked(l2);
@@ -153,7 +158,37 @@ function unlinkSite(id) {
153
158
  else untrustSite(h);
154
159
  }
155
160
  saveLinked(l);
161
+ if (ownSites().some((x) => x.id === id)) { const cfg = loadConfig(); cfg.sites = ownSites().filter((x) => x.id !== id); if (!cfg.sites.length) delete cfg.sites; saveConfig(cfg); }
156
162
  return { ok: true, ...linksState() };
157
163
  }
164
+ // Add a site that isn't one of the buttons, and link it: trusted, opened in the Symbiot
165
+ // Browser to sign in, checked when you click Done there (server.mjs /api/browser/done). A site that is one (gmail,
166
+ // linkedin.com) links that one. input: a host or a web address, best the page you see
167
+ // once signed in (www.domains.co.za/client/dashboard), so the check can tell.
168
+ async function addSite(input, opts = {}) {
169
+ const url = siteUrl(input), host = hostOf(url);
170
+ if (!url || !host || !host.includes(".")) return { error: "Give the site: a host like domains.co.za, or the page you see once you're signed in." };
171
+ const known = catalog().list.find((e) => !e.own && e.hosts.some((h) => host === h || host.endsWith("." + h)));
172
+ if (known) return linkSite(known.id, opts);
173
+ const id = "site-" + host.replace(/[^a-z0-9]+/g, "-").slice(0, 34), cfg = loadConfig(), had = ownSites().find((x) => x.id === id);
174
+ cfg.sites = [...ownSites().filter((x) => x.id !== id), { id, name: host, url: had && /^https?:\/\/[^/]+\/?$/.test(url) ? had.url : url, hosts: [host] }];
175
+ saveConfig(cfg);
176
+ return linkSite(id, opts);
177
+ }
178
+ // A card or a reply that asks you to sign in to a site (an agent's "sign in to
179
+ // domains.co.za in Symbiot's browser"): which, so it carries a button that opens it
180
+ // in the Symbiot Browser (addSite). { site, name } or null. A site among the buttons by its name (LinkedIn) too.
181
+ const ASKS_SIGNIN = /\b(?:sign(?:ed)?[ -]?in|log[ -]?in)(?:\s+again)?(?:\s+(?:to|at|on|into))?\s+(?:your\s+|the\s+)?/gi;
182
+ function signInAsked(text) {
183
+ const t = String(text || "");
184
+ for (const m of t.matchAll(ASKS_SIGNIN)) {
185
+ const rest = t.slice(m.index + m[0].length, m.index + m[0].length + 80);
186
+ const d = rest.match(/^(?:https?:\/\/)?((?:[a-z0-9-]+\.)+[a-z]{2,})(\/[^\s"'`)]*)?/i);
187
+ if (d && !/\.(?:md|json|mjs|js|txt|env|pem)$/i.test(d[1])) return { site: (d[1] + (d[2] || "")).replace(/[.,;:]+$/, ""), name: d[1].toLowerCase().replace(/^www\./, "") };
188
+ const e = catalog().list.find((x) => new RegExp("^" + x.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\b", "i").test(rest));
189
+ if (e) return { site: e.hosts[0], name: e.name, id: e.id };
190
+ }
191
+ return null;
192
+ }
158
193
 
159
- export { CATALOG, catalog, cleanEntry, linksState, linkSite, checkLink, unlinkSite };
194
+ export { CATALOG, catalog, cleanEntry, linksState, linkSite, checkLink, unlinkSite, addSite, ownSites, signInAsked, OWN };
package/mdns.mjs ADDED
@@ -0,0 +1,135 @@
1
+ // symbiot — the computer says it's here on your network (multicast DNS, the way
2
+ // printers and AirPlay are found), so your phone finds it after the router hands
3
+ // it a new address, without pairing again (phone.mjs).
4
+ //
5
+ // announce() answers queries for _symbiot._tcp.local while the phone link is on:
6
+ // a neutral name ("Symbiot-1a2b", never this computer's hostname), its port, and
7
+ // a TXT record with the link key's fingerprint, which is how a paired phone knows
8
+ // it's its computer. browse() asks the network and gives what answers (the tests,
9
+ // and Symbiot in Termux, which has no Android discovery). Only node:dgram.
10
+ import { createSocket } from "node:dgram";
11
+
12
+ const GROUP = "224.0.0.251", PORT = 5353, SERVICE = "_symbiot._tcp.local", TTL = 120;
13
+ const T = { A: 1, PTR: 12, TXT: 16, SRV: 33, ANY: 255 };
14
+
15
+ // ---- DNS messages ------------------------------------------------------------------
16
+ function encName(name) {
17
+ const parts = String(name).replace(/\.$/, "").split("."), out = [];
18
+ for (const p of parts) { const b = Buffer.from(p, "utf8"); out.push(Buffer.from([Math.min(63, b.length)]), b.subarray(0, 63)); }
19
+ out.push(Buffer.from([0]));
20
+ return Buffer.concat(out);
21
+ }
22
+ function readName(buf, at, depth = 0) {
23
+ const labels = []; let end = -1;
24
+ for (let guard = 0; guard < 128; guard++) {
25
+ if (at >= buf.length) throw new Error("short");
26
+ const len = buf[at];
27
+ if (len === 0) { at++; break; }
28
+ if ((len & 0xc0) === 0xc0) {
29
+ if (depth > 8) throw new Error("loop");
30
+ const ptr = ((len & 0x3f) << 8) | buf[at + 1];
31
+ if (end < 0) end = at + 2;
32
+ labels.push(readName(buf, ptr, depth + 1).name); at = -1; break;
33
+ }
34
+ labels.push(buf.toString("utf8", at + 1, at + 1 + len)); at += 1 + len;
35
+ }
36
+ return { name: labels.filter(Boolean).join("."), next: end >= 0 ? end : at };
37
+ }
38
+ const u16 = (n) => { const b = Buffer.alloc(2); b.writeUInt16BE(n); return b; };
39
+ const u32 = (n) => { const b = Buffer.alloc(4); b.writeUInt32BE(n >>> 0); return b; };
40
+ function encRecord({ name, type, ttl = TTL, flush = false, data }) {
41
+ let rd;
42
+ if (type === T.A) rd = Buffer.from(String(data).split(".").map(Number));
43
+ else if (type === T.PTR) rd = encName(data);
44
+ else if (type === T.SRV) rd = Buffer.concat([u16(0), u16(0), u16(data.port), encName(data.target)]);
45
+ else if (type === T.TXT) rd = Buffer.concat(Object.entries(data).map(([k, v]) => { const s = Buffer.from(`${k}=${v}`, "utf8").subarray(0, 255); return Buffer.concat([Buffer.from([s.length]), s]); }));
46
+ return Buffer.concat([encName(name), u16(type), u16((flush ? 0x8000 : 0) | 1), u32(ttl), u16(rd.length), rd]);
47
+ }
48
+ function encMessage({ id = 0, response = false, questions = [], answers = [], additionals = [] }) {
49
+ const head = Buffer.concat([u16(id), u16(response ? 0x8400 : 0), u16(questions.length), u16(answers.length), u16(0), u16(additionals.length)]);
50
+ return Buffer.concat([head, ...questions.map((q) => Buffer.concat([encName(q.name), u16(q.type), u16(q.unicast ? 0x8001 : 1)])), ...answers.map(encRecord), ...additionals.map(encRecord)]);
51
+ }
52
+ function decMessage(buf) {
53
+ const id = buf.readUInt16BE(0), flags = buf.readUInt16BE(2), qd = buf.readUInt16BE(4), an = buf.readUInt16BE(6), ns = buf.readUInt16BE(8), ar = buf.readUInt16BE(10);
54
+ let at = 12; const questions = [], records = [];
55
+ for (let i = 0; i < qd; i++) { const n = readName(buf, at); at = n.next; questions.push({ name: n.name, type: buf.readUInt16BE(at), unicast: !!(buf.readUInt16BE(at + 2) & 0x8000) }); at += 4; }
56
+ for (let i = 0; i < an + ns + ar; i++) {
57
+ const n = readName(buf, at); at = n.next;
58
+ const type = buf.readUInt16BE(at), ttl = buf.readUInt32BE(at + 4), len = buf.readUInt16BE(at + 8); at += 10;
59
+ const rd = buf.subarray(at, at + len); let data = null;
60
+ if (type === T.A && len === 4) data = [...rd].join(".");
61
+ else if (type === T.PTR) data = readName(buf, at).name;
62
+ else if (type === T.SRV) data = { port: rd.readUInt16BE(4), target: readName(buf, at + 6).name };
63
+ else if (type === T.TXT) { data = {}; for (let j = 0; j < rd.length;) { const l = rd[j], s = rd.toString("utf8", j + 1, j + 1 + l), eq = s.indexOf("="); if (s) data[eq < 0 ? s : s.slice(0, eq)] = eq < 0 ? "" : s.slice(eq + 1); j += 1 + l; } }
64
+ records.push({ name: n.name, type, ttl, data }); at += len;
65
+ }
66
+ return { id, response: !!(flags & 0x8000), questions, records };
67
+ }
68
+
69
+ // ---- the computer: answer while the link is on ------------------------------------------
70
+ // opts: { id (a few hex characters, from the fingerprint), port, fp, addresses: () => [...] }.
71
+ // Gives { stop() } (a goodbye, so phones forget it at once), or null where nothing
72
+ // can listen on 5353 (it's in use without sharing, or there's no network).
73
+ // (socket: a dgram-like factory, for the tests)
74
+ const udp = (o) => createSocket(o);
75
+ function announce({ id, port, fp, addresses }, { onError = () => {}, socket = udp } = {}) {
76
+ const inst = `Symbiot-${id}.${SERVICE}`, host = `symbiot-${id}.local`;
77
+ const records = (ttl = TTL) => {
78
+ const ips = addresses();
79
+ return { answers: [{ name: SERVICE, type: T.PTR, ttl, data: inst }],
80
+ 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
+ ...ips.map((ip) => ({ name: host, type: T.A, ttl, flush: true, data: ip }))] };
82
+ };
83
+ const ours = (q) => { const n = q.name.toLowerCase(); return (n === SERVICE.toLowerCase() && (q.type === T.PTR || q.type === T.ANY)) || (n === inst.toLowerCase() && [T.SRV, T.TXT, T.ANY].includes(q.type)) || (n === host && [T.A, T.ANY].includes(q.type)); };
84
+ let sock; try { sock = socket({ type: "udp4", reuseAddr: true }); } catch (e) { onError(e); return null; }
85
+ let closed = false;
86
+ const send = (msg, port_ = PORT, to = GROUP) => { if (!closed) try { sock.send(msg, port_, to); } catch {} };
87
+ sock.on("error", (e) => { onError(e); try { sock.close(); } catch {} closed = true; });
88
+ sock.on("message", (buf, from) => {
89
+ let m; try { m = decMessage(buf); } catch { return; }
90
+ if (m.response || !m.questions.some(ours)) return;
91
+ 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) });
93
+ if (legacy || m.questions.some((q) => q.unicast)) send(msg, from.port, from.address); else send(msg);
94
+ });
95
+ sock.bind(PORT, () => {
96
+ try { sock.setMulticastTTL(255); sock.setMulticastLoopback(true); } catch {}
97
+ const ips = addresses(); let joined = 0;
98
+ for (const ip of ips) try { sock.addMembership(GROUP, ip); joined++; } catch {}
99
+ if (!joined) try { sock.addMembership(GROUP); } catch (e) { onError(e); }
100
+ // say it's here, twice a second apart, as RFC 6762 8.3 asks
101
+ send(encMessage({ response: true, ...records() })); setTimeout(() => send(encMessage({ response: true, ...records() })), 1000).unref();
102
+ });
103
+ sock.unref();
104
+ return { stop() { if (closed) return; send(encMessage({ response: true, ...records(0) })); setTimeout(() => { closed = true; try { sock.close(); } catch {} }, 50); } };
105
+ }
106
+
107
+ // ---- the phone (and the tests): who answers -------------------------------------------
108
+ // [{ name, host, port, addresses, fp }] after `timeout` ms. Asked from a passing
109
+ // port, so answers come straight back here and nothing else needs 5353.
110
+ function browse({ timeout = 2500, iface, socket = udp } = {}) {
111
+ return new Promise((resolve) => {
112
+ const found = new Map(); let sock;
113
+ try { sock = socket({ type: "udp4" }); } catch { return resolve([]); }
114
+ const done = () => { try { sock.close(); } catch {} resolve([...found.values()].filter((x) => x.port && x.addresses.length)); };
115
+ sock.on("error", done);
116
+ sock.on("message", (buf) => {
117
+ let m; try { m = decMessage(buf); } catch { return; }
118
+ if (!m.response) return;
119
+ const recs = m.records;
120
+ for (const p of recs.filter((r) => r.type === T.PTR && r.name.toLowerCase() === SERVICE.toLowerCase())) {
121
+ const inst = p.data, srv = recs.find((r) => r.type === T.SRV && r.name === inst), txt = recs.find((r) => r.type === T.TXT && r.name === inst);
122
+ if (!srv) continue;
123
+ const addrs = recs.filter((r) => r.type === T.A && r.name.toLowerCase() === srv.data.target.toLowerCase()).map((r) => r.data);
124
+ found.set(inst, { name: inst.split(".")[0], host: srv.data.target, port: srv.data.port, addresses: addrs, fp: (txt && txt.data && txt.data.fp) || "" });
125
+ }
126
+ });
127
+ sock.bind(0, () => {
128
+ try { sock.setMulticastLoopback(true); if (iface) sock.setMulticastInterface(iface); } catch {}
129
+ try { sock.send(encMessage({ id: Math.floor(Math.random() * 65535), questions: [{ name: SERVICE, type: T.PTR }] }), PORT, GROUP); } catch { return done(); }
130
+ setTimeout(done, timeout);
131
+ });
132
+ });
133
+ }
134
+
135
+ export { announce, browse, encMessage, decMessage, SERVICE, T };
package/mind.mjs CHANGED
@@ -204,6 +204,7 @@ function rulesFor(lanes = [], self = "", nav = false) {
204
204
  When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" starts a coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). With a "repo", it's that project's agent, working in it; empty, an agent for everything outside a repo (this computer, accounts, services). Use "task" for what's for later. Don't ask their permission to hand it over: the agent asks them first, on the Workdesk, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null.
205
205
  Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${lanes.includes(MARKETING) ? ` "${MARKETING}" is a lane of its own for marketing any of their products (posts, demo videos, launches, campaigns, pricing pages' copy): marketing work goes there, starting with the product it's for in brackets, "[Dailify] a launch post".` : ""}${self ? ` Symbiot itself is "${self}": a flaw in how Symbiot works (how it read a page, what a brief or a card said, anything in the app) goes there.` : ""}
206
206
  Don't wait to be asked to fix Symbiot. When you notice it got something wrong (you misread a page, a brief or a card misled them, a step made them do an agent's job), say so plainly and, in the same reply, start the fix: "agent"${self ? ` with "repo": "${self}"` : ""}, saying what went wrong, an example, and what it should do instead.
207
+ When only they can sign in to a site (their password, an SMS code), say where, with the site's address: "sign in to www.domains.co.za/client/dashboard in the Symbiot Browser (Settings → Connections → Add a site)", never just "in Symbiot's browser": your reply then carries a Sign in button that opens that window on it.
207
208
  A short answer (yes, yeah, ok, sure, go, do it, that one, 2) answers what you last said in "This chat so far", never something from elsewhere in the app. If you'd proposed a task or an agent and they agree, do it now in "do", in the lane you named.
208
209
  Hash it out first: when they bring an idea rather than a clear ask, sharpen it with them: say what's strong, push back where it's weak, add what's missing, and propose it as a task (naming the lane) once it holds up. "do" stays null until they agree.
209
210
  When an ask could mean two or more things (which lane, how much, what done looks like), don't guess: "do" stays null, and the reply gives 2-3 short numbered readings, your pick first, and asks which. A clear ask goes straight through.${nav ? ` Where to go is the exception: see "go".` : ""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.61.1",
3
+ "version": "0.62.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 \u2014 Claude, OpenAI, Gemini, or a local model.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -55,7 +55,9 @@
55
55
  "voice.mjs",
56
56
  "licence.mjs",
57
57
  "lang.mjs",
58
- "browser.html"
58
+ "browser.html",
59
+ "qr.mjs",
60
+ "mdns.mjs"
59
61
  ],
60
62
  "engines": {
61
63
  "node": ">=18"
@@ -88,7 +90,7 @@
88
90
  "@anthropic-ai/sdk": "^0.128.0"
89
91
  },
90
92
  "scripts": {
91
- "test": "node test/load.mjs && node test/fixtures.mjs && node test/links.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/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",
93
+ "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",
92
94
  "lint": "eslint .",
93
95
  "postinstall": "node postinstall.mjs"
94
96
  },