symbiot 0.54.2 → 0.54.4

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/home.mjs CHANGED
@@ -4,24 +4,28 @@
4
4
  // folders), always out front;
5
5
  // - feeds: what's new on the sites you watch (watch.mjs), sized by how much;
6
6
  // - lanes: work handed between agents (lanes.mjs), the latest few.
7
- // Reports agents wrote up that you haven't read (reports.mjs) are a feed too.
7
+ // Reports agents wrote up that you haven't read (reports.mjs) are a feed too, and
8
+ // so is a clash in your company folder that matters (checks.mjs newClashes).
8
9
  // The app's sections (Map, Tasks, Agents, Week…) are shaped by adapt.mjs from
9
10
  // how you use them; this is the live data around them. Home's talk goes to the
10
11
  // same Symbiot as every chat (mind.mjs converse), told what home shows.
11
12
  import { join } from "node:path";
12
- import { readFileSync } from "node:fs";
13
- import { watchBoard, watchState, isMail } from "./watch.mjs";
13
+ import { homedir } from "node:os";
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+ import { watchBoard } from "./watch.mjs";
14
16
  import { pendingReview, pushTasks } from "./tasks.mjs";
15
- import { agentsList, runHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing } from "./agents.mjs";
17
+ import { agentsList, runHandoff, runningHandoff, handoffCmd, connectorsInfo, parkedPaths, agentMissing, settleNeeds, pickAgent } from "./agents.mjs";
16
18
  import { linksState } from "./links.mjs";
17
19
  import { knowledgeState } from "./knowledge.mjs";
18
- import { CONFIG_DIR, loadConfig, loadTasks } from "./core.mjs";
19
- import { lanesState } from "./lanes.mjs";
20
+ import { CONFIG_DIR, loadTasks } from "./core.mjs";
21
+ import { lanesState, stuckHandovers, allowHandover, skipHandover } from "./lanes.mjs";
20
22
  import { converse, actIn, actNow, taskIn } from "./mind.mjs";
21
23
  import { repoPathMap } from "./scan.mjs";
22
24
  import { resolveProvider } from "./ai.mjs";
23
25
  import { reportsNews } from "./reports.mjs";
24
- import { awaitingState } from "./handback.mjs";
26
+ import { awaitingState, inboxSight } from "./handback.mjs";
27
+ import { newClashes } from "./checks.mjs";
28
+ import { postsState } from "./post.mjs";
25
29
 
26
30
  const KEEP = 15000; // the slower reads (git per repo awaiting review) are cached this long
27
31
  let cached = null;
@@ -46,6 +50,41 @@ function displayName(path, folder) {
46
50
  // The lane a folder is: the repo it is (by the scan's map), or an ops run.
47
51
  const laneOfPath = (path, map) => Object.keys(map || {}).find((n) => map[n] === path) || (String(path || "").startsWith(RUNS) ? "" : null);
48
52
 
53
+ // A run that failed shows for a day, until you run it again or skip it; one
54
+ // that's been followed by another run in its folder is past.
55
+ const FAILED_FOR = 86400000, dropped = new Set();
56
+ const firstLine = (t) => String(t || "").split("\n").find((l) => l.trim()) || "";
57
+ // What stopped and can't go on without you, as questions: a handover that couldn't
58
+ // start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"), a run
59
+ // that ended waiting on you (agents.mjs needsOf: a draft for your OK, a sudo step,
60
+ // a step your answer left you) with what to check first, and a run that ended in an
61
+ // error. Each is answered on its blob (homeAnswer), on Home and on its lane in
62
+ // Tasks. repo: its lane ("" for an ops run).
63
+ function troubles({ stuck = [], list = [], map = {}, named = displayName, now = Date.now() } = {}) {
64
+ const out = [], seen = new Set();
65
+ for (const t of stuck) {
66
+ const lane = t.from.lane === RUNS_LANE ? "" : t.from.lane;
67
+ out.push({ kind: "ask", fix: "handover", id: "stuck:" + t.id, path: lane ? map[lane] || t.from.path : t.from.path, repo: lane, name: lane ? named(map[lane], lane) : "Agent runs",
68
+ title: `${lane || "An agent run"} is stuck`, sub: plain(firstLine(t.text), 90), q: t.q, options: t.options, why: plain(t.error, 160), shape: "agents" });
69
+ }
70
+ for (const a of list) {
71
+ if (seen.has(a.path)) continue; seen.add(a.path); // the newest run in each folder (agentsList: newest first)
72
+ if (a.status === "running" || (a.ask && a.ask.questions && a.ask.questions.length)) continue; // at work, or it asked: that's its question
73
+ const lane = laneOfPath(a.path, map), repo = lane || "", nm = repo ? named(map[lane], repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, "").replace(/^\W*What['’]s needed:\W*/i, "").replace(/^./, (c) => c.toUpperCase()), 60);
74
+ const n = a.needs;
75
+ if (n && !dropped.has("needs:" + a.id)) {
76
+ const ok = n.kind === "approve";
77
+ out.push({ kind: "ask", fix: "needs", id: "needs:" + a.id, path: a.path, repo, name: nm, title: ok ? `${nm} waits for your OK` : `${nm} needs you`, sub: plain(n.label || n.what, 90),
78
+ q: n.what + (n.check ? ` Check first: ${n.check}` : ""), options: ok ? ["Go ahead (recommended)", "Skip"] : ["Done it (recommended)", "Skip"], shape: "agents" });
79
+ continue;
80
+ }
81
+ if (a.status !== "failed" || dropped.has(a.id) || (a.endedAt && now - a.endedAt > FAILED_FOR)) continue;
82
+ const said = plain(String(a.tail || "").split(/\n+/).filter((l) => l.trim()).pop() || "", 140);
83
+ out.push({ kind: "ask", fix: "failed", id: "failed:" + a.id, path: a.path, repo, name: nm, title: `${nm}'s run stopped`, sub: said || `it exited with code ${a.exitCode}`,
84
+ q: `Its run stopped with an error${said ? `: ${said}` : ` (exit code ${a.exitCode})`}. Run it again?`, options: ["Run it again (recommended)", "Skip"], shape: "agents" });
85
+ }
86
+ return out;
87
+ }
49
88
  // { you: [{ kind, id, title, sub, shape, … }], feeds: [{ id, title, sub, count, shape }],
50
89
  // lanes: [{ from, to, text, status, times }], working: n, at }
51
90
  // An ask carries what its blob on home needs: the lane (repo, or "" for an ops
@@ -60,11 +99,17 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
60
99
  const connected = deps.connected || (() => !!resolveProvider());
61
100
  const repos = deps.repos || (() => { try { return repoPathMap(); } catch { return {}; } });
62
101
  const waits = deps.waits || (() => { try { return awaitingState().waits; } catch { return []; } });
63
- // can a reply be noticed? Email on (mail on this computer), or an inbox watched
64
- const seesInbox = deps.seesInbox || (() => { try { return !!(loadConfig().mail || {}).enabled || watchState().watches.some((w) => isMail(w.url)); } catch { return true; } });
102
+ const clashes = deps.clashes || (() => { try { return newClashes(); } catch { return []; } });
103
+ // can a reply be noticed? Email on (mail on this computer), or an inbox watched and signed in
104
+ const seesInbox = deps.seesInbox || (() => { try { return inboxSight(); } catch { return true; } });
65
105
  // the agent the handoff command runs, gone from this computer; Claude connectors linked here but signed out
66
106
  const agentGone = deps.agentGone || (() => { try { return handoffCmd() ? agentMissing() : ""; } catch { return ""; } });
67
107
  const signedOut = deps.signedOut || (() => { try { const c = connectorsInfo(); return c.claude ? (c.links || []).filter((x) => x.connector && !x.ready) : []; } catch { return []; } });
108
+ const stuck = deps.stuck || (() => { try { return stuckHandovers({ now }); } catch { return []; } });
109
+ const agentCmd = deps.agentCmd || (() => { try { return handoffCmd(); } catch { return "x"; } });
110
+ const offer = deps.pickAgent || (() => { try { return pickAgent(); } catch { return null; } });
111
+ // Marketing (its own section, off the Dashboard) once posts can be drafted, or there are some
112
+ const marketing = deps.marketing || (() => { try { const p = postsState(); return !!(p.canDraft || p.posts.length || p.done.length); } catch { return false; } });
68
113
  const named = deps.name || displayName;
69
114
  const you = [], map = repos() || {};
70
115
  // What Symbiot works through (an AI, your agent, the connectors runs use) shows
@@ -74,6 +119,9 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
74
119
  const gone = agentGone(); if (gone) you.push({ kind: "setup", id: "setup:agent", urgent: true, title: "Your agent is unavailable", sub: `${gone} isn't on this computer any more, so no task can start`, shape: "settings", focus: "agent" });
75
120
  for (const c of signedOut()) you.push({ kind: "setup", id: "setup:conn:" + c.id, urgent: true, title: `Reconnect ${c.name}`, sub: `its Claude connector is signed out, so agent runs can't use ${c.name}`, shape: "settings", focus: "agent" });
76
121
  if (!Object.keys(map).length) you.push({ kind: "setup", id: "setup:folders", title: "Show me your work", sub: "where your repos are", shape: "settings", focus: "work" });
122
+ // no agent yet: Send to repos and Go would only write TASKS.md files nothing runs, so
123
+ // Home didn't say "All handled" truthfully. One click for the one on this computer.
124
+ if (!agentCmd()) { const p = offer(); you.push({ kind: "setup", id: "setup:pick", title: "Pick your agent", sub: p ? `${p.name} is on this computer: one click and it takes your tasks` : "the coding agent that takes your tasks", shape: "settings", focus: "agent", ...(p ? { pick: { name: p.name, tmpl: p.tmpl } } : {}) }); }
77
125
  for (const r of pending()) {
78
126
  if (!r.path || r.running) continue;
79
127
  const n = (r.tasks || []).length, files = (r.files || []).length;
@@ -89,19 +137,75 @@ function homeState({ now = Date.now(), fresh = false, deps = {} } = {}) {
89
137
  you.push({ kind: "ask", id: "ask:" + a.path, path: a.path, repo, name: repo ? named(lane ? map[lane] : a.path, repo) : plain(String(a.name || "an agent").replace(/^Agent:\s*/, ""), 60),
90
138
  title: `${a.name} asks`, sub: String(q.q || "").slice(0, 90), q: String(q.q || ""), options: (q.options || []).slice(0, 2), ...(qs.length > 1 ? { more: qs.length - 1 } : {}), shape: "agents" });
91
139
  }
140
+ // What stopped and can't go on without you, as a question too: a handover that
141
+ // couldn't start (lanes.mjs stuckHandovers: "Allow this run access to ~/Company?"),
142
+ // and a run that ended in an error. Answered on its blob (homeAnswer).
143
+ you.push(...troubles({ stuck: stuck(), list, map, named, now }));
92
144
  // A run is waiting on an emailed reply that nothing will see: say so, once,
93
- // until an inbox is watched or Email is on (handback.mjs looks in either).
94
- if (waits().some((w) => w.status === "waiting") && !seesInbox()) you.push({ kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
145
+ // until an inbox is watched (and signed in) or Email is on (handback.mjs looks in either).
146
+ if (waits().some((w) => w.status === "waiting")) {
147
+ const s = seesInbox(), sight = s && typeof s === "object" ? s : { sees: !!s, signedOut: [] };
148
+ if (!sight.sees) you.push(sight.signedOut.length
149
+ ? { kind: "setup", id: "setup:inbox", signin: true, title: `Sign in to ${sight.signedOut[0]} again`, sub: "it's signed out, so I won't notice their reply", shape: "settings", focus: "links" }
150
+ : { kind: "setup", id: "setup:inbox", title: "Let me see your inbox", sub: "so I notice their reply", shape: "settings", focus: "links" });
151
+ }
95
152
  const feeds = ((board() || {}).cards || []).filter((c) => c.count > 0).map((c) => ({ id: "feed:" + c.id, title: c.name, sub: c.label, count: c.count, shape: "board" }));
153
+ // a clash in your files that matters (checks.mjs: high), first under Watching, until you've opened it
154
+ const clash = clashes();
155
+ if (clash.length) feeds.unshift({ id: "feed:clash", title: "Where your files disagree", sub: `${clash.length} clash${clash.length > 1 ? "es" : ""} to look at`, latest: plain(clash[0].text, 140), count: clash.length, shape: "settings" });
96
156
  const rep = reports();
97
157
  if (rep.count) feeds.push({ id: "feed:reports", title: "Reports", sub: `${rep.count} unread`, latest: rep.latest, count: rep.count, shape: "reports" });
98
158
  const hs = ((lanes() || {}).handoffs || []).slice(0, 5).map((h) => ({ from: h.from, to: h.to, text: h.text, status: h.status, ...(h.times > 1 ? { times: h.times } : {}) }));
99
159
  const working = list.filter((a) => a.status === "running").length;
100
- const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, at: now };
160
+ const out = { you: you.slice(0, 6), youCount: you.length, feeds: feeds.slice(0, 6), lanes: hs, working, marketing: marketing(), at: now };
101
161
  if (!deps.board) cached = out;
102
162
  return out;
103
163
  }
104
164
 
165
+ // An answer on a stuck handover's, a waiting run's or a failed run's blob. pick: the
166
+ // option's place (0 goes ahead, 1 skips); text: your own words, which go ahead with
167
+ // them unless they say no. The handover's run (lanes.mjs allowHandover) reports back
168
+ // to the agent that asked; a run waiting on your OK or your step starts again with
169
+ // "go ahead" or "done" in its ANSWERS.md (skipped, it doesn't ask again); a failed
170
+ // run starts again in its folder, with your words in its ANSWERS.md. { ok, said } or { error }.
171
+ const SAYS_NO = /^\s*(skip|no|nope|don'?t|do not|cancel|leave it|never ?mind|stop)\b/i;
172
+ // An answer, in the folder's ANSWERS.md, for the run that picks it up.
173
+ function noteAnswer(path, heading, text) {
174
+ const f = join(path, ".symbiot", "ANSWERS.md"); let had = ""; try { had = readFileSync(f, "utf8"); } catch {}
175
+ try { writeFileSync(f, `${(had || "# Answers\n").replace(/\s*$/, "")}\n\n### ${String(heading).replace(/\s+/g, " ").trim()}\n${text}\n_answered ${new Date().toISOString().slice(0, 10)}_\n`); } catch {}
176
+ }
177
+ function homeAnswer(id, { pick, text = "" } = {}, deps = {}) {
178
+ id = String(id || ""); text = String(text || "").trim().slice(0, 2000);
179
+ const go = text ? !SAYS_NO.test(text) : Number(pick) === 0, ref = id.replace(/^\w+:/, "");
180
+ cached = null;
181
+ if (id.startsWith("stuck:")) {
182
+ if (!go) { const r = (deps.skip || skipHandover)(ref); return r.error ? r : { ok: true, said: "Skipped. Its agent knows." }; }
183
+ const r = (deps.allow || allowHandover)(ref, { note: text }); if (r.error) return r;
184
+ return { ok: true, said: `Started a run on it${r.dirs && r.dirs.length ? `, allowed into ${r.dirs.map((d) => (d.startsWith(homedir() + "/") ? "~" + d.slice(homedir().length) : d)).join(" and ")}` : ""}. What it does goes back to the agent that asked.` };
185
+ }
186
+ if (id.startsWith("failed:")) {
187
+ const a = (deps.agents || agentsList)().find((x) => x.id === ref); if (!a) return { error: "That run isn't here any more." };
188
+ if (!go) { dropped.add(ref); return { ok: true, said: "Skipped." }; }
189
+ if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
190
+ if (text) noteAnswer(a.path, "Your last run stopped with an error", text);
191
+ const e = (deps.run || runHandoff)(a.path, { force: true }); dropped.add(ref);
192
+ return e && e.id && !e.blocked ? { ok: true, said: "Running it again.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
193
+ }
194
+ if (id.startsWith("needs:")) {
195
+ const a = (deps.agents || agentsList)().find((x) => x.id === ref), n = a && a.needs;
196
+ if (!n) return { error: "That isn't waiting on you any more." };
197
+ const settle = deps.settle || settleNeeds;
198
+ if (!go) { settle(a.path, n.key); dropped.add(id); return { ok: true, said: "Skipped. It won't ask again." }; }
199
+ if ((deps.running || runningHandoff)(a.path)) return { ok: true, said: "An agent is already at work there." };
200
+ const said = n.kind === "approve" ? `Go ahead: ${text || "do it as you had it, once you've checked what you flagged"}.` : `Done: the user did it${text ? ` (${text})` : ""}. Check it worked, then carry on.`;
201
+ noteAnswer(a.path, n.what, said);
202
+ const e = (deps.run || runHandoff)(a.path, { force: true });
203
+ if (e && e.id && !e.blocked) { settle(a.path, n.key); dropped.add(id); }
204
+ return e && e.id && !e.blocked ? { ok: true, said: n.kind === "approve" ? "Its agent is going ahead." : "Its agent is checking and carrying on.", rerun: e.id } : { error: (e && e.note) || "It didn't start. Check your agent in Settings." };
205
+ }
206
+ return { error: "Answer that one on its block in Agents." };
207
+ }
208
+
105
209
  // Settings' first steps, in the order they matter (FIRST-RUN-AUDIT.md: a new user
106
210
  // found Settings one long page with nothing saying what comes first). Each is
107
211
  // done or not from what's set up, so it ticks itself; the company folder is
@@ -136,7 +240,7 @@ function firstSteps({ deps = {} } = {}) {
136
240
  // One line per thing home shows, for the talk's context.
137
241
  function homeContext(h) {
138
242
  return [
139
- `Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}`),
243
+ `Only the user can do (${h.you.length}):`, ...h.you.map((y) => `- ${y.title}: ${y.sub}${y.q && y.q !== y.sub ? ` (asks: ${y.q}${(y.options || []).length ? ` ${y.options.join(" / ")}` : ""})` : ""}`),
140
244
  `New on what they watch:`, ...(h.feeds.length ? h.feeds.map((f) => `- ${f.title}: ${f.sub}${f.latest ? ` (latest: ${f.latest})` : ""}`) : ["- nothing new"]),
141
245
  `Agents working: ${h.working}. Recent handovers:`, ...(h.lanes.length ? h.lanes.map((l) => `- ${l.from} → ${l.to}: ${l.text} (${l.status}${l.times ? `, handed over ${l.times} times` : ""})`) : ["- none"]),
142
246
  ].join("\n");
@@ -218,6 +322,11 @@ function workScene({ deps = {} } = {}) {
218
322
  for (const q of qs) { p.qs = p.qs || []; if (p.qs.length < 3) p.qs.push({ q: q.q, options: (q.options || []).slice(0, 3), path: a.path }); }
219
323
  for (const s of ideas) { p.ideas = p.ideas || []; if (p.ideas.length < 4) p.ideas.push({ text: plain(s.text, 200), full: String(s.text), repo: s.repo || l, path: a.path }); }
220
324
  }
325
+ const stuck = deps.stuck || (() => { try { return stuckHandovers(); } catch { return []; } });
326
+ for (const t of troubles({ stuck: stuck(), list: AG, map, named })) {
327
+ const p = at(t.repo || RUNS_LANE); p.asks++; p.qs = p.qs || [];
328
+ if (p.qs.length < 3) p.qs.unshift({ q: t.q, options: t.options, path: t.path, fix: t.fix, id: t.id, sub: t.sub });
329
+ }
221
330
  for (const p of Object.values(by)) { p.lit = !!(p.asks || p.ready); if (isParked(p.repo)) p.parked = true; }
222
331
  const projects = Object.values(by).sort((a, b) => (b.lit ? 1 : 0) - (a.lit ? 1 : 0) || (a.parked ? 1 : 0) - (b.parked ? 1 : 0) || (b.running ? 1 : 0) - (a.running ? 1 : 0) || b.ready - a.ready || b.waiting - a.waiting || b.last - a.last || (a.repo < b.repo ? -1 : 1));
223
332
  return { running, ready, waiting: waiting.slice(0, 12), waitingCount: waiting.length, canGo: waiting.filter((w) => !w.busy && !w.parked).length, projects: projects.slice(0, 24), projectCount: projects.length };
@@ -234,4 +343,4 @@ function workGo({ push = pushTasks, run = runHandoff } = {}) {
234
343
  return { started, queued, repos, ...(parked.length ? { parked } : {}), ...(r.unresolved && r.unresolved.length ? { unresolved: r.unresolved.map((u) => u.name) } : {}) };
235
344
  }
236
345
 
237
- export { homeState, homeContext, homeAsk, workScene, workGo, displayName, firstSteps };
346
+ export { homeState, homeContext, homeAsk, homeAnswer, workScene, workGo, displayName, firstSteps };
package/index.mjs CHANGED
@@ -25,8 +25,8 @@
25
25
 
26
26
  import { spawn } from "node:child_process";
27
27
  import { homedir } from "node:os";
28
- import { join } from "node:path";
29
- import { realpathSync } from "node:fs";
28
+ import { join, basename } from "node:path";
29
+ import { realpathSync, readFileSync } from "node:fs";
30
30
  import { createInterface } from "node:readline";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import { EMBEDDED_UI } from "./ui.mjs";
@@ -41,7 +41,7 @@ import { loadScreens, screenImage, blueprint } from "./screens.mjs";
41
41
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, isTrusted } from "./headless.mjs";
42
42
  import { watchState, addWatch, removeWatch, seenWatch, newsSince, markNews, checkWatch, setBrief, draftReply, watchBoard, boardLine, boardChat, boardTalk, clearBoardChat } from "./watch.mjs";
43
43
  import { PORT as PHONE_PORT, phoneState, pairComputer, pollComputer, forgetComputer } from "./phone.mjs";
44
- import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl } from "./post.mjs";
44
+ import { KIND_LABEL as POST_KIND, PATHS as POST_PATHS, draftPosts, postsState, postLog, approvePost, editPost, skipPost, voiceFromLinkedIn, openUrl, addMedia as addPostMedia, removeMedia as removePostMedia, mediaDir as postMediaDir, pictureOfPage, clipOfPage } from "./post.mjs";
45
45
  import { startApp, updateCmd, isAppRunningWeekly } from "./server.mjs";
46
46
  import { listReports, readReport } from "./reports.mjs";
47
47
  import { runSandbox } from "./sandbox.mjs";
@@ -621,18 +621,22 @@ async function cmdPhone() {
621
621
  // Nothing is ever posted by Symbiot: approve copies one post to your clipboard and
622
622
  // gives LinkedIn's share box, where you paste it and post it yourself.
623
623
  async function cmdPost() {
624
- const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since");
624
+ const [sub = "draft", a1, a2] = argv.slice(1).filter((x, i, all) => !x.startsWith("--") && all[i - 1] !== "--since" && all[i - 1] !== "--seconds");
625
625
  const fail = (msg) => { console.log(c.y(msg)); process.exitCode = 1; };
626
626
  const show = (p) => {
627
627
  console.log(`\n${c.b(POST_KIND[p.kind] || p.kind)} ${c.d(`${p.id} · ${p.status}${p.edited ? " · edited" : ""}`)}\n${p.text}`);
628
628
  if (p.sources && p.sources.length) console.log(c.d(" from git: " + p.sources.slice(0, 4).map((s) => s.replace(/^\[\d+\]\s*/, "")).join("\n ") + (p.sources.length > 4 ? `\n …and ${p.sources.length - 4} more` : "")));
629
+ for (const m of p.media || []) console.log(c.d(` ${m.kind} ${m.id}: `) + join(postMediaDir(p.id), m.file) + c.d(m.url ? ` (${m.from === "clip" ? "clip" : "picture"} of ${m.url})` : ""));
630
+ if (p.show && !(p.media || []).length) console.log(c.d(" picture idea: " + p.show));
629
631
  };
630
632
  if (sub === "draft" || sub === "new") {
631
633
  const r = await draftPosts({ days: SINCE_WEEK || 7 });
632
634
  if (r.error) return fail(r.error);
633
635
  for (const p of r.posts) show(p);
636
+ if (r.retried) console.log("\n" + c.d("The first try kept none (each claimed what git doesn't show), so Symbiot tried again by itself."));
637
+ if (r.pictures) console.log(c.d(`${r.pictures === 1 ? "One draft names" : `${r.pictures} drafts name`} a screen of an app you run here, so ${r.pictures === 1 ? "it has its picture" : "they have their pictures"} already: keep or remove (symbiot post remove <id> <media id>).`));
634
638
  if (r.dropped.length) console.log("\n" + c.y(`Dropped ${r.dropped.length}: `) + r.dropped.map((x) => `${POST_KIND[x.kind] || x.kind} (${x.cited ? "claimed " + x.unsupported.join(", ") + ", which git doesn't show" : "cited nothing from git"})`).join("; "));
635
- console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait on the app's Dashboard too.`));
639
+ console.log("\n" + c.d(`${r.posts.length} draft${r.posts.length === 1 ? "" : "s"} from ${r.facts} things git shows this week, in the voice of your ${r.voice} example${r.voice === 1 ? "" : "s"}. They wait under Marketing in the app too.`));
636
640
  console.log(c.d("Nothing is posted until you approve one: symbiot post approve <id> (or edit / skip)."));
637
641
  return;
638
642
  }
@@ -648,10 +652,33 @@ async function cmdPost() {
648
652
  console.log(`${c.g("✓")} Approved.\n\n${r.post.text}\n`);
649
653
  console.log(r.copied ? `${c.g("✓")} Copied to your clipboard (${r.copied}).` : c.y("No clipboard tool found (wl-copy, xclip, xsel, pbcopy or clip): copy it from above."));
650
654
  console.log(`Paste it into LinkedIn's share box: ${c.b(r.share)}` + (has("open") ? "" : c.d(" (--open opens it)")));
651
- if (has("open")) openUrl(r.share);
655
+ if (r.files.length) console.log(`Then add ${r.files.length === 1 ? "this" : "these"} with its photo or video button:\n${r.files.map((f) => " " + f).join("\n")}` + (has("open") ? "" : c.d(" (--open opens their folder)")));
656
+ if (has("open")) { openUrl(r.share); if (r.folder) openUrl(r.folder); }
652
657
  console.log(c.d(r.note));
653
658
  return;
654
659
  }
660
+ // pictures and video on a draft: a file of yours, or a picture or clip of a page
661
+ if (sub === "add") {
662
+ if (!a2) return fail("Give the draft and the file: symbiot post add <id> <picture or video>");
663
+ let data; try { data = readFileSync(a2); } catch (e) { return fail(`Can't read ${a2}: ${e.message}`); }
664
+ const r = addPostMedia(a1, data, { name: basename(a2) });
665
+ if (r.error) return fail(r.error);
666
+ show(r.post); return;
667
+ }
668
+ if (sub === "page") {
669
+ if (!a2) return fail("Give the draft and the page: symbiot post page <id> <web address> [--clip] [--seconds 8]");
670
+ const clip = has("clip"), i = argv.indexOf("--seconds"), seconds = i >= 0 ? Number(argv[i + 1]) || 8 : 8;
671
+ console.log(c.d(clip ? `Recording ${seconds} s of ${a2}…` : `Taking a picture of ${a2}…`));
672
+ // through the app when it runs: its hidden browser and this one share a profile
673
+ const r = (await viaApp("/api/posts/media/page", { id: a1, url: a2, clip, seconds })) || await (clip ? clipOfPage(a1, a2, { seconds }) : pictureOfPage(a1, a2));
674
+ if (r.error) return fail(r.error);
675
+ show(r.post); return;
676
+ }
677
+ if (sub === "unadd" || sub === "remove") {
678
+ const r = removePostMedia(a1, a2);
679
+ if (r.error) return fail(r.error);
680
+ console.log(`${c.g("✓")} Taken off ${r.post.id}.`); return;
681
+ }
655
682
  if (sub === "edit") {
656
683
  if (!a2) return fail('Give the new text: symbiot post edit <id> "the post"');
657
684
  const r = editPost(a1, a2);
@@ -685,6 +712,14 @@ async function cmdPost() {
685
712
  copy it to your clipboard, and give (--open:
686
713
  open) LinkedIn's share box to paste it into
687
714
  symbiot post edit <id> "text" change a draft's words
715
+ symbiot post add <id> <file> put a picture or video of yours on a draft
716
+ (PNG, JPG, GIF, MP4, MOV, WebM)
717
+ symbiot post page <id> <page> [--clip] [--seconds 8]
718
+ a picture of a web page (localhost too) on a
719
+ draft; --clip records a short video of it,
720
+ scrolling down (needs ffmpeg)
721
+ symbiot post remove <id> <media id>
722
+ take a picture or video off a draft
688
723
  symbiot post skip <id> drop a draft
689
724
  symbiot post log everything drafted, edited, approved, skipped
690
725
  symbiot post voice [--linkedin] your example posts (voice.md), or read your
package/lanes.mjs CHANGED
@@ -15,11 +15,13 @@
15
15
  // it starts when it's free), done (reported back) or error (reported back too).
16
16
  // chain: how many handovers in a row led here; past MAX_CHAIN it stops, so two
17
17
  // lanes can't hand the same thing back and forth forever.
18
- import { join, basename } from "node:path";
19
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodSync } from "node:fs";
18
+ import { join, basename, dirname, resolve, relative } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodSync, statSync } from "node:fs";
20
21
  import { createHash, randomBytes } from "node:crypto";
22
+ import { execFile } from "node:child_process";
21
23
  import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
22
- import { runHandoff, runningHandoff, waitingFor, agentQuestions } from "./agents.mjs";
24
+ import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist } from "./agents.mjs";
23
25
  import { addTask, pushTasks } from "./tasks.mjs";
24
26
  import { actNow } from "./mind.mjs";
25
27
  import { repoPathMap } from "./scan.mjs";
@@ -149,7 +151,7 @@ function report(e, o, { run = runHandoff, running = runningHandoff, waiting = wa
149
151
 
150
152
  // One pass: start what's been handed over, from every lane, then report back
151
153
  // what's done. The app runs it every 20 seconds.
152
- function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now() } = {}) {
154
+ function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now(), relink = orcaRelink } = {}) {
153
155
  const ledger = loadLedger();
154
156
  let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
155
157
  const started = [];
@@ -161,8 +163,133 @@ function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, runnin
161
163
  if (report(e, o, { run, running, now })) { e.reportedAt = now; if (e.status !== "error") e.status = "done"; e.result = o.text; reported.push(e); }
162
164
  }
163
165
  if (started.length || reported.length) saveLedger(ledger);
166
+ if (reported.length) relink({ map }).catch(() => {}); // a handover may have renamed a lane's folder
164
167
  return { started, reported };
165
168
  }
169
+
170
+ // ---- a handover that couldn't start: yours to unblock, on Home ---------------------
171
+ // A handover that errored (an ops run limited to its folder handed "needs a run that
172
+ // can edit ~/Company" to ops, its own lane; a lane that doesn't exist; too long a
173
+ // chain) went back to the agent that asked, and stopped there: Home said nothing
174
+ // needed you. Now it's a question on Home (homeState) for STUCK_FOR, until you
175
+ // answer it or something like it goes through another way. Allow starts it as a
176
+ // run of its own, allowed to read, edit and move files in the folders it names
177
+ // (that run only: its .claude/settings.local.json), and its result goes back to the
178
+ // agent that asked, as any handover's does; Skip lets it go.
179
+ const STUCK_FOR = 3 * 86400000;
180
+ const tilde = (p) => { const h = homedir(); return p === h ? "~" : p.startsWith(h + "/") ? "~" + p.slice(h.length) : p; };
181
+ // Two handovers about the same thing: most of the shorter one's words in the other.
182
+ function alike(a, b) {
183
+ const x = keyWords(a), y = keyWords(b), [s, l] = x.size <= y.size ? [x, y] : [y, x]; if (s.size < 3) return false;
184
+ let n = 0; for (const w of s) if (l.has(w)) n++;
185
+ return n / s.size >= 0.5;
186
+ }
187
+ // The folders a handover names that a run would need: ones on this computer, not
188
+ // the asking run's own, not Symbiot's, not your home itself or a hidden one in it
189
+ // (~/.ssh). A file named in one counts as the folder it's in. Outermost only, 3 at most.
190
+ function namedDirs(text, own = "", { exists = (p) => { try { return statSync(p); } catch { return null; } } } = {}) {
191
+ const home = homedir(), out = [];
192
+ const roots = ["~", "/home", "/Users", "/srv", "/opt", "/mnt", "/media", home].map((r) => r.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|");
193
+ for (const m of String(text || "").matchAll(new RegExp(`(?:^|[\\s\`'"(])((?:${roots})\\/[^\\s\`'",;)]+)`, "g"))) {
194
+ let p = resolve(m[1].replace(/^~(?=\/)/, home).replace(/[.:]+$/, "")); const st = exists(p); if (!st) continue;
195
+ if (!st.isDirectory()) p = dirname(p);
196
+ const rel = relative(home, p);
197
+ if (p === home || p === "/" || (!rel.startsWith("..") && rel.split("/")[0].startsWith(".")) || p.startsWith(CONFIG_DIR) || (own && (p === own || p.startsWith(own + "/")))) continue;
198
+ out.push(p);
199
+ }
200
+ return [...new Set(out)].filter((p, i, all) => !all.some((q) => q !== p && p.startsWith(q + "/"))).slice(0, 3);
201
+ }
202
+ // It went through since another way: the same thing handed over again (or done,
203
+ // after it), or the run that asked has run again and ticked all it had.
204
+ function pastIt(e, hs) {
205
+ if (hs.some((x) => x !== e && x.status !== "error" && Math.max(x.at || 0, x.reportedAt || 0) > e.at && alike(x.text, e.text))) return true;
206
+ const md = readSym(e.from.path, "TASKS.md");
207
+ return lastRunStart(e.from.path) > e.at && /^- \[x\]/im.test(md) && !/^\s*- \[ \]/m.test(md);
208
+ }
209
+ // The errored handovers that still wait on you, newest first:
210
+ // [{ id, from, text, error, at, dirs, q, options }].
211
+ function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs } = {}) {
212
+ const hs = ledger.handoffs, out = [];
213
+ hs.forEach((e) => {
214
+ if (e.status !== "error" || e.skipped || now - (e.at || 0) > STUCK_FOR) return;
215
+ if (pastIt(e, hs)) return;
216
+ const dirs = dirsOf(e.text, e.from.path);
217
+ out.unshift({ id: e.id, from: e.from, text: e.text, error: e.error || "", at: e.at, dirs,
218
+ q: dirs.length ? `Allow this run access to ${dirs.map(tilde).join(" and ")}?` : `${e.from.lane}'s handover didn't start (${String(e.error || "").replace(/[.:]\s.*$|\.$/, "")}). Start it as a run of its own?`,
219
+ options: [dirs.length ? "Allow (recommended)" : "Start it (recommended)", "Skip"] });
220
+ });
221
+ return out;
222
+ }
223
+ // The rules a run gets for the folders you allowed it: read, edit and move files
224
+ // there (git mv too), nothing wide.
225
+ const dirRules = (dirs) => dirs.flatMap((d) => [`Read(/${d}/**)`, `Edit(/${d}/**)`, `Write(/${d}/**)`]).concat(dirs.length ? ["Bash(mv:*)", "Bash(git mv:*)", "Bash(mkdir:*)", "Bash(ls:*)", "Bash(find:*)"] : []);
226
+ function grantDirs(dir, dirs) {
227
+ if (!dirs.length) return null;
228
+ try { writeFileSync(join(dir, ".symbiot", "allowlist.proposed.json"), JSON.stringify({ permissions: { allow: dirRules(dirs), additionalDirectories: dirs } }, null, 2) + "\n"); } catch { return null; }
229
+ return installAllowlist(dir);
230
+ }
231
+ // Allow (or Start it): a run of its own on what was handed over, with what you
232
+ // said added, allowed into the folders it names. The ledger's entry becomes that
233
+ // handover, started, so its result goes back to the agent that asked. { ok, job, dirs } or { error }.
234
+ function allowHandover(id, { note = "", act = actNow, run = runHandoff, now = Date.now(), dirsOf = namedDirs } = {}) {
235
+ const ledger = loadLedger(), e = ledger.handoffs.find((x) => x.id === id);
236
+ if (!e || e.status !== "error" || e.skipped) return { error: "That handover isn't waiting on you any more." };
237
+ const dirs = dirsOf(e.text, e.from.path), said = String(note || "").trim().slice(0, 2000);
238
+ const r = act(e.text + (said ? `\n\nThe user said: ${said}` : ""), { now, title: `handed over by ${e.from.lane}`,
239
+ context: `${e.from.lane}'s agent (in ${e.from.path}) handed this over, and carries on once it's done.${dirs.length ? ` The user allowed this run to read, edit and move files in ${dirs.join(", ")}.` : ""}`,
240
+ run: (dir, o) => { grantDirs(dir, dirs); return run(dir, o); } });
241
+ if (!r || r.error) return { error: (r && r.error) || "The run didn't start." };
242
+ Object.assign(e, { status: "started", to: { lane: OPS, path: r.dir }, job: r.job, at: now, allowedAt: now });
243
+ delete e.error; delete e.reportedAt; delete e.result;
244
+ saveLedger(ledger);
245
+ return { ok: true, job: r.job, dirs };
246
+ }
247
+ // Skip: it stops asking, and the agent that handed it over reads that you skipped it.
248
+ function skipHandover(id, { now = Date.now() } = {}) {
249
+ const ledger = loadLedger(), e = ledger.handoffs.find((x) => x.id === id);
250
+ if (!e || e.status !== "error") return { error: "That handover isn't waiting on you any more." };
251
+ e.skipped = now; saveLedger(ledger);
252
+ const file = join(e.from.path, ".symbiot", "ANSWERS.md");
253
+ try { if (existsSync(join(e.from.path, ".symbiot"))) writeFileSync(file, `${(existsSync(file) ? readFileSync(file, "utf8") : "# Answers\n").replace(/\s*$/, "")}\n\n### Handed over to ${e.to.lane}: ${clipWords(firstLine(e.text), 120)}\nThe user skipped it: don't hand it over again.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`); } catch {}
254
+ return { ok: true };
255
+ }
256
+
257
+ // ---- Orca, kept pointing at the lanes ---------------------------------------------
258
+ // A handover can rename a lane's folder (CallForge AI → dailify), and the run that
259
+ // did it can't tell Orca (its CLI is outside what a run may do), so Orca keeps a
260
+ // path that's gone. After a handover comes back, and when the app starts, each repo
261
+ // Orca has at a path that's gone is added again where it is now: the scanned repo
262
+ // with the GitHub remote Orca kept for it, else the one with its name. Orca's CLI
263
+ // can't drop the old entry: it shows as missing there until you remove it.
264
+ const remoteKey = (url) => String(url || "").trim().replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "").replace(/:(?!\d)/, "/").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
265
+ const gitRemote = (path) => new Promise((res) => execFile("git", ["-C", path, "remote", "get-url", "origin"], { timeout: 5000, encoding: "utf8" }, (e, out) => res(e ? "" : out)));
266
+ const runOrca = (cli, args) => new Promise((res) => execFile(cli, args, { timeout: 20000, encoding: "utf8" }, (e, out) => res(e ? "" : out)));
267
+ // Orca's repos ({ path, displayName, gitRemoteIdentity }) whose folder is gone, each
268
+ // with where it is now: [{ from, to, name }]. Only a single match moves it.
269
+ async function orcaMoves(repos, map, { remoteOf = gitRemote, exists = existsSync } = {}) {
270
+ const known = new Set(repos.map((r) => r.path)), free = Object.values(map).filter((p) => !known.has(p) && exists(p)), remotes = new Map();
271
+ const remote = async (p) => { if (!remotes.has(p)) remotes.set(p, remoteKey(await remoteOf(p))); return remotes.get(p); };
272
+ const moves = [];
273
+ for (const r of repos) {
274
+ if (!r.path || exists(r.path)) continue;
275
+ const key = remoteKey((r.gitRemoteIdentity || {}).canonicalKey), hit = [];
276
+ if (key) for (const p of free) if ((await remote(p)) === key) hit.push(p);
277
+ if (!hit.length) { const names = [r.displayName, basename(r.path)].filter(Boolean).map((s) => s.toLowerCase()); hit.push(...free.filter((p) => names.includes(basename(p).toLowerCase()))); }
278
+ if (hit.length === 1 && !moves.some((m) => m.to === hit[0])) moves.push({ from: r.path, to: hit[0], name: r.displayName || basename(hit[0]) });
279
+ }
280
+ return moves;
281
+ }
282
+ // Add each moved repo again in Orca, where it is now. Nothing when Orca isn't
283
+ // installed or isn't running (its CLI can't reach it), and never from a sandbox
284
+ // (symbiot app --fresh): that Orca is your real one. { moves: [{ from, to, name, ok }] }
285
+ async function orcaRelink({ map, cli, orca = runOrca, remoteOf } = {}) {
286
+ if (process.env.SYMBIOT_SANDBOX) return { moves: [] };
287
+ cli = cli ?? findOrcaCli(); if (!cli) return { moves: [] };
288
+ let repos = []; try { repos = JSON.parse(await orca(cli, ["repo", "list", "--json"])).result.repos || []; } catch { return { moves: [] }; }
289
+ const moves = await orcaMoves(repos, map || repoPathMap(), { remoteOf });
290
+ for (const m of moves) m.ok = /"ok":\s*true/.test(await orca(cli, ["repo", "add", "--path", m.to, "--json"]));
291
+ return { moves };
292
+ }
166
293
  // The Agents tab's list: newest first. Each shows its first line; full, all of a
167
294
  // longer one, and result, all of what came back, open under it. What reads the
168
295
  // same (one lane to the same lane, the same first line: a run that handed it
@@ -178,4 +305,4 @@ function lanesState() {
178
305
  return { handoffs: out.slice(0, 30) };
179
306
  }
180
307
 
181
- export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone };
308
+ export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone, remoteKey, orcaMoves, orcaRelink, stuckHandovers, allowHandover, skipHandover, namedDirs, alike };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "symbiot",
3
- "version": "0.54.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.54.4",
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"