symbiot 0.58.2 → 0.58.3
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 +17 -0
- package/README.md +2 -0
- package/agents.mjs +67 -18
- package/core.mjs +29 -3
- package/guard.mjs +4 -3
- package/headless.mjs +105 -9
- package/home.mjs +8 -6
- package/index.mjs +60 -7
- package/lanes.mjs +9 -5
- package/marketing.mjs +86 -11
- package/mind.mjs +96 -20
- package/package.json +4 -3
- package/post.mjs +4 -2
- package/server.mjs +13 -3
- package/tray.mjs +154 -0
- package/ui.mjs +157 -32
- package/watch.mjs +57 -10
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
What each Symbiot release brought, newest first. When Approve bumps the version, it writes that release here from the tasks it approved, worded as release notes by your connected AI (or in the tasks' own words without one). The app shows the releases newer than yours under "What's new" when it offers an update, and once more after you update.
|
|
4
4
|
|
|
5
|
+
## 0.58.3 — 2026-10-09
|
|
6
|
+
|
|
7
|
+
- Fixed urgent requests to a parked project: it is now unparked for the work, and other projects are released if nothing starts.
|
|
8
|
+
- Fixed a stuck urgent hold that kept projects paused for hours; it now clears about two minutes after nothing starts running.
|
|
9
|
+
- Changed agents so they no longer start in a folder with no open tasks or new answers, which avoids wasted sessions.
|
|
10
|
+
- Fixed Symbiot saying an agent was "already on urgent work" when nothing was running.
|
|
11
|
+
- Added a "posted" status for marketing drafts so a posted draft leaves the Marketing orb and can't be posted twice.
|
|
12
|
+
- Added `symbiot screens upload` to attach a picture or video to a page's file field, such as a LinkedIn post.
|
|
13
|
+
- Added a fact-only brand brief for the designer making the Symbiot icon and logo.
|
|
14
|
+
- Changed the LinkedIn reader to capture who sent each message and what each notification is about, not just badge counts.
|
|
15
|
+
- Added a review of the mobile companion spec, with follow-up tasks and open decisions.
|
|
16
|
+
- Added a "superseded" status so a redone marketing post replaces the old one on the Marketing orb.
|
|
17
|
+
- Added the pick tray to the Marketing page, showing captures with switchable blur boxes and one-click media selection.
|
|
18
|
+
- Fixed Home cards erasing what you were typing in "answer in your own words" whenever the page updated.
|
|
19
|
+
- Fixed tall question cards on Home overlapping the working-agents row below them.
|
|
20
|
+
- Changed where Symbiot keeps your AI key and app token so agent runs can no longer read them.
|
|
21
|
+
|
|
5
22
|
## 0.58.2
|
|
6
23
|
|
|
7
24
|
- **A voice that doesn't sound robotic, picked for you.** When Symbiot reads a reply out, it uses your computer's own voice only when that's a modern, natural one (Edge's Natural voices on Windows, Apple's Premium and Enhanced voices, a phone's own). Otherwise, as on Linux, where Chrome only has its older Google voices and espeak, Symbiot speaks with its own voice: Piper, a small open-source speech engine that runs on your computer. The accent follows where you are (British English for South Africa, the UK, Australia and the rest; American for the US and Canada), and both voices are public domain. Nothing to choose, no account, nothing leaves your computer. It downloads once (about 85 MB) the first time it's needed, while the system voice covers that first reply, and then answers sentence by sentence, the first in under a second.
|
package/README.md
CHANGED
|
@@ -1250,6 +1250,8 @@ symbiot screens type <id> "Search mail" "invoice" --enter --yes # type into a
|
|
|
1250
1250
|
# (--yes isn't needed on a site under Trusted sites in Settings)
|
|
1251
1251
|
symbiot screens type <id> To "sam@example.com" --yes # without --enter: the text stays in the field...
|
|
1252
1252
|
symbiot screens press <new id> Send --yes # ...for a press on the screen that just printed
|
|
1253
|
+
symbiot screens upload <id> "Add media" ~/clip.mp4 --yes # put a file in the page's file box: the field,
|
|
1254
|
+
# or the button a site hides it behind ("used" says which box)
|
|
1253
1255
|
symbiot screens scroll <id> # "more": "below"? scroll down and map the next part (or up, top, bottom)
|
|
1254
1256
|
symbiot screens whole <id> # all of that page in one tall screen (or: symbiot screens map <site> --whole)
|
|
1255
1257
|
symbiot screens show <id> # a saved screen's blueprint
|
package/agents.mjs
CHANGED
|
@@ -190,7 +190,7 @@ function withTrust(tmpl, box = null) {
|
|
|
190
190
|
// config.agentSandbox: false turns it off.
|
|
191
191
|
const SANDBOX_DIR = join(CONFIG_DIR, "sandbox");
|
|
192
192
|
const SANDBOX_CACHES = [".npm", ".cache"]; // npm's cache and logs; pip, uv, node-gyp, Chrome for Testing
|
|
193
|
-
const SANDBOX_SECRETS = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", ".config/gcloud", ".config/gh", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/config.json"]; // your AI's key and the app's token too
|
|
193
|
+
const SANDBOX_SECRETS = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker", ".config/gcloud", ".config/gh", ".netrc", ".npmrc", ".pypirc", ".git-credentials", ".config/symbiot/config.json", ".config/symbiot/secrets.json"]; // your AI's key and the app's token too
|
|
194
194
|
// Local sockets are allowed (headless Chrome needs one to start, so the browser tests
|
|
195
195
|
// ran in no sandboxed run), but the ones that would be a way out are hidden: Docker's,
|
|
196
196
|
// and your session's (its bus starts commands outside, through systemd-run; X11 and
|
|
@@ -264,12 +264,14 @@ function noteSession(path, code, resumed) {
|
|
|
264
264
|
// A folder whose last run stopped on questions, with nothing changed since
|
|
265
265
|
// that could answer them, gets no run: { blocked, questions, note }
|
|
266
266
|
// (blockedAgain). force starts one anyway (something changed elsewhere).
|
|
267
|
-
|
|
267
|
+
// A folder with nothing to work from gets none either, forced or not (nothingToDo).
|
|
268
|
+
function runHandoff(repoPath, { force = false, lost = false } = {}) {
|
|
268
269
|
let tmpl = handoffCmd(); if (!tmpl || !repoPath) return null;
|
|
269
270
|
if (isParked(repoPath)) return { blocked: true, parked: true, questions: 0, note: PARKED_NOTE }; // even forced: that's what parking is for
|
|
270
271
|
let opts = {}; try { opts = JSON.parse(readSymbiot(repoPath, "handoff.json")) || {}; } catch {}
|
|
271
272
|
const busy = runningHandoff(repoPath); if (busy) return { busy: true, id: busy.id || "", pid: busy.pid, auto: !!busy.auto };
|
|
272
273
|
releaseHeldTasks(repoPath); // held for an agent another process started, which has since exited
|
|
274
|
+
const idle = !lost && nothingToDo(repoPath); if (idle) return { blocked: true, idle: true, questions: 0, note: idle };
|
|
273
275
|
const scoped = withScope(withConnectors(tmpl, repoPath), repoPath), box = isClaudeCmd(scoped) && trustFull() ? sandboxFor(repoPath, scoped) : null; // a repo run's sandbox, on top of the membrane
|
|
274
276
|
tmpl = withStream(withTrust(scoped, box));
|
|
275
277
|
const resume = isClaudeCmd(tmpl) ? resumeFor(repoPath) : null;
|
|
@@ -282,7 +284,7 @@ function runHandoff(repoPath, { force = false } = {}) {
|
|
|
282
284
|
const own = opts.env && typeof opts.env === "object" ? Object.fromEntries(Object.entries(opts.env).map(([k, v]) => [k, String(v)])) : null, boxEnv = sandboxEnv(box);
|
|
283
285
|
const env = own || boxEnv ? { ...(own || {}), ...(boxEnv || {}) } : null;
|
|
284
286
|
const e = track(typeof opts.name === "string" && opts.name ? opts.name.slice(0, 80) : repoPath.split("/").pop(), fillHandoff(runCmd, repoPath, resume ? RESUME_PROMPT : HANDOFF_PROMPT), repoPath, (code) => {
|
|
285
|
-
if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
|
|
287
|
+
if (noteSession(repoPath, code, !!resume) === "lost") { setTimeout(() => { try { runHandoff(repoPath, { force: true, lost: true }); } catch {} }, 300); return; } // its conversation is gone: start afresh
|
|
286
288
|
try { if (JSON.parse(readFileSync(lock, "utf8")).pid === e.pid) unlinkSync(lock); } catch {}
|
|
287
289
|
noteBlocked(repoPath, tmpl, e.startedAt, code);
|
|
288
290
|
setTimeout(() => { try { autoAllow(repoPath); } catch {} }, 300); // a list it proposed inside your work: turned on, and on it goes
|
|
@@ -296,6 +298,27 @@ function runHandoff(repoPath, { force = false } = {}) {
|
|
|
296
298
|
noteRun(e);
|
|
297
299
|
return e;
|
|
298
300
|
}
|
|
301
|
+
// ---- nothing to do: no run ----------------------------------------------------
|
|
302
|
+
// A run in ~ started with no .symbiot/TASKS.md there, found nothing to action and
|
|
303
|
+
// only cost a session (2026-10-07). A run needs something to work from: an
|
|
304
|
+
// unticked task, or an answer that came in since the folder's last run started
|
|
305
|
+
// (an approved post or draft, a reply to its question: those carry on with every
|
|
306
|
+
// task ticked, and Marketing's folder may have no TASKS.md at all). Else the note
|
|
307
|
+
// for { blocked, idle }, even forced: Start it anyway would start the same
|
|
308
|
+
// nothing. Briefs are written before their run starts (actNow, draftReply,
|
|
309
|
+
// pushTasks), and a held one lands first (runHandoff), so they count. Only the
|
|
310
|
+
// run that restarts a lost conversation skips this: its answer is older than the
|
|
311
|
+
// failed resume's start.
|
|
312
|
+
const OPEN_TASK = /^\s*(?:[-*+]|\d+[.)])\s*\[ \]/m;
|
|
313
|
+
function nothingToDo(path) {
|
|
314
|
+
const md = readSymbiot(path, "TASKS.md"); if (OPEN_TASK.test(md)) return "";
|
|
315
|
+
let answered = 0; try { answered = statSync(join(path, ".symbiot", "ANSWERS.md")).mtimeMs; } catch {}
|
|
316
|
+
const last = [...readSymbiot(path, "agent.log").matchAll(/^=== .* (\d{4}-\d\d-\d\dT[\d:.]+Z) ===$/gm)].pop();
|
|
317
|
+
if (answered && answered > (last ? Date.parse(last[1]) || 0 : 0)) return "";
|
|
318
|
+
const name = path === homedir() ? "~" : path.split(/[\\/]/).filter(Boolean).pop() || path;
|
|
319
|
+
return md.trim() ? `Not started: every task in ${name}'s .symbiot/TASKS.md is ticked and no answer has come in since its last run, so an agent there would have nothing to do. Send it a task first.`
|
|
320
|
+
: `Not started: ${name} has no .symbiot/TASKS.md, so an agent there would have nothing to do. Send it a task first.`;
|
|
321
|
+
}
|
|
299
322
|
// ---- a parked lane: its tasks start no runs ----------------------------------
|
|
300
323
|
// A lane blocked on something only the user can give (a token from Meta) ran 27
|
|
301
324
|
// times: every answer, handover, Go and Start it anyway started it again, to say
|
|
@@ -321,7 +344,12 @@ function parkLane(path, on) {
|
|
|
321
344
|
// test stopped halfway would be void), stops the urgent lane's own routine run (its
|
|
322
345
|
// changes stay in the folder, and the next run carries on from them) so the urgent
|
|
323
346
|
// one starts now, and when that run ends, urgentDone unparks those lanes in their
|
|
324
|
-
// old order and starts the ones with work waiting. Lanes you parked stay parked
|
|
347
|
+
// old order and starts the ones with work waiting. Lanes you parked stay parked,
|
|
348
|
+
// except the urgent lane itself: on 2026-10-09 symbiot was parked, so every urgent
|
|
349
|
+
// handover there parked marketing, started nothing, and still said "it's already on
|
|
350
|
+
// urgent work, and takes this next" while 0 agents worked. Asking for urgent work in
|
|
351
|
+
// a lane is the newest thing you've said about it, so it's unparked for it; and if
|
|
352
|
+
// its run still doesn't start, urgentUndo takes back what urgentFirst did.
|
|
325
353
|
const CAPS_OK = /^(README|TODO|FIXME|HTML|JSON|YAML|HTTP|HTTPS|OAUTH|CHANGELOG|TASKS|ANSWERS|QUESTIONS|HANDOFF|REMEMBER|AWAITING|SKIPPED|LICENSE|CLAUDE|AGENTS|NOTE|ASAP|SAST|UTC|GPU|CPU|RAM|USB|SDK|JDK|API|URL|CSS|DNS|SSH|SMS|PDF|PNG|SVG|MCP|LLM|CLI|ENV|PATH|HOME|WIP|ETA)$/;
|
|
326
354
|
function isUrgent(text) {
|
|
327
355
|
const t = String(text || "");
|
|
@@ -338,24 +366,43 @@ function stopRun(path) {
|
|
|
338
366
|
try { process.kill(-r.pid, "SIGTERM"); } catch { try { process.kill(r.pid, "SIGTERM"); } catch { return false; } } // its own process group (track: detached)
|
|
339
367
|
return true;
|
|
340
368
|
}
|
|
341
|
-
// lanes: the paths to consider ({ name: path }); returns { parked: [names],
|
|
369
|
+
// lanes: the paths to consider ({ name: path }); returns { parked: [names], parkedPaths,
|
|
370
|
+
// stopped, unparked (it was parked: now it isn't), busy: "urgent" | "routine" | "" (a run
|
|
371
|
+
// there now: one already on urgent work, which this waits for, or one it couldn't stop) }
|
|
342
372
|
function urgentFirst(path, { lanes = null, stop = stopRun, running = runningHandoff } = {}) {
|
|
343
|
-
if (!path) return { parked: [], stopped: false };
|
|
373
|
+
if (!path) return { parked: [], parkedPaths: [], stopped: false, unparked: false, busy: "" };
|
|
344
374
|
const u = urgentState() || { at: Date.now(), paths: [], parked: [], runs: [] };
|
|
345
|
-
if (!u.paths.includes(path)) u.paths.push(path);
|
|
346
|
-
if (u.parked.includes(path))
|
|
375
|
+
if (!u.paths.includes(path)) { u.paths.push(path); u.at = Date.now(); }
|
|
376
|
+
if (u.parked.includes(path)) u.parked = u.parked.filter((p) => p !== path); // urgent itself now
|
|
377
|
+
const unparked = isParked(path); if (unparked) parkLane(path, false);
|
|
347
378
|
const all = lanes || Object.fromEntries(loadRuns().map((r) => [String(r.name || r.path.split("/").pop()), r.path]));
|
|
348
|
-
const parked = [], already = parkedPaths();
|
|
379
|
+
const parked = [], mine = [], already = parkedPaths();
|
|
349
380
|
for (const [name, p] of Object.entries(all)) {
|
|
350
381
|
if (!p || u.paths.includes(p) || already.includes(p)) continue; // yours stay yours
|
|
351
382
|
if (!running(p) && !readSymbiot(p, HELD)) continue; // nothing going or waiting there
|
|
352
|
-
parkLane(p, true); u.parked.push(p); parked.push(name);
|
|
383
|
+
parkLane(p, true); u.parked.push(p); parked.push(name); mine.push(p);
|
|
353
384
|
}
|
|
354
|
-
// its own lane: a routine run stops so this one starts now; one that's already on urgent
|
|
355
|
-
|
|
385
|
+
// its own lane: a routine run stops so this one starts now; one that's already on urgent
|
|
386
|
+
// work keeps going, and counts as the urgent run, so its end resumes what was parked
|
|
387
|
+
const busy = running(path), onIt = !!busy && (u.runs.includes(busy.id) || hasOpenUrgent(readSymbiot(path, "TASKS.md")));
|
|
388
|
+
if (onIt && busy.id && !u.runs.includes(busy.id)) u.runs.push(busy.id);
|
|
356
389
|
const stopped = !!busy && !onIt && stop(path);
|
|
357
390
|
saveUrgent(u);
|
|
358
|
-
return { parked, stopped };
|
|
391
|
+
return { parked, parkedPaths: mine, stopped, unparked, busy: !busy ? "" : onIt ? "urgent" : stopped ? "" : "routine" };
|
|
392
|
+
}
|
|
393
|
+
// The urgent lane's run didn't start (no agent command, a step of yours first, its
|
|
394
|
+
// questions unanswered) and nothing runs there: nothing will end to resume the lanes
|
|
395
|
+
// urgentFirst parked, so they're unparked now, and the lane leaves the urgent record.
|
|
396
|
+
function urgentUndo(path, did, { running = runningHandoff } = {}) {
|
|
397
|
+
if (!path || !did || running(path)) return false;
|
|
398
|
+
const u = urgentState(); if (!u) return false;
|
|
399
|
+
const back = (did.parkedPaths || []).filter((p) => u.parked.includes(p));
|
|
400
|
+
for (const p of back) if (parkedPaths().includes(p)) parkLane(p, false);
|
|
401
|
+
u.parked = u.parked.filter((p) => !back.includes(p));
|
|
402
|
+
u.paths = u.paths.filter((p) => p !== path);
|
|
403
|
+
saveUrgent(u.paths.length ? u : null);
|
|
404
|
+
if (!u.paths.length && u.parked.length) resumeParked(u.parked); // parked by an earlier urgent ask that never ran either
|
|
405
|
+
return true;
|
|
359
406
|
}
|
|
360
407
|
// the urgent lane's run started: when it ends, the parked work resumes (runHandoff's exit)
|
|
361
408
|
function noteUrgentRun(path, id) { const u = urgentState(); if (u && u.paths.includes(path) && id && !u.runs.includes(id)) { u.runs.push(id); saveUrgent(u); } }
|
|
@@ -376,11 +423,13 @@ function resumeParked(paths, start = runHandoff) {
|
|
|
376
423
|
return { unparked: paths, resumed };
|
|
377
424
|
}
|
|
378
425
|
// The app restarted under an urgent run (its exit never came), or it never started:
|
|
379
|
-
// the parked lanes don't wait forever.
|
|
380
|
-
|
|
426
|
+
// the parked lanes don't wait forever. A record with no run going in any urgent lane is
|
|
427
|
+
// over once URGENT_GRACE has passed (a stopped routine run takes a moment to exit, and
|
|
428
|
+
// the urgent one starts then): before, one whose run never started held marketing for 6h.
|
|
429
|
+
const URGENT_MAX = 6 * 3600 * 1000, URGENT_GRACE = 2 * 60 * 1000;
|
|
381
430
|
function urgentSweep({ running = runningHandoff, now = Date.now(), start = runHandoff } = {}) {
|
|
382
431
|
const u = urgentState(); if (!u) return null;
|
|
383
|
-
const ended =
|
|
432
|
+
const ended = !u.paths.some((p) => running(p)) && (u.runs.length > 0 || now - (u.at || 0) >= URGENT_GRACE);
|
|
384
433
|
if (!ended && now - (u.at || 0) < URGENT_MAX) return null;
|
|
385
434
|
saveUrgent(null);
|
|
386
435
|
return resumeParked(u.parked, start);
|
|
@@ -999,7 +1048,7 @@ const HARM_RULE = /^Bash\(\s*(rm|dd|mkfs|chmod|chown|shred|truncate)\b/i;
|
|
|
999
1048
|
const wideRule = (r) => ANY_RULE.test(r) || (HARM_RULE.test(r) && /\*/.test(r));
|
|
1000
1049
|
// Whatever an agent is allowed, it never edits Symbiot's own settings: that's where
|
|
1001
1050
|
// what agents may do is kept, so an agent could widen its own permissions there.
|
|
1002
|
-
const GUARD = () => { const c = join(homedir(), ".config", "symbiot", "config.json"); return [`Edit(${c})`, `Write(${c})`]; };
|
|
1051
|
+
const GUARD = () => { const c = join(homedir(), ".config", "symbiot", "config.json"), s = join(homedir(), ".config", "symbiot", "secrets.json"); return [`Edit(${c})`, `Write(${c})`, `Read(${s})`, `Edit(${s})`, `Write(${s})`]; };
|
|
1003
1052
|
function installAllowlist(path) {
|
|
1004
1053
|
let p; try { p = JSON.parse(readFileSync(join(path, ".symbiot", "allowlist.proposed.json"), "utf8")).permissions || {}; } catch { return null; }
|
|
1005
1054
|
const list = (x) => (Array.isArray(x) ? x.filter((r) => typeof r === "string" && r.trim()).map((r) => r.trim()) : []);
|
|
@@ -1190,4 +1239,4 @@ function agentsList() {
|
|
|
1190
1239
|
}).concat(earlierRuns());
|
|
1191
1240
|
}
|
|
1192
1241
|
|
|
1193
|
-
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentDone, urgentSweep, urgentState };
|
|
1242
|
+
export { FACTS, factsOf, knownRun, withStream, workOf, HANDOFFS, HANDOFF_PROMPT, QUESTIONS_MAX, OPTIONS_SHOWN, IDEAS_SHOWN, shSingle, CLAUDE_CMD, ORCA_CLAUDE_CMD, handoffCmd, setHandoffCmd, grantAgent, grantRule, allowTool, claudeConnectors, withConnectors, linkedConnectors, connectorsLine, connectorsInfo, linkReach, fillHandoff, runHandoff, nothingToDo, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, noteUntracked, untrackedBefore, forgetUntracked, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, pickAgent, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList, needsOf, settleNeeds, NEEDS_FOR, readLastWords, readNeeds, parseRead, READ_FILE, autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope, withTrust, sandboxFor, sandboxEnv, sandboxNeeds, sandboxState, sandboxWrites, SANDBOX_DIR, resumeFor, noteSession, trustFull, GUARD_SETTINGS, isUrgent, urgentFirst, urgentUndo, urgentDone, urgentSweep, urgentState };
|
package/core.mjs
CHANGED
|
@@ -21,17 +21,43 @@ async function checkLatest() { try { const r = await fetch(REGISTRY + "/symbiot"
|
|
|
21
21
|
// ---- config ---------------------------------------------------------------
|
|
22
22
|
const CONFIG_DIR = join(homedir(), ".config", "symbiot");
|
|
23
23
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
24
|
+
// Symbiot's own keys (your AI's API key, the app's token) live apart from its
|
|
25
|
+
// settings, in secrets.json: a run outside the sandbox could `cat` config.json, and
|
|
26
|
+
// with the token call Symbiot's own API and change the agent command, which the
|
|
27
|
+
// membrane never sees. The membrane (guard.mjs) and a run's sandbox (agents.mjs
|
|
28
|
+
// SANDBOX_SECRETS) refuse secrets.json. loadConfig joins the two and saveConfig
|
|
29
|
+
// splits them, so nothing else changes; a config.json from before still reads, and
|
|
30
|
+
// its keys move out on the next save (the app saves once as it starts: moveSecrets).
|
|
31
|
+
const SECRETS_PATH = join(CONFIG_DIR, "secrets.json"), SECRET_KEYS = ["apiKey", "appToken"];
|
|
32
|
+
const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
33
|
+
function splitSecrets(cfg) {
|
|
34
|
+
const pub = { ...cfg }, sec = {};
|
|
35
|
+
for (const k of SECRET_KEYS) if (k in pub) { sec[k] = pub[k]; delete pub[k]; }
|
|
36
|
+
for (const [k, v] of Object.entries(pub)) if (isObj(v) && "apiKey" in v) { sec[k] = { apiKey: v.apiKey }; pub[k] = { ...v }; delete pub[k].apiKey; } // a provider's: { anthropic: { apiKey, model } }
|
|
37
|
+
return { pub, sec };
|
|
38
|
+
}
|
|
39
|
+
const readJson = (f) => { try { const o = JSON.parse(readFileSync(f, "utf8")); return isObj(o) ? o : {}; } catch { return {}; } };
|
|
24
40
|
function loadConfig() {
|
|
25
|
-
|
|
41
|
+
const cfg = readJson(CONFIG_PATH), sec = readJson(SECRETS_PATH);
|
|
42
|
+
for (const [k, v] of Object.entries(sec)) {
|
|
43
|
+
if (SECRET_KEYS.includes(k)) { if (!(k in cfg)) cfg[k] = v; } // one still in config.json is newer (an older Symbiot wrote it)
|
|
44
|
+
else if (isObj(v) && "apiKey" in v && !(isObj(cfg[k]) && "apiKey" in cfg[k])) cfg[k] = { ...(isObj(cfg[k]) ? cfg[k] : {}), apiKey: v.apiKey };
|
|
45
|
+
}
|
|
46
|
+
return cfg;
|
|
26
47
|
}
|
|
27
48
|
function saveConfig(cfg) {
|
|
28
49
|
try {
|
|
29
50
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
30
|
-
|
|
51
|
+
const { pub, sec } = splitSecrets(cfg || {});
|
|
52
|
+
writeFileSync(SECRETS_PATH, JSON.stringify(sec, null, 2) + "\n", { mode: 0o600 }); // first: a key is never only in neither
|
|
53
|
+
try { chmodSync(SECRETS_PATH, 0o600); } catch {}
|
|
54
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(pub, null, 2) + "\n");
|
|
31
55
|
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
32
56
|
return true;
|
|
33
57
|
} catch { return false; }
|
|
34
58
|
}
|
|
59
|
+
// Keys still in config.json (from before secrets.json): moved out. True if any were.
|
|
60
|
+
function moveSecrets() { const raw = readJson(CONFIG_PATH); return Object.keys(splitSecrets(raw).sec).length ? saveConfig(loadConfig()) : false; }
|
|
35
61
|
// ---- tasks: a persistent checklist (~/.config/symbiot/tasks.json) ---------
|
|
36
62
|
const TASKS_PATH = join(CONFIG_DIR, "tasks.json");
|
|
37
63
|
function loadTasks() { try { return JSON.parse(readFileSync(TASKS_PATH, "utf8")); } catch { return []; } }
|
|
@@ -180,4 +206,4 @@ function repoState(repoPath) {
|
|
|
180
206
|
return { branch, dirty, del, mod, add, stale, staleBy, behind };
|
|
181
207
|
}
|
|
182
208
|
|
|
183
|
-
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, loadConfig, saveConfig, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
|
|
209
|
+
export { VERSION, LATEST_VERSION, semverGt, REGISTRY, checkLatest, CONFIG_DIR, CONFIG_PATH, SECRETS_PATH, loadConfig, saveConfig, moveSecrets, splitSecrets, loadTasks, saveTasks, TASK_MAX, clipWords, taskWords, sameTask, uniqueTasks, sh, hasCmd, chromeBinary, repoState };
|
package/guard.mjs
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// - push to main/master, or force-push; publish a package
|
|
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
|
-
// - read SSH keys or cloud credentials
|
|
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
12
|
// - open the user's running Symbiot in a browser (a check uses a sandbox copy)
|
|
12
13
|
// - in a sandboxed repo run (agents.mjs sandboxFor), edit or write a file outside its
|
|
13
14
|
// repo and your folders (SYMBIOT_WRITES): its commands are held there by the sandbox
|
|
@@ -18,7 +19,7 @@ import { execFileSync } from "node:child_process";
|
|
|
18
19
|
import { readFileSync } from "node:fs";
|
|
19
20
|
import { fileURLToPath } from "node:url";
|
|
20
21
|
|
|
21
|
-
const SECRET = [".ssh", ".gnupg", ".aws", ".azure", ".kube", ".docker/config.json", ".config/gcloud", ".config/gh/hosts.yml", ".netrc", ".npmrc", ".pypirc", ".git-credentials"];
|
|
22
|
+
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"];
|
|
22
23
|
const under = (p, d) => p === d || p.startsWith(d.endsWith("/") ? d : d + "/");
|
|
23
24
|
const expand = (p, home, cwd) => resolve(cwd, String(p).replace(/^~(?=\/|$)/, home).replace(/^\$HOME(?=\/|$)/, home).replace(/^\$\{HOME\}(?=\/|$)/, home));
|
|
24
25
|
const BROWSER = /\b(google-chrome(-stable)?|chromium(-browser)?|chrome|firefox|msedge|playwright|puppeteer|wkhtmltoimage|cutycapt)\b/i;
|
|
@@ -65,7 +66,7 @@ function judgeBash(cmd, { cwd, home, branch }) {
|
|
|
65
66
|
const own = w[0] === "scp" ? SCP_VALUE : null;
|
|
66
67
|
for (const p of w.slice(1).filter((a, i, all) => !a.startsWith("-") && !(own && own.test(all[i - 1] || "")))) { if (secretPath(expand(p, home, cwd), home)) return { why: "your keys and cloud credentials stay yours" }; }
|
|
67
68
|
}
|
|
68
|
-
if (/(^|\s)(>|>>|tee|sed\s+-i)\s*\S*\.config\/symbiot\/config\.json/.test(s)) return { why: "Symbiot's own settings are yours to change" };
|
|
69
|
+
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" };
|
|
69
70
|
}
|
|
70
71
|
if (/\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/.test(c)) return { why: "running a script straight from the internet" };
|
|
71
72
|
// a browser on the user's running Symbiot: each page it opened counted as a new window and
|
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).
|