symbiot 0.63.5 → 0.63.7
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 +42 -0
- package/README.md +39 -5
- package/agents.mjs +43 -10
- package/browser.html +21 -3
- package/capture.mjs +761 -0
- package/demo.mjs +171 -0
- package/guard.mjs +23 -5
- package/handback.mjs +22 -8
- package/handover.mjs +1 -1
- package/headless.mjs +281 -80
- package/home.mjs +26 -5
- package/index.mjs +65 -11
- package/lanes.mjs +99 -9
- package/links.mjs +52 -2
- package/marketing.mjs +40 -7
- package/mdns.mjs +6 -4
- package/package.json +5 -3
- package/phone.mjs +78 -11
- package/reports.mjs +15 -1
- package/server.mjs +89 -42
- package/tray.mjs +44 -4
- package/ui.mjs +364 -53
package/demo.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// symbiot — A Symbiot with made-up work in it, to show: Acme, a company that doesn't
|
|
2
|
+
// exist, its six repos with a few weeks of git, four agents each asking a question
|
|
3
|
+
// with two answers, tasks, what arrived in its inbox, GitHub, WhatsApp and LinkedIn,
|
|
4
|
+
// its week written up, and a Marketing draft. Nothing in it is anyone's real work, so
|
|
5
|
+
// it can be captured and posted. `symbiot capture Symbiot` (capture.mjs) starts one in
|
|
6
|
+
// a throwaway home, captures it and stops it, instead of your own app with your work in
|
|
7
|
+
// it. Ported from the marketing lane's seed.py, which made the first tray (9 October).
|
|
8
|
+
//
|
|
9
|
+
// seedDemo(home): fills that home. startDemo(): a throwaway home, seeded, and Symbiot
|
|
10
|
+
// started in it as a sandbox (sandbox.mjs: no AI keys of yours, its own port, never an
|
|
11
|
+
// update), no window: { url, stop } or { error }. stop() quits it and deletes the home.
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { mkdirSync, writeFileSync, appendFileSync, readFileSync, existsSync, mkdtempSync, rmSync, realpathSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { createServer } from "node:net";
|
|
18
|
+
import { sandboxEnv } from "./sandbox.mjs";
|
|
19
|
+
|
|
20
|
+
const HERE = fileURLToPath(new URL(".", import.meta.url));
|
|
21
|
+
const VERSION = (() => { try { return JSON.parse(readFileSync(join(HERE, "package.json"), "utf8")).version; } catch { return ""; } })();
|
|
22
|
+
const DAY = 86400000;
|
|
23
|
+
const REPOS = {
|
|
24
|
+
storefront: ["# Storefront\nAcme's web shop: product pages, cart and checkout.\n", "ts", ["Add guest checkout", "Cache product images at the edge", "Fix cart total rounding on discounts", "Move checkout to the new payments page", "Tidy the product grid on small screens", "Release 2.4.0"]],
|
|
25
|
+
"billing-api": ["# Billing API\nInvoices, subscriptions and payment webhooks for Acme.\n", "py", ["Retry failed webhooks with backoff", "Add proration for mid-cycle upgrades", "Invoice PDFs in the customer's currency", "Release 1.9.0"]],
|
|
26
|
+
"mobile-app": ["# Acme Mobile\nThe iOS and Android app.\n", "kt", ["Push notifications for order updates", "Dark mode on every screen", "Offline basket", "Fix login loop on Android 15"]],
|
|
27
|
+
"docs-site": ["# Docs\nAcme's public docs.\n", "md", ["Getting started in five minutes", "API reference from the OpenAPI spec", "Search across every page"]],
|
|
28
|
+
"data-pipeline": ["# Data pipeline\nNightly sales and stock reports.\n", "py", ["Load stock levels hourly", "Weekly sales report to the team", "Drop the old CSV export"]],
|
|
29
|
+
"design-system": ["# Design system\nAcme's shared components.\n", "tsx", ["Button and input tokens", "Accessible colour contrast", "Date picker"]],
|
|
30
|
+
};
|
|
31
|
+
const QUESTIONS = {
|
|
32
|
+
storefront: ["Send the price-change email to the 1,240 customers on the old plan?", "It's drafted and goes out in your name, so it waits for your OK.", "🤖 Agent: send it on Monday at 09:00 (recommended)", "🤖 Agent: hold it until the new pricing page is live"],
|
|
33
|
+
"billing-api": ["Refund the duplicate charge on order 4471 now?", "A webhook ran twice on Tuesday, so the customer paid twice for one order.", "🤖 Agent: refund the second charge and tell the customer (recommended)", "🤖 Agent: refund it, and add a guard so it can't happen again"],
|
|
34
|
+
"mobile-app": ["Which release goes to the App Store first?", "3.2 is ready and tested; 3.3 adds the offline basket but needs another week.", "🤖 Agent: ship 3.2 today, 3.3 next week (recommended)", "🤖 Agent: wait and ship 3.3 as one release"],
|
|
35
|
+
"docs-site": ["Publish the new getting-started guide?", "It's checked against the current API and every example runs.", "🤖 Agent: publish it now (recommended)", "🤖 Agent: publish it with Monday's release notes"],
|
|
36
|
+
};
|
|
37
|
+
const DONE = {
|
|
38
|
+
storefront: "Apple Pay is in checkout and tested. The price-change email is drafted and waits for your OK.",
|
|
39
|
+
"billing-api": "Found why order 4471 was charged twice: the webhook ran twice. The refund waits for your OK.",
|
|
40
|
+
"mobile-app": "3.2 passed every test on iOS and Android and is ready to submit.",
|
|
41
|
+
"docs-site": "The getting-started guide is written and every example in it runs.",
|
|
42
|
+
};
|
|
43
|
+
const TASKS = [
|
|
44
|
+
["storefront", "Add Apple Pay to checkout", false], ["storefront", "Lazy-load the reviews block on product pages", false], ["storefront", "Fix the 404 on old category links", true],
|
|
45
|
+
["billing-api", "Add a guard so one webhook can never charge twice", false], ["billing-api", "Send a receipt email on every refund", false],
|
|
46
|
+
["mobile-app", "Ship 3.2 to the App Store", false], ["mobile-app", "Fix the crash on rotate in the order screen", false],
|
|
47
|
+
["docs-site", "Add a page for the new refunds API", false], ["data-pipeline", "Alert in chat when the nightly job fails", false], ["design-system", "Publish the date picker", false],
|
|
48
|
+
];
|
|
49
|
+
const WATCH = { gmail: ["a1b2c3d4", "Inbox", "https://mail.google.com/mail/u/0/#inbox"], github: ["b2c3d4e5", "GitHub notifications", "https://github.com/notifications"], whatsapp: ["c3d4e5f6", "WhatsApp", "https://web.whatsapp.com/"], linkedin: ["d4e5f6a7", "LinkedIn", "https://www.linkedin.com/notifications/"] };
|
|
50
|
+
const NEWS = [
|
|
51
|
+
["github", 25, "acme/billing-api · CI failed on main: 2 tests in webhooks"], ["github", 70, "acme/storefront · Pull request #212 approved: guest checkout"], ["github", 140, "acme/mobile-app · Release 3.2 build passed"],
|
|
52
|
+
["gmail", 35, "Supplier: the new stock feed goes live on Monday"], ["gmail", 95, "Accountant: September invoices are ready to check"], ["gmail", 180, "Payments provider: your payout is on its way"],
|
|
53
|
+
["whatsapp", 50, "Design team: the new banners are in the shared folder"], ["linkedin", 120, "3 people viewed your profile"], ["linkedin", 300, "Your post on guest checkout has 41 reactions"],
|
|
54
|
+
];
|
|
55
|
+
const BRIEFS = [
|
|
56
|
+
["github", 20, 3, "billing-api's CI failed on main: 2 webhook tests. That's the one that needs you.\nThe guest checkout PR is approved and the 3.2 build passed: nothing to do."],
|
|
57
|
+
["gmail", 30, 3, "The accountant needs you to check September's invoices.\nThe stock feed and the payout are only news."],
|
|
58
|
+
["whatsapp", 45, 1, "The design team's new banners are in: nothing waits on you."],
|
|
59
|
+
["linkedin", 110, 2, "Your guest checkout post is doing well. Nothing needs a reply."],
|
|
60
|
+
];
|
|
61
|
+
const WEEK = `**Storefront** (main focus): I shipped 2.4.0 this week.
|
|
62
|
+
- **Checkout:** guest checkout is live, and checkout moved to the new payments page.
|
|
63
|
+
- **Speed:** product images are cached at the edge, and the grid is tidier on small screens.
|
|
64
|
+
- **Fixes:** the cart total no longer rounds wrong on discounts.
|
|
65
|
+
|
|
66
|
+
**Billing API:**
|
|
67
|
+
- Failed webhooks now retry with backoff, and mid-cycle upgrades are prorated.
|
|
68
|
+
- Invoice PDFs come in the customer's currency. Released 1.9.0.
|
|
69
|
+
- Still open: a webhook ran twice on Tuesday; the guard against it is next.
|
|
70
|
+
|
|
71
|
+
**Acme Mobile:**
|
|
72
|
+
- Order updates arrive as push notifications, and every screen has dark mode.
|
|
73
|
+
- The Android 15 login loop is fixed. 3.2 is ready for the App Store.
|
|
74
|
+
|
|
75
|
+
**Docs, data and design:**
|
|
76
|
+
- A five-minute getting-started guide and an API reference built from the spec.
|
|
77
|
+
- Stock levels load hourly, and the weekly sales report goes to the team.
|
|
78
|
+
- Buttons and inputs share tokens, with accessible contrast.
|
|
79
|
+
`;
|
|
80
|
+
const POST = `# Guest checkout is live
|
|
81
|
+
product: Storefront
|
|
82
|
+
platform: linkedin
|
|
83
|
+
|
|
84
|
+
## Post
|
|
85
|
+
You can now buy from Acme without making an account.
|
|
86
|
+
|
|
87
|
+
Guest checkout went live this week: pick what you want, pay with your card or Apple Pay, and you're done. No password to remember, no sign-up form in the way.
|
|
88
|
+
|
|
89
|
+
Since we turned it on, 1 in 3 orders has used it.
|
|
90
|
+
|
|
91
|
+
What's the one step you'd remove from checkout?
|
|
92
|
+
|
|
93
|
+
#Ecommerce #ProductUpdate
|
|
94
|
+
|
|
95
|
+
## Notes
|
|
96
|
+
From this week's storefront release (2.4.0).
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
// The same made-up history every time: a small seeded random, not Math.random.
|
|
100
|
+
function rng(seed) { let s = seed >>> 0; return () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 2 ** 32; }; }
|
|
101
|
+
const writeJson = (f, o) => writeFileSync(f, JSON.stringify(o, null, 2));
|
|
102
|
+
|
|
103
|
+
function seedDemo(home, { now = Date.now() } = {}) {
|
|
104
|
+
const C = join(home, ".config", "symbiot"), code = join(home, "code"), rand = rng(4), hex = (n) => Array.from({ length: n }, () => Math.floor(rand() * 16).toString(16)).join("");
|
|
105
|
+
mkdirSync(C, { recursive: true });
|
|
106
|
+
writeFileSync(join(home, ".gitconfig"), "[user]\n\tname = Sam Rivera\n\temail = sam@acme.example\n[init]\n\tdefaultBranch = main\n");
|
|
107
|
+
const env = { ...process.env, HOME: home, GIT_CONFIG_GLOBAL: join(home, ".gitconfig"), GIT_TERMINAL_PROMPT: "0" };
|
|
108
|
+
const git = (args, extra = {}) => { const r = spawnSync("git", args, { env: { ...env, ...extra }, encoding: "utf8", timeout: 20000 }); if (r.status !== 0) throw new Error(`git ${args[0]}: ${(r.stderr || r.error || "").toString().trim().split("\n").pop()}`); };
|
|
109
|
+
for (const [name, [readme, ext, commits]] of Object.entries(REPOS)) {
|
|
110
|
+
const d = join(code, name); if (existsSync(join(d, ".git"))) continue;
|
|
111
|
+
mkdirSync(join(d, "src"), { recursive: true });
|
|
112
|
+
git(["init", "-q", "-b", "main", d]);
|
|
113
|
+
writeFileSync(join(d, "README.md"), readme);
|
|
114
|
+
for (const f of ["index", "routes", "models", "utils"]) writeFileSync(join(d, "src", `${f}.${ext}`), `// ${f}\n`);
|
|
115
|
+
commits.forEach((msg, i) => {
|
|
116
|
+
const at = new Date(now - (commits.length - i) * Math.round(DAY * (0.6 + rand() * 0.5))).toISOString();
|
|
117
|
+
appendFileSync(join(d, "CHANGES.md"), `- ${msg}\n`);
|
|
118
|
+
git(["-C", d, "add", "-A"]); git(["-C", d, "commit", "-q", "-m", msg], { GIT_AUTHOR_DATE: at, GIT_COMMITTER_DATE: at });
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
// four agents, each with its question and what it said
|
|
122
|
+
writeJson(join(C, "runs.json"), Object.entries(QUESTIONS).map(([name, [q, why, a, b]], i) => {
|
|
123
|
+
const s = join(code, name, ".symbiot"); mkdirSync(s, { recursive: true });
|
|
124
|
+
writeFileSync(join(s, "QUESTIONS.md"), `# Questions\n\n## Questions\n### ${q}\n${why}\n- ${a}\n- ${b}\n`);
|
|
125
|
+
writeFileSync(join(s, "agent.log"), JSON.stringify({ type: "result", subtype: "success", result: DONE[name] }) + "\n");
|
|
126
|
+
return { path: join(code, name), name, startedAt: now - (i + 1) * 3600000 };
|
|
127
|
+
}));
|
|
128
|
+
writeJson(join(C, "tasks.json"), TASKS.map(([repo, text, done], i) => ({ id: hex(12), text, repo, done, ts: now - (i + 2) * 7200000, ...(done ? { review: true, reviewAt: now - 3600000 } : {}) })));
|
|
129
|
+
// what arrived: Watch's sources, what's new in each, and a line on each that says what needs you
|
|
130
|
+
const watches = Object.values(WATCH).map(([id, name, url]) => ({ id, name, url, every: 525600, added: now - 5 * DAY, last: now, seen: [], checked: now - 600000, cleared: 0, marksUnread: false }));
|
|
131
|
+
const news = NEWS.map(([w, min, text]) => ({ id: hex(8), watch: WATCH[w][0], name: WATCH[w][1], ts: now - min * 60000, text, href: WATCH[w][2] }));
|
|
132
|
+
const briefs = BRIEFS.map(([w, min, count, text]) => ({ id: hex(8), watch: WATCH[w][0], name: WATCH[w][1], ts: now - min * 60000, count, text }));
|
|
133
|
+
writeJson(join(C, "watch.json"), { watches, news, briefs });
|
|
134
|
+
// set up already: its AI, its agent (a stand-in that does nothing: no agent of yours runs on Acme), its folders, its links
|
|
135
|
+
writeJson(join(C, "config.json"), { apiKey: "sk-ant-demo-not-a-real-key", agentCmd: `"${process.execPath}" -e "" "{prompt}"`, scanRoots: [code], watchBrief: true,
|
|
136
|
+
linked: Object.fromEntries(Object.entries(WATCH).map(([k, [id]]) => [k, { at: now - 5 * DAY, watch: id, hosts: k === "linkedin" ? ["linkedin.com"] : [] }])),
|
|
137
|
+
seenVersion: VERSION, lastSeenVersion: VERSION, onboarding: { pending: false, step: "done", skipped: [], done: now - 5 * DAY } });
|
|
138
|
+
mkdirSync(join(C, "weeks"), { recursive: true });
|
|
139
|
+
writeFileSync(join(C, "weeks", `${new Date(now).toISOString().slice(0, 10)}.md`), WEEK);
|
|
140
|
+
// Marketing: a lane of its own, with a post waiting for your OK
|
|
141
|
+
const MK = join(C, "marketing"); mkdirSync(join(MK, "drafts", "storefront"), { recursive: true });
|
|
142
|
+
writeFileSync(join(MK, "README.md"), "# Marketing\n"); writeFileSync(join(MK, ".gitignore"), ".symbiot/\n");
|
|
143
|
+
git(["init", "-q", "-b", "main", MK]); git(["-C", MK, "add", "-A"]); git(["-C", MK, "commit", "-qm", "Marketing: a lane of its own"]);
|
|
144
|
+
writeFileSync(join(MK, "drafts", "storefront", "guest-checkout.md"), POST);
|
|
145
|
+
return { home, code };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const freePort = () => new Promise((r) => { const s = createServer().listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => r(p)); }); s.on("error", () => r(0)); });
|
|
149
|
+
// The names it shows, for a capture to blur the way it would anyone's (none of them is real).
|
|
150
|
+
const DEMO_NAMES = ["Sam Rivera"];
|
|
151
|
+
async function startDemo({ bin = join(HERE, "index.mjs"), wait = 30000, now = Date.now() } = {}) {
|
|
152
|
+
let home = "";
|
|
153
|
+
try { home = realpathSync(mkdtempSync(join(tmpdir(), "symbiot-demo-"))); seedDemo(home, { now }); }
|
|
154
|
+
catch (e) { if (home) rmSync(home, { recursive: true, force: true }); return { error: `Couldn't make the made-up Symbiot to capture: ${(e && e.message) || e}` }; }
|
|
155
|
+
const port = await freePort();
|
|
156
|
+
const app = spawn(process.execPath, [bin, "app"], { env: { ...sandboxEnv(home, process.env, port || 7381), SYMBIOT_NO_OPEN: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
157
|
+
let out = "", gone = false; app.on("exit", () => { gone = true; });
|
|
158
|
+
const stop = async () => {
|
|
159
|
+
if (!gone) { app.kill("SIGTERM"); for (let i = 0; i < 40 && !gone; i++) await new Promise((r) => setTimeout(r, 100)); if (!gone) app.kill("SIGKILL"); }
|
|
160
|
+
rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
161
|
+
};
|
|
162
|
+
const url = await new Promise((done) => {
|
|
163
|
+
const look = (d) => { out = (out + d).slice(-20000); const m = out.replace(/\x1b\[[0-9;]*m/g, "").match(/http:\/\/127\.0\.0\.1:\d+\/\?t=[\w-]+/); if (m) done(m[0]); };
|
|
164
|
+
app.stdout.on("data", look); app.stderr.on("data", (d) => { out = (out + d).slice(-20000); });
|
|
165
|
+
app.on("exit", () => done("")); setTimeout(() => done(""), wait);
|
|
166
|
+
});
|
|
167
|
+
if (!url) { await stop(); return { error: `The made-up Symbiot didn't start: ${out.trim().split("\n").pop() || "no address within " + wait / 1000 + " s"}` }; }
|
|
168
|
+
return { url, home, stop };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export { seedDemo, startDemo, DEMO_NAMES, REPOS as DEMO_REPOS };
|
package/guard.mjs
CHANGED
|
@@ -8,15 +8,16 @@
|
|
|
8
8
|
// - delete outside the agent's own folder, or wipe a disk
|
|
9
9
|
// - sudo, or pipe something from the internet into a shell
|
|
10
10
|
// - read SSH keys or cloud credentials, or Symbiot's own keys (secrets.json: your AI's
|
|
11
|
-
// key and the app's token); change Symbiot's own settings
|
|
11
|
+
// key and the app's token); change Symbiot's own settings. A public key (~/.ssh/*.pub)
|
|
12
|
+
// can be read: it's made to be handed out
|
|
12
13
|
// - open the user's running Symbiot in a browser (a check uses a sandbox copy)
|
|
13
14
|
// - in a sandboxed repo run (agents.mjs sandboxFor), edit or write a file outside its
|
|
14
15
|
// repo and your folders (SYMBIOT_WRITES): its commands are held there by the sandbox
|
|
15
16
|
// judge(tool, input, { cwd, home }) → null (go ahead) or { why }.
|
|
16
|
-
import { resolve, join, relative } from "node:path";
|
|
17
|
+
import { resolve, join, relative, dirname, basename } from "node:path";
|
|
17
18
|
import { homedir } from "node:os";
|
|
18
19
|
import { execFileSync } from "node:child_process";
|
|
19
|
-
import { readFileSync } from "node:fs";
|
|
20
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
20
21
|
import { fileURLToPath } from "node:url";
|
|
21
22
|
|
|
22
23
|
const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/secrets.json"];
|
|
@@ -25,6 +26,20 @@ const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, ho
|
|
|
25
26
|
const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
|
|
26
27
|
const SCP_VALUE = /^-[346ABCOpqRrTv]*[iFoPSJcl]$/; // an scp option (alone or after flags, -rpi) whose value is the next word
|
|
27
28
|
function secretPath(p, home) { return SECRET.some((s) => under(p, join(home, s))); }
|
|
29
|
+
// A public key is made to be handed out, so reading one goes ahead (the Read tool, or a plain
|
|
30
|
+
// cat or head): a file directly in ~/.ssh named <name>.pub, that's still a .pub there once
|
|
31
|
+
// links are followed (a x.pub pointing at a private key isn't). Writing or copying one, and
|
|
32
|
+
// everything else in ~/.ssh, stays stopped. Ops made a key for the WinLove server and
|
|
33
|
+
// couldn't read its .pub to send to the server's admin (2026-10-12).
|
|
34
|
+
function publicKey(p, home) {
|
|
35
|
+
const dir = join(home, ".ssh");
|
|
36
|
+
if (dirname(p) !== dir || !/^[\w.@+-]+\.pub$/.test(basename(p))) return false;
|
|
37
|
+
try { const real = realpathSync(p); return dirname(real) === realpathSync(dir) && real.endsWith(".pub") && statSync(real).isFile(); } catch { return false; }
|
|
38
|
+
}
|
|
39
|
+
// in a shell, only a path named outright: no glob, variable or quoting trick that could
|
|
40
|
+
// reach a private key too (~/.ssh/*, ~/.ssh/$K)
|
|
41
|
+
const PUB_READ = /^(cat|less|more|head|tail|grep|rg|base64|xxd|strings)$/;
|
|
42
|
+
const PLAIN = /^(~|\$HOME|\$\{HOME\})?[\w.@+\/-]+$/;
|
|
28
43
|
function symbiotConfig(p, home) { const d = join(home, ".config", "symbiot"); return p === join(d, "config.json") || p === join(d, "licence-revoked.json"); }
|
|
29
44
|
// Symbiot's own installed files (where this guard runs from): never edited by an agent, so
|
|
30
45
|
// it can't be asked to take out its own limits or rules. Ghost AI's owner key lets its own
|
|
@@ -99,7 +114,10 @@ function judgeBash(cmd, { cwd, home, branch }) {
|
|
|
99
114
|
// counts: `scp ~/.ssh/id_rsa host:` stays blocked. The argena lane's approved deploy
|
|
100
115
|
// (`scp -i ~/.ssh/oracle_key server/x.py ubuntu@host:…`) was blocked (2026-10-08).
|
|
101
116
|
const own = w[0] === "scp" ? SCP_VALUE : null;
|
|
102
|
-
for (const p of w.slice(1).filter((a, i, all) => !a.startsWith("-") && !(own && own.test(all[i - 1] || "")))) {
|
|
117
|
+
for (const p of w.slice(1).filter((a, i, all) => !a.startsWith("-") && !(own && own.test(all[i - 1] || "")))) {
|
|
118
|
+
const abs = expand(unquote(p), home, cwd);
|
|
119
|
+
if (secretPath(abs, home) && !(PUB_READ.test(w[0]) && PLAIN.test(unquote(p)) && publicKey(abs, home))) return { why: "your keys and cloud credentials stay yours" };
|
|
120
|
+
}
|
|
103
121
|
}
|
|
104
122
|
if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/(config|secrets)\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
|
|
105
123
|
}
|
|
@@ -116,7 +134,7 @@ function judge(tool, input = {}, { cwd = process.cwd(), home = homedir(), branch
|
|
|
116
134
|
const p = input.file_path || input.path || input.notebook_path;
|
|
117
135
|
if (!p) return null;
|
|
118
136
|
const abs = expand(p, home, cwd);
|
|
119
|
-
if (secretPath(abs, home)) return { why: "your keys and cloud credentials stay yours" };
|
|
137
|
+
if (secretPath(abs, home) && !(t === "Read" && publicKey(abs, home))) return { why: "your keys and cloud credentials stay yours" };
|
|
120
138
|
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && symbiotConfig(abs, home)) return { why: "Symbiot's own settings are yours to change" };
|
|
121
139
|
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && selfFile(abs)) return { why: "Symbiot doesn't edit its own code" };
|
|
122
140
|
if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(t) && Array.isArray(writes) && writes.length && !writes.some((d) => under(abs, d))) return { why: "this run writes only in its repo and your folders (its sandbox), not " + p };
|
package/handback.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { remember, actIn } from "./mind.mjs";
|
|
|
33
33
|
import { newsSince, watchState, isMail, isSignedOut } from "./watch.mjs";
|
|
34
34
|
import { inboxMail } from "./mail.mjs";
|
|
35
35
|
import { desktopNotify } from "./desktop.mjs";
|
|
36
|
-
import { laneMap } from "./scan.mjs";
|
|
36
|
+
import { laneMap, me } from "./scan.mjs";
|
|
37
37
|
|
|
38
38
|
const AWAIT_FILE = join(CONFIG_DIR, "awaiting.json");
|
|
39
39
|
const RUNS_DIR = join(CONFIG_DIR, "drafts"); // runs of their own (act-…) and drafted replies
|
|
@@ -99,20 +99,33 @@ function collectWaits(d, { map, running = runningHandoff, now = Date.now() }) {
|
|
|
99
99
|
// subject from someone it went to (anyone, when it doesn't say whom). From
|
|
100
100
|
// Watch: an inbox row (sender, subject, preview) that shows its subject, new
|
|
101
101
|
// since it was sent: Gmail lists a thread in the inbox once someone answers.
|
|
102
|
-
|
|
102
|
+
// Never the user's own mail: not from one of `mine` (their addresses), and not a
|
|
103
|
+
// row whose newest message is theirs. Gmail starts a row with who's on the thread,
|
|
104
|
+
// the newest last ("Cale, Jono, me 5, Fwd: winlove setup, …"), so a thread the user
|
|
105
|
+
// just wrote on lists "me" last. That row started an ops agent on a reply Jono
|
|
106
|
+
// hadn't sent: it was Garth's own email to him (2026-10-12).
|
|
107
|
+
function answers(w, m, mine = []) {
|
|
103
108
|
if (!(m.ts > w.sent)) return false;
|
|
104
109
|
const want = subjectCore(w.subject); if (want.length < 4) return false;
|
|
105
110
|
if (m.subject !== undefined) {
|
|
106
111
|
if (subjectCore(m.subject) !== want) return false;
|
|
112
|
+
if (m.from && mine.includes(String(m.from.addr || "").toLowerCase())) return false;
|
|
107
113
|
const to = addrs(w); return !to.length || (!!m.from && to.includes(m.from.addr));
|
|
108
114
|
}
|
|
109
|
-
|
|
115
|
+
if (m.mine || m.from === "you") return false;
|
|
116
|
+
const text = String(m.text || "").toLowerCase().replace(/\s+/g, " "), at = text.indexOf(want.slice(0, 80));
|
|
117
|
+
return at >= 0 && lastSender(text.slice(0, at)) !== "me";
|
|
110
118
|
}
|
|
119
|
+
// The newest sender in what a row says before its subject ("cale, jono, me 5, fwd: "
|
|
120
|
+
// → "me"): the last name, past the message count and any Re:/Fwd:.
|
|
121
|
+
const lastSender = (lead) => (lead.replace(/(?:\b(?:re|fwd?|aw|sv|antw|wg)\s*(?:\[\d+\])?\s*:\s*)+$/i, "").replace(/[\s,·:;|-]*(?:\(?\d+\)?)?[\s,·:;|-]*$/, "").split(/[\s,·]+/).pop() || "");
|
|
122
|
+
// The user's own addresses: those under Email, and git's.
|
|
123
|
+
const myAddrs = () => { let m = {}; try { m = loadConfig().mail || {}; } catch {} return [...(Array.isArray(m.addresses) ? m.addresses : []), me().email].map((a) => String(a || "").trim().toLowerCase()).filter((a) => a.includes("@")); };
|
|
111
124
|
// The reply to `w` among what came in, oldest first, or null: { ts, from, subject, via }.
|
|
112
|
-
function replyTo(w, { news = [], inbox = [] }) {
|
|
113
|
-
const mail = inbox.filter((m) => answers(w, m)).sort((a, b) => a.ts - b.ts)[0];
|
|
125
|
+
function replyTo(w, { news = [], inbox = [], mine = [] }) {
|
|
126
|
+
const mail = inbox.filter((m) => answers(w, m, mine)).sort((a, b) => a.ts - b.ts)[0];
|
|
114
127
|
if (mail) return { ts: mail.ts, from: (mail.from && (mail.from.name || mail.from.addr)) || "", subject: mail.subject, via: "your inbox on this computer" };
|
|
115
|
-
const n = news.filter((x) => answers(w, x)).sort((a, b) => a.ts - b.ts)[0];
|
|
128
|
+
const n = news.filter((x) => answers(w, x, mine)).sort((a, b) => a.ts - b.ts)[0];
|
|
116
129
|
return n ? { ts: n.ts, from: "", subject: w.subject, via: `Watch (${n.name || "your inbox"})`, text: clipWords(n.text, 300) } : null;
|
|
117
130
|
}
|
|
118
131
|
|
|
@@ -151,14 +164,15 @@ function handOn(w, r, { map, act = actIn, notify = desktopNotify, now = Date.now
|
|
|
151
164
|
// One pass, from the app every minute: take in new waits, look for their
|
|
152
165
|
// replies, and hand on the ones that came in. `news`, `inbox`, `act`, `notify`
|
|
153
166
|
// and `running` can be given (the tests do).
|
|
154
|
-
function awaitTick({ map = laneMap(), now = Date.now(), news, inbox, act, notify, running } = {}) {
|
|
167
|
+
function awaitTick({ map = laneMap(), now = Date.now(), news, inbox, mine, act, notify, running } = {}) {
|
|
155
168
|
const d = loadWaits(), added = collectWaits(d, { map, running, now }), open = d.waits.filter((w) => w.status === "waiting");
|
|
156
169
|
const replied = [];
|
|
157
170
|
if (open.length) {
|
|
158
171
|
const since = Math.min(...open.map((w) => w.sent));
|
|
159
172
|
if (!news) { const mailWatch = new Set(watchState().watches.filter((x) => isMail(x.url)).map((x) => x.id)); news = newsSince(Math.max(1, (now - since) / 3600000) + 1, now).filter((n) => mailWatch.has(n.watch)); }
|
|
160
173
|
if (!inbox) { const m = loadConfig().mail || {}; inbox = m.enabled ? inboxMail({ since, sources: Array.isArray(m.sources) ? m.sources : [] }) : []; }
|
|
161
|
-
|
|
174
|
+
if (!mine) mine = myAddrs();
|
|
175
|
+
for (const w of open) { const r = replyTo(w, { news, inbox, mine }); if (r) { handOn(w, r, { map, act, notify, now }); replied.push(w); } }
|
|
162
176
|
}
|
|
163
177
|
if (added.length || replied.length) saveWaits(d);
|
|
164
178
|
return { added, replied };
|
package/handover.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const HANDOVER_MAX = 20000;
|
|
|
19
19
|
function handoverRules(lanes = [], here = "") {
|
|
20
20
|
const mk = lanes.includes(MARKETING) && here !== MARKETING, others = [...new Set(lanes.filter((l) => l && l !== here && l !== MARKETING))].slice(0, MAX_LANES_LISTED);
|
|
21
21
|
return ["## Hand over what's another lane's job",
|
|
22
|
-
`Each agent has its own lane${here ? `: this one is \`${here}\`` : ""}. When something you need is another lane's job, don't stop, and don't ask the user to do it: hand it over. Write \`.symbiot/HANDOFF.md\` with a \`### \` heading naming the lane, then what's needed, self-contained (names, paths, links, ids) and why. Symbiot starts that lane's agent on it,
|
|
22
|
+
`Each agent has its own lane${here ? `: this one is \`${here}\`` : ""}. When something you need is another lane's job, don't stop, and don't ask the user to do it: hand it over. Write \`.symbiot/HANDOFF.md\` with a \`### \` heading naming the lane, then what's needed, self-contained (names, paths, links, ids) and why. Symbiot starts that lane's agent on it within 20 seconds, then rewrites the file as a list of what went where, so a HANDOFF.md with no \`### \` section means everything in it was sent (the Agents tab shows how far each lane's agent has got). Its result comes back to you in \`.symbiot/ANSWERS.md\`. Carry on with what doesn't depend on it, and don't tick what does.`,
|
|
23
23
|
`- \`${OPS}\`: anything outside a repo: this computer (find or install a tool, a JDK, an SDK), accounts and services (cloud, GitHub, DNS, email), the user's connectors.`,
|
|
24
24
|
...(mk ? [`- \`${MARKETING}\`: marketing for any product: posts, demo videos and screenshots, launches, landing-page copy, campaigns. It drafts them; the user approves each one's preview, and then it posts or schedules it itself through Symbiot's signed-in browser.`] : []),
|
|
25
25
|
`- A repo, by its folder name, for work that belongs in that project${others.length ? `: ${others.map((l) => `\`${l}\``).join(", ")}` : ""}.`,
|