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/CHANGELOG.md +41 -0
- package/README.md +39 -5
- package/agents.mjs +52 -11
- package/browser.html +21 -3
- package/capture.mjs +761 -0
- package/demo.mjs +171 -0
- package/guard.mjs +23 -5
- package/handback.mjs +22 -8
- package/handover.mjs +6 -2
- package/headless.mjs +281 -80
- package/home.mjs +23 -41
- package/index.mjs +65 -11
- package/lanes.mjs +99 -9
- package/links.mjs +52 -2
- package/marketing.mjs +40 -7
- package/mdns.mjs +6 -4
- package/package.json +6 -3
- package/phone.mjs +79 -12
- package/plain.mjs +48 -0
- package/reports.mjs +15 -1
- package/server.mjs +89 -42
- package/tray.mjs +44 -4
- package/ui.mjs +317 -47
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
|
|
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.
|
|
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 <
|
|
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,
|
|
229
|
-
//
|
|
248
|
+
// approve from the phone has to match), what Watch found, what was approved from a
|
|
249
|
+
// phone lately, and the newest reports (reportsForPhone). v changes only when something
|
|
250
|
+
// in it does.
|
|
230
251
|
let snapCache = null;
|
|
231
252
|
const plainText = (s, n = 280) => { const t = String(s || "").replace(/\s+/g, " ").trim(); return t.length > n ? t.slice(0, n - 1) + "…" : t; };
|
|
232
253
|
const approveSum = (r) => sha(JSON.stringify([(r.tasks || []).map((t) => t.id).sort(), (r.files || []).map((f) => (typeof f === "string" ? f : f.path || f.file || JSON.stringify(f))).sort(), r.stat || ""])).slice(0, 16);
|
|
233
254
|
const askId = (path) => "ask:" + sha(String(path)).slice(0, 12);
|
|
255
|
+
// The newest few of what this computer's agents wrote up (reports.mjs), so the phone's
|
|
256
|
+
// Reports has them too, to read an audit or a plan away from the desk, offline as well.
|
|
257
|
+
// Each is cut at a paragraph past REPORT_CAP: the copy goes through the relay, which
|
|
258
|
+
// takes 256 KB at most, and the rest is here to read.
|
|
259
|
+
const PHONE_REPORTS = 5, REPORT_CAP = 12000;
|
|
260
|
+
function cutReport(text, cap = REPORT_CAP) {
|
|
261
|
+
const t = String(text || ""); if (t.length <= cap) return { text: t, cut: false };
|
|
262
|
+
const head = t.slice(0, cap), k = head.lastIndexOf("\n\n");
|
|
263
|
+
return { text: (k > cap * 0.5 ? head.slice(0, k) : head).replace(/\s+$/, ""), cut: true };
|
|
264
|
+
}
|
|
265
|
+
async function reportsForPhone({ list } = {}) {
|
|
266
|
+
if (!list) list = (await import("./reports.mjs")).listReports();
|
|
267
|
+
return list.slice(0, PHONE_REPORTS).map((r) => {
|
|
268
|
+
let t = ""; try { t = readFileSync(r.file, "utf8"); } catch { return null; }
|
|
269
|
+
const c = cutReport(t);
|
|
270
|
+
return { id: r.id, title: plainText(r.title, 140), name: r.name, lane: r.lane || "", run: r.run || "", mtime: r.mtime, size: r.size, text: c.text, ...(c.cut ? { cut: true } : {}) };
|
|
271
|
+
}).filter(Boolean);
|
|
272
|
+
}
|
|
273
|
+
// On the phone: the copy, and the computer it's from, for its Reports (server.mjs). null on a computer.
|
|
274
|
+
function computerCopy() { const c = loadConfig().computer, copy = c ? loadCopy() : null; return copy ? { copy, from: copy.name || c.name || "your computer" } : null; }
|
|
234
275
|
async function snapshot({ now = Date.now(), fresh = false } = {}) {
|
|
235
276
|
if (!fresh && snapCache && now - snapCache.at < 5000) return snapCache.snap;
|
|
236
277
|
const tryOr = async (f, d) => { try { return await f(); } catch { return d; } };
|
|
@@ -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
|
|
293
|
+
const reports = await tryOr(() => reportsForPhone(), []);
|
|
294
|
+
const body = { tasks, needs, asks, approves, watch: seen, recent, reports, relay: linkCfg().relay && relayAllowed() };
|
|
253
295
|
const snap = { v: sha(JSON.stringify(body)).slice(0, 16), name: hostname(), at: now, ...body };
|
|
254
296
|
snapCache = { at: now, snap };
|
|
255
297
|
return snap;
|
|
@@ -326,7 +368,7 @@ let mdns = null;
|
|
|
326
368
|
function startMdns() {
|
|
327
369
|
if (mdns || process.env.SYMBIOT_NO_MDNS === "1") return;
|
|
328
370
|
const l = linkCfg(), k = linkKeys();
|
|
329
|
-
mdns = announce({ id: sha(Buffer.from(k.pub, "base64url")).slice(0, 4), port: l.port, fp: fingerprint(k.pub), addresses: () =>
|
|
371
|
+
mdns = announce({ id: sha(Buffer.from(k.pub, "base64url")).slice(0, 4), port: l.port, fp: fingerprint(k.pub), addresses: (asker) => mdnsAddresses(asker) }, { onError: () => {} });
|
|
330
372
|
}
|
|
331
373
|
function stopMdns() { if (mdns) mdns.stop(); mdns = null; }
|
|
332
374
|
|
|
@@ -526,7 +568,7 @@ function computerView() {
|
|
|
526
568
|
const st = computerState(), copy = loadCopy(), q = loadQueue();
|
|
527
569
|
if (!copy) return { ...st, copy: null };
|
|
528
570
|
const v = JSON.parse(JSON.stringify(copy));
|
|
529
|
-
for (const k of ["tasks", "needs", "asks", "approves", "watch", "recent", "notes"]) if (!Array.isArray(v[k])) v[k] = [];
|
|
571
|
+
for (const k of ["tasks", "needs", "asks", "approves", "watch", "recent", "notes", "reports"]) if (!Array.isArray(v[k])) v[k] = [];
|
|
530
572
|
for (const o of q) {
|
|
531
573
|
const a = o.args || {};
|
|
532
574
|
if (o.op === "task.add") v.tasks.unshift({ id: "q:" + o.id, text: plainText(a.text), repo: a.repo || "", done: false, waiting: true });
|
|
@@ -552,14 +594,37 @@ function queueChange(op, args = {}) {
|
|
|
552
594
|
if (process.env.SYMBIOT_NO_POLL !== "1") setTimeout(() => { pollComputer().catch(() => {}); }, 30).unref();
|
|
553
595
|
return computerView();
|
|
554
596
|
}
|
|
597
|
+
// What waits on you there, each by a key that holds while it waits: an agent's
|
|
598
|
+
// questions (by what they ask, so a new question tells you again), work waiting for
|
|
599
|
+
// your Approve (by its tasks: a file more isn't news, a task more is), and Home's
|
|
600
|
+
// other needs. Watch's news is told on its own (newsNotice), so it isn't here.
|
|
601
|
+
function waitingOn(s) {
|
|
602
|
+
const out = new Map();
|
|
603
|
+
for (const a of (s && s.asks) || []) { const qs = (a.questions || []).map((q) => q.q); out.set(a.id + ":" + sha(JSON.stringify(qs)).slice(0, 8), `${a.name} asks: ${qs[0] || "a question"}`); }
|
|
604
|
+
for (const r of (s && s.approves) || []) { const ids = (r.tasks || []).map((t) => t.id).sort(), n = ids.length;
|
|
605
|
+
out.set("approve:" + r.repo + ":" + sha(JSON.stringify(ids)).slice(0, 8), `${r.repo}: ${n ? `${n} task${n > 1 ? "s" : ""} done` : "changes without a task"}, waiting for your Approve`); }
|
|
606
|
+
for (const n of (s && s.needs) || []) out.set("need:" + n.id, n.sub ? `${n.title}: ${n.sub}` : n.title);
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
609
|
+
// What's newly waiting since the copy before, as one notification (a poll that finds
|
|
610
|
+
// three things buzzes once), or null. Nothing for the first copy after pairing: what
|
|
611
|
+
// was already waiting isn't news. What went (answered, approved) says nothing.
|
|
612
|
+
function waitingNotice(was, now, name = "your computer") {
|
|
613
|
+
if (!was || !now) return null;
|
|
614
|
+
const before = waitingOn(was), fresh = [...waitingOn(now)].filter(([k]) => !before.has(k)).map(([, t]) => plainText(t, 160));
|
|
615
|
+
if (!fresh.length) return null;
|
|
616
|
+
const more = fresh.length > 4 ? `\nand ${fresh.length - 4} more` : "";
|
|
617
|
+
return [`${fresh.length === 1 ? "1 thing needs" : `${fresh.length} things need`} you · ${name}`, fresh.slice(0, 4).join("\n") + more];
|
|
618
|
+
}
|
|
555
619
|
function dismissNote(id) { const c = loadCopy(); if (c) saveCopy(c, { notes: (c.notes || []).filter((n) => n.id !== id) }); return computerView(); }
|
|
556
620
|
// Ask the computer: send what was queued here, then what's new (notified, one
|
|
557
|
-
// notification per watch per read), then the copy (unchanged costs nothing)
|
|
621
|
+
// notification per watch per read), then the copy (unchanged costs nothing), and
|
|
622
|
+
// tell what newly waits on you in it (waitingNotice).
|
|
558
623
|
async function pollComputer(opts = {}) { if (polling) return polling; polling = pollOnce(opts).finally(() => { polling = null; schedulePoll(); }); return polling; }
|
|
559
624
|
async function pollOnce({ fetchFn = fetch, notify = desktopNotify, discover } = {}) {
|
|
560
625
|
const c = loadConfig().computer; if (!c) return { ...computerState(), shown: 0 };
|
|
561
626
|
if (!readSecret()) return { ...computerState(), shown: 0 };
|
|
562
|
-
const via = { fetchFn, ...(discover ? { discover } : {}) };
|
|
627
|
+
const via = { fetchFn, ...(discover ? { discover } : {}) }, was = loadCopy();
|
|
563
628
|
const away = () => { fails++; pollErr = `Couldn't reach ${c.name}${fails > 1 ? "" : `. It's asked again in ${BACKOFF[0] / 1000} seconds`} (it may be off or asleep${c.relay ? "" : ", or on another network"}).`; return { ...computerState(), shown: 0 }; };
|
|
564
629
|
const refused = (r) => {
|
|
565
630
|
pollErr = (r.body && r.body.error) || `${c.name} answered ${r.status}.`; fails = 0;
|
|
@@ -592,7 +657,9 @@ async function pollOnce({ fetchFn = fetch, notify = desktopNotify, discover } =
|
|
|
592
657
|
const copy = loadCopy(), st = await ask("state", { etag: (copy && copy.v) || "" }, via);
|
|
593
658
|
if (st.status === 200 && st.body && st.body.v) { saveCopy(st.body); const cfg = loadConfig(); if (cfg.computer && !!cfg.computer.relayOff !== (st.body.relay === false)) { cfg.computer.relayOff = st.body.relay === false; saveConfig(cfg); } }
|
|
594
659
|
else if (st.status === 304 && copy) saveCopy(copy);
|
|
595
|
-
|
|
660
|
+
const waiting = waitingNotice(was, loadCopy(), c.name);
|
|
661
|
+
if (waiting) notify(...waiting);
|
|
662
|
+
return { ...computerState(), shown: batches.size + (waiting ? 1 : 0) };
|
|
596
663
|
}
|
|
597
664
|
// Every 2 minutes; after a failed ask sooner (15 s, 30 s, a minute), so a phone
|
|
598
665
|
// that changed networks or a computer that woke is back without a wait.
|
|
@@ -609,6 +676,6 @@ const phoneState = () => (PHONE ? computerView() : linkState());
|
|
|
609
676
|
// While the app runs: the computer listens if it's switched on; the phone asks if it's paired.
|
|
610
677
|
function startPhone() { if (PHONE) startComputerPoll(); else startPhoneLink(); }
|
|
611
678
|
|
|
612
|
-
export { PORT, PHONE, ANDROID_APP, PHONE_OPS, lanAddresses, linkState, newCode, unpairPhone, setRelay, startPhoneLink, stopPhoneLink, setPhoneLink, setPhoneApprove, phoneApprovals, snapshot,
|
|
613
|
-
computerUrl, parsePairLink, pairLink, computerState, computerView, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, startComputerPoll, phoneState, startPhone,
|
|
679
|
+
export { computerCopy, reportsForPhone, cutReport, PHONE_REPORTS, REPORT_CAP, PORT, PHONE, ANDROID_APP, PHONE_OPS, lanAddresses, mdnsAddresses, linkState, newCode, unpairPhone, setRelay, startPhoneLink, stopPhoneLink, setPhoneLink, setPhoneApprove, phoneApprovals, snapshot,
|
|
680
|
+
computerUrl, parsePairLink, pairLink, computerState, computerView, waitingNotice, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, startComputerPoll, phoneState, startPhone,
|
|
614
681
|
newKeys, linkKey, fingerprint, seal, unseal, relayOf, answer };
|
package/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 };
|