symbiot 0.54.1 → 0.54.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 +14 -0
- package/README.md +34 -3
- package/agents.mjs +2 -2
- package/checks.mjs +16 -1
- package/handback.mjs +16 -6
- package/headless.mjs +55 -3
- package/home.mjs +88 -13
- package/index.mjs +40 -5
- package/lanes.mjs +132 -5
- package/package.json +1 -1
- package/post.mjs +244 -33
- package/sandbox.mjs +1 -1
- package/server.mjs +25 -5
- package/ui.mjs +101 -33
- package/watch.mjs +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.54.3 — 2026-10-07
|
|
6
|
+
|
|
7
|
+
- Added questions on Home for stuck or failed agent runs, so you can allow access, skip, or run them again right there.
|
|
8
|
+
- Added a ready picture to post drafts whose idea is a screen of an app you run locally; keep it or remove it.
|
|
9
|
+
- Changed post drafting to try once more by itself when every draft gets dropped for claiming things git doesn't show.
|
|
10
|
+
- Added a separate sandbox for trying Symbiot's first-run setup without touching your real settings, drafts or files.
|
|
11
|
+
- Fixed Orca losing track of a repo when a handover renames its folder; Symbiot now adds it again at the new location.
|
|
12
|
+
- Fixed reply tracking treating a signed-out inbox as watched; Home now asks you to sign in again so replies aren't missed.
|
|
13
|
+
- Added a "Where your files disagree" alert under Watching on Home when your files contradict each other on something important.
|
|
14
|
+
|
|
15
|
+
## 0.54.2
|
|
16
|
+
|
|
17
|
+
- **Lighter without a graphics card.** When the liquid is drawn by the CPU (no GPU, or a headless browser like the ones agents check Symbiot's screens in), it used 6 cores or more. Now it draws at a third of the resolution, without the breathing, and only when something actually moved, at most 20 times a second: about a third of the CPU. It does the same when your system asks for less motion. With a graphics card, Home also draws half as often at rest and a sixth as often behind an open panel, where it barely shows.
|
|
18
|
+
|
|
5
19
|
## 0.54.1
|
|
6
20
|
|
|
7
21
|
- **No more lag from a big agent log.** An agent's log keeps every run and everything it reads, screenshots included; one reached 144 MB, and Symbiot reread all of it on almost every refresh, so the app stalled for 5–28 seconds at a time and ate a core. Now it reads only the newest run, from the end of the file, and keeps it until the file changes. A log past 8 MB starts afresh at the next run, with the old one kept as `agent.log.old`.
|
package/README.md
CHANGED
|
@@ -387,7 +387,10 @@ preset only fills in the command box; the saved command is what runs.
|
|
|
387
387
|
- **Orca IDE** (any OS): opens the repo in Orca — either just the repo, to use
|
|
388
388
|
Orca's own agent, or with Claude running in a new tab (allowed `npm test` and
|
|
389
389
|
`node` like the Claude preset, and upgraded the same way). It launches Orca if
|
|
390
|
-
it's closed and waits for it to be ready first.
|
|
390
|
+
it's closed and waits for it to be ready first. When a handover renames a
|
|
391
|
+
repo's folder, Symbiot adds it to Orca again at its new path (matched by its
|
|
392
|
+
GitHub remote, else its name). Orca keeps the old entry, marked missing, until
|
|
393
|
+
you remove it there.
|
|
391
394
|
- **Editors — opens only, no review:** VS Code, Cursor, Windsurf, Zed, Sublime
|
|
392
395
|
Text, IntelliJ IDEA, Neovim (on macOS, also found as `.app` bundles when the CLI
|
|
393
396
|
isn't on your PATH). They just open the repo. Nothing comes back for review unless
|
|
@@ -575,6 +578,15 @@ and the blob dissolves. More than fit across go on a second page. Clicking the o
|
|
|
575
578
|
questions and its Approve under **Needs you here** at the top of its tasks, with its
|
|
576
579
|
handovers that are waiting; an ops run's question on its block in Agents.
|
|
577
580
|
|
|
581
|
+
What stopped and can't go on without you is a question there too, on its lane.
|
|
582
|
+
A handover that couldn't start (an ops run, limited to its own folder, handed
|
|
583
|
+
"needs a run that can edit `~/Company`" to ops) asks **Allow this run access to
|
|
584
|
+
~/Company?**: **Allow** starts a run of its own on it, allowed to read, edit and
|
|
585
|
+
move files in that folder (that run only), and what it does goes back to the agent
|
|
586
|
+
that asked; **Skip** lets it go, and that agent reads that you skipped it. It asks
|
|
587
|
+
for 3 days, or until the same thing goes through another way. A run that ended in
|
|
588
|
+
an error asks **Run it again?** for a day, until another run starts there.
|
|
589
|
+
|
|
578
590
|
Under the band, around the core, are the parts of the app, grouped the
|
|
579
591
|
**P.A.R.A.** way, each group a row with its name at its start, always in the same
|
|
580
592
|
place and order:
|
|
@@ -678,10 +690,22 @@ and nothing else. **It never posts by itself**, and it doesn't schedule.
|
|
|
678
690
|
recent posts in Symbiot's signed-in browser. Without examples it doesn't draft.
|
|
679
691
|
- **Only what git shows.** Your AI gets the week as numbered facts and must say
|
|
680
692
|
which ones each post uses. A post that names a number, version, name or user
|
|
681
|
-
count the facts don't show is sent back once, then dropped.
|
|
693
|
+
count the facts don't show is sent back once, then dropped. When every draft is
|
|
694
|
+
dropped, Symbiot tries once more from the start by itself before giving up.
|
|
682
695
|
- **You approve each one.** The drafts wait on the **Dashboard** with **Approve**,
|
|
683
696
|
**Edit** and **Skip**. Approve copies the post to your clipboard and opens
|
|
684
697
|
LinkedIn's share box: you paste it and post it yourself.
|
|
698
|
+
- **Pictures and videos.** Each draft says in a line what picture or short video
|
|
699
|
+
would show it best. Add one of yours (**Add picture or video**: PNG, JPG, GIF,
|
|
700
|
+
MP4, MOV, WebM), or click **From a page** and give a web address (your app on
|
|
701
|
+
`localhost` works) to **Take a picture** of it, sharp enough for a feed, or
|
|
702
|
+
**Record a clip**: 5–30 seconds of it, scrolling slowly down, as an MP4 (needs
|
|
703
|
+
`ffmpeg`). A post takes a video or up to 20 pictures, as LinkedIn does. They're
|
|
704
|
+
copies, kept in `~/.config/symbiot/post-media/`; Approve opens their folder for
|
|
705
|
+
you to add them with LinkedIn's photo or video button. A draft whose idea is a
|
|
706
|
+
screen of an app you run here (listening on `localhost`, started in that repo's
|
|
707
|
+
folder, or Symbiot's own app: "the new Reports view" opens Reports) arrives with
|
|
708
|
+
its picture already on it: keep it or remove it.
|
|
685
709
|
- **Everything is logged** in `~/.config/symbiot/posts-log.jsonl`: each draft,
|
|
686
710
|
edit, approval and skip, with its text, date and platform.
|
|
687
711
|
- **Replies.** Linked LinkedIn is watched like an inbox: new comments and mentions
|
|
@@ -695,6 +719,9 @@ symbiot post # draft this week's 3 posts
|
|
|
695
719
|
symbiot post list # the drafts waiting on you
|
|
696
720
|
symbiot post approve <id> # copy it, and get LinkedIn's share box (--open opens it)
|
|
697
721
|
symbiot post edit <id> "text" # change a draft's words
|
|
722
|
+
symbiot post add <id> <file> # put a picture or video of yours on it
|
|
723
|
+
symbiot post page <id> <page> # a picture of a web page on it (--clip: a short video)
|
|
724
|
+
symbiot post remove <id> <m> # take a picture or video off it
|
|
698
725
|
symbiot post skip <id> # drop one
|
|
699
726
|
symbiot post log # everything that happened
|
|
700
727
|
```
|
|
@@ -765,7 +792,9 @@ Every brief also tells the agent what else it can leave in `.symbiot/`:
|
|
|
765
792
|
gets it as a task and its agent starts, told what was asked for and what to do
|
|
766
793
|
next. The task it's for says so in its chat, and you get a notification. The
|
|
767
794
|
Dashboard lists **Waiting on replies**, with **Stop waiting** on each. Anything
|
|
768
|
-
the reply leaves for you to decide comes back as that agent's question.
|
|
795
|
+
the reply leaves for you to decide comes back as that agent's question. While
|
|
796
|
+
one waits and nothing can see the reply (no inbox watched and Email off, or the
|
|
797
|
+
watched inbox signed out), Home and the Dashboard say so.
|
|
769
798
|
- **A report** (findings, an audit, a plan, a pitch): any `.md` named for what it
|
|
770
799
|
is, with a `# ` title. **Reports** in the app lists every one your runs left,
|
|
771
800
|
in your repos and in runs of their own, newest first, unread ones marked; click
|
|
@@ -861,6 +890,8 @@ CSV and Markdown tables, front matter and "Owner:" lines, and examples are never
|
|
|
861
890
|
used as facts. The list, most pressing first and each with its files, is under
|
|
862
891
|
**Settings → Knowledge folders**; it's checked again whenever a file changes, and
|
|
863
892
|
**Check again** does it now. It's kept in `~/.config/symbiot/checks.json`, yours only.
|
|
893
|
+
A clash that matters (a deadline on someone's leave or a holiday, a renewal or
|
|
894
|
+
price told two ways) also shows on Home, under Watching, until you open it.
|
|
864
895
|
|
|
865
896
|
- **Examples aren't facts.** Say which paths in a folder hold worked examples
|
|
866
897
|
(`--examples templates/,active/`, or the second box in Settings; `templates/`
|
package/agents.mjs
CHANGED
|
@@ -885,8 +885,8 @@ function agentsList() {
|
|
|
885
885
|
const { tail, work, progress } = workOf(e.path, readRunLog(e.log));
|
|
886
886
|
const first = !seen.has(e.path); seen.add(e.path);
|
|
887
887
|
const facts = first && e.status !== "running" ? factsOf(e.path) : [];
|
|
888
|
-
return { id: e.id, name: e.name, path: e.path, status: e.status, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
|
|
888
|
+
return { id: e.id, name: e.name, path: e.path, status: e.status, startedAt: e.startedAt, endedAt: e.endedAt, elapsed: (e.endedAt || Date.now()) - e.startedAt, exitCode: e.exitCode, tail, work, progress, changed: agentChanges(e.path, e.startedAt), ask: first ? agentQuestions(e.path, e.name) : null, held: first ? heldTasks(e.path) : null, waiting: first && e.status !== "running" ? waitingFor(e.path) : null, remember: facts.length ? facts : null, fromHeld: !!e.fromHeld };
|
|
889
889
|
}).concat(earlierRuns());
|
|
890
890
|
}
|
|
891
891
|
|
|
892
|
-
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, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , autoAllow, autoAllowSweep, allowlistInWork, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
|
|
892
|
+
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, fillHandoff, runHandoff, PARKED_NOTE, parkedPaths, isParked, parkLane, agentMissing, blockedAgain, runningHandoff, loadRuns, earlierRuns, namedFiles, waitingFor, startWaiting, writeTasks, droppedTasks, releaseHeldTasks, startHeldTasks, detectHandoffs, findOrcaCli, orcaHandoffCmd, migrateOrcaCmd, migrateClaudeCmd, track, agentChanges, parseQuestions, suggestionTarget, skipIdea, agentQuestions, answerQuestions, agentsList , autoAllow, autoAllowSweep, allowlistInWork, installAllowlist, inWork, withScope , withTrust, resumeFor, noteSession, trustFull, GUARD_SETTINGS };
|
package/checks.mjs
CHANGED
|
@@ -647,5 +647,20 @@ function checksState({ file = CHECKS_FILE } = {}) {
|
|
|
647
647
|
try { const d = JSON.parse(readFileSync(file, "utf8")); return { at: d.at || 0, clashes: d.clashes || [], counts: d.counts || {}, officeDays: d.officeDays || [], folders: d.folders || [], ms: d.ms || 0 }; }
|
|
648
648
|
catch { return { at: 0, clashes: [], counts: {}, officeDays: [], folders: [], ms: 0 }; }
|
|
649
649
|
}
|
|
650
|
+
// Home's "Where your files disagree" droplet: the high clashes (a deadline on
|
|
651
|
+
// someone's leave or a holiday, a renewal or price told two ways) you haven't
|
|
652
|
+
// opened from it yet. Opening it marks them seen (checks-seen.json), so the same
|
|
653
|
+
// clash reaches you once; a new one, or one that changes, comes back.
|
|
654
|
+
const SEEN_FILE = join(CONFIG_DIR, "checks-seen.json");
|
|
655
|
+
const clashKey = (c) => c.kind + "|" + c.text;
|
|
656
|
+
function seenClashes({ file = SEEN_FILE } = {}) { try { const d = JSON.parse(readFileSync(file, "utf8")); return Array.isArray(d) ? d : []; } catch { return []; } }
|
|
657
|
+
function newClashes({ state = checksState(), seen = seenClashes() } = {}) {
|
|
658
|
+
const s = new Set(seen);
|
|
659
|
+
return (state.clashes || []).filter((c) => c.severity === "high" && !s.has(clashKey(c)));
|
|
660
|
+
}
|
|
661
|
+
function markClashesSeen({ state = checksState(), file = SEEN_FILE } = {}) {
|
|
662
|
+
const keys = [...new Set([...seenClashes({ file }), ...(state.clashes || []).filter((c) => c.severity === "high").map(clashKey)])].slice(-MAX_CLASHES);
|
|
663
|
+
try { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, JSON.stringify(keys), { mode: 0o600 }); return { ok: true }; } catch { return { error: "Couldn't write " + file + "." }; }
|
|
664
|
+
}
|
|
650
665
|
|
|
651
|
-
export { CHECKS_FILE, parseDates, timeOf, loadFiles, extractFacts, findClashes, runChecks, checksState };
|
|
666
|
+
export { CHECKS_FILE, parseDates, timeOf, loadFiles, extractFacts, findClashes, runChecks, checksState, newClashes, markClashesSeen, seenClashes };
|
package/handback.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { CONFIG_DIR, loadConfig, loadTasks, saveTasks, sameTask, clipWords } fro
|
|
|
30
30
|
import { OPS, parseAwaiting } from "./handover.mjs";
|
|
31
31
|
import { FACTS, factsOf, knownRun, loadRuns, HANDOFFS, runningHandoff } from "./agents.mjs";
|
|
32
32
|
import { remember, actIn } from "./mind.mjs";
|
|
33
|
-
import { newsSince, watchState, isMail } from "./watch.mjs";
|
|
33
|
+
import { newsSince, watchState, isMail, isSignedOut } from "./watch.mjs";
|
|
34
34
|
import { inboxMail } from "./mail.mjs";
|
|
35
35
|
import { desktopNotify } from "./desktop.mjs";
|
|
36
36
|
import { repoPathMap } from "./scan.mjs";
|
|
@@ -163,11 +163,21 @@ function awaitTick({ map = repoPathMap(), now = Date.now(), news, inbox, act, no
|
|
|
163
163
|
if (added.length || replied.length) saveWaits(d);
|
|
164
164
|
return { added, replied };
|
|
165
165
|
}
|
|
166
|
-
//
|
|
167
|
-
|
|
166
|
+
// Can a reply be noticed? Email on (the inbox on this computer), or a watched
|
|
167
|
+
// inbox that's signed in: a signed-out one reads nothing, so it doesn't count.
|
|
168
|
+
// { sees, signedOut: [the signed-out inboxes' names] }
|
|
169
|
+
function inboxSight({ mail, watches } = {}) {
|
|
170
|
+
if (!mail) { try { mail = loadConfig().mail || {}; } catch { mail = {}; } }
|
|
171
|
+
if (!watches) { try { watches = watchState().watches; } catch { watches = []; } }
|
|
172
|
+
const inboxes = watches.filter((w) => isMail(w.url)), out = inboxes.filter(isSignedOut);
|
|
173
|
+
return { sees: !!mail.enabled || inboxes.length > out.length, signedOut: out.map((w) => w.name) };
|
|
174
|
+
}
|
|
175
|
+
// The Dashboard's list: what's still waiting, then what came in lately, newest
|
|
176
|
+
// first; and, while one waits, whether anything can see the reply arrive.
|
|
177
|
+
function awaitingState({ sight = inboxSight } = {}) {
|
|
168
178
|
const view = (w) => ({ id: w.id, from: w.from.lane, to: w.to, subject: w.subject, asked: w.asked, task: w.task, lane: w.lane, sent: w.sent, status: w.status, ...(w.reply ? { reply: w.reply } : {}), ...(w.handed ? { handed: w.handed } : {}), ...(w.error ? { error: w.error } : {}) });
|
|
169
|
-
const all = loadWaits().waits.slice().reverse();
|
|
170
|
-
return { waits: [...
|
|
179
|
+
const all = loadWaits().waits.slice().reverse(), open = all.filter((w) => w.status === "waiting");
|
|
180
|
+
return { waits: [...open, ...all.filter((w) => w.status !== "waiting" && w.status !== "stopped").slice(0, 10)].map(view), ...(open.length ? { sight: sight() } : {}) };
|
|
171
181
|
}
|
|
172
182
|
// Stop waiting (its button): it stays in the file, so the run's AWAITING.json doesn't bring it back.
|
|
173
183
|
function stopWaiting(id) {
|
|
@@ -175,4 +185,4 @@ function stopWaiting(id) {
|
|
|
175
185
|
w.status = "stopped"; return saveWaits(d) ? { ok: true, ...awaitingState() } : { error: "Couldn't write " + AWAIT_FILE + "." };
|
|
176
186
|
}
|
|
177
187
|
|
|
178
|
-
export { AWAIT_FILE, RUNS_DIR, runFolders, laneOf, runTitle, subjectCore, keepFacts, skipFacts, loadWaits, collectWaits, answers, replyTo, nextRequest, awaitTick, awaitingState, stopWaiting };
|
|
188
|
+
export { AWAIT_FILE, RUNS_DIR, runFolders, laneOf, runTitle, subjectCore, keepFacts, skipFacts, loadWaits, collectWaits, answers, replyTo, nextRequest, awaitTick, inboxSight, awaitingState, stopWaiting };
|
package/headless.mjs
CHANGED
|
@@ -80,7 +80,7 @@ function connect(proc) {
|
|
|
80
80
|
function done(v) { clearTimeout(t); listeners.delete(f); resolve(v); }
|
|
81
81
|
listeners.add(f);
|
|
82
82
|
});
|
|
83
|
-
return { send, until, on: (f) => listeners.add(f), closed: () => closed };
|
|
83
|
+
return { send, until, on: (f) => { listeners.add(f); return () => listeners.delete(f); }, closed: () => closed };
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
// One browser at a time: they share a profile, and Chrome locks it.
|
|
@@ -139,7 +139,9 @@ async function launch() {
|
|
|
139
139
|
// Sites serve "HeadlessChrome" something else (or a block page): look like the browser it is.
|
|
140
140
|
const { userAgent } = await c.send("Browser.getVersion");
|
|
141
141
|
if (userAgent) await send("Network.setUserAgentOverride", { userAgent: userAgent.replace(/HeadlessChrome/g, "Chrome") });
|
|
142
|
-
|
|
142
|
+
// every `method` event on this tab, until the function it gives is called
|
|
143
|
+
const on = (method, f) => c.on((m) => { if (m && m.sessionId === sessionId && m.method === method) f(m.params || {}); });
|
|
144
|
+
b.page = { send, until, idle, on };
|
|
143
145
|
return b;
|
|
144
146
|
})();
|
|
145
147
|
return await Promise.race([run, failed]);
|
|
@@ -497,6 +499,56 @@ function readTexts(input, selector, { scrolls = 3 } = {}) {
|
|
|
497
499
|
}, false)).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
498
500
|
}
|
|
499
501
|
|
|
502
|
+
// A picture, or a short clip, of a page, for a post (post.mjs): only on your
|
|
503
|
+
// click, and it only looks, like readPage. The picture is the window at twice its
|
|
504
|
+
// pixels, so it stays sharp in a feed: { png, url, title }. The clip records the
|
|
505
|
+
// window for `seconds` (the DevTools screencast: a frame each time the page
|
|
506
|
+
// changes, with its time), scrolling slowly down a page longer than the window,
|
|
507
|
+
// so even a still page moves: { frames: [{ ts, jpeg }], end, url, title }, the
|
|
508
|
+
// times in seconds. post.mjs makes it a video.
|
|
509
|
+
const CLIP = { min: 3, max: 30, speed: 450 }; // seconds; pixels a second, scrolling
|
|
510
|
+
const SCROLL_THROUGH = (ms) => `(() => { ${SCROLLER}
|
|
511
|
+
const sc = scroller(); if (!sc) return 0;
|
|
512
|
+
const hold = 800, run = Math.max(500, ${ms} - 2 * hold), dist = Math.min(sc.scrollHeight - sc.clientHeight, run / 1000 * ${CLIP.speed}), t0 = performance.now();
|
|
513
|
+
const ease = (x) => x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2;
|
|
514
|
+
(function step(now) { const k = Math.min(1, Math.max(0, (now - t0 - hold) / run)); sc.scrollTop = Math.round(dist * ease(k)); if (k < 1) requestAnimationFrame(step); })(t0);
|
|
515
|
+
return Math.round(dist);
|
|
516
|
+
})()`;
|
|
517
|
+
function pagePicture(input) {
|
|
518
|
+
const url = siteUrl(input);
|
|
519
|
+
if (!url) return Promise.resolve({ error: "Give a page: a web address (localhost:3000 works), a host (github.com/you) or a site's name." });
|
|
520
|
+
return oneAtATime(() => withPage(async (page) => {
|
|
521
|
+
await page.send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 2, mobile: false });
|
|
522
|
+
try {
|
|
523
|
+
await open(page, url);
|
|
524
|
+
const { data } = await page.send("Page.captureScreenshot", { format: "png" });
|
|
525
|
+
return { png: Buffer.from(data, "base64"), url: String((await evaluate(page, "location.href")) || url), title: String((await evaluate(page, "document.title")) || "") };
|
|
526
|
+
} finally { await page.send("Emulation.setDeviceMetricsOverride", { width: VIEW.w, height: VIEW.h, deviceScaleFactor: 1, mobile: false }).catch(() => {}); }
|
|
527
|
+
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
528
|
+
}
|
|
529
|
+
function pageClip(input, seconds = 8) {
|
|
530
|
+
const url = siteUrl(input);
|
|
531
|
+
if (!url) return Promise.resolve({ error: "Give a page: a web address (localhost:3000 works), a host (github.com/you) or a site's name." });
|
|
532
|
+
const ms = Math.round(Math.min(CLIP.max, Math.max(CLIP.min, Number(seconds) || 8)) * 1000);
|
|
533
|
+
return oneAtATime(() => withPage(async (page) => {
|
|
534
|
+
await open(page, url);
|
|
535
|
+
const frames = [];
|
|
536
|
+
const off = page.on("Page.screencastFrame", (p) => {
|
|
537
|
+
frames.push({ ts: (p.metadata && p.metadata.timestamp) || Date.now() / 1000, jpeg: Buffer.from(p.data, "base64") });
|
|
538
|
+
page.send("Page.screencastFrameAck", { sessionId: p.sessionId }).catch(() => {});
|
|
539
|
+
});
|
|
540
|
+
try {
|
|
541
|
+
await page.send("Page.startScreencast", { format: "jpeg", quality: 88, maxWidth: VIEW.w, maxHeight: VIEW.h, everyNthFrame: 1 });
|
|
542
|
+
await evaluate(page, SCROLL_THROUGH(ms));
|
|
543
|
+
await sleep(ms);
|
|
544
|
+
await page.send("Page.stopScreencast").catch(() => {});
|
|
545
|
+
} finally { off(); }
|
|
546
|
+
const end = frames.length ? Math.max(frames[frames.length - 1].ts, frames[0].ts + ms / 1000) : 0;
|
|
547
|
+
if (!frames.length) return { error: "The page showed nothing to record." };
|
|
548
|
+
return { frames, end, url: String((await evaluate(page, "location.href")) || url), title: String((await evaluate(page, "document.title")) || "") };
|
|
549
|
+
})).catch((e) => ({ error: String((e && e.message) || e) }));
|
|
550
|
+
}
|
|
551
|
+
|
|
500
552
|
// Trusted sites (Settings → Screens): on a page from one of these, press and type
|
|
501
553
|
// go ahead without asking, for you and for agents. Anywhere else each one asks.
|
|
502
554
|
// A host covers its subdomains (google.com covers mail.google.com). Only the app's
|
|
@@ -662,4 +714,4 @@ async function signIn(input) {
|
|
|
662
714
|
return { ok: true, url };
|
|
663
715
|
}
|
|
664
716
|
|
|
665
|
-
export { siteUrl, browserArgs, mapPage, wholePage, readPage, readTexts, isSend, pressRegion, typeRegion, scrollPage, SCROLLS, signIn, keepBrowserOpen, closeBrowser, browserOpen, trustedSites, isTrusted, trustSite, untrustSite, PROFILE };
|
|
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 };
|
package/home.mjs
CHANGED
|
@@ -4,24 +4,27 @@
|
|
|
4
4
|
// folders), always out front;
|
|
5
5
|
// - feeds: what's new on the sites you watch (watch.mjs), sized by how much;
|
|
6
6
|
// - lanes: work handed between agents (lanes.mjs), the latest few.
|
|
7
|
-
// Reports agents wrote up that you haven't read (reports.mjs) are a feed too
|
|
7
|
+
// Reports agents wrote up that you haven't read (reports.mjs) are a feed too, and
|
|
8
|
+
// so is a clash in your company folder that matters (checks.mjs newClashes).
|
|
8
9
|
// The app's sections (Map, Tasks, Agents, Week…) are shaped by adapt.mjs from
|
|
9
10
|
// how you use them; this is the live data around them. Home's talk goes to the
|
|
10
11
|
// same Symbiot as every chat (mind.mjs converse), told what home shows.
|
|
11
12
|
import { join } from "node:path";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { watchBoard } from "./watch.mjs";
|
|
14
16
|
import { pendingReview, pushTasks } from "./tasks.mjs";
|
|
15
|
-
import { agentsList, runHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
|
|
17
|
+
import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
|
|
16
18
|
import { linksState } from "./links.mjs";
|
|
17
19
|
import { knowledgeState } from "./knowledge.mjs";
|
|
18
|
-
import { CONFIG_DIR,
|
|
19
|
-
import { lanesState } from "./lanes.mjs";
|
|
20
|
+
import { CONFIG_DIR, loadTasks } from "./core.mjs";
|
|
21
|
+
import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
|
|
20
22
|
import { converse, actIn, actNow, taskIn } from "./mind.mjs";
|
|
21
23
|
import { repoPathMap } from "./scan.mjs";
|
|
22
24
|
import { resolveProvider } from "./ai.mjs";
|
|
23
25
|
import { reportsNews } from "./reports.mjs";
|
|
24
|
-
import { awaitingState } from "./handback.mjs";
|
|
26
|
+
import { awaitingState, inboxSight } from "./handback.mjs";
|
|
27
|
+
import { newClashes } from "./checks.mjs";
|
|
25
28
|
|
|
26
29
|
const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
|
|
27
30
|
let cached = null;
|
|
@@ -46,6 +49,32 @@ function displayName(path, folder) {
|
|
|
46
49
|
// The lane a folder is: the repo it is (by the scan's map), or an ops run.
|
|
47
50
|
const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] === path) || (String(path || "").startsWith(RUNS) ? "" : null);
|
|
48
51
|
|
|
52
|
+
// A run that failed shows for a day, until you run it again or skip it; one
|
|
53
|
+
// that's been followed by another run in its folder is past.
|
|
54
|
+
const FAILED_FOR = 86400000, dropped = new Set();
|
|
55
|
+
const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
|
|
56
|
+
// What stopped and can't go on without you, as questions: a handover that couldn't
|
|
57
|
+
// start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"), and a run
|
|
58
|
+
// that ended in an error. Each is answered on its blob (homeAnswer), on Home and on
|
|
59
|
+
// its lane in Tasks. repo: its lane ("" for an ops run).
|
|
60
|
+
function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
|
|
61
|
+
const out = [], seen = new Set();
|
|
62
|
+
for (const t of stuck) {
|
|
63
|
+
const lane = t.from.lane === RUNS_LANE ? "" : t.from.lane;
|
|
64
|
+
out.push({ kind: "ask", fix: "handover", id: "stuck:" + t.id, path: lane ? map[lane] || t.from.path : t.from.path, repo: lane, name: lane ? named(map[lane], lane) : "Agent runs",
|
|
65
|
+
title: `${lane || "An agent run"} is stuck`, sub: plain(firstLine(t.text), 90), q: t.q, options: t.options, why: plain(t.error, 160), shape: "agents" });
|
|
66
|
+
}
|
|
67
|
+
for (const a of list) {
|
|
68
|
+
if (seen.has(a.path)) continue; seen.add(a.path); // the newest run in each folder (agentsList: newest first)
|
|
69
|
+
if (a.status !== "failed" || dropped.has(a.id) || (a.endedAt && now - a.endedAt > FAILED_FOR)) continue;
|
|
70
|
+
if (a.ask && a.ask.questions && a.ask.questions.length) continue; // it asked: that's its question
|
|
71
|
+
const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60);
|
|
72
|
+
const said = plain(String(a.tail || "").split(/\n+/).filter((l) => l.trim()).pop() || "", 140);
|
|
73
|
+
out.push({ kind: "ask", fix: "failed", id: "failed:" + a.id, path: a.path, repo, name: nm, title: `${nm}'s run stopped`, sub: said || `it exited with code ${a.exitCode}`,
|
|
74
|
+
q: `Its run stopped with an error${said ? `: ${said}` : ` (exit code ${a.exitCode})`}. Run it again?`, options: ["Run it again (recommended)", "Skip"], shape: "agents" });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
49
78
|
// { you: [{ kind, id, title, sub, shape, … }], feeds: [{ id, title, sub, count, shape }],
|
|
50
79
|
// lanes: [{ from, to, text, status, times }], working: n, at }
|
|
51
80
|
// An ask carries what its blob on home needs: the lane (repo, or "" for an ops
|
|
@@ -60,11 +89,13 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
60
89
|
const connected = deps.connected || (() => !!resolveProvider());
|
|
61
90
|
const repos = deps.repos || (() => { try { return repoPathMap(); } catch { return {}; } });
|
|
62
91
|
const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
|
|
63
|
-
|
|
64
|
-
|
|
92
|
+
const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
|
|
93
|
+
// can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
|
|
94
|
+
const seesInbox = deps.seesInbox || (() => { try { return inboxSight(); } catch { return true; } });
|
|
65
95
|
// the agent the handoff command runs, gone from this computer; Claude connectors linked here but signed out
|
|
66
96
|
const agentGone = deps.agentGone || (() => { try { return handoffCmd() ? agentMissing() : ""; } catch { return ""; } });
|
|
67
97
|
const signedOut = deps.signedOut || (() => { try { const c = connectorsInfo(); return c.claude ? (c.links || []).filter((x) => x.connector && !x.ready) : []; } catch { return []; } });
|
|
98
|
+
const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
|
|
68
99
|
const named = deps.name || displayName;
|
|
69
100
|
const you = [], map = repos() || {};
|
|
70
101
|
// What Symbiot works through (an AI, your agent, the connectors runs use) shows
|
|
@@ -89,10 +120,22 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
89
120
|
you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
|
|
90
121
|
title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), q: String(q.q || ""), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), shape: "agents" });
|
|
91
122
|
}
|
|
123
|
+
// What stopped and can't go on without you, as a question too: a handover that
|
|
124
|
+
// couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
|
|
125
|
+
// and a run that ended in an error. Answered on its blob (homeAnswer).
|
|
126
|
+
you.push(...troubles({ stuck: stuck(), list, map, named, now }));
|
|
92
127
|
// A run is waiting on an emailed reply that nothing will see: say so, once,
|
|
93
|
-
// until an inbox is watched or Email is on (handback.mjs looks in either).
|
|
94
|
-
if (waits().some((w) => w.status === "waiting")
|
|
128
|
+
// until an inbox is watched (and signed in) or Email is on (handback.mjs looks in either).
|
|
129
|
+
if (waits().some((w) => w.status === "waiting")) {
|
|
130
|
+
const s = seesInbox(), sight = s && typeof s === "object" ? s : { sees: !!s, signedOut: [] };
|
|
131
|
+
if (!sight.sees) you.push(sight.signedOut.length
|
|
132
|
+
? { kind: "setup", id: "setup:inbox", signin: true, title: `Sign in to ${sight.signedOut[0]} again`, sub: "it's signed out, so I won't notice their reply", shape: "settings", focus: "links" }
|
|
133
|
+
: { kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
|
|
134
|
+
}
|
|
95
135
|
const feeds = ((board() || {}).cards || []).filter((c) => c.count > 0).map((c) => ({ id: "feed:" + c.id, title: c.name, sub: c.label, count: c.count, shape: "board" }));
|
|
136
|
+
// a clash in your files that matters (checks.mjs: high), first under Watching, until you've opened it
|
|
137
|
+
const clash = clashes();
|
|
138
|
+
if (clash.length) feeds.unshift({ id: "feed:clash", title: "Where your files disagree", sub: `${clash.length} clash${clash.length > 1 ? "es" : ""} to look at`, latest: plain(clash[0].text, 140), count: clash.length, shape: "settings" });
|
|
96
139
|
const rep = reports();
|
|
97
140
|
if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
|
|
98
141
|
const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status, ...(h.times > 1 ? { times: h.times } : {}) }));
|
|
@@ -102,6 +145,33 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
|
|
|
102
145
|
return out;
|
|
103
146
|
}
|
|
104
147
|
|
|
148
|
+
// An answer on a stuck handover's or a failed run's blob. pick: the option's place
|
|
149
|
+
// (0 goes ahead, 1 skips); text: your own words, which go ahead with them unless
|
|
150
|
+
// they say no. The handover's run (lanes.mjs allowHandover) reports back to the
|
|
151
|
+
// agent that asked; a failed run starts again in its folder, with your words in its
|
|
152
|
+
// ANSWERS.md. { ok, said } or { error }.
|
|
153
|
+
const SAYS_NO = /^\s*(skip|no|nope|don'?t|do not|cancel|leave it|never ?mind|stop)\b/i;
|
|
154
|
+
function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
|
|
155
|
+
id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
|
|
156
|
+
const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
|
|
157
|
+
cached = null;
|
|
158
|
+
if (id.startsWith("stuck:")) {
|
|
159
|
+
if (!go) { const r = (deps.skip || skipHandover)(ref); return r.error ? r : { ok: true, said: "Skipped. Its agent knows." }; }
|
|
160
|
+
const r = (deps.allow || allowHandover)(ref, { note: text }); if (r.error) return r;
|
|
161
|
+
return { ok: true, said: `Started a run on it${r.dirs && r.dirs.length ? `, allowed into ${r.dirs.map((d) => (d.startsWith(homedir() + "/") ? "~" + d.slice(homedir().length) : d)).join(" and ")}` : ""}. What it does goes back to the agent that asked.` };
|
|
162
|
+
}
|
|
163
|
+
if (id.startsWith("failed:")) {
|
|
164
|
+
const a = (deps.agents || agentsList)().find((x) => x.id === ref); if (!a) return { error: "That run isn't here any more." };
|
|
165
|
+
if (!go) { dropped.add(ref); return { ok: true, said: "Skipped." }; }
|
|
166
|
+
if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
|
|
167
|
+
if (text) { const f = join(a.path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
|
|
168
|
+
try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### Your last run stopped with an error\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {} }
|
|
169
|
+
const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
|
|
170
|
+
return e && e.id && !e.blocked ? { ok: true, said: "Running it again.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
|
|
171
|
+
}
|
|
172
|
+
return { error: "Answer that one on its block in Agents." };
|
|
173
|
+
}
|
|
174
|
+
|
|
105
175
|
// Settings' first steps, in the order they matter (FIRST-RUN-AUDIT.md: a new user
|
|
106
176
|
// found Settings one long page with nothing saying what comes first). Each is
|
|
107
177
|
// done or not from what's set up, so it ticks itself; the company folder is
|
|
@@ -136,7 +206,7 @@ function firstSteps({ deps = {} } = {}) {
|
|
|
136
206
|
// One line per thing home shows, for the talk's context.
|
|
137
207
|
function homeContext(h) {
|
|
138
208
|
return [
|
|
139
|
-
`Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}`),
|
|
209
|
+
`Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}${y.q && y.q !== y.sub ? ` (asks: ${y.q}${(y.options || []).length ? ` ${y.options.join(" / ")}` : ""})` : ""}`),
|
|
140
210
|
`New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}${f.latest ? ` (latest: ${f.latest})` : ""}`) : ["- nothing new"]),
|
|
141
211
|
`Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
|
|
142
212
|
].join("\n");
|
|
@@ -218,6 +288,11 @@ function workScene({ deps = {} } = {}) {
|
|
|
218
288
|
for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path }); }
|
|
219
289
|
for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
|
|
220
290
|
}
|
|
291
|
+
const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
|
|
292
|
+
for (const t of troubles({ stuck: stuck(), list: AG, map, named })) {
|
|
293
|
+
const p = at(t.repo || RUNS_LANE); p.asks++; p.qs = p.qs || [];
|
|
294
|
+
if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub });
|
|
295
|
+
}
|
|
221
296
|
for (const p of Object.values(by)) { p.lit = !!(p.asks || p.ready); if (isParked(p.repo)) p.parked = true; }
|
|
222
297
|
const projects = Object.values(by).sort((a, b) => (b.lit ? 1 : 0) - (a.lit ? 1 : 0) || (a.parked ? 1 : 0) - (b.parked ? 1 : 0) || (b.running ? 1 : 0) - (a.running ? 1 : 0) || b.ready - a.ready || b.waiting - a.waiting || b.last - a.last || (a.repo < b.repo ? -1 : 1));
|
|
223
298
|
return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy && !w.parked).length, projects: projects.slice(0, 24), projectCount: projects.length };
|
|
@@ -234,4 +309,4 @@ function workGo({ push = pushTasks, run = runHandoff } = {}) {
|
|
|
234
309
|
return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
|
|
235
310
|
}
|
|
236
311
|
|
|
237
|
-
export { homeState, homeContext, homeAsk, workScene, workGo, displayName, firstSteps };
|
|
312
|
+
export { homeState, homeContext, homeAsk, homeAnswer, workScene, workGo, displayName, firstSteps };
|
package/index.mjs
CHANGED
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
|
-
import { join } from "node:path";
|
|
29
|
-
import { realpathSync } from "node:fs";
|
|
28
|
+
import { join, basename } from "node:path";
|
|
29
|
+
import { realpathSync, readFileSync } from "node:fs";
|
|
30
30
|
import { createInterface } from "node:readline";
|
|
31
31
|
import { fileURLToPath } from "node:url";
|
|
32
32
|
import { EMBEDDED_UI } from "./ui.mjs";
|
|
@@ -41,7 +41,7 @@ import { loadScreens, screenImage, blueprint } from "./screens.mjs";
|
|
|
41
41
|
import { mapPage, wholePage, pressRegion, typeRegion, 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
|
-
import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl } from "./post.mjs";
|
|
44
|
+
import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
|
|
45
45
|
import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
|
|
46
46
|
import { listReports, readReport } from "./reports.mjs";
|
|
47
47
|
import { runSandbox } from "./sandbox.mjs";
|
|
@@ -621,16 +621,20 @@ async function cmdPhone() {
|
|
|
621
621
|
// Nothing is ever posted by Symbiot: approve copies one post to your clipboard and
|
|
622
622
|
// gives LinkedIn's share box, where you paste it and post it yourself.
|
|
623
623
|
async function cmdPost() {
|
|
624
|
-
const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since");
|
|
624
|
+
const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since" && all[i - 1] !== "--seconds");
|
|
625
625
|
const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
|
|
626
626
|
const show = (p) => {
|
|
627
627
|
console.log(`\n${c.b(POST_KIND[p.kind] || p.kind)} ${c.d(`${p.id} · ${p.status}${p.edited ? " · edited" : ""}`)}\n${p.text}`);
|
|
628
628
|
if (p.sources && p.sources.length) console.log(c.d(" from git: " + p.sources.slice(0, 4).map((s) => s.replace(/^\[\d+\]\s*/, "")).join("\n ") + (p.sources.length > 4 ? `\n …and ${p.sources.length - 4} more` : "")));
|
|
629
|
+
for (const m of p.media || []) console.log(c.d(` ${m.kind} ${m.id}: `) + join(postMediaDir(p.id), m.file) + c.d(m.url ? ` (${m.from === "clip" ? "clip" : "picture"} of ${m.url})` : ""));
|
|
630
|
+
if (p.show && !(p.media || []).length) console.log(c.d(" picture idea: " + p.show));
|
|
629
631
|
};
|
|
630
632
|
if (sub === "draft" || sub === "new") {
|
|
631
633
|
const r = await draftPosts({ days: SINCE_WEEK || 7 });
|
|
632
634
|
if (r.error) return fail(r.error);
|
|
633
635
|
for (const p of r.posts) show(p);
|
|
636
|
+
if (r.retried) console.log("\n" + c.d("The first try kept none (each claimed what git doesn't show), so Symbiot tried again by itself."));
|
|
637
|
+
if (r.pictures) console.log(c.d(`${r.pictures === 1 ? "One draft names" : `${r.pictures} drafts name`} a screen of an app you run here, so ${r.pictures === 1 ? "it has its picture" : "they have their pictures"} already: keep or remove (symbiot post remove <id> <media id>).`));
|
|
634
638
|
if (r.dropped.length) console.log("\n" + c.y(`Dropped ${r.dropped.length}: `) + r.dropped.map((x) => `${POST_KIND[x.kind] || x.kind} (${x.cited ? "claimed " + x.unsupported.join(", ") + ", which git doesn't show" : "cited nothing from git"})`).join("; "));
|
|
635
639
|
console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait on the app's Dashboard too.`));
|
|
636
640
|
console.log(c.d("Nothing is posted until you approve one: symbiot post approve <id> (or edit / skip)."));
|
|
@@ -648,10 +652,33 @@ async function cmdPost() {
|
|
|
648
652
|
console.log(`${c.g("✓")} Approved.\n\n${r.post.text}\n`);
|
|
649
653
|
console.log(r.copied ? `${c.g("✓")} Copied to your clipboard (${r.copied}).` : c.y("No clipboard tool found (wl-copy, xclip, xsel, pbcopy or clip): copy it from above."));
|
|
650
654
|
console.log(`Paste it into LinkedIn's share box: ${c.b(r.share)}` + (has("open") ? "" : c.d(" (--open opens it)")));
|
|
651
|
-
if (has("open")
|
|
655
|
+
if (r.files.length) console.log(`Then add ${r.files.length === 1 ? "this" : "these"} with its photo or video button:\n${r.files.map((f) => " " + f).join("\n")}` + (has("open") ? "" : c.d(" (--open opens their folder)")));
|
|
656
|
+
if (has("open")) { openUrl(r.share); if (r.folder) openUrl(r.folder); }
|
|
652
657
|
console.log(c.d(r.note));
|
|
653
658
|
return;
|
|
654
659
|
}
|
|
660
|
+
// pictures and video on a draft: a file of yours, or a picture or clip of a page
|
|
661
|
+
if (sub === "add") {
|
|
662
|
+
if (!a2) return fail("Give the draft and the file: symbiot post add <id> <picture or video>");
|
|
663
|
+
let data; try { data = readFileSync(a2); } catch (e) { return fail(`Can't read ${a2}: ${e.message}`); }
|
|
664
|
+
const r = addPostMedia(a1, data, { name: basename(a2) });
|
|
665
|
+
if (r.error) return fail(r.error);
|
|
666
|
+
show(r.post); return;
|
|
667
|
+
}
|
|
668
|
+
if (sub === "page") {
|
|
669
|
+
if (!a2) return fail("Give the draft and the page: symbiot post page <id> <web address> [--clip] [--seconds 8]");
|
|
670
|
+
const clip = has("clip"), i = argv.indexOf("--seconds"), seconds = i >= 0 ? Number(argv[i + 1]) || 8 : 8;
|
|
671
|
+
console.log(c.d(clip ? `Recording ${seconds} s of ${a2}…` : `Taking a picture of ${a2}…`));
|
|
672
|
+
// through the app when it runs: its hidden browser and this one share a profile
|
|
673
|
+
const r = (await viaApp("/api/posts/media/page", { id: a1, url: a2, clip, seconds })) || await (clip ? clipOfPage(a1, a2, { seconds }) : pictureOfPage(a1, a2));
|
|
674
|
+
if (r.error) return fail(r.error);
|
|
675
|
+
show(r.post); return;
|
|
676
|
+
}
|
|
677
|
+
if (sub === "unadd" || sub === "remove") {
|
|
678
|
+
const r = removePostMedia(a1, a2);
|
|
679
|
+
if (r.error) return fail(r.error);
|
|
680
|
+
console.log(`${c.g("✓")} Taken off ${r.post.id}.`); return;
|
|
681
|
+
}
|
|
655
682
|
if (sub === "edit") {
|
|
656
683
|
if (!a2) return fail('Give the new text: symbiot post edit <id> "the post"');
|
|
657
684
|
const r = editPost(a1, a2);
|
|
@@ -685,6 +712,14 @@ async function cmdPost() {
|
|
|
685
712
|
copy it to your clipboard, and give (--open:
|
|
686
713
|
open) LinkedIn's share box to paste it into
|
|
687
714
|
symbiot post edit <id> "text" change a draft's words
|
|
715
|
+
symbiot post add <id> <file> put a picture or video of yours on a draft
|
|
716
|
+
(PNG, JPG, GIF, MP4, MOV, WebM)
|
|
717
|
+
symbiot post page <id> <page> [--clip] [--seconds 8]
|
|
718
|
+
a picture of a web page (localhost too) on a
|
|
719
|
+
draft; --clip records a short video of it,
|
|
720
|
+
scrolling down (needs ffmpeg)
|
|
721
|
+
symbiot post remove <id> <media id>
|
|
722
|
+
take a picture or video off a draft
|
|
688
723
|
symbiot post skip <id> drop a draft
|
|
689
724
|
symbiot post log everything drafted, edited, approved, skipped
|
|
690
725
|
symbiot post voice [--linkedin] your example posts (voice.md), or read your
|