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/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
- const body = { id: s.id, region: r.id, confirmed: has("yes"), noSend: !!process.env.SYMBIOT_DRAFT, ...(sub === "type" ? { text: a3, enter: has("enter") } : {}) };
420
- const done = (await viaApp("/api/screens/" + sub, body)) || (sub === "press" ? await pressRegion(s.id, r.id, { confirmed: body.confirmed, noSend: body.noSend }) : await typeRegion(s.id, r.id, a3, { enter: body.enter, confirmed: body.confirmed, noSend: body.noSend }));
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), done (reported back) or error (reported back too).
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, status: r.id && !r.busy && !r.blocked ? "started" : "held", ...(r.id ? { job: r.id } : {}) });
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, status: r.id && !r.busy && !r.blocked ? "started" : "held", ...(r.id ? { job: r.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
- function statusOf(dir, rel, body) { const s = draftStatuses(dir)[rel]; return s && s.sig === sigOf(body) ? s : null; }
188
- // Approve or skip a draft: kept by its text (a changed post asks again). { ok, status, draft } or { error }.
189
- function setDraftStatus(rel, status, { dir = MARKETING_DIR, now = Date.now() } = {}) {
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); all[rel] = { status, sig: sigOf(d.body), at: now };
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
- function actIn(request, repo, { map = {}, known = "", title, context, run = runHandoff, add = addTask, push = pushTasks, ops = actNow, first = urgentFirst, now = Date.now() } = {}) {
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]); if (!e) return { error: "The agent didn't start. Check its command in Settings → Handoff." };
190
- return { ok: true, lane, task: t.id, ...(e.busy || e.blocked ? { queued: true } : { job: e.id }), ...(urgent ? { urgent } : {}) };
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 want = j.do && typeof j.do === "object" ? j.do : null;
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 && did.urgent ? `\n\n→ Urgent, so it goes first: handed to ${did.lane}'s agent${did.urgent.stopped ? `, which stopped what it was on (its changes stay) to start on this now` : did.queued ? " (it's already on urgent work, and takes this next)" : ""}. ${did.urgent.parked.length ? `Parked till it's done: ${did.urgent.parked.join(", ")} (a run already going there finishes first, then they wait). They pick up again in that order once it's done.` : "Nothing else was running to park."} It's on the Workdesk.`
237
- : did.lane ? `\n\n→ Handed to ${did.lane}'s agent, as a task there${did.queued ? " (it starts once the run there now finishes)" : ""}. It's on the Workdesk.`
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\n→ Added to ${did.lane}'s tasks: ${did.text}`
243
- : `\n\n→ Added 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}`;
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
- return { reply, ...(did ? { did } : {}), remembered, steps };
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.2",
4
- "description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
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 });