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/CHANGELOG.md +21 -0
- package/README.md +80 -34
- package/browser.html +2 -1
- package/core.mjs +3 -1
- package/guard.mjs +30 -1
- package/handover.mjs +1 -1
- package/headless.mjs +82 -8
- package/home.mjs +48 -12
- package/index.mjs +31 -17
- package/links.mjs +40 -5
- package/mdns.mjs +135 -0
- package/mind.mjs +1 -0
- package/package.json +5 -3
- package/phone.mjs +525 -78
- package/post.mjs +29 -12
- package/qr.mjs +160 -0
- package/server.mjs +23 -11
- package/tasks.mjs +12 -2
- package/ui.mjs +173 -35
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`:
|
|
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,
|
|
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 →
|
|
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 →
|
|
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
|
|
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("
|
|
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
|
-
|
|
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("
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
},
|