symbiot 0.58.2 → 0.59.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/headless.mjs CHANGED
@@ -2,11 +2,12 @@
2
2
  // screenshot and map every button, link and field on it by itself, as a screen
3
3
  // whose regions are already named (screens.mjs). Nothing to bring to the front,
4
4
  // nothing to drag. Each region keeps how to find it again (a CSS selector), so it
5
- // can be pressed (or a field typed into) in that same hidden browser, which maps
6
- // the page it lands on: map → press → map is how an agent finds its way around a
7
- // site. On a site you trust (Settings), that goes ahead without asking. In the
8
- // app the browser stays open for a few minutes after each action, so the next
9
- // one carries on from the page as it is (type into a field, then press Send).
5
+ // can be pressed (a field typed into, a file put in a file box) in that same
6
+ // hidden browser, which maps the page it lands on: map → press → map is how an
7
+ // agent finds its way around a site. On a site you trust (Settings), that goes
8
+ // ahead without asking. In the app the browser stays open for a few minutes
9
+ // after each action, so the next one carries on from the page as it is (type
10
+ // into a field, then press Send).
10
11
  // A map has what fits in the window: Scroll down maps the next part, and Whole
11
12
  // page maps all of it in one tall screenshot: the page, or, where a list scrolls
12
13
  // inside it (Gmail's mail), that list opened out to its full length.
@@ -19,8 +20,8 @@
19
20
  // site once there (signIn opens it as a normal window) and later maps see it
20
21
  // signed in. It's separate from your everyday browser profile.
21
22
  import { spawn } from "node:child_process";
22
- import { join } from "node:path";
23
- import { mkdirSync, existsSync } from "node:fs";
23
+ import { join, resolve as resolvePath } from "node:path";
24
+ import { mkdirSync, existsSync, statSync } from "node:fs";
24
25
  import { CONFIG_DIR, chromeBinary, loadConfig, saveConfig } from "./core.mjs";
25
26
  import { loadScreens, addPageScreen, center, stitchPng, stitchListPng } from "./screens.mjs";
26
27
 
@@ -499,6 +500,39 @@ function readTexts(input, selector, { scrolls = 3 } = {}) {
499
500
  }, false)).catch((e) => ({ error: String((e && e.message) || e) }));
500
501
  }
501
502
 
503
+ // LinkedIn's own lists, for Watch (watch.mjs linkedinDetail): its top bar's badges
504
+ // say "Messaging, 1 new notification", never who, and the brief could only say
505
+ // "2 new notifications" (2026-10-09). This reads who and what, in the signed-in
506
+ // hidden browser, only looking, like readPage: "messages" (each conversation: who,
507
+ // its last line, when, unread) or "notifications" (each card: its words, who, its
508
+ // link, unread). LinkedIn's class names drift, so each has a fallback: its heading,
509
+ // its first paragraph. { items } or { busy } / { error }.
510
+ const LI_TEXT = "const tx = (r, s) => { const e = r.querySelector(s); return e ? String(e.innerText || e.textContent || '').replace(/\\s+/g, ' ').trim() : ''; };";
511
+ const LI_UNREAD = "const un = (r) => /unread/i.test(String(r.className || '')) || !!r.querySelector('[class*=unread],.notification-badge--show') || /\\bunread\\b/i.test(r.getAttribute('aria-label') || '');";
512
+ const LI_READ = {
513
+ messages: ["https://www.linkedin.com/messaging/", `(() => { ${LI_TEXT} ${LI_UNREAD}
514
+ return [...document.querySelectorAll('li.msg-conversation-listitem, li.msg-conversations-container__convo-item')].slice(0, 20).map((r) => ({
515
+ name: tx(r, '.msg-conversation-listitem__participant-names, .msg-conversation-card__participant-names, h3'),
516
+ preview: tx(r, '.msg-conversation-card__message-snippet, .msg-conversation-card__message-snippet-body, p').slice(0, 200),
517
+ time: tx(r, 'time, .msg-conversation-listitem__time-stamp, .msg-conversation-card__time-stamp'), unread: un(r) })).filter((x) => x.name);
518
+ })()`],
519
+ notifications: ["https://www.linkedin.com/notifications/", `(() => { ${LI_TEXT} ${LI_UNREAD}
520
+ return [...document.querySelectorAll('article.nt-card, .nt-card-list article, main article')].slice(0, 20).map((r) => ({
521
+ text: (tx(r, '.nt-card__text--3-line, .nt-card__headline, .nt-card__text') || String(r.innerText || '').replace(/\\s+/g, ' ').trim()).slice(0, 300),
522
+ actor: tx(r, '.nt-card__text--3-line strong, .nt-card__headline strong, strong').slice(0, 80),
523
+ href: String((r.querySelector('a[href*="linkedin.com/"], a[href^="/"]') || {}).href || '').slice(0, 500), unread: un(r) })).filter((x) => x.text);
524
+ })()`],
525
+ };
526
+ function readLinkedIn(kind) {
527
+ const it = LI_READ[kind]; if (!it) return Promise.resolve({ error: "messages or notifications" });
528
+ return oneAtATime(() => browserOpen() ? { busy: true } : withPage(async (page) => {
529
+ await open(page, it[0]);
530
+ const at = String((await evaluate(page, "location.href")) || it[0]);
531
+ if (signInPage(at)) return { login: true, url: at };
532
+ return { url: at, items: (await evaluate(page, it[1])) || [] };
533
+ }, false)).catch((e) => ({ error: String((e && e.message) || e) }));
534
+ }
535
+
502
536
  // A picture, or a short clip, of a page, for a post (post.mjs): only on your
503
537
  // click, and it only looks, like readPage. The picture is the window at twice its
504
538
  // pixels, so it stays sharp in a feed: { png, url, title }. The clip records the
@@ -630,7 +664,7 @@ function actOnRegion(id, regionId, verb, confirmed, check, act) {
630
664
  const r = (s.regions || []).find((x) => x.id === regionId); if (!r) return Promise.resolve({ error: "That region is gone. Reload the screen." });
631
665
  const bad = check(r); if (bad) return Promise.resolve({ error: bad });
632
666
  const host = hostOf(s.page.url);
633
- if (!confirmed && !isTrusted(s.page.url)) return Promise.resolve({ error: `${verb === "Type" ? "Typing into" : "Pressing"} "${r.label}" acts on the real site, signed in as you, and ${host} isn't one of your trusted sites.`, confirm: true, host });
667
+ if (!confirmed && !isTrusted(s.page.url)) return Promise.resolve({ error: `${verb === "Type" ? "Typing into" : verb === "Upload" ? "Attaching a file with" : "Pressing"} "${r.label}" acts on the real site, signed in as you, and ${host} isn't one of your trusted sites.`, confirm: true, host });
634
668
  return oneAtATime(() => withPage(async (page, b) => {
635
669
  const here = await showScreen(page, b, s);
636
670
  // A new tab would leave this one where it was, so a link opens here instead.
@@ -701,6 +735,68 @@ function typeRegion(id, regionId, text, { enter = false, confirmed = false, noSe
701
735
  });
702
736
  }
703
737
 
738
+ // Upload: put a file (or a few) in a page's file box, as picking it in the file
739
+ // dialog would (DevTools' DOM.setFileInputFiles): a picture or video for a LinkedIn
740
+ // post. Sites hide the real <input type=file> behind a button ("Add media"), and a
741
+ // map only has what shows, so the field is usually that button: the box is then
742
+ // the one inside it, its label's, else the nearest one going up from it, and
743
+ // `used` says which. A page that makes its box only once the button is pressed:
744
+ // pressed, with the file dialog caught instead of shown, and the file put in the
745
+ // box that dialog was for. The files are checked (absolute, there) before a
746
+ // browser starts, and off your trusted sites it asks first, like press and type.
747
+ const FILE_BOX = (sel, at) => `(() => {
748
+ const isBox = (x) => !!x && x.tagName === 'INPUT' && String(x.type).toLowerCase() === 'file', all = [...document.querySelectorAll('input[type=file]')];
749
+ let e = null; try { e = ${JSON.stringify(sel || "")} && document.querySelector(${JSON.stringify(sel || "")}); } catch (x) {}
750
+ e = e || document.elementFromPoint(${Number(at.x) || 0}, ${Number(at.y) || 0});
751
+ let box = null, how = '';
752
+ if (isBox(e)) { box = e; how = 'the field itself'; }
753
+ else if (e) {
754
+ const lab = e.closest('label'), inside = e.querySelector('input[type=file]');
755
+ if (inside) { box = inside; how = 'the file box inside it'; }
756
+ else if (lab && isBox(lab.control)) { box = lab.control; how = "its label's file box"; }
757
+ else for (let n = e.parentElement; n && !box; n = n.parentElement) { const f = n.querySelector('input[type=file]'); if (f) { box = f; how = all.length === 1 ? 'the only file box on the page' : "the nearest of the page's " + all.length + ' file boxes'; } }
758
+ }
759
+ if (!box && all.length === 1) { box = all[0]; how = 'the only file box on the page'; }
760
+ if (!box) return null;
761
+ window.__symbiotFileBox = box;
762
+ const name = box.getAttribute('aria-label') || box.name || box.id || '';
763
+ return { how, box: 'input[type=file]' + (name ? ' "' + name + '"' : '') + (box.accept ? ' (takes ' + box.accept + ')' : ''), multiple: !!box.multiple };
764
+ })()`;
765
+ async function attachFiles(page, at, r, files) {
766
+ const box = await evaluate(page, FILE_BOX(r.selector, at));
767
+ if (box) {
768
+ if (files.length > 1 && !box.multiple) throw new Error(`${box.box} takes one file at a time.`);
769
+ const { result } = await page.send("Runtime.evaluate", { expression: "window.__symbiotFileBox" });
770
+ if (!result || !result.objectId) throw new Error("Lost the page's file box. Map the page again.");
771
+ await page.send("DOM.setFileInputFiles", { files, objectId: result.objectId });
772
+ await page.send("Runtime.evaluate", { expression: "delete window.__symbiotFileBox" }).catch(() => {});
773
+ return { used: `${box.box}, ${box.how}` };
774
+ }
775
+ // no box on the page yet: press it, and catch the file dialog that opens
776
+ await page.send("Page.setInterceptFileChooserDialog", { enabled: true });
777
+ try {
778
+ const opened = page.until("Page.fileChooserOpened", 5000);
779
+ await clickAt(page, at);
780
+ const m = await opened, p = (m && m.params) || {};
781
+ if (!p.backendNodeId) throw new Error(`No file box on the page, and pressing "${r.label}" didn't open a file dialog. Press it, then upload with what the new map shows.`);
782
+ if (files.length > 1 && p.mode !== "selectMultiple") throw new Error("That file box takes one file at a time.");
783
+ await page.send("DOM.setFileInputFiles", { files, backendNodeId: p.backendNodeId });
784
+ return { used: "the file box the file dialog it opened was for" };
785
+ } finally { await page.send("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {}); }
786
+ }
787
+ // files: a path or a list of them, relative to where you are
788
+ function uploadFiles(files) {
789
+ const list = (Array.isArray(files) ? files : [files]).filter((f) => f != null && String(f).trim()).map((f) => resolvePath(String(f)));
790
+ if (!list.length) return { error: "Give the file to upload." };
791
+ const bad = list.find((f) => { try { return !statSync(f).isFile(); } catch { return true; } });
792
+ return bad ? { error: `No file at ${bad}.` } : { files: list };
793
+ }
794
+ function uploadRegion(id, regionId, files, { confirmed = false } = {}) {
795
+ const f = uploadFiles(files);
796
+ return actOnRegion(id, regionId, "Upload", confirmed, () => f.error || "",
797
+ async (page, at, r) => ({ uploaded: r.label, files: f.files, ...(await attachFiles(page, at, r, f.files)) }));
798
+ }
799
+
704
800
  // Open the site in Symbiot's browser profile as a normal window, to sign in (or
705
801
  // accept cookies) once. Close it before mapping: the profile is shared. The hidden
706
802
  // browser is closed first, or the window would open in it, out of sight.
@@ -714,4 +810,4 @@ async function signIn(input) {
714
810
  return { ok: true, url };
715
811
  }
716
812
 
717
- export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
813
+ export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, readLinkedIn, LI_READ, pagePicture, pageClip, CLIP, isSend, pressRegion, typeRegion, uploadRegion, uploadFiles, attachFiles, FILE_BOX, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
package/home.mjs CHANGED
@@ -28,7 +28,7 @@ import { reportsNews, listReports, readReport, decide } from "./reports.mjs";
28
28
  import { awaitingState, inboxSight } from "./handback.mjs";
29
29
  import { newClashes } from "./checks.mjs";
30
30
  import { postsState } from "./post.mjs";
31
- import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles, setDraftStatus } from "./marketing.mjs";
31
+ import { MARKETING, MARKETING_DIR, MARKETING_WORDS, displayName, productNames, productOf, untagged, tagged, draftFiles, setDraftStatus, postedCmd } from "./marketing.mjs";
32
32
 
33
33
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
34
34
  let cached = null;
@@ -223,7 +223,9 @@ function goLane(repo, map, { push = pushTasks, run = runHandoff } = {}) {
223
223
  if (!map[repo]) return { error: `${repo} isn't one of your projects any more.` };
224
224
  const p = (push || pushTasks)({ repo }); if (!p || !p.written || !p.written.length) return { error: `Nothing of ${repo}'s is waiting to start.` };
225
225
  const e = (run || runHandoff)(map[repo]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
226
- return e.parked ? { error: `${repo} is parked: unpark it to start its agent.` } : { ok: true, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }) };
226
+ if (e.parked) return { error: `${repo} is parked: unpark it to start its agent.` };
227
+ if (e.blocked && !e.busy) return { error: `${repo}'s agent didn't start: ${e.note || "it's waiting on something"}` }; // queued only behind a run that's really going
228
+ return { ok: true, ...(e.busy ? { queued: true } : { job: e.id }) };
227
229
  }
228
230
 
229
231
  // ---- Marketing: a lane of its own, across products (marketing.mjs) ----------------------
@@ -280,7 +282,7 @@ function marketingDraftAnswer(rel, status, deps = {}) {
280
282
  cached = null;
281
283
  const d = r.draft, when = d.when ? `, scheduled in ${d.platform}'s own scheduler for ${d.when}` : "";
282
284
  if (status === "skipped") { noteAnswer(dir, `Skipped: ${rel}`, "Don't post it. Leave the draft where it is."); return { ok: true, said: "Skipped. Its agent won't post it." }; }
283
- noteAnswer(dir, `Approved: ${rel}`, `The user approved its preview. Post it on ${d.platform}${when}, through Symbiot's signed-in browser, with exactly the text under its post (${d.body.length} characters, unchanged)${d.media.length ? ` and ${d.media.join(", ")} attached` : ""}. Then check it's there, and say so in your last message.`);
285
+ noteAnswer(dir, `Approved: ${rel}`, `The user approved its preview. Post it on ${d.platform}${when}, through Symbiot's signed-in browser, with exactly the text under its post (${d.body.length} characters, unchanged)${d.media.length ? ` and ${d.media.join(", ")} attached` : ""}. Then check it's there, mark it posted (\`${postedCmd(rel)}\`), and say so in your last message.`);
284
286
  if ((deps.running || runningHandoff)(dir)) return { ok: true, said: "Approved. Its agent posts it once the run there now finishes." };
285
287
  const e = (deps.run || runHandoff)(dir, { force: true });
286
288
  return e && e.id && !e.blocked ? { ok: true, said: `Approved. Its agent is ${d.when ? "scheduling" : "posting"} it now.`, rerun: e.id } : { ok: true, said: `Approved. ${(e && e.note) || "Start its agent to post it."}` };
@@ -491,12 +493,12 @@ async function homeAsk(question, { ask, now = Date.now(), state, images = [] } =
491
493
  const h = state || homeState({ now });
492
494
  let map = {}; try { map = laneMap(); } catch {}
493
495
  const role = "Here they're on Symbiot's home: one liquid surface that shows what only they can do, what's new on what they watch, and the lanes. Answer from what it shows, and act on what they ask.";
494
- const r = await converse({ where: "Home", role, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data, path }) => ({ mime, data, path })), ...(ask ? { ask } : {}),
496
+ const r = await converse({ where: "Home", role, nav: true, context: homeContext(h), question: question + (pics.length ? `\n(The user attached ${pics.length} screenshot${pics.length > 1 ? "s" : ""}: you can see ${pics.length > 1 ? "them" : "it"}.)` : ""), map, now, images: pics.map(({ mime, data, path }) => ({ mime, data, path })), ...(ask ? { ask } : {}),
495
497
  act: {
496
498
  agent: (req, known, repo) => (repo ? actIn(req + shotNote, repo, { map, known, title: "Home" }) : actNow(req + shotNote, { title: "Home", known })),
497
499
  task: (text, repo) => taskIn(text + shotNote, repo, { map }),
498
500
  } });
499
- return { answer: r.reply, ...(r.did ? { did: r.did } : {}), steps: r.steps || [] };
501
+ return { answer: r.reply, spoken: r.spoken, ...(r.did ? { did: r.did } : {}), ...(r.go ? { go: r.go } : {}), steps: r.steps || [] };
500
502
  }
501
503
 
502
504
  // ---- acting on a report, where you read it (reports.mjs reportIdeas) -------------------
@@ -527,7 +529,7 @@ async function reportAsk(id, question, { ask, deps = {} } = {}) {
527
529
  agent: (req, known, repo) => ((repo || lane) ? actIn(req, repo || lane, { map, known, title: "Reports" }) : actNow(req, { title: "Reports", known })),
528
530
  task: (text, repo) => taskIn(text, repo || lane, { map }),
529
531
  } });
530
- return { answer: res.reply, ...(res.did ? { did: res.did } : {}), steps: res.steps || [] };
532
+ return { answer: res.reply, spoken: res.spoken, ...(res.did ? { did: res.did } : {}), steps: res.steps || [] };
531
533
  }
532
534
  // Approve or Reject on a draft report: its agent is told in ANSWERS.md, and an approved
533
535
  // one goes ahead (its agent starts, or takes it once the run there finishes).
package/index.mjs CHANGED
@@ -38,7 +38,7 @@ import { gitDefaultBranch, loadDeploys, driftRepo, computeDrift } from "./drift.
38
38
  import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks } from "./tasks.mjs";
39
39
  import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
40
40
  import { loadScreens, screenImage, blueprint } from "./screens.mjs";
41
- import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
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
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
44
44
  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";
@@ -320,7 +320,7 @@ function screenJson(s) {
320
320
  if (!s || s.error) return s;
321
321
  const bp = blueprint(s), image = screenImage(s.id), sc = s.page && s.page.scroll;
322
322
  const more = sc ? [sc.y > 0 && "above", sc.y < sc.max && "below"].filter(Boolean).join(" and ") : "";
323
- return { id: s.id, ...(image ? { image } : {}), ...(s.note ? { note: s.note } : {}), ...(more ? { more } : {}), ...(s.scrolled ? { scrolled: s.scrolled } : {}), ...(s.pressed ? { pressed: s.pressed, found: s.found } : {}), ...(s.typed ? { typed: s.typed, entered: s.entered, found: s.found } : {}), ...(s.kept ? { kept: true } : {}), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}), ...bp, regions: bp.regions.map((r, i) => ({ id: s.regions[i].id, ...r })) };
323
+ return { id: s.id, ...(image ? { image } : {}), ...(s.note ? { note: s.note } : {}), ...(more ? { more } : {}), ...(s.scrolled ? { scrolled: s.scrolled } : {}), ...(s.pressed ? { pressed: s.pressed, found: s.found } : {}), ...(s.typed ? { typed: s.typed, entered: s.entered, found: s.found } : {}), ...(s.uploaded ? { uploaded: s.uploaded, files: s.files, used: s.used, found: s.found } : {}), ...(s.kept ? { kept: true } : {}), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}), ...bp, regions: bp.regions.map((r, i) => ({ id: s.regions[i].id, ...r })) };
324
324
  }
325
325
 
326
326
  // map, press, type and signin go through the app when it's running, whose hidden
@@ -389,7 +389,7 @@ async function cmdUninstall() {
389
389
  if (existsSync(CONFIG_DIR) && !keep) console.log(c.d("(Something wrote to " + CONFIG_DIR + " meanwhile; delete it if you like.)"));
390
390
  }
391
391
  async function cmdScreens() {
392
- const [sub = "list", a1, a2, a3] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--name");
392
+ const [sub = "list", a1, a2, a3, ...more] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--name");
393
393
  const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
394
394
  const find = (id) => loadScreens().find((s) => s.id === id);
395
395
  if (sub === "list") {
@@ -410,14 +410,17 @@ async function cmdScreens() {
410
410
  }
411
411
  if (sub === "show") { const s = find(a1); return out(s ? screenJson(s) : { error: "No screen " + (a1 || "") + ". symbiot screens lists them." }); }
412
412
  if (sub === "signin") { const r = (await viaApp("/api/screens/signin", { site: a1 })) || await signIn(a1); return out(r.ok ? { ...r, next: "Sign in in the window that opened, close it, then map again." } : r); }
413
- if (sub === "press" || sub === "type") {
413
+ if (sub === "press" || sub === "type" || sub === "upload") {
414
414
  const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
415
415
  const want = String(a2 || "").toLowerCase(), rs = s.regions || [];
416
416
  const r = rs.find((x) => x.id === a2) || rs.find((x) => x.label.toLowerCase() === want) || (rs.filter((x) => x.label.toLowerCase().includes(want)).length === 1 && rs.find((x) => x.label.toLowerCase().includes(want)));
417
417
  if (!want || !r) return out({ error: `No region "${a2 || ""}" on that screen (give its id, or a label that matches one region).` });
418
418
  // a draft reply's agent (SYMBIOT_DRAFT, watch.mjs) never presses Send, or Enter (it sends in a chat)
419
- const body = { id: s.id, region: r.id, confirmed: has("yes"), noSend: !!process.env.SYMBIOT_DRAFT, ...(sub === "type" ? { text: a3, enter: has("enter") } : {}) };
420
- const done = (await viaApp("/api/screens/" + sub, body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend }) : await typeRegion(s.id, r.id, a3, { enter: body.enter, confirmed: body.confirmed, noSend: body.noSend }));
419
+ // upload: the files resolved and checked here, where a relative path means something
420
+ const f = sub === "upload" ? uploadFiles([a3, ...more]) : null; if (f && f.error) return out(f);
421
+ const body = { id: s.id, region: r.id, confirmed: has("yes"), noSend: !!process.env.SYMBIOT_DRAFT, ...(sub === "type" ? { text: a3, enter: has("enter") } : {}), ...(f ? { files: f.files } : {}) };
422
+ const done = (await viaApp("/api/screens/" + sub, body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend }) : sub === "type" ? await typeRegion(s.id, r.id, a3, { enter: body.enter, confirmed: body.confirmed, noSend: body.noSend }) : await uploadRegion(s.id, r.id, f.files, { confirmed: body.confirmed }));
423
+ if (f && done && /answered 404/.test(done.error || "")) return out({ error: "The Symbiot app that's running is older than this command and can't upload: restart it (symbiot app), then try again." });
421
424
  // not a trusted site: say how to go ahead (only you can trust a site, in the app's Settings)
422
425
  if (done && done.confirm) return out({ error: `${done.error} Add --yes to go ahead, or list ${done.host} under Trusted sites in Symbiot's Settings.` });
423
426
  return out(screenJson(done));
@@ -432,6 +435,11 @@ async function cmdScreens() {
432
435
  symbiot screens press <id> <region> [--yes] press a region there, map where it lands
433
436
  symbiot screens type <id> <field> "text" [--enter] [--yes]
434
437
  type into a field (Enter sends it), map the result
438
+ symbiot screens upload <id> <field> <file> [<file>…] [--yes]
439
+ put a file in the page's file box (a picture or
440
+ video for a post): <field> is the box, or the
441
+ button it hides behind ("Add media"); says which
442
+ box it used, maps the result
435
443
  symbiot screens scroll <id> [down|up|top|bottom]
436
444
  scroll the page, map what's in the window then
437
445
  (a map's "more" says there's more below or above)
@@ -439,7 +447,7 @@ async function cmdScreens() {
439
447
  (where a list scrolls inside the page, like
440
448
  Gmail's mail, that list opened out)
441
449
  symbiot screens signin <site> sign in once, in Symbiot's browser window
442
- --yes is needed unless the page's site is under Trusted sites in the app's Settings.
450
+ --yes is needed (press, type, upload) unless the page's site is under Trusted sites in the app's Settings.
443
451
  While the app runs, these use its hidden browser, which stays open a few minutes:
444
452
  press on the screen the last command printed carries on from that page as it is
445
453
  (type without --enter, then press the form's button).`);
@@ -785,6 +793,47 @@ async function cmdPost() {
785
793
  if (sub !== "help") process.exitCode = 1;
786
794
  }
787
795
 
796
+ // ---- `symbiot marketing`: a draft of Marketing's lane, marked posted or superseded ----
797
+ // What its agent runs once a post is out, or redone in a new file (marketing.mjs): either
798
+ // takes it off the Marketing orb for good, and it can't be approved or posted again.
799
+ async function cmdMarketing() {
800
+ const VAL = ["--url", "--on", "--where", "--at", "--by"];
801
+ const [sub = "help", rel, ...more] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !VAL.includes(all[i - 1]));
802
+ const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
803
+ const M = await import("./marketing.mjs");
804
+ if (sub === "posted" || sub === "superseded") {
805
+ if (!rel) return fail(`Give the draft: symbiot marketing ${sub} drafts/<product>/<post>.md`);
806
+ const r = M.setDraftStatus(rel, sub, { url: flag("url", ""), on: flag("on", flag("where", "")), at: flag("at", ""), by: flag("by", "") });
807
+ if (r.error) return fail(r.error);
808
+ if (r.already) { console.log(c.d(r.said)); return; }
809
+ const s = M.draftStatuses()[r.rel] || {};
810
+ console.log(`${c.g("✓")} ${r.rel}: ${sub}${s.postedOn ? ` on ${s.postedOn}` : ""}${s.posted ? `, ${M.day(s.posted)}` : ""}${s.url ? ` (${s.url})` : ""}${(s.by || []).length ? ` by ${s.by.join(", ")}` : ""}. It's off the Marketing orb, and can't be approved or posted again.`);
811
+ return;
812
+ }
813
+ if (sub === "media") {
814
+ if (!rel) return fail("Give the draft and its pictures or video: symbiot marketing media <draft> <file>…");
815
+ const r = M.setDraftMedia(rel, more);
816
+ if (r.error) return fail(r.error);
817
+ console.log(`${c.g("✓")} ${r.rel}: ${r.media.length ? "media: " + r.media.join(", ") : "no media"}${r.reopened ? c.y(" (it was approved with another: it asks for the user's OK again)") : ""}`); return;
818
+ }
819
+ if (sub === "list" || sub === "status") {
820
+ const fs = M.draftFiles(M.MARKETING_DIR, [], { max: 200 }), all = M.draftStatuses();
821
+ if (!fs.length) console.log(c.d("No drafts in Marketing's lane yet."));
822
+ for (const f of fs) { const s = all[f.rel] || {}; console.log(`${(f.status || "waiting").padEnd(10)} ${f.rel}${s.url ? c.d(" " + s.url) : ""}${(s.by || []).length ? c.d(" by " + [].concat(s.by).join(", ")) : ""}`); }
823
+ return;
824
+ }
825
+ console.log(`${c.b("symbiot marketing")} ${c.d("— Marketing's drafts (drafts/<product>/<post>.md in its lane)")}
826
+ symbiot marketing list each draft and its status
827
+ symbiot marketing posted <draft> [--url <link>] [--on linkedin] [--at "YYYY-MM-DD HH:MM"]
828
+ it's out (or in the platform's scheduler)
829
+ symbiot marketing superseded <draft> [--by <new draft>[,<another>]]
830
+ a new draft replaces it
831
+ symbiot marketing media <draft> <file>… its picture(s) or video, by path in the lane
832
+ Posted and superseded take a draft off the Marketing orb for good: it can't be
833
+ approved or posted again.`);
834
+ if (sub !== "help") process.exitCode = 1;
835
+ }
836
+
788
837
  // ---- `symbiot reports`: what agents wrote up for you (reports.mjs) ---------------
789
838
  function cmdReports() {
790
839
  const id = argv.slice(1).find((x) => !x.startsWith("--"));
@@ -834,6 +883,9 @@ ${c.b("Experimental")}
834
883
  symbiot post draft 3 LinkedIn posts from this week's git,
835
884
  in your voice; never posts by itself
836
885
  (symbiot post help for more)
886
+ symbiot marketing posted <draft> --url <link>
887
+ mark a Marketing draft posted (or superseded);
888
+ symbiot marketing help for more
837
889
  symbiot screens map <site> map a web page's buttons in a hidden browser
838
890
  (symbiot screens help for more)
839
891
  symbiot watch add <screen id> keep track of a mapped page: what's new on it
@@ -885,6 +937,7 @@ async function main() {
885
937
  if (cmd === "watch") return cmdWatch();
886
938
  if (cmd === "phone") return cmdPhone();
887
939
  if (cmd === "post" || cmd === "posts") return cmdPost();
940
+ if (cmd === "marketing") return cmdMarketing();
888
941
  if (cmd === "knowledge" || cmd === "know") return cmdKnowledge();
889
942
  if (cmd === "reports" || cmd === "report") return cmdReports();
890
943
  if (cmd === "week")return cmdRun("week");
package/lanes.mjs CHANGED
@@ -12,7 +12,8 @@
12
12
  // The ledger, ~/.config/symbiot/lanes.json: { handoffs: [{ id, key, from: { lane,
13
13
  // path }, to: { lane, path }, text, at, chain, status, job?, task?, error?,
14
14
  // result?, reportedAt? }] }. status: started, held (that lane's agent was busy:
15
- // it starts when it's free), done (reported back) or error (reported back too).
15
+ // it starts when it's free), blocked (it didn't start, and nothing running there will
16
+ // start it: note says why), done (reported back) or error (reported back too).
16
17
  // chain: how many handovers in a row led here; past MAX_CHAIN it stops, so two
17
18
  // lanes can't hand the same thing back and forth forever.
18
19
  import { join, basename, dirname, resolve, relative } from "node:path";
@@ -50,6 +51,9 @@ function laneTarget(lane, map) {
50
51
  const name = Object.keys(map).find((n) => n.toLowerCase() === lane.toLowerCase());
51
52
  return name ? { lane: name, path: map[name] } : null;
52
53
  }
54
+ // Where a handover to a repo stands once its agent was asked to start (runHandoff's
55
+ // result): started; held behind a run that's really going there; or blocked, with why.
56
+ const startedAs = (r) => r.id && !r.busy && !r.blocked ? { status: "started", job: r.id } : r.busy ? { status: "held", ...(r.id ? { job: r.id } : {}) } : { status: "blocked", note: String(r.note || "its agent didn't start").slice(0, 200) };
53
57
 
54
58
  // A handover whose text changed while the run doing the earlier version is still
55
59
  // going (the LinkedIn auto-poster spec reached ops twice, on top of a run already
@@ -62,7 +66,7 @@ function laneTarget(lane, map) {
62
66
  // back, about the same thing (alike), and its run still going (ops) or its task
63
67
  // still open (a repo). null when there's none.
64
68
  function supersedes(h, from, ledger, { running, tasks }) {
65
- return ledger.handoffs.slice().reverse().find((x) => x.from.path === from.path && x.to.lane.toLowerCase() === h.lane.toLowerCase() && !x.reportedAt && (x.status === "started" || x.status === "held") && x.text !== h.text && alike(x.text, h.text)
69
+ return ledger.handoffs.slice().reverse().find((x) => x.from.path === from.path && x.to.lane.toLowerCase() === h.lane.toLowerCase() && !x.reportedAt && (x.status === "started" || x.status === "held" || x.status === "blocked") && x.text !== h.text && alike(x.text, h.text)
66
70
  && (x.to.lane === OPS ? !!x.to.path && !!running(x.to.path) : !!x.task && tasks().some((t) => t.id === x.task && !t.done && !t.archived && !t.review))) || null;
67
71
  }
68
72
  function resupply(e, text, { now, title, context }) {
@@ -97,7 +101,7 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
97
101
  if (t.error) { Object.assign(prior, { status: "error", error: t.error, text: h.text, key, was }); started.push(prior); continue; }
98
102
  push({ repo: prior.to.lane });
99
103
  const r = run(prior.to.path) || {};
100
- Object.assign(prior, { text: h.text, key, was, at: now, task: t.id, superseded: (prior.superseded || 0) + 1, status: r.id && !r.busy && !r.blocked ? "started" : "held", ...(r.id ? { job: r.id } : {}) });
104
+ Object.assign(prior, { text: h.text, key, was, at: now, task: t.id, superseded: (prior.superseded || 0) + 1, ...startedAs(r) });
101
105
  }
102
106
  started.push(prior); continue;
103
107
  }
@@ -116,7 +120,7 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
116
120
  else {
117
121
  push({ repo: to.lane });
118
122
  const r = run(to.path) || {};
119
- Object.assign(e, { to, task: t.id, status: r.id && !r.busy && !r.blocked ? "started" : "held", ...(r.id ? { job: r.id } : {}) });
123
+ Object.assign(e, { to, task: t.id, ...startedAs(r) });
120
124
  }
121
125
  }
122
126
  ledger.handoffs.push(e); started.push(e);
@@ -342,7 +346,7 @@ function lanesState() {
342
346
  for (const e of loadLedger().handoffs.slice().reverse()) {
343
347
  const k = [e.from.lane, e.to.lane, firstLine(e.text).replace(/\s+/g, " ").trim()].join("\n").toLowerCase();
344
348
  if (byKey.has(k)) { byKey.get(k).times++; continue; }
345
- const row = { id: e.id, from: e.from.lane, to: e.to.lane, text: clipWords(firstLine(e.text), 160), ...(e.text.trim() !== firstLine(e.text).trim() || e.text.length > 160 ? { full: e.text } : {}), at: e.at, status: e.status, ...(e.error ? { error: e.error } : {}), ...(e.result ? { result: e.result } : {}), times: 1 };
349
+ const row = { id: e.id, from: e.from.lane, to: e.to.lane, text: clipWords(firstLine(e.text), 160), ...(e.text.trim() !== firstLine(e.text).trim() || e.text.length > 160 ? { full: e.text } : {}), at: e.at, status: (e.status === "held" || e.status === "blocked") && e.to.path && lastRunStart(e.to.path) >= e.at ? "started" : e.status, ...(e.status === "blocked" && e.note ? { note: e.note } : {}), ...(e.error ? { error: e.error } : {}), ...(e.result ? { result: e.result } : {}), times: 1 };
346
350
  byKey.set(k, row); out.push(row);
347
351
  }
348
352
  return { handoffs: out.slice(0, 30) };
package/licence.mjs ADDED
@@ -0,0 +1,144 @@
1
+ // symbiot — Symbiot Free and Symbiot Pro.
2
+ //
3
+ // Free: the orb and Home, the week, standups, voice, the Away screen, one agent at a
4
+ // time, up to 3 projects and one connected inbox. Pro: any number of agents at once,
5
+ // every project, every inbox and chat, the marketing lane and Approve-and-ship.
6
+ //
7
+ // A Pro key is a few lines of text signed by Ghost AI's private key (Ed25519); every
8
+ // copy of Symbiot carries the public key below and checks keys offline: no account,
9
+ // no server. Keys can carry an end date; a key that's been cancelled is listed at
10
+ // symbiot.co.za/licences/revoked.json, which Symbiot reads about once a day (missing
11
+ // it changes nothing). Everyone gets a 14-day Pro trial, from the first time this
12
+ // version runs. Nothing already running is ever stopped: a limit only stops the next
13
+ // thing from starting, with a line saying why and what Pro adds.
14
+ //
15
+ // Symbiot never works on its own code (its repo, a fork of it, or its installed
16
+ // files), so it can't be asked to take its own limits out; Ghost AI's owner key is
17
+ // the exception. This is part of the licence (Elastic License 2.0: no getting round
18
+ // the licence key).
19
+ //
20
+ // SYMBIOT_LICENCE_PUBLIC_KEY replaces the public key (tests sign with their own).
21
+ import { createPublicKey, verify as edVerify } from "node:crypto";
22
+ import { existsSync, readFileSync, writeFileSync, realpathSync } from "node:fs";
23
+ import { join, dirname, resolve, sep } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ import { spawnSync } from "node:child_process";
26
+ import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
27
+
28
+ const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
29
+ MCowBQYDK2VwAyEAGLISPmrOCX+t/KVY/u99rmFhjbXFbvRCGw1tStG1t8A=
30
+ -----END PUBLIC KEY-----`;
31
+ const REVOKED_URL = "https://symbiot.co.za/licences/revoked.json";
32
+ const TRIAL_DAYS = 14, GRACE_DAYS = 7, DAY = 86400000;
33
+ const FREE = { agents: 1, projects: 3, inboxes: 1 };
34
+ const PRICE = "US$12 a month or US$99 a year";
35
+ const UPGRADE_URL = "https://symbiot.co.za/#pro";
36
+
37
+ const unb64u = (s) => Buffer.from(String(s).replace(/-/g, "+").replace(/_/g, "/"), "base64");
38
+ const pubKey = () => createPublicKey(process.env.SYMBIOT_LICENCE_PUBLIC_KEY || PUBLIC_KEY);
39
+
40
+ // "SYM1-<payload>.<signature>", whitespace and line breaks ignored (keys get pasted from mail).
41
+ function readKey(text) {
42
+ const t = String(text || "").replace(/\s+/g, "");
43
+ const m = /^SYM1-([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)$/.exec(t);
44
+ if (!m) return { ok: false, why: "That isn't a Symbiot key. It starts with SYM1-." };
45
+ let p;
46
+ try {
47
+ if (!edVerify(null, Buffer.from(m[1]), pubKey(), unb64u(m[2]))) return { ok: false, why: "That key wasn't issued by Ghost AI (its signature doesn't match)." };
48
+ p = JSON.parse(unb64u(m[1]).toString("utf8"));
49
+ } catch { return { ok: false, why: "That key is damaged. Copy it again, all of it." }; }
50
+ if (!p || p.v !== 1 || !["pro", "owner"].includes(p.p) || !p.id) return { ok: false, why: "That key is for something this version of Symbiot doesn't know." };
51
+ return { ok: true, key: t, id: String(p.id), plan: p.p, email: String(p.e || ""), name: String(p.n || ""), issued: Number(p.i) || 0, expires: Number(p.x) || 0 };
52
+ }
53
+
54
+ // Cancelled keys, from symbiot.co.za, refreshed at most once a day; a failed fetch keeps the last list.
55
+ const REVOKED_FILE = () => join(CONFIG_DIR, "licence-revoked.json");
56
+ function revokedIds() { try { return new Set(JSON.parse(readFileSync(REVOKED_FILE(), "utf8")).ids || []); } catch { return new Set(); } }
57
+ async function refreshRevoked(now = Date.now()) {
58
+ try {
59
+ const cur = existsSync(REVOKED_FILE()) ? JSON.parse(readFileSync(REVOKED_FILE(), "utf8")) : {};
60
+ if (cur.at && now - cur.at < DAY) return false;
61
+ const r = await fetch(process.env.SYMBIOT_REVOKED_URL || REVOKED_URL, { signal: AbortSignal.timeout(8000) });
62
+ if (!r.ok) return false;
63
+ const j = await r.json();
64
+ writeFileSync(REVOKED_FILE(), JSON.stringify({ at: now, ids: Array.isArray(j.ids) ? j.ids.map(String) : [] }));
65
+ return true;
66
+ } catch { return false; }
67
+ }
68
+
69
+ // Where you stand: { plan: "pro" | "owner" | "trial" | "free", ... }. Starts the trial clock on first sight.
70
+ function licenceState(now = Date.now(), cfg = loadConfig()) {
71
+ const L = cfg.licence || {};
72
+ if (!L.trialStart) { L.trialStart = now; cfg.licence = L; try { saveConfig(cfg); } catch {} }
73
+ const base = { limits: FREE, price: PRICE, upgrade: UPGRADE_URL };
74
+ if (L.key) {
75
+ const k = readKey(L.key);
76
+ if (k.ok && revokedIds().has(k.id)) return { ...base, plan: "free", keyProblem: "This key has been cancelled. If that's a mistake, reply to the email it came in." };
77
+ if (k.ok) {
78
+ const left = k.expires ? k.expires - now : Infinity;
79
+ const who = { email: k.email, name: k.name, keyId: k.id, ...(k.expires ? { expires: k.expires } : {}) };
80
+ if (left > 0) return { ...base, plan: k.plan, ...who, ...(left < 14 * DAY ? { renewSoon: Math.ceil(left / DAY) } : {}) };
81
+ if (left > -GRACE_DAYS * DAY) return { ...base, plan: k.plan, ...who, grace: Math.ceil((left + GRACE_DAYS * DAY) / DAY) };
82
+ return { ...base, plan: "free", ...who, keyProblem: "Your Pro key has ended. Renew it to keep Pro." };
83
+ }
84
+ return { ...base, plan: "free", keyProblem: k.why };
85
+ }
86
+ const trialLeft = L.trialStart + TRIAL_DAYS * DAY - now;
87
+ if (trialLeft > 0) return { ...base, plan: "trial", trialDaysLeft: Math.ceil(trialLeft / DAY) };
88
+ return { ...base, plan: "free", trialEnded: true };
89
+ }
90
+ const isPro = (st = licenceState()) => st.plan === "pro" || st.plan === "owner" || st.plan === "trial";
91
+
92
+ function setKey(text) {
93
+ const k = readKey(text);
94
+ if (!k.ok) return { error: k.why };
95
+ if (revokedIds().has(k.id)) return { error: "This key has been cancelled." };
96
+ const cfg = loadConfig(); cfg.licence = { ...(cfg.licence || {}), key: k.key }; saveConfig(cfg);
97
+ return licenceState();
98
+ }
99
+ function clearKey() { const cfg = loadConfig(); if (cfg.licence) { delete cfg.licence.key; saveConfig(cfg); } return licenceState(); }
100
+
101
+ // What Free stops, and how it says so. ctx: { running } agents now, { count } projects/inboxes now.
102
+ const WHY = {
103
+ agents: (n) => `Symbiot Free runs one agent at a time, and one is at work. This one starts when it's done, or Pro runs as many as you like at once (${PRICE}).`,
104
+ projects: () => `Symbiot Free works in ${FREE.projects} projects. Pro works in all of them (${PRICE}).`,
105
+ inboxes: () => `Symbiot Free connects one inbox or chat. Pro connects all of them: every mail account, WhatsApp, LinkedIn and your calendars (${PRICE}).`,
106
+ marketing: () => `The marketing lane is part of Symbiot Pro (${PRICE}).`,
107
+ ship: () => `Approve-and-ship (Symbiot opening the pull request and shipping it) is part of Symbiot Pro (${PRICE}). Your agent's changes are kept on their branch either way.`,
108
+ };
109
+ function can(feature, ctx = {}, st = licenceState()) {
110
+ if (isPro(st)) return { ok: true };
111
+ const n = Number(ctx.count ?? ctx.running ?? 0);
112
+ if (feature === "agents" && n < FREE.agents) return { ok: true };
113
+ if (feature === "projects" && n < FREE.projects) return { ok: true };
114
+ if (feature === "inboxes" && n < FREE.inboxes) return { ok: true };
115
+ if (!WHY[feature]) return { ok: true };
116
+ return { ok: false, pro: true, feature, why: WHY[feature](n), upgrade: UPGRADE_URL };
117
+ }
118
+
119
+ // ---- Symbiot never works on its own code -------------------------------------
120
+ const HERE = (() => { try { return realpathSync(dirname(fileURLToPath(import.meta.url))); } catch { return dirname(fileURLToPath(import.meta.url)); } })();
121
+ const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
122
+ const inside = (p, root) => { const a = real(p), b = real(root); return a === b || a.startsWith(b + sep); };
123
+ // Its repo or a fork (whatever it's renamed to), or the copy of Symbiot that's installed.
124
+ function isSymbiotCode(dir) {
125
+ if (!dir) return false;
126
+ const d = real(dir);
127
+ if (inside(d, HERE)) return true;
128
+ let top = d;
129
+ try { const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: d, encoding: "utf8", timeout: 5000, windowsHide: true }); if (r.status === 0 && r.stdout.trim()) top = r.stdout.trim(); } catch {}
130
+ try { const pj = JSON.parse(readFileSync(join(top, "package.json"), "utf8")); if (pj.name === "symbiot" || pj.name === "symbiot-desktop") return true; } catch {}
131
+ const has = (f) => existsSync(join(top, f));
132
+ if (has("licence.mjs") && has("ui.mjs") && has("server.mjs")) return true;
133
+ try { if (/EMBEDDED_UI/.test(readFileSync(join(top, "ui.mjs"), "utf8"))) return true; } catch {}
134
+ try { const r = spawnSync("git", ["remote", "-v"], { cwd: top, encoding: "utf8", timeout: 5000, windowsHide: true }); if (/[/:]symbiot(\.git)?\s/i.test(r.stdout || "")) return true; } catch {}
135
+ return false;
136
+ }
137
+ const SELF_WHY = "Symbiot doesn't work on its own code (Symbiot's repo, a copy of it, or its installed files). That keeps its licence and its safety rules from being edited away.";
138
+ function canWorkIn(dir, st = licenceState()) {
139
+ if (!isSymbiotCode(dir)) return { ok: true };
140
+ if (st.plan === "owner") return { ok: true };
141
+ return { ok: false, self: true, why: SELF_WHY };
142
+ }
143
+
144
+ export { readKey, licenceState, isPro, setKey, clearKey, can, refreshRevoked, isSymbiotCode, canWorkIn, FREE, PRICE, TRIAL_DAYS, SELF_WHY, HERE as SYMBIOT_HOME };
package/links.mjs CHANGED
@@ -18,6 +18,7 @@
18
18
  // trusted sites, there's no command an agent could call to link a site itself.
19
19
  //
20
20
  // What's linked is config.linked: { <id>: { at, watch?, hosts: [trusted by the link] } }.
21
+ import { can } from "./licence.mjs";
21
22
  import { join } from "node:path";
22
23
  import { readFileSync } from "node:fs";
23
24
  import { CONFIG_DIR, loadConfig, saveConfig } from "./core.mjs";
@@ -100,10 +101,16 @@ function linksState() {
100
101
  // Link one: trust its hosts, watch its page, open it to sign in. Clicking a link
101
102
  // that's already there just opens it again to sign in (signed out, or a second
102
103
  // account). Gives { ok, url, item } or { error }.
104
+ // what counts as an inbox or chat for Free's one
105
+ const INBOX_GROUPS = new Set(["Mail", "Calendar", "Chat", "Social"]);
103
106
  async function linkSite(id, { open = signIn } = {}) {
104
107
  const e = catalog().list.find((x) => x.id === id);
105
108
  if (!e) return { error: `No link called ${id}.` };
106
109
  const l = linked(), had = l[id], before = new Set(trustedSites()), added = [];
110
+ if (!had && INBOX_GROUPS.has(e.group)) { // Free connects one inbox or chat (licence.mjs)
111
+ const n = Object.keys(l).filter((k) => { const x = catalog().list.find((c) => c.id === k); return x && INBOX_GROUPS.has(x.group); }).length;
112
+ const ok = can("inboxes", { count: n }); if (!ok.ok) return { error: ok.why, pro: true };
113
+ }
107
114
  for (const h of e.hosts) if (!before.has(h)) { const r = trustSite(h); if (r && r.ok) added.push(h); }
108
115
  let watch = had && had.watch;
109
116
  if (e.watch && !(watch && watchState().watches.some((x) => x.id === watch))) {