symbiot 0.63.6 → 0.63.8

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/marketing.mjs CHANGED
@@ -126,9 +126,9 @@ function draftFiles(dir = MARKETING_DIR, names = [], { max = 30 } = {}) {
126
126
  // two `---` lines, and the rest is notes (with the picture and the date they name).
127
127
  const MEDIA_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", mp4: "video/mp4", mov: "video/quicktime", webm: "video/webm" };
128
128
  const SEE_MORE = 210; // where LinkedIn's feed cuts a post off with "…see more"
129
- const HEAD_KEYS = { product: "product", platform: "platform", when: "when", schedule: "when", date: "when", media: "media", image: "media", video: "media", picture: "media" };
129
+ const HEAD_KEYS = { product: "product", platform: "platform", when: "when", schedule: "when", date: "when", media: "media", image: "media", video: "media", picture: "media", where: "where", subreddit: "where", community: "where", flair: "flair" };
130
130
  function parseDraft(text) {
131
- const src = String(text || "").replace(/\r\n/g, "\n"), out = { title: "", product: "", platform: "", when: "", media: [], body: "", notes: "", format: "" };
131
+ const src = String(text || "").replace(/\r\n/g, "\n"), out = { title: "", product: "", platform: "", when: "", media: [], body: "", notes: "", format: "", where: "", flair: "" };
132
132
  const lines = src.split("\n"), head = [];
133
133
  let i = 0;
134
134
  for (; i < lines.length; i++) { const l = lines[i]; if (/^##\s/.test(l) || /^\s*-{3,}\s*$/.test(l)) break; head.push(l); }
@@ -175,10 +175,12 @@ function author({ cfg = loadConfig(), dir = MARKETING_DIR } = {}) {
175
175
  return { name: name || "You", headline: String(p.headline || "").trim(), avatar: !!avatar, initials: (name || "You").split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase() };
176
176
  }
177
177
  // A file of the lane's, by its path there, only if it is there (no ../ out of it).
178
+ // Never in .symbiot/ (the unblurred originals a capture was made from, tray-originals, are
179
+ // there) nor .git/: nothing there is served, previewed or put on a post.
178
180
  function inLane(dir, rel) {
179
181
  if (!rel || /\0/.test(rel)) return "";
180
182
  const f = join(dir, String(rel));
181
- try { const real = realpathSync(f), root = realpathSync(dir); return real.startsWith(root + "/") ? real : ""; } catch { return ""; }
183
+ try { const real = realpathSync(f), root = realpathSync(dir); return real.startsWith(root + "/") && !/^\.(?:symbiot|git)(?:\/|$)/.test(real.slice(root.length + 1)) ? real : ""; } catch { return ""; }
182
184
  }
183
185
  // The text an approval is for: a draft's post changed after you approved it isn't approved.
184
186
  const sigOf = (body) => createHash("sha256").update(String(body || "")).digest("hex").slice(0, 16);
@@ -247,15 +249,41 @@ function draftPreview(rel, { dir = MARKETING_DIR, cfg } = {}) {
247
249
  const d = parseDraft(readFileSync(f, "utf8")), base = relative(realpathSync(dir), dirname(f));
248
250
  const media = d.media.map((m) => { const r = join(base, m), ext = m.split(".").pop().toLowerCase(); return inLane(dir, r) && MEDIA_TYPES[ext] ? { rel: r, name: basename(m), kind: MEDIA_TYPES[ext].startsWith("video") ? "video" : "image" } : null; }).filter(Boolean);
249
251
  const st = statusOf(dir, rel, d.body);
250
- return { rel, title: d.title, product: d.product, platform: d.platform, when: d.when, body: d.body, ...seeMore(d.body), hashtags: d.body.match(/#[\p{L}\p{N}_]+/gu) || [], chars: d.body.length,
252
+ return { rel, title: d.title, product: d.product, platform: d.platform, when: d.when, ...whereOf(d), body: d.body, ...seeMore(d.body), hashtags: d.body.match(/#[\p{L}\p{N}_]+/gu) || [], chars: d.body.length,
251
253
  media, missing: d.media.filter((m) => !media.some((x) => x.name === basename(m))), notes: d.notes, format: d.format, author: author({ cfg, dir }), status: st ? st.status : "", statusAt: st ? st.at : 0,
252
254
  ...(st && FINAL.includes(st.status) ? { final: finalSay(st), posted: st.posted || 0, postedOn: st.postedOn || "", url: st.url || "", by: [].concat(st.by || []), edited: !!st.edited } : {}) };
253
255
  }
256
+ // Where a draft goes, past its platform: a subreddit (its where: or subreddit: line, else
257
+ // the first r/<name> its notes give, as r/ClaudeAI's draft does), and its flair (a flair:
258
+ // line, else 'flair "…"' in its notes). { where, flair, handle? }, each "" when it doesn't
259
+ // say; handle, the u/<name> a Reddit draft's notes say it's posted as.
260
+ function whereOf(d) {
261
+ const reddit = /reddit/i.test(d.platform || ""), sub = (t) => { const m = String(t || "").match(/(?:^|[^\w/])\/?r\/([A-Za-z0-9_]{2,21})\b/); return m ? `r/${m[1]}` : ""; };
262
+ const w = String(d.where || "").trim(), where = w ? (reddit && (sub(w) || (/^[A-Za-z0-9_]{2,21}$/.test(w) ? `r/${w}` : ""))) || w : reddit ? sub(d.notes) : "";
263
+ const fl = d.flair || (reddit ? (String(d.notes || "").match(/\bflair\s*[:]?\s*["“]([^"”\n]{1,64})["”]/i) || [])[1] || "" : "");
264
+ const handle = reddit ? (String(d.notes || "").match(/(?:^|[^\w/])\/?u\/([A-Za-z0-9_-]{3,20})\b/) || [])[1] || "" : ""; // who it's posted as, where the notes say
265
+ return { where, flair: String(fl).trim(), ...(handle ? { handle: `u/${handle}` } : {}) };
266
+ }
254
267
  // A picture or video a draft shows, or the avatar in config.profile: { file, type } or null.
255
268
  function laneMedia(rel, { dir = MARKETING_DIR, cfg = loadConfig() } = {}) {
256
269
  const f = rel === "avatar" ? String(((cfg && cfg.profile) || {}).avatar || "") : inLane(dir, rel), type = f && MEDIA_TYPES[f.split(".").pop().toLowerCase()];
257
270
  return type && existsSync(f) ? { file: f, type } : null;
258
271
  }
272
+ // A capture of a pick tray (tray.mjs) that Symbiot couldn't vouch for alone (a desktop
273
+ // recording, a clip, one whose pictures' text wasn't read) says why in its .blur.json
274
+ // (review) until you've looked it over (reviewed); a reel, until its clips have been. Till
275
+ // then it goes on no post. reviewOf: why, for a capture by name; lookOver: for a file.
276
+ const readJsonFile = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
277
+ function reviewOf(root, name, kind = "", parts = []) {
278
+ const one = (n) => { const sp = /^\w[\w.-]{0,100}$/.test(String(n)) ? readJsonFile(join(root, `${n}.blur.json`)) : null; return sp && sp.review && !sp.reviewed ? String(sp.review) : ""; };
279
+ if (kind === "reel") { const p = (parts || []).find((x) => one(x)); return p ? `Look over its clip ${p} first: ${one(p)}` : ""; }
280
+ return one(name);
281
+ }
282
+ function lookOver(rel, { dir = MARKETING_DIR } = {}) {
283
+ const m = String(rel || "").match(/^(drafts\/[^/]+\/tray)\/([^/]+)$/); if (!m) return "";
284
+ const idx = readJsonFile(join(dir, m[1], "tray.json")) || {}, it = (Array.isArray(idx.items) ? idx.items : []).find((x) => x && Array.isArray(x.files) && x.files.includes(m[2]));
285
+ return it ? reviewOf(join(dir, m[1]), it.name, it.kind, it.parts) : "";
286
+ }
259
287
  // A post's picture or video, picked (the pick tray, tray.mjs): its `media:` line set to the
260
288
  // files, by their path in the lane, written next to the draft's own (`media: tray/home.png`);
261
289
  // the post itself untouched. Approved before, it asks for your OK again: what you approved
@@ -269,6 +297,7 @@ function setDraftMedia(rel, files, { dir = MARKETING_DIR } = {}) {
269
297
  for (const m of [].concat(files || []).map((x) => draftRel(x, dir)).filter(Boolean)) {
270
298
  const p = inLane(dir, m), type = p && MEDIA_TYPES[p.split(".").pop().toLowerCase()];
271
299
  if (!type) return { error: `${m} isn't a picture or video in the lane.` };
300
+ const why = lookOver(m, { dir }); if (why) return { error: `Look it over first, on the Marketing page: ${why}`, review: true };
272
301
  media.push({ name: relative(dirname(f), p), video: type.startsWith("video") });
273
302
  }
274
303
  if (media.length > 1 && media.some((m) => m.video)) return { error: "A post takes one video, or pictures, not both." };
@@ -289,20 +318,24 @@ function setDraftMedia(rel, files, { dir = MARKETING_DIR } = {}) {
289
318
  // ---- its brief ----------------------------------------------------------------------------
290
319
  // The lines TASKS.md gets for this lane (tasks.mjs buildTasksMd): what it is, the products
291
320
  // its tasks name and where each one's code and docs are, and its rules.
292
- function marketingBrief(list = [], { map = {}, names = productNames(map) } = {}) {
321
+ function marketingBrief(list = [], { map = {}, names = productNames(map), dir = MARKETING_DIR } = {}) {
293
322
  const named = [...new Set(list.map((t) => productOf(t.text, names)).filter(Boolean))];
294
323
  // its repo: by folder, or by its name as people say it (Dailify is in CallForge AI)
295
324
  const pathOf = (p) => { const k = Object.keys(map).find((n) => n !== MARKETING && map[n] && [n, basename(map[n]), displayName(map[n], n)].some((x) => x.toLowerCase() === p.toLowerCase())); return k ? map[k] : ""; };
296
325
  const where = named.map((p) => ({ p, path: pathOf(p) }));
326
+ // a product with no pick tray yet: its first run starts by capturing it (capture.mjs)
327
+ const trayless = where.filter((w) => w.path && !existsSync(join(dir, DRAFTS, w.p.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""), "tray", "tray.json")));
297
328
  return ["## This lane: marketing",
298
329
  "This lane markets every product the user makes, not one repo's: posts, demo videos and screenshots, launches, landing-page copy, campaigns. This folder is the lane's own (a local git repo, no remote): what you write here is reviewed and approved under Marketing in Symbiot.",
299
330
  ...(where.length ? ["- **Products in these tasks** (read each one's README and docs for what it is and its end goal):", ...where.map((w) => ` - ${w.p}${w.path ? `: \`${w.path}\`` : " (not one of the user's repos here: ask in QUESTIONS.md where it lives, or hand it over)"}`)] : []),
331
+ ...trayless.map((w) => `- **${w.p} has no pick tray yet: start by capturing it.** Run \`node "${CLI}" capture "${w.p}"\` (add \`--url <its address>\` if it isn't running here; start it first with its demo data if it has some; \`--window "<title>"\` for a desktop app). Symbiot opens it in a browser of its own, takes its key screens from its routes and README as pictures and short clips, and blurs anything private before they reach \`${DRAFTS}/<product>/tray/\`. Then build its posts around those.`),
332
+ `- Pictures and clips of a product come from Symbiot's capture (\`node "${CLI}" capture <product>\`), never a capture script of your own: Symbiot won't run one with the app's access. A post's picture or video is one from its pick tray (\`${DRAFTS}/<product>/tray/\`), blurred, that the user has looked over there; never an unblurred original (\`.symbiot/tray-originals/\`), and never a screenshot you took yourself. Say in the draft's notes which capture you'd use; the user picks it on the Marketing page.`,
300
333
  `- Write each piece as a \`.md\` under \`${DRAFTS}/<product>/\` (\`${DRAFTS}/dailify/launch-post.md\`), a \`# \` title first, then \`product: <Product>\`, so the Marketing page tags it. Its screenshots and clips go next to it.`,
301
334
  "- Every post opens with a strong hook (its first line written for reach), says plainly what the product is, and states its end goal. Where the end goal isn't written down, draft one and flag it in QUESTIONS.md for the user to confirm.",
302
335
  `- Each draft holds the post apart from your notes: a \`# \` title, then \`product:\`, \`platform:\` (linkedin), \`when:\` (YYYY-MM-DD HH:MM, if it's scheduled) and \`media:\` (its picture or video, next to it) lines, then \`## Post\` with exactly the text that goes out (its line breaks, its hashtags) and nothing else, then \`## Notes\` for your reasoning, sources and anything for the user. Symbiot shows \`## Post\` as the platform's own preview, and what you post is that text, unchanged.`,
303
- `- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\`, and \`screens upload <id> "<its Add media button>" <file>\` for its pictures or video), with the approved text and media, then check it's there.`
336
+ `- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\` (to change part of a post already written or scheduled, \`screens type <id> <field> --replace "<old words>" "<new words>"\`: retyping all of it drops its picture), and \`screens upload <id> "<its Add media button>" <file>\` for its pictures or video), with the approved text and media, then check it's there.`
304
337
  + " A post not yet approved is a question in QUESTIONS.md with a 🤖 Agent: option (\"🤖 Agent: post it on Tuesday at 08:00, once you approve its preview\"), and what to check first in its context line. Never offer doing it by hand, and never ask them to sign in to or link a platform that's linked: use its session. Only signing in (a platform not linked yet, or a real attempt found its session expired: say so) is the user's: `👤 You (only you: signing in to LinkedIn)`. Never sign in as them, and never post what they haven't approved.",
305
338
  `- Once a post is out (or in the platform's scheduler), mark it posted with its link: \`node "${CLI}" marketing posted <its file> --url <the post's link>\` (\`--at "YYYY-MM-DD HH:MM"\` for a scheduled one). A post you redo in a new file: mark the old one \`node "${CLI}" marketing superseded <old file> --by <new file>\`. Either takes it off the user's list for good, and Symbiot then refuses to approve or post it again: never edit \`.symbiot/drafts.json\` by hand.`];
306
339
  }
307
340
 
308
- export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia, draftRel, setDraftMedia, postedCmd, MEDIA_TYPES, inLane, day };
341
+ export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia, draftRel, setDraftMedia, reviewOf, lookOver, postedCmd, MEDIA_TYPES, inLane, day };
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.6",
3
+ "version": "0.63.8",
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,9 @@
46
46
  "away.mjs",
47
47
  "marketing.mjs",
48
48
  "tray.mjs",
49
+ "plain.mjs",
50
+ "capture.mjs",
51
+ "demo.mjs",
49
52
  "postinstall.mjs",
50
53
  "icon.svg",
51
54
  "scanworker.mjs",
@@ -77,7 +80,7 @@
77
80
  "developer-tools",
78
81
  "cli"
79
82
  ],
80
- "author": "Garth White <garthwhite507@gmail.com>",
83
+ "author": "Garth White <garth@symbiot.co.za>",
81
84
  "license": "Elastic-2.0",
82
85
  "repository": {
83
86
  "type": "git",
@@ -91,7 +94,7 @@
91
94
  "@anthropic-ai/sdk": "^0.128.0"
92
95
  },
93
96
  "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",
97
+ "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
98
  "lint": "eslint .",
96
99
  "postinstall": "node postinstall.mjs"
97
100
  },
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, and what was approved
229
- // from a phone lately. v changes only when something in it does.
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; } };
@@ -242,14 +283,15 @@ async function snapshot({ now = Date.now(), fresh = false } = {}) {
242
283
  const asks = [];
243
284
  for (const y of (await tryOr(() => agents.agentsList(), [])).filter((a, i, all) => all.findIndex((b) => b.path === a.path) === i)) {
244
285
  const qs = await tryOr(() => agents.agentQuestions(y.path, "").questions, []);
245
- if (qs.length) asks.push({ id: askId(y.path), path: y.path, name: plainText(String(y.name || "an agent").replace(/^Agent:\s*/, ""), 60), questions: qs.slice(0, 5).map((q) => ({ q: plainText(q.q, 300), options: (q.options || []).slice(0, 2).map((o) => plainText(o, 200)) })) });
286
+ if (qs.length) asks.push({ id: askId(y.path), path: y.path, name: plainText(String(y.name || "an agent").replace(/^Agent:\s*/, ""), 60), questions: qs.slice(0, 5).map((q) => ({ q: plainText(q.q, 300), ...(q.shown ? { shown: plainText(q.shown, 400) } : {}), options: (q.options || []).slice(0, 2).map((o) => plainText(o, 200)), ...(q.labels ? { labels: q.labels.slice(0, 2).map((o) => plainText(o, 200)) } : {}) })) });
246
287
  }
247
288
  const approves = (await tryOr(() => tasksMod.pendingReview(), [])).filter((r) => r.path && !r.running && ((r.tasks || []).length || (r.files || []).length))
248
289
  .map((r) => ({ repo: r.repo, tasks: (r.tasks || []).map((t) => ({ id: t.id, text: plainText(t.text, 200) })), files: (r.files || []).length, stat: plainText(r.stat, 120), ...(r.untasked ? { untasked: true } : {}), sum: approveSum(r) }));
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 body = { tasks, needs, asks, approves, watch: seen, recent, relay: linkCfg().relay };
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: () => lanAddresses() }, { onError: () => {} });
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
- return { ...computerState(), shown: batches.size };
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/plain.mjs ADDED
@@ -0,0 +1,48 @@
1
+ // A question an agent left, in words for someone who doesn't code (plainAsk): Home,
2
+ // the Workdesk's agent blocks, a project's card and the phone all show it this way, so
3
+ // "allow the list in .symbiot/allowlist.proposed.json" never reaches a screen. It was
4
+ // only Home's list: the same ask on the Workdesk still read as a file (2026-10-12).
5
+ import { readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ // An ask in words for someone who doesn't code: a permission request lists what the agent
9
+ // wants to run, with Allow / Don't allow; any file path left in the question or choices
10
+ // becomes "a file". The answer sent back is still the choice's own text (labels only change what shows).
11
+ const PATHISH = /`?(?:~|\.{0,2})?\/?(?:[\w.-]+\/)+[\w.-]+\.(?:json|md|mjs|js|env|txt|sh)`?|`?\b[\w-]+\.(?:json|mjs)\b`?/g;
12
+ // What a rule lets an agent do, said the way you'd say it to someone who doesn't code.
13
+ const base = (p) => String(p || "").replace(/[/*]+$/, "").split("/").filter(Boolean).pop() || "your files";
14
+ function PLAIN_RULE(r) {
15
+ const m = String(r).match(/^(\w+)\((.*)\)$/); if (!m) return "work in " + base(r);
16
+ const [, tool, arg] = m, a = arg.replace(/:?\*+$/, "").trim(), first = a.split(/\s+/)[0].replace(/:\*?$/, "").split("/").pop();
17
+ if (tool === "Read") return `read files in ${base(a)}`;
18
+ if (/^(Edit|Write|MultiEdit)$/.test(tool)) return `change files in ${base(a)}`;
19
+ if (tool !== "Bash") return `use ${tool}`;
20
+ const host = (a.match(/@([\w.-]+)/) || [])[1];
21
+ if (/^(ssh|scp|rsync)$/.test(first)) return host ? `connect to the computer at ${host}` : "connect to another computer";
22
+ if (first === "adb") return "use your phone over the USB cable";
23
+ if (/^(flutter|gradle|gradlew|godot|xcodebuild)$/.test(first) || /\bbuild\b/.test(a)) return "build the app";
24
+ if (first === "ffmpeg" || first === "ffprobe") return "make and trim video clips";
25
+ if (first === "git" && /\bpush\b/.test(a)) return "send code to GitHub";
26
+ if (/^(npm|yarn|pnpm)$/.test(first) && /\bpublish\b/.test(a)) return "publish a new version";
27
+ if (/^(bash|sh|zsh)$/.test(first)) return "run a script it wrote";
28
+ if (/^(python\d*|node)$/.test(first)) return "run code in this project";
29
+ if (/^(rm|shred)$/.test(first)) return "delete files";
30
+ if (first === "sudo") return "make changes as administrator";
31
+ return `run ${first}`;
32
+ }
33
+ function plainAsk(path, q, { max = 2 } = {}) {
34
+ const text = String(q.q || ""), opts = (q.options || []).slice(0, max);
35
+ let ask = text, labels = opts.map((o) => o.replace(PATHISH, "a file"));
36
+ if (opts.some((o) => /allowlist\.proposed/i.test(o))) {
37
+ let rules = []; try { const p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; rules = [...new Set([...(p.allow || []).map(PLAIN_RULE), ...(p.additionalDirectories || []).map((d) => `work in ${base(d)}`)])]; } catch {}
38
+ ask = `${text.replace(PATHISH, "a file")}${rules.length ? ` It wants to ${rules.slice(0, 6).join(", ")}${rules.length > 6 ? `, and ${rules.length - 6} more things` : ""}.` : ""} If you say no, it skips that part and tells you what it left out.`;
39
+ labels = opts.map((o) => (/allowlist\.proposed/i.test(o) ? "Allow" + (/recommended/i.test(o) ? " (recommended)" : "") : o.replace(PATHISH, "a file")));
40
+ }
41
+ // raw: the question as the agent wrote it, the key its answer is saved under (agents.mjs
42
+ // answerQuestions). Sending the plain words back matched nothing, so a permission card's
43
+ // Allow, and Send under it, did nothing at all (whatsapp_module, 2026-10-12).
44
+ const shown = ask.replace(PATHISH, "a file");
45
+ return { q: shown, ...(shown !== text ? { raw: text } : {}), ...(labels.some((l, i) => l !== opts[i]) ? { labels } : {}) };
46
+ }
47
+
48
+ export { PATHISH, PLAIN_RULE, plainAsk };
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 };