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/CHANGELOG.md +23 -0
- package/LICENSE +98 -21
- package/README.md +28 -0
- package/agents.mjs +89 -19
- package/claudesetup.mjs +4 -1
- package/core.mjs +29 -3
- package/guard.mjs +12 -4
- package/headless.mjs +105 -9
- package/home.mjs +8 -6
- package/index.mjs +60 -7
- package/lanes.mjs +9 -5
- package/licence.mjs +144 -0
- package/links.mjs +7 -0
- package/marketing.mjs +86 -11
- package/mind.mjs +96 -20
- package/package.json +6 -4
- package/post.mjs +4 -2
- package/server.mjs +26 -7
- package/tasks.mjs +3 -0
- package/tray.mjs +154 -0
- package/ui.mjs +180 -33
- package/watch.mjs +57 -10
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 (
|
|
6
|
-
// the page it lands on: map → press → map is how an
|
|
7
|
-
// site. On a site you trust (Settings), that goes
|
|
8
|
-
// app the browser stays open for a few minutes
|
|
9
|
-
// one carries on from the page as it is (type
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
const
|
|
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),
|
|
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,
|
|
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,
|
|
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))) {
|