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/index.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import { gitDefaultBranch, loadDeploys, driftRepo, computeDrift } from "./drift.
|
|
|
38
38
|
import { buildTasksMd, taskType, shipChanges, shipWithBump, bumpOffer, learnNpm, releaseNeeded, withReleases, setVersion, syncTasks, pendingReview, unreleased, publishesOnMerge, addTask, approveRepo, approveChanges, sendBack, pushTasks } from "./tasks.mjs";
|
|
39
39
|
import { produce, mailState, setMail, sentMail } from "./writeups.mjs";
|
|
40
40
|
import { loadScreens, screenImage, blueprint } from "./screens.mjs";
|
|
41
|
-
import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
|
|
41
|
+
import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, uploadFiles, scrollPage, signIn, isTrusted } from "./headless.mjs";
|
|
42
42
|
import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
|
|
43
43
|
import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
|
|
44
44
|
import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
|
|
@@ -320,7 +320,7 @@ function screenJson(s) {
|
|
|
320
320
|
if (!s || s.error) return s;
|
|
321
321
|
const bp = blueprint(s), image = screenImage(s.id), sc = s.page && s.page.scroll;
|
|
322
322
|
const more = sc ? [sc.y > 0 && "above", sc.y < sc.max && "below"].filter(Boolean).join(" and ") : "";
|
|
323
|
-
return { id: s.id, ...(image ? { image } : {}), ...(s.note ? { note: s.note } : {}), ...(more ? { more } : {}), ...(s.scrolled ? { scrolled: s.scrolled } : {}), ...(s.pressed ? { pressed: s.pressed, found: s.found } : {}), ...(s.typed ? { typed: s.typed, entered: s.entered, found: s.found } : {}), ...(s.kept ? { kept: true } : {}), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}), ...bp, regions: bp.regions.map((r, i) => ({ id: s.regions[i].id, ...r })) };
|
|
323
|
+
return { id: s.id, ...(image ? { image } : {}), ...(s.note ? { note: s.note } : {}), ...(more ? { more } : {}), ...(s.scrolled ? { scrolled: s.scrolled } : {}), ...(s.pressed ? { pressed: s.pressed, found: s.found } : {}), ...(s.typed ? { typed: s.typed, entered: s.entered, found: s.found } : {}), ...(s.uploaded ? { uploaded: s.uploaded, files: s.files, used: s.used, found: s.found } : {}), ...(s.kept ? { kept: true } : {}), ...(s.page ? { trusted: isTrusted(s.page.url) } : {}), ...bp, regions: bp.regions.map((r, i) => ({ id: s.regions[i].id, ...r })) };
|
|
324
324
|
}
|
|
325
325
|
|
|
326
326
|
// map, press, type and signin go through the app when it's running, whose hidden
|
|
@@ -389,7 +389,7 @@ async function cmdUninstall() {
|
|
|
389
389
|
if (existsSync(CONFIG_DIR) && !keep) console.log(c.d("(Something wrote to " + CONFIG_DIR + " meanwhile; delete it if you like.)"));
|
|
390
390
|
}
|
|
391
391
|
async function cmdScreens() {
|
|
392
|
-
const [sub = "list", a1, a2, a3] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--name");
|
|
392
|
+
const [sub = "list", a1, a2, a3, ...more] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--name");
|
|
393
393
|
const out = (x) => { console.log(JSON.stringify(x, null, 2)); if (x && x.error) process.exitCode = 1; };
|
|
394
394
|
const find = (id) => loadScreens().find((s) => s.id === id);
|
|
395
395
|
if (sub === "list") {
|
|
@@ -410,14 +410,17 @@ async function cmdScreens() {
|
|
|
410
410
|
}
|
|
411
411
|
if (sub === "show") { const s = find(a1); return out(s ? screenJson(s) : { error: "No screen " + (a1 || "") + ". symbiot screens lists them." }); }
|
|
412
412
|
if (sub === "signin") { const r = (await viaApp("/api/screens/signin", { site: a1 })) || await signIn(a1); return out(r.ok ? { ...r, next: "Sign in in the window that opened, close it, then map again." } : r); }
|
|
413
|
-
if (sub === "press" || sub === "type") {
|
|
413
|
+
if (sub === "press" || sub === "type" || sub === "upload") {
|
|
414
414
|
const s = find(a1); if (!s) return out({ error: "No screen " + (a1 || "") + ". symbiot screens lists them." });
|
|
415
415
|
const want = String(a2 || "").toLowerCase(), rs = s.regions || [];
|
|
416
416
|
const r = rs.find((x) => x.id === a2) || rs.find((x) => x.label.toLowerCase() === want) || (rs.filter((x) => x.label.toLowerCase().includes(want)).length === 1 && rs.find((x) => x.label.toLowerCase().includes(want)));
|
|
417
417
|
if (!want || !r) return out({ error: `No region "${a2 || ""}" on that screen (give its id, or a label that matches one region).` });
|
|
418
418
|
// a draft reply's agent (SYMBIOT_DRAFT, watch.mjs) never presses Send, or Enter (it sends in a chat)
|
|
419
|
-
|
|
420
|
-
const
|
|
419
|
+
// upload: the files resolved and checked here, where a relative path means something
|
|
420
|
+
const f = sub === "upload" ? uploadFiles([a3, ...more]) : null; if (f && f.error) return out(f);
|
|
421
|
+
const body = { id: s.id, region: r.id, confirmed: has("yes"), noSend: !!process.env.SYMBIOT_DRAFT, ...(sub === "type" ? { text: a3, enter: has("enter") } : {}), ...(f ? { files: f.files } : {}) };
|
|
422
|
+
const done = (await viaApp("/api/screens/" + sub, body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend }) : sub === "type" ? await typeRegion(s.id, r.id, a3, { enter: body.enter, confirmed: body.confirmed, noSend: body.noSend }) : await uploadRegion(s.id, r.id, f.files, { confirmed: body.confirmed }));
|
|
423
|
+
if (f && done && /answered 404/.test(done.error || "")) return out({ error: "The Symbiot app that's running is older than this command and can't upload: restart it (symbiot app), then try again." });
|
|
421
424
|
// not a trusted site: say how to go ahead (only you can trust a site, in the app's Settings)
|
|
422
425
|
if (done && done.confirm) return out({ error: `${done.error} Add --yes to go ahead, or list ${done.host} under Trusted sites in Symbiot's Settings.` });
|
|
423
426
|
return out(screenJson(done));
|
|
@@ -432,6 +435,11 @@ async function cmdScreens() {
|
|
|
432
435
|
symbiot screens press <id> <region> [--yes] press a region there, map where it lands
|
|
433
436
|
symbiot screens type <id> <field> "text" [--enter] [--yes]
|
|
434
437
|
type into a field (Enter sends it), map the result
|
|
438
|
+
symbiot screens upload <id> <field> <file> [<file>…] [--yes]
|
|
439
|
+
put a file in the page's file box (a picture or
|
|
440
|
+
video for a post): <field> is the box, or the
|
|
441
|
+
button it hides behind ("Add media"); says which
|
|
442
|
+
box it used, maps the result
|
|
435
443
|
symbiot screens scroll <id> [down|up|top|bottom]
|
|
436
444
|
scroll the page, map what's in the window then
|
|
437
445
|
(a map's "more" says there's more below or above)
|
|
@@ -439,7 +447,7 @@ async function cmdScreens() {
|
|
|
439
447
|
(where a list scrolls inside the page, like
|
|
440
448
|
Gmail's mail, that list opened out)
|
|
441
449
|
symbiot screens signin <site> sign in once, in Symbiot's browser window
|
|
442
|
-
--yes is needed unless the page's site is under Trusted sites in the app's Settings.
|
|
450
|
+
--yes is needed (press, type, upload) unless the page's site is under Trusted sites in the app's Settings.
|
|
443
451
|
While the app runs, these use its hidden browser, which stays open a few minutes:
|
|
444
452
|
press on the screen the last command printed carries on from that page as it is
|
|
445
453
|
(type without --enter, then press the form's button).`);
|
|
@@ -785,6 +793,47 @@ async function cmdPost() {
|
|
|
785
793
|
if (sub !== "help") process.exitCode = 1;
|
|
786
794
|
}
|
|
787
795
|
|
|
796
|
+
// ---- `symbiot marketing`: a draft of Marketing's lane, marked posted or superseded ----
|
|
797
|
+
// What its agent runs once a post is out, or redone in a new file (marketing.mjs): either
|
|
798
|
+
// takes it off the Marketing orb for good, and it can't be approved or posted again.
|
|
799
|
+
async function cmdMarketing() {
|
|
800
|
+
const VAL = ["--url", "--on", "--where", "--at", "--by"];
|
|
801
|
+
const [sub = "help", rel, ...more] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && !VAL.includes(all[i - 1]));
|
|
802
|
+
const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
|
|
803
|
+
const M = await import("./marketing.mjs");
|
|
804
|
+
if (sub === "posted" || sub === "superseded") {
|
|
805
|
+
if (!rel) return fail(`Give the draft: symbiot marketing ${sub} drafts/<product>/<post>.md`);
|
|
806
|
+
const r = M.setDraftStatus(rel, sub, { url: flag("url", ""), on: flag("on", flag("where", "")), at: flag("at", ""), by: flag("by", "") });
|
|
807
|
+
if (r.error) return fail(r.error);
|
|
808
|
+
if (r.already) { console.log(c.d(r.said)); return; }
|
|
809
|
+
const s = M.draftStatuses()[r.rel] || {};
|
|
810
|
+
console.log(`${c.g("✓")} ${r.rel}: ${sub}${s.postedOn ? ` on ${s.postedOn}` : ""}${s.posted ? `, ${M.day(s.posted)}` : ""}${s.url ? ` (${s.url})` : ""}${(s.by || []).length ? ` by ${s.by.join(", ")}` : ""}. It's off the Marketing orb, and can't be approved or posted again.`);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (sub === "media") {
|
|
814
|
+
if (!rel) return fail("Give the draft and its pictures or video: symbiot marketing media <draft> <file>…");
|
|
815
|
+
const r = M.setDraftMedia(rel, more);
|
|
816
|
+
if (r.error) return fail(r.error);
|
|
817
|
+
console.log(`${c.g("✓")} ${r.rel}: ${r.media.length ? "media: " + r.media.join(", ") : "no media"}${r.reopened ? c.y(" (it was approved with another: it asks for the user's OK again)") : ""}`); return;
|
|
818
|
+
}
|
|
819
|
+
if (sub === "list" || sub === "status") {
|
|
820
|
+
const fs = M.draftFiles(M.MARKETING_DIR, [], { max: 200 }), all = M.draftStatuses();
|
|
821
|
+
if (!fs.length) console.log(c.d("No drafts in Marketing's lane yet."));
|
|
822
|
+
for (const f of fs) { const s = all[f.rel] || {}; console.log(`${(f.status || "waiting").padEnd(10)} ${f.rel}${s.url ? c.d(" " + s.url) : ""}${(s.by || []).length ? c.d(" by " + [].concat(s.by).join(", ")) : ""}`); }
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
console.log(`${c.b("symbiot marketing")} ${c.d("— Marketing's drafts (drafts/<product>/<post>.md in its lane)")}
|
|
826
|
+
symbiot marketing list each draft and its status
|
|
827
|
+
symbiot marketing posted <draft> [--url <link>] [--on linkedin] [--at "YYYY-MM-DD HH:MM"]
|
|
828
|
+
it's out (or in the platform's scheduler)
|
|
829
|
+
symbiot marketing superseded <draft> [--by <new draft>[,<another>]]
|
|
830
|
+
a new draft replaces it
|
|
831
|
+
symbiot marketing media <draft> <file>… its picture(s) or video, by path in the lane
|
|
832
|
+
Posted and superseded take a draft off the Marketing orb for good: it can't be
|
|
833
|
+
approved or posted again.`);
|
|
834
|
+
if (sub !== "help") process.exitCode = 1;
|
|
835
|
+
}
|
|
836
|
+
|
|
788
837
|
// ---- `symbiot reports`: what agents wrote up for you (reports.mjs) ---------------
|
|
789
838
|
function cmdReports() {
|
|
790
839
|
const id = argv.slice(1).find((x) => !x.startsWith("--"));
|
|
@@ -834,6 +883,9 @@ ${c.b("Experimental")}
|
|
|
834
883
|
symbiot post draft 3 LinkedIn posts from this week's git,
|
|
835
884
|
in your voice; never posts by itself
|
|
836
885
|
(symbiot post help for more)
|
|
886
|
+
symbiot marketing posted <draft> --url <link>
|
|
887
|
+
mark a Marketing draft posted (or superseded);
|
|
888
|
+
symbiot marketing help for more
|
|
837
889
|
symbiot screens map <site> map a web page's buttons in a hidden browser
|
|
838
890
|
(symbiot screens help for more)
|
|
839
891
|
symbiot watch add <screen id> keep track of a mapped page: what's new on it
|
|
@@ -885,6 +937,7 @@ async function main() {
|
|
|
885
937
|
if (cmd === "watch") return cmdWatch();
|
|
886
938
|
if (cmd === "phone") return cmdPhone();
|
|
887
939
|
if (cmd === "post" || cmd === "posts") return cmdPost();
|
|
940
|
+
if (cmd === "marketing") return cmdMarketing();
|
|
888
941
|
if (cmd === "knowledge" || cmd === "know") return cmdKnowledge();
|
|
889
942
|
if (cmd === "reports" || cmd === "report") return cmdReports();
|
|
890
943
|
if (cmd === "week")return cmdRun("week");
|
package/lanes.mjs
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
// The ledger, ~/.config/symbiot/lanes.json: { handoffs: [{ id, key, from: { lane,
|
|
13
13
|
// path }, to: { lane, path }, text, at, chain, status, job?, task?, error?,
|
|
14
14
|
// result?, reportedAt? }] }. status: started, held (that lane's agent was busy:
|
|
15
|
-
// it starts when it's free),
|
|
15
|
+
// it starts when it's free), blocked (it didn't start, and nothing running there will
|
|
16
|
+
// start it: note says why), done (reported back) or error (reported back too).
|
|
16
17
|
// chain: how many handovers in a row led here; past MAX_CHAIN it stops, so two
|
|
17
18
|
// lanes can't hand the same thing back and forth forever.
|
|
18
19
|
import { join, basename, dirname, resolve, relative } from "node:path";
|
|
@@ -50,6 +51,9 @@ function laneTarget(lane, map) {
|
|
|
50
51
|
const name = Object.keys(map).find((n) => n.toLowerCase() === lane.toLowerCase());
|
|
51
52
|
return name ? { lane: name, path: map[name] } : null;
|
|
52
53
|
}
|
|
54
|
+
// Where a handover to a repo stands once its agent was asked to start (runHandoff's
|
|
55
|
+
// result): started; held behind a run that's really going there; or blocked, with why.
|
|
56
|
+
const startedAs = (r) => r.id && !r.busy && !r.blocked ? { status: "started", job: r.id } : r.busy ? { status: "held", ...(r.id ? { job: r.id } : {}) } : { status: "blocked", note: String(r.note || "its agent didn't start").slice(0, 200) };
|
|
53
57
|
|
|
54
58
|
// A handover whose text changed while the run doing the earlier version is still
|
|
55
59
|
// going (the LinkedIn auto-poster spec reached ops twice, on top of a run already
|
|
@@ -62,7 +66,7 @@ function laneTarget(lane, map) {
|
|
|
62
66
|
// back, about the same thing (alike), and its run still going (ops) or its task
|
|
63
67
|
// still open (a repo). null when there's none.
|
|
64
68
|
function supersedes(h, from, ledger, { running, tasks }) {
|
|
65
|
-
return ledger.handoffs.slice().reverse().find((x) => x.from.path === from.path && x.to.lane.toLowerCase() === h.lane.toLowerCase() && !x.reportedAt && (x.status === "started" || x.status === "held") && x.text !== h.text && alike(x.text, h.text)
|
|
69
|
+
return ledger.handoffs.slice().reverse().find((x) => x.from.path === from.path && x.to.lane.toLowerCase() === h.lane.toLowerCase() && !x.reportedAt && (x.status === "started" || x.status === "held" || x.status === "blocked") && x.text !== h.text && alike(x.text, h.text)
|
|
66
70
|
&& (x.to.lane === OPS ? !!x.to.path && !!running(x.to.path) : !!x.task && tasks().some((t) => t.id === x.task && !t.done && !t.archived && !t.review))) || null;
|
|
67
71
|
}
|
|
68
72
|
function resupply(e, text, { now, title, context }) {
|
|
@@ -97,7 +101,7 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
|
|
|
97
101
|
if (t.error) { Object.assign(prior, { status: "error", error: t.error, text: h.text, key, was }); started.push(prior); continue; }
|
|
98
102
|
push({ repo: prior.to.lane });
|
|
99
103
|
const r = run(prior.to.path) || {};
|
|
100
|
-
Object.assign(prior, { text: h.text, key, was, at: now, task: t.id, superseded: (prior.superseded || 0) + 1,
|
|
104
|
+
Object.assign(prior, { text: h.text, key, was, at: now, task: t.id, superseded: (prior.superseded || 0) + 1, ...startedAs(r) });
|
|
101
105
|
}
|
|
102
106
|
started.push(prior); continue;
|
|
103
107
|
}
|
|
@@ -116,7 +120,7 @@ function dispatch(path, { map = laneMap(), act = actNow, run = runHandoff, add =
|
|
|
116
120
|
else {
|
|
117
121
|
push({ repo: to.lane });
|
|
118
122
|
const r = run(to.path) || {};
|
|
119
|
-
Object.assign(e, { to, task: t.id,
|
|
123
|
+
Object.assign(e, { to, task: t.id, ...startedAs(r) });
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
126
|
ledger.handoffs.push(e); started.push(e);
|
|
@@ -342,7 +346,7 @@ function lanesState() {
|
|
|
342
346
|
for (const e of loadLedger().handoffs.slice().reverse()) {
|
|
343
347
|
const k = [e.from.lane, e.to.lane, firstLine(e.text).replace(/\s+/g, " ").trim()].join("\n").toLowerCase();
|
|
344
348
|
if (byKey.has(k)) { byKey.get(k).times++; continue; }
|
|
345
|
-
const row = { id: e.id, from: e.from.lane, to: e.to.lane, text: clipWords(firstLine(e.text), 160), ...(e.text.trim() !== firstLine(e.text).trim() || e.text.length > 160 ? { full: e.text } : {}), at: e.at, status: e.status, ...(e.error ? { error: e.error } : {}), ...(e.result ? { result: e.result } : {}), times: 1 };
|
|
349
|
+
const row = { id: e.id, from: e.from.lane, to: e.to.lane, text: clipWords(firstLine(e.text), 160), ...(e.text.trim() !== firstLine(e.text).trim() || e.text.length > 160 ? { full: e.text } : {}), at: e.at, status: (e.status === "held" || e.status === "blocked") && e.to.path && lastRunStart(e.to.path) >= e.at ? "started" : e.status, ...(e.status === "blocked" && e.note ? { note: e.note } : {}), ...(e.error ? { error: e.error } : {}), ...(e.result ? { result: e.result } : {}), times: 1 };
|
|
346
350
|
byKey.set(k, row); out.push(row);
|
|
347
351
|
}
|
|
348
352
|
return { handoffs: out.slice(0, 30) };
|
package/marketing.mjs
CHANGED
|
@@ -184,17 +184,61 @@ function inLane(dir, rel) {
|
|
|
184
184
|
const sigOf = (body) => createHash("sha256").update(String(body || "")).digest("hex").slice(0, 16);
|
|
185
185
|
const STATUS = ".symbiot/drafts.json";
|
|
186
186
|
function draftStatuses(dir = MARKETING_DIR) { try { const d = JSON.parse(readFileSync(join(dir, STATUS), "utf8")); return d && typeof d === "object" ? d : {}; } catch { return {}; } }
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
|
|
187
|
+
// Posted and superseded are for good: an edit after either doesn't reopen it (approved and
|
|
188
|
+
// skipped are of a text, so a changed post asks again). One its agent marked posted by
|
|
189
|
+
// hand before there was a status for it ({ status: "approved", posted: <ms> }) is posted.
|
|
190
|
+
const FINAL = ["posted", "superseded"];
|
|
191
|
+
const stateOf = (s) => (!s ? "" : s.status === "posted" || s.posted ? "posted" : String(s.status || ""));
|
|
192
|
+
function statusOf(dir, rel, body) {
|
|
193
|
+
const s = draftStatuses(dir)[rel], st = stateOf(s);
|
|
194
|
+
return FINAL.includes(st) ? { ...s, status: st, edited: s.sig !== sigOf(body) } : s && s.sig === sigOf(body) ? s : null;
|
|
195
|
+
}
|
|
196
|
+
// a moment as people read it here: local YYYY-MM-DD HH:MM (what --at takes)
|
|
197
|
+
const day = (ms) => { const d = new Date(ms), p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; };
|
|
198
|
+
// Why a posted or superseded draft can't be approved, skipped or posted again, in a line.
|
|
199
|
+
function finalSay(s) {
|
|
200
|
+
if (stateOf(s) === "posted") return `It's posted already${s.postedOn ? ` on ${s.postedOn}` : ""}${s.posted ? `, ${day(s.posted)}` : ""}${s.url ? ` (${s.url})` : ""}: it can't be approved or posted again.`;
|
|
201
|
+
const by = [].concat(s.by || []);
|
|
202
|
+
return `It was superseded${by.length ? ` by ${by.join(", ")}` : ""}: ${by.length ? "approve that one" : "approve the one that replaced it"} instead.`;
|
|
203
|
+
}
|
|
204
|
+
// A draft's path in the lane, as given: lane-relative, ./-relative, or absolute inside it.
|
|
205
|
+
function draftRel(p, dir = MARKETING_DIR) {
|
|
206
|
+
p = String(p || "").trim(); if (!p.startsWith("/")) return p.replace(/^\.\//, "");
|
|
207
|
+
try { const root = realpathSync(dir), r = relative(root, realpathSync(p)); return r && !r.startsWith("..") ? r : p; } catch { return p; }
|
|
208
|
+
}
|
|
209
|
+
// Approve or skip a draft, mark it posted ({ url, on, at }: its link, where, when; default
|
|
210
|
+
// now on its platform) or superseded ({ by }: the draft or drafts that replace it). Posted
|
|
211
|
+
// and superseded take it off the Marketing orb for good. { ok, status, draft } or { error }.
|
|
212
|
+
function setDraftStatus(rel, status, { dir = MARKETING_DIR, now = Date.now(), url = "", on = "", at = "", by = [] } = {}) {
|
|
213
|
+
rel = draftRel(rel, dir);
|
|
190
214
|
const f = inLane(dir, rel); if (!f || !/\.md$/i.test(f)) return { error: "That draft isn't there any more." };
|
|
191
|
-
if (!["approved", "skipped"].includes(status)) return { error: "Approve or skip it." };
|
|
215
|
+
if (!["approved", "skipped", ...FINAL].includes(status)) return { error: "Approve or skip it, or mark it posted or superseded." };
|
|
192
216
|
const d = parseDraft(readFileSync(f, "utf8"));
|
|
193
|
-
if (!d.body) return { error: "That draft has no post in it yet." };
|
|
194
|
-
const all = draftStatuses(dir)
|
|
217
|
+
if (!d.body && status !== "superseded") return { error: "That draft has no post in it yet." };
|
|
218
|
+
const all = draftStatuses(dir), prev = all[rel], was = stateOf(prev);
|
|
219
|
+
// posted is the end of it; superseded can still be marked posted (what went out, said)
|
|
220
|
+
if (was === "posted" && status === "posted") {
|
|
221
|
+
if (!prev.url && /^https?:\/\/\S+$/i.test(String(url || "").trim())) { prev.url = String(url).trim(); try { writeFileSync(join(dir, STATUS), JSON.stringify(all, null, 2)); } catch {} } // its link, said late
|
|
222
|
+
return { ok: true, status, already: true, draft: d, rel, said: finalSay(prev) };
|
|
223
|
+
}
|
|
224
|
+
if (was === "posted" || (was === "superseded" && status !== "posted" && status !== "superseded")) return { error: finalSay(prev) };
|
|
225
|
+
let entry = { status, sig: sigOf(d.body), at: now };
|
|
226
|
+
if (status === "posted") {
|
|
227
|
+
url = String(url || "").trim(); if (url && !/^https?:\/\/\S+$/i.test(url)) return { error: "Its link is the post's web address (https://…)." };
|
|
228
|
+
const when = at ? (typeof at === "number" ? at : Date.parse(String(at).replace(" ", "T"))) : now; if (!Number.isFinite(when)) return { error: `"${at}" isn't a date (YYYY-MM-DD HH:MM).` };
|
|
229
|
+
entry = { ...entry, posted: when, postedOn: String(on || d.platform || "").toLowerCase(), ...(url ? { url } : {}) };
|
|
230
|
+
}
|
|
231
|
+
if (status === "superseded") {
|
|
232
|
+
const list = [].concat(by || []).flatMap((x) => String(x || "").split(",")).map((x) => draftRel(x, dir)).filter(Boolean);
|
|
233
|
+
const bad = list.find((x) => x === rel || !/\.md$/i.test(x) || !inLane(dir, x)); if (bad) return { error: bad === rel ? "A draft can't replace itself." : `${bad} isn't a draft in the lane.` };
|
|
234
|
+
if (list.length) entry.by = [...new Set(list)];
|
|
235
|
+
}
|
|
236
|
+
all[rel] = entry;
|
|
195
237
|
try { mkdirSync(join(dir, ".symbiot"), { recursive: true }); writeFileSync(join(dir, STATUS), JSON.stringify(all, null, 2)); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
|
|
196
|
-
return { ok: true, status, draft: d };
|
|
238
|
+
return { ok: true, status, draft: d, rel };
|
|
197
239
|
}
|
|
240
|
+
// What its agent runs once a post is out (or in the platform's scheduler), so it leaves the orb.
|
|
241
|
+
const postedCmd = (rel) => `node "${CLI}" marketing posted "${rel}" --url <the post's link>`;
|
|
198
242
|
// Everything the Marketing page needs to show a draft as the platform's post: who it's
|
|
199
243
|
// from, the text whole and cut where the feed cuts it, its hashtags, its picture or
|
|
200
244
|
// video (each a file next to it), its notes apart, and whether you approved it.
|
|
@@ -204,13 +248,43 @@ function draftPreview(rel, { dir = MARKETING_DIR, cfg } = {}) {
|
|
|
204
248
|
const media = d.media.map((m) => { const r = join(base, m), ext = m.split(".").pop().toLowerCase(); return inLane(dir, r) && MEDIA_TYPES[ext] ? { rel: r, name: basename(m), kind: MEDIA_TYPES[ext].startsWith("video") ? "video" : "image" } : null; }).filter(Boolean);
|
|
205
249
|
const st = statusOf(dir, rel, d.body);
|
|
206
250
|
return { rel, title: d.title, product: d.product, platform: d.platform, when: d.when, body: d.body, ...seeMore(d.body), hashtags: d.body.match(/#[\p{L}\p{N}_]+/gu) || [], chars: d.body.length,
|
|
207
|
-
media, missing: d.media.filter((m) => !media.some((x) => x.name === basename(m))), notes: d.notes, format: d.format, author: author({ cfg, dir }), status: st ? st.status : "", statusAt: st ? st.at : 0
|
|
251
|
+
media, missing: d.media.filter((m) => !media.some((x) => x.name === basename(m))), notes: d.notes, format: d.format, author: author({ cfg, dir }), status: st ? st.status : "", statusAt: st ? st.at : 0,
|
|
252
|
+
...(st && FINAL.includes(st.status) ? { final: finalSay(st), posted: st.posted || 0, postedOn: st.postedOn || "", url: st.url || "", by: [].concat(st.by || []), edited: !!st.edited } : {}) };
|
|
208
253
|
}
|
|
209
254
|
// A picture or video a draft shows, or the avatar in config.profile: { file, type } or null.
|
|
210
255
|
function laneMedia(rel, { dir = MARKETING_DIR, cfg = loadConfig() } = {}) {
|
|
211
256
|
const f = rel === "avatar" ? String(((cfg && cfg.profile) || {}).avatar || "") : inLane(dir, rel), type = f && MEDIA_TYPES[f.split(".").pop().toLowerCase()];
|
|
212
257
|
return type && existsSync(f) ? { file: f, type } : null;
|
|
213
258
|
}
|
|
259
|
+
// A post's picture or video, picked (the pick tray, tray.mjs): its `media:` line set to the
|
|
260
|
+
// files, by their path in the lane, written next to the draft's own (`media: tray/home.png`);
|
|
261
|
+
// the post itself untouched. Approved before, it asks for your OK again: what you approved
|
|
262
|
+
// showed another picture. Not on one posted or superseded. { ok, media, reopened } or { error }.
|
|
263
|
+
const MEDIA_LINE = /^\s*(?:media|image|video|picture)\s*:/i;
|
|
264
|
+
function setDraftMedia(rel, files, { dir = MARKETING_DIR } = {}) {
|
|
265
|
+
rel = draftRel(rel, dir);
|
|
266
|
+
const f = inLane(dir, rel); if (!f || !/\.md$/i.test(f)) return { error: "That draft isn't there any more." };
|
|
267
|
+
const all = draftStatuses(dir), prev = all[rel]; if (FINAL.includes(stateOf(prev))) return { error: finalSay(prev) };
|
|
268
|
+
const media = [];
|
|
269
|
+
for (const m of [].concat(files || []).map((x) => draftRel(x, dir)).filter(Boolean)) {
|
|
270
|
+
const p = inLane(dir, m), type = p && MEDIA_TYPES[p.split(".").pop().toLowerCase()];
|
|
271
|
+
if (!type) return { error: `${m} isn't a picture or video in the lane.` };
|
|
272
|
+
media.push({ name: relative(dirname(f), p), video: type.startsWith("video") });
|
|
273
|
+
}
|
|
274
|
+
if (media.length > 1 && media.some((m) => m.video)) return { error: "A post takes one video, or pictures, not both." };
|
|
275
|
+
const src = readFileSync(f, "utf8"), lines = src.split(/\r?\n/);
|
|
276
|
+
let end = lines.findIndex((l) => /^##\s/.test(l) || /^\s*-{3,}\s*$/.test(l)); if (end < 0) end = lines.length;
|
|
277
|
+
const head = lines.slice(0, end), at = head.findIndex((l) => MEDIA_LINE.test(l)), kept = head.filter((l) => !MEDIA_LINE.test(l));
|
|
278
|
+
// where it goes: where it was, else after the head's last key line (product:, when:…), else the title
|
|
279
|
+
let i = at >= 0 ? head.slice(0, at).filter((l) => !MEDIA_LINE.test(l)).length : -1;
|
|
280
|
+
if (i < 0) { kept.forEach((l, k) => { const m = l.match(/^\s*([a-z]+)\s*:/i); if ((m && HEAD_KEYS[m[1].toLowerCase()]) || (/^#\s/.test(l) && i < 0)) i = k + 1; }); if (i < 0) i = 0; }
|
|
281
|
+
const line = media.length ? `media: ${media.map((m) => m.name).join(", ")}` : "";
|
|
282
|
+
if (head.filter((l) => MEDIA_LINE.test(l)).join("\n") === line) return { ok: true, rel, media: media.map((m) => m.name), reopened: false, same: true };
|
|
283
|
+
if (line) kept.splice(i, 0, line);
|
|
284
|
+
try { writeFileSync(f, [...kept, ...lines.slice(end)].join(src.includes("\r\n") ? "\r\n" : "\n")); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
|
|
285
|
+
const reopened = stateOf(prev) === "approved"; if (reopened) { delete all[rel]; try { writeFileSync(join(dir, STATUS), JSON.stringify(all, null, 2)); } catch {} }
|
|
286
|
+
return { ok: true, rel, media: media.map((m) => m.name), reopened };
|
|
287
|
+
}
|
|
214
288
|
|
|
215
289
|
// ---- its brief ----------------------------------------------------------------------------
|
|
216
290
|
// The lines TASKS.md gets for this lane (tasks.mjs buildTasksMd): what it is, the products
|
|
@@ -226,8 +300,9 @@ function marketingBrief(list = [], { map = {}, names = productNames(map) } = {})
|
|
|
226
300
|
`- Write each piece as a \`.md\` under \`${DRAFTS}/<product>/\` (\`${DRAFTS}/dailify/launch-post.md\`), a \`# \` title first, then \`product: <Product>\`, so the Marketing page tags it. Its screenshots and clips go next to it.`,
|
|
227
301
|
"- Every post opens with a strong hook (its first line written for reach), says plainly what the product is, and states its end goal. Where the end goal isn't written down, draft one and flag it in QUESTIONS.md for the user to confirm.",
|
|
228
302
|
`- Each draft holds the post apart from your notes: a \`# \` title, then \`product:\`, \`platform:\` (linkedin), \`when:\` (YYYY-MM-DD HH:MM, if it's scheduled) and \`media:\` (its picture or video, next to it) lines, then \`## Post\` with exactly the text that goes out (its line breaks, its hashtags) and nothing else, then \`## Notes\` for your reasoning, sources and anything for the user. Symbiot shows \`## Post\` as the platform's own preview, and what you post is that text, unchanged.`,
|
|
229
|
-
`- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\`), with the approved text and media, then check it's there.`
|
|
230
|
-
+ " A post not yet approved is a question in QUESTIONS.md with a 🤖 Agent: option (\"🤖 Agent: post it on Tuesday at 08:00, once you approve its preview\"), and what to check first in its context line. Never offer doing it by hand, and never ask them to sign in to or link a platform that's linked: use its session. Only signing in (a platform not linked yet, or a real attempt found its session expired: say so) is the user's: `👤 You (only you: signing in to LinkedIn)`. Never sign in as them, and never post what they haven't approved."
|
|
303
|
+
`- The user's only step is approving each post's preview under Marketing in Symbiot: never ask them to post, schedule, paste or attach anything themselves, and never mark that as a 👤 step. Symbiot's browser is signed in to the platforms they linked (LinkedIn among them): once a post is approved (ANSWERS.md says "Approved: <its file>"), you post it, or schedule it in the platform's own scheduler for its \`when:\`, through that browser (\`node "${CLI}" screens map <the platform's page>\`, then \`screens type\` and \`screens press\`, and \`screens upload <id> "<its Add media button>" <file>\` for its pictures or video), with the approved text and media, then check it's there.`
|
|
304
|
+
+ " A post not yet approved is a question in QUESTIONS.md with a 🤖 Agent: option (\"🤖 Agent: post it on Tuesday at 08:00, once you approve its preview\"), and what to check first in its context line. Never offer doing it by hand, and never ask them to sign in to or link a platform that's linked: use its session. Only signing in (a platform not linked yet, or a real attempt found its session expired: say so) is the user's: `👤 You (only you: signing in to LinkedIn)`. Never sign in as them, and never post what they haven't approved.",
|
|
305
|
+
`- Once a post is out (or in the platform's scheduler), mark it posted with its link: \`node "${CLI}" marketing posted <its file> --url <the post's link>\` (\`--at "YYYY-MM-DD HH:MM"\` for a scheduled one). A post you redo in a new file: mark the old one \`node "${CLI}" marketing superseded <old file> --by <new file>\`. Either takes it off the user's list for good, and Symbiot then refuses to approve or post it again: never edit \`.symbiot/drafts.json\` by hand.`];
|
|
231
306
|
}
|
|
232
307
|
|
|
233
|
-
export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia };
|
|
308
|
+
export { MARKETING, MARKETING_DIR, displayName, productNames, ensureMarketing, productOf, untagged, tagged, MARKETING_WORDS, draftFiles, marketingBrief, parseDraft, seeMore, SEE_MORE, draftPreview, setDraftStatus, draftStatuses, laneMedia, draftRel, setDraftMedia, postedCmd, MEDIA_TYPES, inLane, day };
|
package/mind.mjs
CHANGED
|
@@ -22,7 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { randomBytes } from "node:crypto";
|
|
23
23
|
import { VERSION, CONFIG_DIR, clipWords } from "./core.mjs";
|
|
24
24
|
import { write } from "./ai.mjs";
|
|
25
|
-
import { handoffCmd, runHandoff, isUrgent, urgentFirst } from "./agents.mjs";
|
|
25
|
+
import { handoffCmd, runHandoff, isUrgent, urgentFirst, urgentUndo } from "./agents.mjs";
|
|
26
26
|
import { addTask, pushTasks } from "./tasks.mjs";
|
|
27
27
|
import { laneMap } from "./scan.mjs";
|
|
28
28
|
import { OPS, MARKETING, HANDOVER_MAX, handoverRules, ONLY_YOU, HANDBACK } from "./handover.mjs";
|
|
@@ -150,25 +150,63 @@ function actNow(request, { title, context, known, run = runHandoff, now = Date.n
|
|
|
150
150
|
} catch (e) { return { error: "Couldn't write the brief: " + ((e && e.message) || e) }; }
|
|
151
151
|
const e = run(dir, { force: true });
|
|
152
152
|
if (!e) return { error: "Your agent didn't start. Check its command in Settings → Handoff." };
|
|
153
|
+
if (e.blocked || e.busy) return { error: `Your agent didn't start: ${e.note || "something held it back"}` }; // never "handed over" when nothing runs
|
|
153
154
|
return { ok: true, job: e.id, dir };
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
// ---- one voice --------------------------------------------------------------------
|
|
157
158
|
const IDENTITY = `You are Symbiot, the one assistant in the user's Symbiot app. The Dashboard's cards, the Tasks list and the rest are only places they talk to you: it's all you, and you remember across them (see "What you know" and "Lately, elsewhere in the app").`;
|
|
159
|
+
// The pages a chat can open (Home's: converse({ nav: true })), as the app names them,
|
|
160
|
+
// with what's on each, so a vague "where are my updates" lands somewhere sensible.
|
|
161
|
+
// "lane:<name>" is a lane's own page (its tasks and agent).
|
|
162
|
+
const PAGES = {
|
|
163
|
+
home: ["Home", "what only they can do, what's new on what they watch, agents at work"],
|
|
164
|
+
board: ["Dashboard", "the cards of what they watch (Gmail, GitHub, WhatsApp, LinkedIn…) and what's new on each"],
|
|
165
|
+
tasks: ["Workdesk", "every lane's tasks and agents: what's running, waiting, done for review, agents' questions"],
|
|
166
|
+
reports: ["Reports", "what agents wrote up for them to read (audits, findings, plans)"],
|
|
167
|
+
marketing: ["Marketing", "the marketing lane: drafts of posts to approve, its agent"],
|
|
168
|
+
week: ["Week", "their week, written up from their real work"],
|
|
169
|
+
standup: ["Standup", "a standup from yesterday's work"],
|
|
170
|
+
todo: ["Todo", "TODOs and FIXMEs found in their code"],
|
|
171
|
+
drift: ["Drift", "uncommitted, unpushed and stale work across repos"],
|
|
172
|
+
map: ["Map", "their repos as a map"],
|
|
173
|
+
settings: ["Settings", "the AI, their agent, folders, linked sites, email, voice"],
|
|
174
|
+
archive: ["Archive", "tasks that were done or dropped"],
|
|
175
|
+
};
|
|
176
|
+
// A "go" from the model, checked: { to, label, why?, alts: [{ to, label }] }, or null.
|
|
177
|
+
// A page or a lane that isn't there is dropped, never made up.
|
|
178
|
+
function goOf(g, lanes = []) {
|
|
179
|
+
if (!g) return null;
|
|
180
|
+
const one = (x) => {
|
|
181
|
+
const t = String(x || "").trim().toLowerCase().replace(/^#/, "");
|
|
182
|
+
const m = t.match(/^lane:\s*(.+)$/); if (m) { const n = lanes.find((l) => l.toLowerCase() === m[1].trim()); return n ? { to: "lane:" + n, label: n } : null; }
|
|
183
|
+
const id = PAGES[t] ? t : t === "agents" ? "tasks" : Object.keys(PAGES).find((k) => PAGES[k][0].toLowerCase() === t);
|
|
184
|
+
if (id) return { to: id, label: PAGES[id][0] };
|
|
185
|
+
const n = lanes.find((l) => l.toLowerCase() === t); return n ? { to: "lane:" + n, label: n } : null;
|
|
186
|
+
};
|
|
187
|
+
const to = one(typeof g === "string" ? g : g.to); if (!to) return null;
|
|
188
|
+
const why = typeof g === "object" ? String(g.why || "").replace(/\s+/g, " ").trim().slice(0, 200) : "";
|
|
189
|
+
const alts = (typeof g === "object" && Array.isArray(g.alts) ? g.alts : []).map(one).filter((a) => a && a.to !== to.to).filter((a, i, l) => l.findIndex((b) => b.to === a.to) === i).slice(0, 2);
|
|
190
|
+
return { ...to, ...(why ? { why } : {}), alts };
|
|
191
|
+
}
|
|
192
|
+
const navRules = () => `
|
|
193
|
+
"go": null, or a page to open now: {"to": "${Object.keys(PAGES).join("|")}|lane:<a lane>", "why": "", "alts": []}. Pages: ${Object.entries(PAGES).map(([k, [n, what]]) => `${k} (${n}: ${what})`).join("; ")}.
|
|
194
|
+
You can open any of these pages: when they ask to go somewhere (go to, take me to, navigate to, open, show me, where's), open it with "go", and never say you can't switch pages. A clear ask ("take me to reports") just goes: "why" empty, "alts" [], the reply a few words ("Opening Reports."). A vague one ("show me that thing from earlier", "where are my updates"): don't ask which, opening a page is easy to undo. Pick the likeliest from what you know, this chat, other chats lately and what Home shows; "why" says in one short line why you picked it, also said in the reply; "alts" has 1-2 other pages they may have meant (these are your other readings, offered as one tap each).`;
|
|
158
195
|
// lanes: the repos "do" can point at; self: Symbiot's own repo among them, where
|
|
159
|
-
// a flaw in Symbiot itself is fixed.
|
|
160
|
-
function rulesFor(lanes = [], self = "") {
|
|
196
|
+
// a flaw in Symbiot itself is fixed; nav: this chat can open pages ("go").
|
|
197
|
+
function rulesFor(lanes = [], self = "", nav = false) {
|
|
161
198
|
return `Reply with JSON only, no markdown fence:
|
|
162
199
|
{"reply": "what you say, plain text, brief, no preamble",
|
|
163
|
-
"do": null or {"agent": "what a coding agent should do now: self-contained, with names, links and ids", "repo": "the lane it's for, or empty"} or {"task": "a task for later", "repo": "the lane it's for, or empty"}
|
|
200
|
+
"do": null or {"agent": "what a coding agent should do now: self-contained, with names, links and ids", "repo": "the lane it's for, or empty"} or {"task": "a task for later", "repo": "the lane it's for, or empty"},${nav ? `
|
|
201
|
+
"go": null or {"to": "…", "why": "…", "alts": ["…"]},` : ""}
|
|
164
202
|
"remember": [{"name": "…", "kind": "person|account|site|repo|project|decision|preference", "fact": "…"}]}
|
|
165
203
|
When they ask you to do something (look into it, find out, fix, set up, sign in, close, send, chase), do it, don't explain what you can't do: "agent" starts a coding agent now, with their tools and connectors (MCP, the command line, a browser signed in to their linked sites). With a "repo", it's that project's agent, working in it; empty, an agent for everything outside a repo (this computer, accounts, services). Use "task" for what's for later. Don't ask their permission to hand it over: the agent asks them first, on the Workdesk, before anything hard to undo (closing an account, deleting, paying, sending); say so in your reply when it applies, with what to check first. A question you can answer from what's here: answer it, "do": null.
|
|
166
204
|
Lanes ("repo" is one of these, exactly): ${lanes.length ? lanes.slice(0, 60).join(", ") : "(none found)"}.${lanes.includes(MARKETING) ? ` "${MARKETING}" is a lane of its own for marketing any of their products (posts, demo videos, launches, campaigns, pricing pages' copy): marketing work goes there, starting with the product it's for in brackets, "[Dailify] a launch post".` : ""}${self ? ` Symbiot itself is "${self}": a flaw in how Symbiot works (how it read a page, what a brief or a card said, anything in the app) goes there.` : ""}
|
|
167
205
|
Don't wait to be asked to fix Symbiot. When you notice it got something wrong (you misread a page, a brief or a card misled them, a step made them do an agent's job), say so plainly and, in the same reply, start the fix: "agent"${self ? ` with "repo": "${self}"` : ""}, saying what went wrong, an example, and what it should do instead.
|
|
168
206
|
A short answer (yes, yeah, ok, sure, go, do it, that one, 2) answers what you last said in "This chat so far", never something from elsewhere in the app. If you'd proposed a task or an agent and they agree, do it now in "do", in the lane you named.
|
|
169
207
|
Hash it out first: when they bring an idea rather than a clear ask, sharpen it with them: say what's strong, push back where it's weak, add what's missing, and propose it as a task (naming the lane) once it holds up. "do" stays null until they agree.
|
|
170
|
-
When an ask could mean two or more things (which lane, how much, what done looks like), don't guess: "do" stays null, and the reply gives 2-3 short numbered readings, your pick first, and asks which. A clear ask goes straight through
|
|
171
|
-
"remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else
|
|
208
|
+
When an ask could mean two or more things (which lane, how much, what done looks like), don't guess: "do" stays null, and the reply gives 2-3 short numbered readings, your pick first, and asks which. A clear ask goes straight through.${nav ? ` Where to go is the exception: see "go".` : ""}
|
|
209
|
+
"remember": only lasting facts worth knowing on another page (who someone is, which account is what, a decision, how they like things); [] for anything else.${nav ? navRules() : ""}`;
|
|
172
210
|
}
|
|
173
211
|
// Symbiot's own repo among your lanes (its package.json is symbiot's), or "".
|
|
174
212
|
function selfLane(map) {
|
|
@@ -180,14 +218,49 @@ const laneName = (repo, map) => Object.keys(map).find((n) => n.toLowerCase() ===
|
|
|
180
218
|
// there, the way Send to repos does; anything else, an agent of its own (actNow).
|
|
181
219
|
// Urgent work (agents.mjs isUrgent) goes first: every other lane with work going
|
|
182
220
|
// parks, and the lane's own routine run stops, so this one starts now.
|
|
183
|
-
|
|
221
|
+
// What it says comes from what happened: job (a run started on it), queued (behind a
|
|
222
|
+
// run that really is going there: behind, "urgent" or "routine") or notStarted (its
|
|
223
|
+
// agent didn't start, and why: the task stays in the lane). An urgent one that didn't
|
|
224
|
+
// start takes back the parking it did (urgentUndo): nothing would end to resume it.
|
|
225
|
+
function actIn(request, repo, { map = {}, known = "", title, context, run = runHandoff, add = addTask, push = pushTasks, ops = actNow, first = urgentFirst, undo = urgentUndo, now = Date.now() } = {}) {
|
|
184
226
|
const lane = laneName(repo, map);
|
|
185
227
|
if (!lane) return { ...ops(request, { title, context, known, now }), lane: "" };
|
|
186
228
|
const t = add(request, lane); if (t && t.error) return { error: t.error };
|
|
187
229
|
const p = push({ repo: lane }); if (!p || !p.written || !p.written.length) return { error: `Couldn't write ${lane}'s tasks for its agent.` };
|
|
188
230
|
const urgent = isUrgent(request) ? first(map[lane], { lanes: map }) : null;
|
|
189
|
-
const e = run(map[lane]);
|
|
190
|
-
|
|
231
|
+
const e = run(map[lane]);
|
|
232
|
+
if (!e || (e.blocked && !e.busy)) { if (urgent) { try { undo(map[lane], urgent); } catch {} urgent.parked = []; } }
|
|
233
|
+
if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff.", ...(urgent ? { urgent } : {}) };
|
|
234
|
+
const state = e.busy ? { queued: true, behind: (urgent && urgent.busy) || "routine" } : e.blocked ? { notStarted: e.note || "its agent didn't start." } : { job: e.id };
|
|
235
|
+
return { ok: true, lane, task: t.id, ...state, ...(urgent ? { urgent } : {}) };
|
|
236
|
+
}
|
|
237
|
+
// The line under a handover's reply, from what actIn did (see there).
|
|
238
|
+
function handedLine(did) {
|
|
239
|
+
const lane = did.lane, u = did.urgent, un = u && u.unparked ? ` ${lane} was parked; I unparked it for this.` : "";
|
|
240
|
+
if (did.notStarted) return `\n\nAdded to ${lane}'s tasks, but its agent didn't start: ${did.notStarted.replace(/\.?$/, ".")}${un} It's on the Workdesk.`;
|
|
241
|
+
const parked = u && u.parked.length ? ` Parked till it's done: ${u.parked.join(", ")} (a run already going there finishes first, then they wait). They pick up again in that order once it's done.` : "";
|
|
242
|
+
if (u) {
|
|
243
|
+
const how = did.job ? (u.stopped ? `, which stopped what it was on (its changes stay) and started on this now` : ", which started on it now")
|
|
244
|
+
: did.behind === "urgent" ? ` (it's on other urgent work right now, and takes this next)`
|
|
245
|
+
: u.stopped ? `, which is stopping what it was on (its changes stay) to start on this` : ` (it starts once the run there now finishes)`;
|
|
246
|
+
return `\n\nUrgent, so it goes first: handed to ${lane}'s agent${how}.${un}${parked || (did.job ? " Nothing else was running to park." : "")} It's on the Workdesk.`;
|
|
247
|
+
}
|
|
248
|
+
return `\n\nHanded to ${lane}'s agent, as a task there${did.queued ? " (it starts once the run there now finishes)" : ", which started on it now"}.${un} It's on the Workdesk.`;
|
|
249
|
+
}
|
|
250
|
+
// The same line, read aloud: a short natural phrase, never the task or the agent's brief
|
|
251
|
+
// (the screen has those), and nothing when the reply already said it ("done, it's
|
|
252
|
+
// queued in symbiot"). Reported 2026-10-09: "right arrow added to symbiote tasks: …".
|
|
253
|
+
const SAID_TASK = /\b(add(ed|ing)?|queued?|put it|noted|on (the|your|its|their) (list|tasks|workdesk)|in (the |your |its )?tasks)\b/i;
|
|
254
|
+
const SAID_AGENT = /\b(hand(ed|ing)? (it |this |that )?(to|over|off)|queued?|on it|started|starting|kicked off|(agent|it)('s| is) (on|working|looking|fixing|doing))\b/i;
|
|
255
|
+
function spokenLine(did, reply = "") {
|
|
256
|
+
if (!did) return "";
|
|
257
|
+
if (did.error) return did.kind === "agent" ? "I couldn't hand it to an agent. Why is on screen." : "I couldn't add the task. Why is on screen.";
|
|
258
|
+
if (did.kind === "task") return SAID_TASK.test(reply) ? "" : did.lane ? `I've added it to ${did.lane}'s tasks.` : "I've added it to your tasks, but not to a project yet.";
|
|
259
|
+
if (did.notStarted) return `I've added it to ${did.lane}'s tasks, but its agent didn't start. It's on the Workdesk.`;
|
|
260
|
+
if (SAID_AGENT.test(reply)) return "";
|
|
261
|
+
if (!did.lane) return "I've handed it to an agent.";
|
|
262
|
+
if (did.urgent) return `It's urgent, so ${did.lane}'s agent does it first.`;
|
|
263
|
+
return did.queued ? `I've handed it to the ${did.lane} agent. It starts once its current run finishes.` : `I've handed it to the ${did.lane} agent.`;
|
|
191
264
|
}
|
|
192
265
|
// For later: on the list, in a repo's lane if it names one Symbiot knows.
|
|
193
266
|
function taskIn(text, repo, { map = {}, add = addTask } = {}) {
|
|
@@ -206,7 +279,7 @@ function parseReply(raw) {
|
|
|
206
279
|
// what this page is for; context: what the page shows; history: this chat's
|
|
207
280
|
// own last turns. act: { agent(request) -> result, task(text, repo) -> result },
|
|
208
281
|
// what "do" runs here. Gives { reply, did?, remembered }.
|
|
209
|
-
async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now(), images = [] }) {
|
|
282
|
+
async function converse({ where, role = "", context = "", history = "", question, act = {}, ask = write, map = null, now = Date.now(), images = [], nav = false }) {
|
|
210
283
|
const d = loadMind(), hits = recall(question, now, d), known = recallText(hits), elsewhere = lately(where, d);
|
|
211
284
|
if (!history) history = ownThread(where, d, now);
|
|
212
285
|
// what it did to answer, to show under the reply (as the app shows an agent's work)
|
|
@@ -220,38 +293,41 @@ async function converse({ where, role = "", context = "", history = "", question
|
|
|
220
293
|
let lanes = map; if (!lanes) { try { lanes = laneMap(); } catch { lanes = {}; } }
|
|
221
294
|
// how they talk, from what they've typed into any chat (adapt.mjs: accommodation)
|
|
222
295
|
const voice = styleLine(styleOf(d.log.filter((l) => l.role === "user").map((l) => l.text).concat(question)));
|
|
223
|
-
const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes))}${voice ? "\n" + voice : ""}`;
|
|
296
|
+
const system = `${IDENTITY} ${role}\n\n${rulesFor(Object.keys(lanes), selfLane(lanes), nav)}${voice ? "\n" + voice : ""}`;
|
|
224
297
|
const prompt = (known ? `What you know (from across the app):\n${known}\n\n` : "") + (kn.text ? kn.text + "\n\n" : "") + (elsewhere ? `Lately, elsewhere in the app (other chats: a short answer here doesn't reply to these):\n${elsewhere}\n\n` : "") +
|
|
225
298
|
(context ? context + "\n\n" : "") + (history ? `This chat so far:\n${history}\n\n` : "") + `They say (on ${where}): ${question}`;
|
|
226
299
|
const raw = await ask(system, prompt, images.length ? { images } : undefined);
|
|
227
300
|
if (!raw || /^\(?couldn't reach the model/i.test(String(raw))) return { reply: "(couldn't reach the model)", remembered: 0 };
|
|
228
301
|
const j = parseReply(raw);
|
|
229
302
|
let reply = String(j.reply || "").trim().slice(0, 4000) || "(no answer)", did = null;
|
|
230
|
-
const
|
|
303
|
+
const said = reply;
|
|
304
|
+
const want = j.do && typeof j.do === "object" ? j.do : null, go = nav ? goOf(j.go, Object.keys(lanes)) : null;
|
|
231
305
|
// say where it actually went, from what happened, not from what the model meant
|
|
232
306
|
const repo = want ? String(want.repo || "") : "";
|
|
233
307
|
if (want && want.agent && act.agent) {
|
|
234
308
|
did = { kind: "agent", request: String(want.agent).slice(0, HANDOVER_MAX), ...(await act.agent(String(want.agent), known, repo)) };
|
|
235
309
|
reply += did.error ? `\n\n(I couldn't hand it to an agent: ${did.error})`
|
|
236
|
-
: did.lane
|
|
237
|
-
:
|
|
238
|
-
: "\n\n→ Handed to your agent. It's on the Workdesk, and it asks you there before anything hard to undo.";
|
|
310
|
+
: did.lane ? handedLine(did)
|
|
311
|
+
: "\n\nHanded to your agent. It's on the Workdesk, and it asks you there before anything hard to undo.";
|
|
239
312
|
} else if (want && want.task && act.task) {
|
|
240
313
|
did = { kind: "task", text: String(want.task).slice(0, 300), ...(await act.task(String(want.task), repo)) };
|
|
241
314
|
reply += did.error ? `\n\n(I couldn't add the task: ${did.error})`
|
|
242
|
-
: did.lane ? `\n\
|
|
243
|
-
: `\n\
|
|
315
|
+
: did.lane ? `\n\nAdded to ${did.lane}'s tasks: ${did.text}`
|
|
316
|
+
: `\n\nAdded to your tasks, but not to a repo${repo ? ` (there's no lane called ${repo})` : ""}, so no agent will pick it up until it has one: ${did.text}`;
|
|
244
317
|
}
|
|
318
|
+
// what's read aloud: the reply, then the line under it as a short phrase (spokenLine)
|
|
319
|
+
const line = spokenLine(did, said), spoken = line ? `${said} ${line}` : said;
|
|
245
320
|
// read again: another chat may have written while the model answered
|
|
246
321
|
const d2 = loadMind(), remembered = rememberIn(d2, j.remember, where, now);
|
|
247
322
|
logTurn(where, "user", question, now, d2); logTurn(where, "ai", reply, now, d2);
|
|
248
323
|
saveMind(d2);
|
|
249
324
|
if (voice) steps.push("matched how you talk");
|
|
250
|
-
if (did && !did.error) steps.push(did.kind === "agent" ? (did.lane ? `handed it to ${did.lane}'s agent` : "handed it to an agent") : did.lane ? `added a task to ${did.lane}` : "added a task");
|
|
325
|
+
if (did && !did.error) steps.push(did.kind === "agent" ? (did.notStarted ? `added a task to ${did.lane}: its agent didn't start` : did.lane ? `handed it to ${did.lane}'s agent` : "handed it to an agent") : did.lane ? `added a task to ${did.lane}` : "added a task");
|
|
251
326
|
if (remembered) steps.push(`remembered ${remembered} new thing${remembered > 1 ? "s" : ""}`);
|
|
252
|
-
|
|
327
|
+
if (go) steps.push(`opened ${go.label}`);
|
|
328
|
+
return { reply, spoken, ...(did ? { did } : {}), ...(go ? { go } : {}), remembered, steps };
|
|
253
329
|
}
|
|
254
330
|
// The usual "task" act, for a caller with no lanes of its own to pass.
|
|
255
331
|
const addToTasks = (text, repo) => { let map = {}; try { map = laneMap(); } catch {} return taskIn(text, repo, { map }); };
|
|
256
332
|
|
|
257
|
-
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, addToTasks , ownThread };
|
|
333
|
+
export { MIND_FILE, loadMind, remember, recall, recallText, lately, mindState, forget, actBrief, actNow, actIn, taskIn, selfLane, rulesFor, parseReply, converse, spokenLine, goOf, PAGES, addToTasks , ownThread };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.58.
|
|
4
|
-
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice
|
|
3
|
+
"version": "0.58.3",
|
|
4
|
+
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice — Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"symbiot": "index.mjs"
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"guard.mjs",
|
|
46
46
|
"away.mjs",
|
|
47
47
|
"marketing.mjs",
|
|
48
|
+
"tray.mjs",
|
|
48
49
|
"postinstall.mjs",
|
|
49
50
|
"icon.svg",
|
|
50
51
|
"scanworker.mjs",
|
|
@@ -84,7 +85,7 @@
|
|
|
84
85
|
"@anthropic-ai/sdk": "^0.128.0"
|
|
85
86
|
},
|
|
86
87
|
"scripts": {
|
|
87
|
-
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
88
|
+
"test": "node test/load.mjs && node test/fixtures.mjs && node test/links.mjs && node test/post.mjs && node test/peek.mjs && node test/mind.mjs && node test/knowledge.mjs && node test/checks.mjs &&node test/lanes.mjs && node test/handback.mjs && node test/autoallow.mjs && node test/guard.mjs && node test/reports.mjs && node test/sandbox.mjs && node test/adapt.mjs && node test/urgent.mjs && node test/drafts.mjs && node test/linkedin.mjs && node test/secrets.mjs && node test/work.mjs && node test/mapknn.mjs && node test/away.mjs && node test/voice.mjs && node test/subscription.mjs && node test/claudesetup.mjs && node test/launcher.mjs && node test/onboarding.mjs && node test/windows.mjs && node test/marketing.mjs && node test/tray.mjs && node test/paths.mjs && node test/smoke.mjs && node test/app.mjs && node test/install.mjs",
|
|
88
89
|
"lint": "eslint .",
|
|
89
90
|
"postinstall": "node postinstall.mjs"
|
|
90
91
|
},
|
package/post.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { resolveProvider, write } from "./ai.mjs";
|
|
|
33
33
|
import { commits, discoveredRepos, laneMap } from "./scan.mjs";
|
|
34
34
|
import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
|
|
35
35
|
import { runHandoff, runningHandoff } from "./agents.mjs";
|
|
36
|
-
import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames } from "./marketing.mjs";
|
|
36
|
+
import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames, draftStatuses, postedCmd } from "./marketing.mjs";
|
|
37
37
|
|
|
38
38
|
const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md"), media: join(CONFIG_DIR, "post-media"), test: join(CONFIG_DIR, "post-test.json") };
|
|
39
39
|
const PLATFORM = "linkedin";
|
|
@@ -321,6 +321,8 @@ function handToMarketing(p, { now = Date.now(), paths = PATHS, dir = MARKETING_D
|
|
|
321
321
|
if (!ensureMarketing(dir)) return { error: "Couldn't make Marketing's folder." };
|
|
322
322
|
if (!names) { try { names = productNames(laneMap()); } catch { names = []; } }
|
|
323
323
|
const product = productOf(p.text, names), folder = (product.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "linkedin"), rel = `drafts/${folder}/post-${p.id}.md`;
|
|
324
|
+
// posted already (its agent marked it): never written over and handed out a second time
|
|
325
|
+
const was = draftStatuses(dir)[rel]; if (was && (was.status === "posted" || was.posted)) return { error: `It's posted already (${rel}): it isn't posted twice.` };
|
|
324
326
|
const media = [];
|
|
325
327
|
try {
|
|
326
328
|
mkdirSync(join(dir, "drafts", folder), { recursive: true });
|
|
@@ -330,7 +332,7 @@ function handToMarketing(p, { now = Date.now(), paths = PATHS, dir = MARKETING_D
|
|
|
330
332
|
const st = setDraftStatus(rel, "approved", { dir, now }); if (st.error) return st;
|
|
331
333
|
try {
|
|
332
334
|
const f = join(dir, ".symbiot", "ANSWERS.md"), had = existsSync(f) ? readFileSync(f, "utf8") : "# Answers\n";
|
|
333
|
-
writeFileSync(f, `${had.replace(/\s*$/, "")}\n\n### Approved: ${rel}\nThe user approved this post under Drafts to post. Post it on LinkedIn now, through Symbiot's signed-in browser, with exactly the text under its post (${p.text.length} characters, unchanged)${media.length ? ` and ${media.join(", ")} attached` : ""}. Then check it's there, and say so in your last message.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`);
|
|
335
|
+
writeFileSync(f, `${had.replace(/\s*$/, "")}\n\n### Approved: ${rel}\nThe user approved this post under Drafts to post. Post it on LinkedIn now, through Symbiot's signed-in browser, with exactly the text under its post (${p.text.length} characters, unchanged)${media.length ? ` and ${media.join(", ")} attached` : ""}. Then check it's there, mark it posted (\`${postedCmd(rel)}\`), and say so in your last message.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`);
|
|
334
336
|
} catch (e) { return { error: "Couldn't tell Marketing's agent: " + ((e && e.message) || e) }; }
|
|
335
337
|
if (running(dir)) return { rel, queued: true, said: "Approved. Marketing's agent posts it on LinkedIn once the run there now finishes." };
|
|
336
338
|
const e = run(dir, { force: true });
|