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/post.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  // symbiot — Post: your week's real work as three draft posts (LinkedIn), in your
2
- // own voice, each waiting on you on the Dashboard: Approve, Edit or Skip.
2
+ // own voice, each waiting on you under Marketing: Approve, Edit or Skip.
3
3
  //
4
4
  // - What it writes from: the last 7 days of commits, the release tags (and gh's
5
5
  // releases, when gh works) and the CHANGELOG.md sections dated in that window,
@@ -14,20 +14,25 @@
14
14
  // share box for you to paste it. It doesn't schedule.
15
15
  // - Every action (drafted, edited, approved, skipped, dropped, replaced) is
16
16
  // appended to posts-log.jsonl, with the text, the date and the platform.
17
+ // - Pictures and videos: each draft says in a line what would show it best
18
+ // (show), and can carry its own: a picture or video of yours, a picture of a
19
+ // page, or a short clip of one (see "pictures and videos" below). One whose idea
20
+ // is a screen of an app you run here arrives with its picture (a picture by itself).
17
21
  //
18
22
  // Stored in ~/.config/symbiot/posts.json, readable by you only:
19
- // { posts: [{ id, kind, text, facts: [n], sources: [fact line], platform, status, drafted, edited?, approved?, skipped? }] }
20
- // status: waiting (on the Dashboard), approved, skipped or replaced (a newer draft took its place).
23
+ // { posts: [{ id, kind, text, show, facts: [n], sources: [fact line], media: [{ id, kind, file, name, from, url?, bytes, added }], platform, status, drafted, edited?, approved?, skipped? }], lastUrl }
24
+ // status: waiting (under Marketing), approved, skipped or replaced (a newer draft took its place).
21
25
  import { join, dirname } from "node:path";
22
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, chmodSync, existsSync } from "node:fs";
26
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, chmodSync, existsSync, readdirSync, rmSync, readlinkSync } from "node:fs";
23
27
  import { spawn, spawnSync } from "node:child_process";
24
28
  import { randomBytes } from "node:crypto";
29
+ import { connect } from "node:net";
25
30
  import { CONFIG_DIR, sh, hasCmd, loadConfig } from "./core.mjs";
26
31
  import { resolveProvider, write } from "./ai.mjs";
27
32
  import { commits, discoveredRepos } from "./scan.mjs";
28
- import { readTexts } from "./headless.mjs";
33
+ import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
29
34
 
30
- const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md") };
35
+ 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") };
31
36
  const PLATFORM = "linkedin";
32
37
  // LinkedIn's share box, opened for you to paste an approved post into.
33
38
  const SHARE_URL = "https://www.linkedin.com/feed/?shareActive=true";
@@ -37,7 +42,7 @@ const MAX_FACTS = 120, MAX_TEXT = 3000, KEEP = 200; // LinkedIn takes 3000 chara
37
42
  const q = (s) => JSON.stringify(String(s));
38
43
 
39
44
  const NO_AI = "Symbiot needs an AI to draft your posts, and none is connected. Connect one with symbiot login (or Settings in the app), then run symbiot post again. Nothing was drafted, saved or logged.";
40
- const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (Dashboard → Links → LinkedIn, sign in once) and click Fill from LinkedIn (or run symbiot post voice --linkedin), or paste 5–10 posts you wrote into ${file}, with a line of --- between each. Nothing was drafted.`;
45
+ const noVoice = (file) => `Symbiot drafts posts in your voice, from examples of your own posts, and has none yet. Either link LinkedIn (Marketing → Link LinkedIn, sign in once) and click Fill from LinkedIn (or run symbiot post voice --linkedin), or paste 5–10 posts you wrote into ${file}, with a line of --- between each. Nothing was drafted.`;
41
46
 
42
47
  // ---- your voice ------------------------------------------------------------------
43
48
  // voice.md's examples: what's between lines of ---, each long enough to be a post.
@@ -57,7 +62,7 @@ const tidyPost = (t) => noTagLabels(t).replace(/\s*…\s*(see )?more\s*$/i, "").
57
62
  async function voiceFromLinkedIn({ read = readTexts, paths = PATHS, max = 10 } = {}) {
58
63
  const r = (await read(LINKEDIN_ACTIVITY, LINKEDIN_POST_TEXT)) || { error: "Nothing came back from LinkedIn." };
59
64
  if (r.busy) return { error: "Symbiot's browser is busy (a sign-in window, or a map). Close it, then try again." };
60
- if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn on the Dashboard (Links → LinkedIn), sign in in the window that opens, close it, then try again." };
65
+ if (r.login || /linkedin\.com\/(login|authwall|uas\/|checkpoint|signup)/i.test(r.url || "")) return { error: "You're not signed in to LinkedIn in Symbiot's browser. Link LinkedIn (Marketing → Link LinkedIn, or Connections), sign in in the window that opens, close it, then try again." };
61
66
  if (r.error) return { error: r.error };
62
67
  const found = []; for (const t of (r.texts || []).map(tidyPost)) if (t.length >= 40 && !found.includes(t)) found.push(t);
63
68
  if (!found.length) return { error: `Found no posts of yours on LinkedIn's activity page. Paste 5–10 of your posts into ${paths.voice} instead, with a line of --- between each.` };
@@ -176,7 +181,7 @@ function extractJson(s) {
176
181
  // The AI's answer as drafts, by kind: [{ kind, text, facts: [n] }].
177
182
  function parseDrafts(raw) {
178
183
  const j = extractJson(raw), list = j && Array.isArray(j.posts) ? j.posts : [];
179
- return list.map((p, i) => ({ kind: KINDS.includes(p && p.kind) ? p.kind : KINDS[i] || "", text: noTagLabels((p && p.text) || "").trim().slice(0, MAX_TEXT), facts: (Array.isArray(p && p.facts) ? p.facts : []).map(Number).filter(Number.isInteger) }))
184
+ return list.map((p, i) => ({ kind: KINDS.includes(p && p.kind) ? p.kind : KINDS[i] || "", text: noTagLabels((p && p.text) || "").trim().slice(0, MAX_TEXT), show: String((p && p.show) || "").replace(/\s+/g, " ").trim().slice(0, 200), facts: (Array.isArray(p && p.facts) ? p.facts : []).map(Number).filter(Number.isInteger) }))
180
185
  .filter((p) => p.kind && p.text);
181
186
  }
182
187
  // A draft checked against the facts: the facts it cites that exist, and what it
@@ -192,49 +197,63 @@ const SYSTEM = `You draft LinkedIn posts for a developer, from their real work t
192
197
  `Write the way their example posts do (length, tone, emoji, hashtags, how they open and close), but take nothing from the examples as fact: they're old posts. ` +
193
198
  `Every claim must come from the numbered facts. Never invent anything: no numbers, versions, names, users, downloads, revenue, results or testimonials the facts don't show, ` +
194
199
  `and no counts ("12 commits"). Use versions and names exactly as the facts write them. Plain text, no markdown, no links unless a fact has one. Never say it was written by AI. ` +
195
- `Answer with JSON only: {"posts":[{"kind":"shipped","text":"…","facts":[1,4]},{"kind":"learned","text":"…","facts":[…]},{"kind":"long","text":"…","facts":[…]}]}, ` +
196
- `where "facts" lists the numbers of every fact the post uses.`;
197
- // Draft this week's 3 posts and put them on the Dashboard (waiting on you). With
200
+ `Answer with JSON only: {"posts":[{"kind":"shipped","text":"…","show":"…","facts":[1,4]},{"kind":"learned","text":"…","show":"…","facts":[…]},{"kind":"long","text":"…","show":"…","facts":[…]}]}, ` +
201
+ `where "facts" lists the numbers of every fact the post uses, and "show" is one short line (not posted) saying what picture or short video would show it best, ` +
202
+ `for them to take: the screen, page or command the post is about, as the facts name it (e.g. "a picture of the new Reports view", "a clip of the install running").`;
203
+ // Draft this week's 3 posts and put them under Marketing (waiting on you). With
198
204
  // no AI connected it says so and does nothing else: nothing is read, written or
199
205
  // logged. `ask` is ai.mjs write (the tests pass their own); `repos`, `gh`, `now`
200
206
  // and `paths` likewise. Gives { ok, posts, dropped, facts } or { error, code }.
201
- async function draftPosts({ ask = write, now = Date.now(), days = 7, repos, gh, paths = PATHS } = {}) {
207
+ async function draftPosts({ ask = write, now = Date.now(), days = 7, repos, gh, paths = PATHS, apps = localApps, shoot = pagePicture } = {}) {
202
208
  if (ask === write && !resolveProvider()) return { error: NO_AI, code: "not-connected" };
203
209
  const voice = loadVoice(paths.voice);
204
210
  if (!voice.length) return { error: noVoice(paths.voice), code: "no-voice" };
205
- const facts = gatherFacts({ days, now, repos, gh });
211
+ const list = (repos || discoveredRepos()).filter((r) => r && r.path);
212
+ const facts = gatherFacts({ days, now, repos: list, gh });
206
213
  if (!facts.length) return { error: `Nothing in git in the last ${days} days (no commits, release tags or dated CHANGELOG.md sections) in the repos Symbiot scans, so there's nothing true to post about. Nothing was drafted.`, code: "no-facts" };
207
214
  const listed = facts.map(factLine).join("\n");
208
215
  const prompt = `Example posts of mine (my voice; not facts about this week):\n\n${voice.slice(0, 10).map((v, i) => `Example ${i + 1}:\n${v}`).join("\n\n")}\n\n` +
209
216
  `This week's facts (the only things you may claim), numbered:\n${listed}\n\nDraft the 3 posts.`;
210
217
  const said = async (sys, p) => { const raw = await ask(sys, p); return !raw || /^\(?couldn't reach the model/i.test(raw) ? null : raw; };
211
- const first = await said(SYSTEM, prompt);
212
- if (first == null) return { error: "Your AI didn't answer (or turned the request down). Nothing was drafted. Check it with symbiot whoami, then try again.", code: "no-answer" };
213
- const byKind = new Map(); for (const p of parseDrafts(first)) if (!byKind.has(p.kind)) byKind.set(p.kind, vetDraft(p, facts, voice));
214
- // once more for any that's missing or claims what git doesn't show, told what was wrong
215
- const wrong = KINDS.filter((k) => !byKind.get(k) || !byKind.get(k).ok);
216
- if (wrong.length) {
217
- const why = wrong.map((k) => { const d = byKind.get(k); return `- "${k}": ${!d ? "missing" : !d.facts.length ? "it cited no facts" : `it claims ${d.unsupported.map((x) => `"${x}"`).join(", ")}, which the facts don't show`}`; }).join("\n");
218
- const again = await said(SYSTEM, `${prompt}\n\nYour last answer had problems:\n${why}\n\nWrite only ${wrong.map((k) => `"${k}"`).join(", ")} again, in the same JSON shape, using only what the facts show.`);
219
- for (const p of again ? parseDrafts(again) : []) if (wrong.includes(p.kind)) { const v = vetDraft(p, facts, voice); if (v.ok || !byKind.get(p.kind)) byKind.set(p.kind, v); }
218
+ // One try: the 3 drafts, then once more for any that's missing or claims what git
219
+ // doesn't show, told what was wrong. null when the AI didn't answer.
220
+ const attempt = async () => {
221
+ const first = await said(SYSTEM, prompt); if (first == null) return null;
222
+ const byKind = new Map(); for (const p of parseDrafts(first)) if (!byKind.has(p.kind)) byKind.set(p.kind, vetDraft(p, facts, voice));
223
+ const wrong = KINDS.filter((k) => !byKind.get(k) || !byKind.get(k).ok);
224
+ if (wrong.length) {
225
+ const why = wrong.map((k) => { const d = byKind.get(k); return `- "${k}": ${!d ? "missing" : !d.facts.length ? "it cited no facts" : `it claims ${d.unsupported.map((x) => `"${x}"`).join(", ")}, which the facts don't show`}`; }).join("\n");
226
+ const again = await said(SYSTEM, `${prompt}\n\nYour last answer had problems:\n${why}\n\nWrite only ${wrong.map((k) => `"${k}"`).join(", ")} again, in the same JSON shape, using only what the facts show.`);
227
+ for (const p of again ? parseDrafts(again) : []) if (wrong.includes(p.kind)) { const v = vetDraft(p, facts, voice); if (v.ok || !byKind.get(p.kind)) byKind.set(p.kind, v); }
228
+ }
229
+ return KINDS.map((k) => byKind.get(k)).filter(Boolean);
230
+ };
231
+ const logDropped = (ds) => { for (const x of ds.filter((y) => !y.ok)) logAction("dropped", { id: "", kind: x.kind, text: x.text, platform: PLATFORM }, now, paths, { why: x.facts.length ? `claims ${x.unsupported.join(", ")}, which git doesn't show` : "cited no facts" }); };
232
+ let drafts = await attempt(), retried = false;
233
+ if (drafts == null) return { error: "Your AI didn't answer (or turned the request down). Nothing was drafted. Check it with symbiot whoami, then try again.", code: "no-answer" };
234
+ // every one dropped: a fresh try by itself before giving up (week 1's first try
235
+ // kept none of 3; the second, the same request again, kept all 3)
236
+ if (!drafts.some((x) => x.ok)) {
237
+ logDropped(drafts); retried = true;
238
+ const again = await attempt(); if (again && again.length) drafts = again; else drafts = drafts.map((x) => ({ ...x, logged: true }));
220
239
  }
221
- const good = KINDS.map((k) => byKind.get(k)).filter((d) => d && d.ok), dropped = KINDS.map((k) => byKind.get(k)).filter((d) => d && !d.ok);
222
- const logDropped = () => { for (const x of dropped) logAction("dropped", { id: "", kind: x.kind, text: x.text, platform: PLATFORM }, now, paths, { why: x.facts.length ? `claims ${x.unsupported.join(", ")}, which git doesn't show` : "cited no facts" }); };
240
+ const good = drafts.filter((x) => x.ok), dropped = drafts.filter((x) => !x.ok);
223
241
  const droppedOut = dropped.map((x) => ({ kind: x.kind, unsupported: x.unsupported, cited: x.facts.length }));
224
- // none that git backs: the drafts already waiting stay
225
- if (!good.length) { logDropped(); return { error: "Your AI's drafts all claimed things git doesn't show (or cited nothing), so none was kept. Try again, or add to voice.md.", code: "all-dropped", dropped: droppedOut }; }
242
+ // none that git backs, twice: the drafts already waiting stay
243
+ if (!good.length) { logDropped(dropped.filter((x) => !x.logged)); return { error: "Your AI's drafts all claimed things git doesn't show (or cited nothing), twice, so none was kept. Try again, or add to voice.md.", code: "all-dropped", dropped: droppedOut, retried }; }
226
244
  const d = loadPosts(paths);
227
245
  for (const p of d.posts) if (p.status === "waiting") { p.status = "replaced"; p.replaced = now; logAction("replaced", p, now, paths); }
228
- const posts = good.map((g) => ({ id: randomBytes(4).toString("hex"), kind: g.kind, text: g.text, facts: g.facts, sources: g.facts.map((n) => factLine(facts[n - 1], n - 1)), platform: PLATFORM, status: "waiting", drafted: now }));
246
+ const posts = good.map((g) => ({ id: randomBytes(4).toString("hex"), kind: g.kind, text: g.text, show: g.show, facts: g.facts, sources: g.facts.map((n) => factLine(facts[n - 1], n - 1)), media: [], platform: PLATFORM, status: "waiting", drafted: now }));
229
247
  d.posts = [...posts, ...d.posts].slice(0, KEEP);
230
- savePosts(d, paths);
248
+ savePosts(d, paths); pruneMedia(d, paths);
231
249
  for (const p of posts) logAction("drafted", p, now, paths);
232
- logDropped();
233
- return { ok: true, posts, dropped: droppedOut, facts: facts.length, voice: voice.length };
250
+ logDropped(dropped);
251
+ let pictures = 0; try { pictures = await picturesFor(posts, facts, { apps: await apps(list), shoot, now, paths }); } catch {}
252
+ return { ok: true, posts, dropped: droppedOut, facts: facts.length, voice: voice.length, ...(retried ? { retried: true } : {}), ...(pictures ? { pictures } : {}) };
234
253
  }
235
254
 
236
255
  // ---- the drafts, and what you do with them ------------------------------------------
237
- function loadPosts(paths = PATHS) { try { const d = JSON.parse(readFileSync(paths.posts, "utf8")); return { posts: Array.isArray(d.posts) ? d.posts : [] }; } catch { return { posts: [] }; } }
256
+ function loadPosts(paths = PATHS) { try { const d = JSON.parse(readFileSync(paths.posts, "utf8")); return { posts: Array.isArray(d.posts) ? d.posts : [], lastUrl: typeof d.lastUrl === "string" ? d.lastUrl : "" }; } catch { return { posts: [], lastUrl: "" }; } }
238
257
  function savePosts(d, paths = PATHS) { try { saveText(paths.posts, JSON.stringify(d, null, 2)); return true; } catch { return false; } }
239
258
  // The log: one JSON line per action, only ever appended to. Yours only (0600).
240
259
  function logAction(action, p, now = Date.now(), paths = PATHS, extra = {}) {
@@ -246,14 +265,16 @@ function logAction(action, p, now = Date.now(), paths = PATHS, extra = {}) {
246
265
  } catch { return false; }
247
266
  }
248
267
  function postLog(paths = PATHS) { try { return readFileSync(paths.log, "utf8").split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); } catch { return []; } }
249
- // What the Dashboard shows: the drafts waiting on you, the last few you dealt with,
268
+ // What Marketing shows: the drafts waiting on you, the last few you dealt with,
250
269
  // and whether it can draft: an AI connected, and your voice (examples in voice.md,
251
- // or LinkedIn linked to fill it from). Until then the Dashboard says so in a line.
270
+ // or LinkedIn linked to fill it from). Until then Marketing says so in a line, and
271
+ // Home shows no Marketing orb (home.mjs).
252
272
  function postsState(paths = PATHS, { linked = () => (loadConfig().linked || {}) } = {}) {
253
273
  const d = loadPosts(paths), voice = loadVoice(paths.voice).length, connected = !!resolveProvider();
254
274
  let linkedin = false; try { linkedin = !!linked().linkedin; } catch {}
255
275
  return { posts: d.posts.filter((p) => p.status === "waiting"), done: d.posts.filter((p) => p.status === "approved" || p.status === "skipped").slice(0, 5),
256
- voice: { count: voice, file: paths.voice }, connected, linkedin, canDraft: connected && (linkedin || voice > 0), share: SHARE_URL, labels: KIND_LABEL };
276
+ voice: { count: voice, file: paths.voice }, connected, linkedin, canDraft: connected && (linkedin || voice > 0), share: SHARE_URL, labels: KIND_LABEL,
277
+ lastUrl: d.lastUrl, canClip: canClip(), clip: CLIP };
257
278
  }
258
279
  // One waiting draft, by id (or the start of one), and the store it's in.
259
280
  function waiting(id, paths) {
@@ -281,13 +302,203 @@ function skipPost(id, { now = Date.now(), paths = PATHS } = {}) {
281
302
  }
282
303
  // Approve: your yes to this one post. It's logged, then copied to your clipboard
283
304
  // (`copy`: copyText in a terminal; the app's page copies it itself, so it passes
284
- // null) for you to paste into LinkedIn's share box (`share`). Symbiot doesn't post
285
- // or schedule it.
305
+ // null) for you to paste into LinkedIn's share box (`share`), with its pictures or
306
+ // video (`files`, in `folder`) to add there. Symbiot doesn't post or schedule it.
286
307
  function approvePost(id, { copy = copyText, now = Date.now(), paths = PATHS } = {}) {
287
308
  const w = waiting(id, paths); if (w.error) return w;
288
- w.p.status = "approved"; w.p.approved = now; savePosts(w.d, paths); logAction("approved", w.p, now, paths);
309
+ w.p.status = "approved"; w.p.approved = now; savePosts(w.d, paths); logAction("approved", w.p, now, paths, mediaNote(w.p));
289
310
  const copied = copy ? copy(w.p.text) : "";
290
- return { ok: true, post: w.p, copied, share: SHARE_URL, note: "Symbiot doesn't post or schedule it: paste it into LinkedIn's share box and post it yourself." };
311
+ const files = mediaFiles(w.p, paths), what = mediaWords(w.p.media);
312
+ return { ok: true, post: w.p, copied, share: SHARE_URL, files, folder: files.length ? mediaDir(w.p.id, paths) : "",
313
+ note: `Symbiot doesn't post or schedule it: paste it into LinkedIn's share box${what ? `, add its ${what} there (the photo or video button)` : ""} and post it yourself.` };
314
+ }
315
+
316
+ // ---- pictures and videos -----------------------------------------------------------
317
+ // A post on LinkedIn takes one video, or up to 20 pictures, not both. A draft can
318
+ // carry its own: a picture or video of yours, a picture of a page (the hidden
319
+ // browser, at twice the pixels), or a short clip of one (recorded there, scrolling
320
+ // slowly down, and made an MP4 by ffmpeg). Each is a copy, kept yours only in
321
+ // post-media/<post id>/ in Symbiot's config folder. Symbiot attaches nothing:
322
+ // LinkedIn's share box can't be filled in from outside, so Approve gives you the
323
+ // files (and the app opens their folder) to add there yourself.
324
+ const MEDIA = { png: ["picture", "image/png"], jpg: ["picture", "image/jpeg"], gif: ["picture", "image/gif"], mp4: ["video", "video/mp4"], mov: ["video", "video/quicktime"], webm: ["video", "video/webm"] };
325
+ const MAX_PICTURES = 20, MAX_BYTES = { picture: 20 * 1024 * 1024, video: 500 * 1024 * 1024 };
326
+ const NOT_MEDIA = "That isn't a picture or video LinkedIn takes: a PNG, JPG or GIF picture, or an MP4, MOV or WebM video.";
327
+ // What a file is, by its first bytes (not its name): a key of MEDIA, or "".
328
+ function mediaType(buf) {
329
+ if (!Buffer.isBuffer(buf) || buf.length < 12) return "";
330
+ if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
331
+ if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg";
332
+ if (buf.subarray(0, 4).toString("latin1") === "GIF8") return "gif";
333
+ const box = buf.subarray(4, 8).toString("latin1");
334
+ if (box === "ftyp") return buf.subarray(8, 10).toString("latin1") === "qt" ? "mov" : "mp4";
335
+ if (["moov", "mdat", "wide"].includes(box)) return "mov";
336
+ if (buf.readUInt32BE(0) === 0x1a45dfa3) return "webm";
337
+ return "";
338
+ }
339
+ const mediaDir = (postId, paths = PATHS) => join(paths.media, String(postId).replace(/[^\w-]/g, ""));
340
+ const mediaFiles = (p, paths = PATHS) => (p.media || []).map((m) => join(mediaDir(p.id, paths), m.file));
341
+ // "2 pictures", "its video": what a post carries, in words ("" for nothing)
342
+ function mediaWords(media = []) {
343
+ const v = media.filter((m) => m.kind === "video").length, n = media.length - v;
344
+ return v ? "video" : n ? (n === 1 ? "picture" : `${n} pictures`) : "";
345
+ }
346
+ const mediaNote = (p) => ((p.media || []).length ? { media: (p.media || []).map((m) => ({ kind: m.kind, from: m.from, file: m.file, ...(m.url ? { url: m.url } : {}) })) } : {});
347
+ // Add a picture or video (a Buffer, or base64) to a waiting draft. `from`: yours, page or clip.
348
+ function addMedia(id, data, { name = "", from = "yours", url = "", now = Date.now(), paths = PATHS } = {}) {
349
+ const w = waiting(id, paths); if (w.error) return w;
350
+ const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data || ""), "base64"), ext = mediaType(buf);
351
+ if (!ext) return { error: NOT_MEDIA };
352
+ const kind = MEDIA[ext][0], have = w.p.media || [];
353
+ if (buf.length > MAX_BYTES[kind]) return { error: `That ${kind} is ${Math.round(buf.length / 1048576)} MB: Symbiot keeps ${kind === "video" ? "videos" : "pictures"} up to ${MAX_BYTES[kind] / 1048576} MB.` };
354
+ if (kind === "video" && have.length) return { error: have.some((m) => m.kind === "video") ? "LinkedIn takes one video a post: remove this one's first." : "LinkedIn takes pictures or a video in a post, not both: remove the pictures first." };
355
+ if (kind === "picture" && have.some((m) => m.kind === "video")) return { error: "LinkedIn takes pictures or a video in a post, not both: remove the video first." };
356
+ if (have.length >= MAX_PICTURES) return { error: `LinkedIn takes up to ${MAX_PICTURES} pictures a post.` };
357
+ const mid = randomBytes(4).toString("hex");
358
+ const m = { id: mid, kind, file: `${mid}.${ext}`, name: String(name || "").replace(/\s+/g, " ").trim().slice(0, 120), from, ...(url ? { url: String(url).slice(0, 500) } : {}), bytes: buf.length, added: now };
359
+ try { mkdirSync(mediaDir(w.p.id, paths), { recursive: true, mode: 0o700 }); saveText(join(mediaDir(w.p.id, paths), m.file), buf); } catch (e) { return { error: `Couldn't keep it: ${e.message}` }; }
360
+ w.p.media = [...have, m]; savePosts(w.d, paths); logAction("media", w.p, now, paths, { added: { kind, from, file: m.file, ...(m.url ? { url: m.url } : {}) } });
361
+ return { ok: true, post: w.p, media: m };
362
+ }
363
+ function removeMedia(id, mediaId, { now = Date.now(), paths = PATHS } = {}) {
364
+ const w = waiting(id, paths); if (w.error) return w;
365
+ const m = (w.p.media || []).find((x) => x.id === mediaId); if (!m) return { error: "That picture or video isn't on this draft." };
366
+ try { rmSync(join(mediaDir(w.p.id, paths), m.file), { force: true }); } catch {}
367
+ w.p.media = w.p.media.filter((x) => x !== m); savePosts(w.d, paths); logAction("media", w.p, now, paths, { removed: { kind: m.kind, file: m.file } });
368
+ return { ok: true, post: w.p };
369
+ }
370
+ // A draft's picture or video, for the app to show: { file, type }, or null.
371
+ function mediaFile(postId, mediaId, paths = PATHS) {
372
+ const p = loadPosts(paths).posts.find((x) => x.id === postId), m = p && (p.media || []).find((x) => x.id === mediaId);
373
+ const file = m && join(mediaDir(p.id, paths), m.file);
374
+ return file && existsSync(file) ? { file, type: (MEDIA[m.file.split(".").pop()] || [])[1] || "application/octet-stream" } : null;
375
+ }
376
+ // The folders of drafts no longer kept (posts.json keeps the last KEEP), and a clip's leftovers, go.
377
+ function pruneMedia(d, paths = PATHS) {
378
+ const ids = new Set(d.posts.map((p) => p.id));
379
+ let dirs = []; try { dirs = readdirSync(paths.media); } catch { return; }
380
+ for (const x of dirs) if (!ids.has(x)) try { rmSync(join(paths.media, x), { recursive: true, force: true }); } catch {}
381
+ }
382
+ // The page you gave, kept as the next one's default.
383
+ function rememberUrl(url, paths) { const d = loadPosts(paths); d.lastUrl = url; savePosts(d, paths); }
384
+ // A picture of a page (headless.mjs pagePicture), added to a waiting draft. Only on your click.
385
+ async function pictureOfPage(id, input, { shoot = pagePicture, now = Date.now(), paths = PATHS } = {}) {
386
+ const w = waiting(id, paths); if (w.error) return w;
387
+ const url = siteUrl(input); if (!url) return { error: "Give a page: a web address (localhost:3000 works), a host (github.com/you) or a site's name." };
388
+ rememberUrl(String(input).trim(), paths);
389
+ const r = (await shoot(url)) || { error: "Nothing came back from the page." };
390
+ if (r.error) return { error: r.error };
391
+ return addMedia(id, r.png, { name: r.title || url, from: "page", url: r.url || url, now, paths });
392
+ }
393
+ // A clip of a page: recorded (headless.mjs pageClip), made an MP4 (`encode`), added. Needs ffmpeg.
394
+ let FFMPEG = null;
395
+ const canClip = () => (FFMPEG === null ? (FFMPEG = hasCmd("ffmpeg")) : FFMPEG);
396
+ const NO_FFMPEG = "Recording a clip needs ffmpeg, which isn't installed (on Linux: sudo apt install ffmpeg; on a Mac: brew install ffmpeg). Or add a video of yours.";
397
+ async function clipOfPage(id, input, { seconds = 8, record = pageClip, encode = toVideo, now = Date.now(), paths = PATHS } = {}) {
398
+ const w = waiting(id, paths); if (w.error) return w;
399
+ if (encode === toVideo && !canClip()) return { error: NO_FFMPEG };
400
+ if ((w.p.media || []).length) return { error: (w.p.media || []).some((m) => m.kind === "video") ? "LinkedIn takes one video a post: remove this one's first." : "LinkedIn takes pictures or a video in a post, not both: remove the pictures first." };
401
+ const url = siteUrl(input); if (!url) return { error: "Give a page: a web address (localhost:3000 works), a host (github.com/you) or a site's name." };
402
+ rememberUrl(String(input).trim(), paths);
403
+ const r = (await record(url, seconds)) || { error: "Nothing came back from the page." };
404
+ if (r.error) return { error: r.error };
405
+ const v = encode(r.frames, r.end, paths);
406
+ if (v.error) return { error: v.error };
407
+ return addMedia(id, v.mp4, { name: r.title || url, from: "clip", url: r.url || url, now, paths });
408
+ }
409
+ // ffmpeg's concat list for frames that each show until the next one (the last
410
+ // until `end`), times in seconds: so a page that sat still stays still that long.
411
+ function concatList(frames, end) {
412
+ const name = (i) => `f${String(i).padStart(5, "0")}.jpg`, out = ["ffconcat version 1.0"];
413
+ frames.forEach((f, i) => out.push(`file ${name(i)}`, `duration ${Math.max(0.001, (i + 1 < frames.length ? frames[i + 1].ts : end) - f.ts).toFixed(3)}`));
414
+ if (frames.length) out.push(`file ${name(frames.length - 1)}`); // ffmpeg keeps the last one's duration only when it's listed again
415
+ return out.join("\n") + "\n";
416
+ }
417
+ const FFMPEG_ARGS = ["-y", "-loglevel", "error", "-f", "concat", "-i", "list.ffconcat", "-vf", "fps=30,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p",
418
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-movflags", "+faststart", "-an", "clip.mp4"];
419
+ // The frames as an MP4 that plays anywhere (H.264, yuv420p): { mp4 } or { error }.
420
+ function toVideo(frames, end, paths = PATHS) {
421
+ const dir = join(paths.media, `.clip-${randomBytes(4).toString("hex")}`);
422
+ try {
423
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
424
+ frames.forEach((f, i) => writeFileSync(join(dir, `f${String(i).padStart(5, "0")}.jpg`), f.jpeg));
425
+ writeFileSync(join(dir, "list.ffconcat"), concatList(frames, end));
426
+ const r = spawnSync("ffmpeg", FFMPEG_ARGS, { cwd: dir, encoding: "utf8", timeout: 180000 });
427
+ if (r.error || r.status !== 0) return { error: `ffmpeg couldn't make the video: ${String((r.error && r.error.message) || r.stderr || "").trim().split("\n").pop() || "it stopped"}` };
428
+ return { mp4: readFileSync(join(dir, "clip.mp4")) };
429
+ } catch (e) { return { error: `Couldn't make the video: ${e.message}` }; }
430
+ finally { try { rmSync(dir, { recursive: true, force: true }); } catch {} }
431
+ }
432
+
433
+ // ---- a picture by itself ---------------------------------------------------------------
434
+ // A draft whose picture idea (show) names a screen of an app you run here arrives
435
+ // with a picture of it, taken in the hidden browser as it's drafted: keep it or
436
+ // remove it (it saves that step most weeks). The apps (localApps): each one
437
+ // listening on localhost, as the repo it was started in (its process's folder),
438
+ // and Symbiot's own app, open, as the symbiot repo: its screens by name (the Reports
439
+ // view → #reports). Only a draft about that repo, whose idea is a picture of a
440
+ // screen (not a clip, not a command), and nothing on failure: the idea stays.
441
+ const SCREEN_WORD = /\b(screens?|pages?|views?|dashboards?|tabs?|panels?|panes?|home ?page|ui|app|window|cards?|blobs?|map)\b/i;
442
+ const NOT_A_PICTURE = /\b(clip|video|recording|gif|terminal|command|cli|commit|diff|code|log|chart|graph)\b/i;
443
+ const SYMBIOT_SCREENS = [["reports", /\breports?\b/i], ["board", /\bdashboard\b/i], ["map", /\bmap\b/i], ["agents", /\bagents?\b/i], ["tasks", /\btasks?\b/i], ["week", /\bweek(ly)?\b/i], ["standup", /\bstand-?up\b/i], ["todo", /\bto-?do\b/i], ["drift", /\bdrift\b/i], ["settings", /\bsettings\b/i]];
444
+ const isSymbiot = (path) => { try { return JSON.parse(readFileSync(join(path, "package.json"), "utf8")).name === "symbiot"; } catch { return false; } };
445
+ // What's listening on localhost: [{ port, pid }]. ss on Linux, else lsof.
446
+ function listening() {
447
+ const out = [], local = /^(127\.0\.0\.1|0\.0\.0\.0|\*|\[::1?\]|::1?|localhost)$/;
448
+ if (hasCmd("ss")) {
449
+ for (const l of sh("ss -ltnpH 2>/dev/null").split("\n")) {
450
+ const f = l.trim().split(/\s+/), addr = f[3] || "", at = addr.lastIndexOf(":"), pid = (l.match(/pid=(\d+)/) || [])[1];
451
+ if (at > 0 && pid && local.test(addr.slice(0, at))) out.push({ port: Number(addr.slice(at + 1)), pid: Number(pid) });
452
+ }
453
+ } else if (hasCmd("lsof")) {
454
+ let pid = 0; for (const l of sh("lsof -nP -iTCP -sTCP:LISTEN -Fpn 2>/dev/null").split("\n")) {
455
+ if (l[0] === "p") pid = Number(l.slice(1));
456
+ else if (l[0] === "n") { const at = l.lastIndexOf(":"); if (at > 1 && local.test(l.slice(1, at))) out.push({ port: Number(l.slice(at + 1)), pid }); }
457
+ }
458
+ }
459
+ return out.filter((x) => x.port > 0 && x.pid > 0);
460
+ }
461
+ function cwdOf(pid) {
462
+ try { return readlinkSync(`/proc/${pid}/cwd`); } catch {}
463
+ return (sh(`lsof -a -p ${Number(pid)} -d cwd -Fn 2>/dev/null`).split("\n").find((l) => l[0] === "n") || "").slice(1);
464
+ }
465
+ // The apps running here, by repo: [{ repo, url, symbiot? }]; url carries no token.
466
+ async function localApps(repos = [], { ports = listening, cwd = cwdOf, app = symbiotApp } = {}) {
467
+ const out = [];
468
+ for (const r of repos) {
469
+ if (isSymbiot(r.path)) { const a = await app(); if (a) out.push({ repo: r.name, url: a.url, open: a.open, symbiot: true }); continue; }
470
+ const hit = ports().filter((x) => { const d = cwd(x.pid); return d && (d === r.path || d.startsWith(r.path + "/")); }).sort((a, b) => a.port - b.port)[0];
471
+ if (hit) out.push({ repo: r.name, url: `http://localhost:${hit.port}/` });
472
+ }
473
+ return out;
474
+ }
475
+ // Symbiot's own app, if it's open: { url, open } (open has its token, to load it).
476
+ // Whether it's listening, by a local connection: post.mjs never sends anything out.
477
+ async function symbiotApp() {
478
+ const cfg = loadConfig(), port = Number(process.env.SYMBIOT_PORT || cfg.appPort) || 7391; if (!cfg.appToken) return null;
479
+ const up = await new Promise((res) => { const c = connect({ host: "127.0.0.1", port }); const done = (v) => { c.destroy(); res(v); }; c.setTimeout(800, () => done(false)); c.on("connect", () => done(true)); c.on("error", () => done(false)); });
480
+ return up ? { url: `http://127.0.0.1:${port}/`, open: `http://127.0.0.1:${port}/?t=${encodeURIComponent(cfg.appToken)}` } : null;
481
+ }
482
+ // The page a draft's idea names, in an app of its repo's: { url, open } or null.
483
+ function screenFor(post, apps, facts) {
484
+ const show = String(post.show || ""); if (!show || NOT_A_PICTURE.test(show) || !SCREEN_WORD.test(show)) return null;
485
+ const repos = new Set((post.facts || []).map((n) => facts[n - 1] && facts[n - 1].repo).filter(Boolean));
486
+ const a = apps.find((x) => repos.has(x.repo)); if (!a) return null;
487
+ if (!a.symbiot) { const path = (show.match(/(?:^|\s)(\/[\w./-]*[\w/])/) || [])[1] || ""; return { url: a.url.replace(/\/$/, "") + (path || "/"), open: a.url.replace(/\/$/, "") + (path || "/") }; }
488
+ const s = (SYMBIOT_SCREENS.find(([, re]) => re.test(show)) || [])[0];
489
+ return { url: a.url + (s ? "#" + s : ""), open: a.open + (s ? "#" + s : "") };
490
+ }
491
+ // Take each one's picture (one at a time: the hidden browser is one). How many came.
492
+ async function picturesFor(posts, facts, { apps = [], shoot = pagePicture, now = Date.now(), paths = PATHS } = {}) {
493
+ let n = 0;
494
+ for (const p of posts) {
495
+ const at = screenFor(p, apps, facts); if (!at) continue;
496
+ const r = await shoot(at.open).catch(() => null);
497
+ if (!r || r.error || !r.png) continue;
498
+ const m = addMedia(p.id, r.png, { name: r.title || at.url, from: "page", url: at.url, now, paths });
499
+ if (m.ok) { p.media = m.post.media; n++; }
500
+ }
501
+ return n;
291
502
  }
292
503
 
293
504
  // ---- clipboard, and opening the share box, from a terminal -----------------------
@@ -310,13 +521,71 @@ function openUrl(url) {
310
521
 
311
522
  // ---- replies: who might be a customer ------------------------------------------------
312
523
  // A comment or mention that asks how to install it, what it costs or about its
313
- // licence, or about using it in a team: "maybe a customer" on the Dashboard.
524
+ // licence, or about using it in a team: "maybe a customer" under Marketing and on the Dashboard.
314
525
  // Gives why ("install", "pricing", "team") or "".
315
526
  const CUSTOMER = [
316
527
  ["pricing", /\b(?:pric(?:e|es|ed|ing)|costs?|how much|licen[cs](?:e|es|ing)|subscriptions?|paid (?:plan|version|tier)|free tier|pay(?:ing)? for|per (?:seat|user|month))\b/i],
317
528
  ["install", /\b(?:install(?:ing|ed|ation)?|npm i|npx|download(?:ing)?)\b|\bhow (?:do|can|would|could) (?:i|we|you|one) (?:set (?:it |this )?up|get started|start|use|try|get|run)\b|\bwhere (?:can|do) (?:i|we) (?:get|find)\b/i],
318
529
  ["team", /\b(?:my|our|whole)\s+(?:dev\s+|engineering\s+)?(?:team|teams|company|org|organi[sz]ation|startup)\b|\bteam (?:use|plan|licen[cs]e|version|pricing)\b|\bfor (?:a |small )?teams\b|\b(?:enterprise|seats)\b/i],
319
530
  ];
531
+ // ---- the 4-week test: a row a week (Marketing) -------------------------------------
532
+ // Does posting bring anyone? Four weeks, a row each. Weeks run from the day of your
533
+ // first draft; week 1 is the first of them a post went out in (until one has, the one
534
+ // you're in: a week with nothing posted doesn't count), or the day post-test.json's
535
+ // start names, when you've moved it ({ "start": "2026-10-13" }). Each row: posts published, and how many
536
+ // carried a picture or video; replies on LinkedIn (its watched notifications, marked
537
+ // by watch.mjs markNews), the ones that may be a customer and those asking what it
538
+ // costs; and npm installs of the packages your posts are about. Profile visits are
539
+ // only in LinkedIn's own analytics. null before there's a first draft.
540
+ const TEST_WEEKS = 4;
541
+ const dayStart = (ts) => { const d = new Date(ts); d.setHours(0, 0, 0, 0); return d.getTime(); };
542
+ const addDays = (ts, n) => { const d = new Date(ts); d.setDate(d.getDate() + n); return d.getTime(); };
543
+ const ymd = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; };
544
+ function testPlan(paths = PATHS, now = Date.now()) {
545
+ const posts = loadPosts(paths).posts;
546
+ let set = NaN; try { set = Date.parse(String(JSON.parse(readFileSync(paths.test, "utf8")).start || "").slice(0, 10) + "T00:00:00"); } catch {}
547
+ const first = posts.reduce((m, p) => Math.min(m, Number(p.drafted) || Infinity), Infinity);
548
+ if (!set && first === Infinity) return null;
549
+ let start = set ? dayStart(set) : dayStart(first);
550
+ if (!set) { const out = postLog(paths).filter((l) => l.action === "approved").map((l) => Number(l.ts)).sort((a, b) => a - b)[0], to = out || now; while (addDays(start, 7) <= to) start = addDays(start, 7); }
551
+ return { start, weeks: Array.from({ length: TEST_WEEKS }, (_, i) => [addDays(start, 7 * i), addDays(start, 7 * (i + 1))]), posts };
552
+ }
553
+ // The npm packages your posts are about: the repos their git facts came from
554
+ // ("[21] symbiot · 2026-10-06 · changelog: …"), each one's package.json name unless private.
555
+ function testPackages(posts, map = {}) {
556
+ const repos = new Set(posts.flatMap((p) => (Array.isArray(p.sources) ? p.sources : []).map((x) => (String(x).match(/^(?:\[\d+\]\s*)?(.+?)\s+·\s/) || [])[1]).filter(Boolean)));
557
+ const out = [];
558
+ for (const r of repos) { if (!map[r]) continue; try { const j = JSON.parse(readFileSync(join(map[r], "package.json"), "utf8")); if (j.name && !j.private && !out.includes(j.name)) out.push(j.name); } catch {} }
559
+ return out.slice(0, 3);
560
+ }
561
+ function testWeeks({ paths = PATHS, now = Date.now(), news = [], installs = null, map = {} } = {}) {
562
+ const plan = testPlan(paths, now); if (!plan) return null;
563
+ const log = postLog(paths);
564
+ const rows = plan.weeks.map(([from, to], i) => {
565
+ const inW = (t) => Number(t) >= from && Number(t) < to, pub = log.filter((l) => l.action === "approved" && inW(l.ts)), rep = news.filter((n) => n.social && inW(n.ts));
566
+ return { week: i + 1, from: ymd(from), to: ymd(addDays(to, -1)), started: now >= from, over: now >= to, published: pub.length, media: pub.filter((l) => (l.media || []).length).length,
567
+ replies: rep.length, customers: rep.filter((n) => n.customer).length, pricing: rep.filter((n) => n.customer === "pricing").length, installs: installs && installs[i] != null ? installs[i] : null };
568
+ });
569
+ return { start: ymd(plan.start), end: ymd(addDays(plan.weeks[TEST_WEEKS - 1][1], -1)), rows, packages: testPackages(plan.posts, map), week: rows.filter((r) => r.started).length };
570
+ }
571
+ // npm's daily downloads for those packages, summed per test week (weeks not begun:
572
+ // null): [n, …] or null. get(url) reads npm's JSON (the app passes it: nothing here
573
+ // goes on the network by itself). One read per package for the whole test, kept a few hours.
574
+ const NPM_DAYS = "https://api.npmjs.org/downloads/range/";
575
+ let npmCache = { key: "", at: 0, counts: null };
576
+ async function testInstalls(t, { now = Date.now(), get = null } = {}) {
577
+ if (!t || !t.packages.length || !t.rows[0].started || typeof get !== "function") return null;
578
+ const key = t.packages.join(",") + "|" + t.start; if (npmCache.key === key && now - npmCache.at < 3 * 3600000) return npmCache.counts;
579
+ const counts = t.rows.map((r) => (r.started ? 0 : null));
580
+ for (const p of t.packages) {
581
+ let d = null; try { d = await get(`${NPM_DAYS}${t.start}:${t.end}/${encodeURIComponent(p)}`); } catch {}
582
+ if (!d || !Array.isArray(d.downloads)) return null;
583
+ for (const x of d.downloads) { const r = t.rows.findIndex((w) => x.day >= w.from && x.day <= w.to); if (r >= 0 && counts[r] != null) counts[r] += Number(x.downloads) || 0; }
584
+ }
585
+ npmCache = { key, at: now, counts };
586
+ return counts;
587
+ }
320
588
  function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
321
589
 
322
- export { PATHS, PLATFORM, SHARE_URL, KINDS, KIND_LABEL, LINKEDIN_ACTIVITY, NO_AI, voiceOf, loadVoice, voiceFromLinkedIn, tagsIn, changelogIn, gatherFacts, factLine, numbersIn, namesIn, checkClaims, parseDrafts, vetDraft, draftPosts, loadPosts, postLog, postsState, editPost, skipPost, approvePost, copyText, openUrl, maybeCustomer };
590
+ export { TEST_WEEKS, testWeeks, testInstalls, testPackages, PATHS, PLATFORM, SHARE_URL, KINDS, KIND_LABEL, LINKEDIN_ACTIVITY, NO_AI, voiceOf, loadVoice, voiceFromLinkedIn, tagsIn, changelogIn, gatherFacts, factLine, numbersIn, namesIn, checkClaims, parseDrafts, vetDraft, draftPosts, loadPosts, postLog, postsState, editPost, skipPost, approvePost, copyText, openUrl, maybeCustomer,
591
+ mediaType, mediaDir, mediaWords, addMedia, removeMedia, mediaFile, pictureOfPage, clipOfPage, concatList, toVideo, canClip, NO_FFMPEG, localApps, screenFor, picturesFor, listening };
package/sandbox.mjs CHANGED
@@ -40,7 +40,7 @@ function runSandbox({ bin, args = [], keep = false, log = console.log, port = PO
40
40
  const home = realpathSync(mkdtempSync(join(tmpdir(), "symbiot-fresh-")));
41
41
  log(`Sandbox: a brand-new Symbiot, with its own empty home at ${home}.`);
42
42
  log("No AI connected, no memory, no linked accounts, no lanes, none of your repos. Nothing of yours is read or changed.");
43
- log(keep ? "That folder stays when you quit (--keep)." : "Quit it (the Quit button, or Ctrl+C here) and that folder is deleted.");
43
+ log(keep ? "That folder stays when you quit (--keep)." : "Quit it (the X in its top corner, or Ctrl+C here) and that folder is deleted.");
44
44
  const child = spawn(process.execPath, [bin, "app", ...args], { env: sandboxEnv(home, process.env, port), stdio: "inherit" });
45
45
  const stop = () => {}; // Ctrl+C reaches the app too; wait for it to quit, then clean up
46
46
  process.on("SIGINT", stop); process.on("SIGTERM", () => child.kill("SIGTERM"));
package/server.mjs CHANGED
@@ -13,7 +13,7 @@ import { createServer } from "node:http";
13
13
  import { randomBytes } from "node:crypto";
14
14
  import { EMBEDDED_UI } from "./ui.mjs";
15
15
  import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
16
- import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
16
+ import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull } from "./agents.mjs";
17
17
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
18
18
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap } from "./scan.mjs";
19
19
  import { computeDrift } from "./drift.mjs";
@@ -22,18 +22,18 @@ import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailSt
22
22
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
23
23
  import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
24
24
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart } from "./desktop.mjs";
25
- import { watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
25
+ import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
26
26
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
27
- import { postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn } from "./post.mjs";
27
+ import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
28
28
  import { mindState, forget } from "./mind.mjs";
29
- import { lanesTick, lanesState, partlyDone } from "./lanes.mjs";
29
+ import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
30
30
  import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
31
31
  import { adaptState, noteUse } from "./adapt.mjs";
32
- import { homeState, homeAsk, workScene, workGo, firstSteps } from "./home.mjs";
32
+ import { homeState, homeAsk, homeAnswer, workScene, workGo, firstSteps } from "./home.mjs";
33
33
  import { listReports, readReport, markAllRead } from "./reports.mjs";
34
34
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
35
35
  import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
36
- import { runChecks, checksState } from "./checks.mjs";
36
+ import { runChecks, checksState, markClashesSeen } from "./checks.mjs";
37
37
 
38
38
  // The in-app update installs the EXACT newest version (not the `latest` tag, which
39
39
  // npm's cache/propagation can resolve stale — that caused an update loop where the
@@ -120,6 +120,16 @@ function readBody(req) {
120
120
  req.on("end", () => { try { resolve(d ? JSON.parse(d) : {}); } catch { resolve({}); } });
121
121
  });
122
122
  }
123
+ // A body as it came (a file the page sends), up to max bytes: a Buffer, or null when it's bigger.
124
+ const POST_MEDIA_MAX = 500 * 1024 * 1024;
125
+ function readBytes(req, max) {
126
+ return new Promise((resolve) => {
127
+ const parts = []; let n = 0, over = false;
128
+ req.on("data", (ch) => { if (over) return; n += ch.length; if (n > max) { over = true; parts.length = 0; return; } parts.push(ch); });
129
+ req.on("end", () => resolve(over ? null : Buffer.concat(parts)));
130
+ req.on("error", () => resolve(null));
131
+ });
132
+ }
123
133
  // http://127.0.0.1:<port>/?t=<token> -> symbiot://127.0.0.1:<port>/?t=<token>, for the Android app
124
134
  function appLink(url) { return url.replace(/^http:\/\//, "symbiot://"); }
125
135
  // Symbiot running in Termux (not the Android app's own)
@@ -259,6 +269,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
259
269
  // where two of the folders' files disagree (checks.mjs): the last result, or checked again now
260
270
  if (u.pathname === "/api/knowledge/checks") return json(res, checksState());
261
271
  if (u.pathname === "/api/knowledge/checks/run" && req.method === "POST") return json(res, runChecks());
272
+ // Home's "Where your files disagree" droplet, opened: its clashes reached you
273
+ if (u.pathname === "/api/knowledge/checks/seen" && req.method === "POST") { const r = markClashesSeen(); try { homeState({ fresh: true }); } catch {} return json(res, r); }
262
274
  if (u.pathname === "/api/knowledge/search") return json(res, { hits: searchKnowledge(u.searchParams.get("q") || "", { examples: u.searchParams.get("examples") === "1" }) });
263
275
  if (u.pathname === "/api/agentcfg") { const d = detectHandoffs(); return json(res, { cmd: handoffCmd(), agents: d.agents, editors: d.editors, connectors: connectorsInfo() }); }
264
276
  if (u.pathname === "/api/agentcmd" && req.method === "POST") { const b = await readBody(req); return json(res, setHandoffCmd(b.cmd)); }
@@ -320,17 +332,31 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
320
332
  if (u.pathname === "/api/mind") return json(res, mindState());
321
333
  if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
322
334
  // Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
323
- if (u.pathname === "/api/links") return json(res, linksState());
335
+ if (u.pathname === "/api/links") { let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
324
336
  if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""))); }
325
337
  if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
326
338
  if (u.pathname === "/api/links/unlink" && req.method === "POST") { const b = await readBody(req); return json(res, unlinkSite(String(b.id || ""))); }
327
339
  // Posts (post.mjs): the week's drafts, waiting on you. Approve only records your yes:
328
340
  // the page copies the post itself and opens LinkedIn's share box; nothing is posted.
329
341
  if (u.pathname === "/api/posts") return json(res, postsState());
342
+ // Marketing: replies on LinkedIn (its watched notifications, marked "maybe a customer"),
343
+ // and the 4-week test's table, with npm installs of the packages the posts are about
344
+ if (u.pathname === "/api/marketing") {
345
+ const ws = watchState(), news = markNews(newsSince(24 * 60), ws.watches), p = postsState(), t = testWeeks({ news, map: repoPathMap() });
346
+ let n = null; try { n = await testInstalls(t, { get: (url) => fetch(url, { signal: AbortSignal.timeout(8000) }).then((r) => (r.ok ? r.json() : null)) }); } catch {}
347
+ if (t && n) t.rows.forEach((r, i) => { r.installs = n[i]; });
348
+ return json(res, { linkedin: p.linkedin, replies: news.filter((x) => x.social && x.ts >= Date.now() - 30 * 86400000).slice(0, 30), test: t });
349
+ }
330
350
  if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
331
351
  if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""), { copy: null })); }
332
352
  if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
333
353
  if (u.pathname === "/api/posts/skip" && req.method === "POST") { const b = await readBody(req); return json(res, skipPost(String(b.id || ""))); }
354
+ // a draft's pictures and video: yours (the file itself as the body), a picture or clip of a page, shown, removed, their folder opened
355
+ if (u.pathname === "/api/posts/media") { const m = mediaFile(String(u.searchParams.get("post") || ""), String(u.searchParams.get("m") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=86400" }); res.end(readFileSync(m.file)); return; }
356
+ if (u.pathname === "/api/posts/media/add" && req.method === "POST") { const data = await readBytes(req, POST_MEDIA_MAX); return json(res, data ? addMedia(String(u.searchParams.get("id") || ""), data, { name: String(u.searchParams.get("name") || "") }) : { error: "That file is too big: Symbiot keeps videos up to 500 MB." }); }
357
+ if (u.pathname === "/api/posts/media/page" && req.method === "POST") { const b = await readBody(req); return json(res, b.clip ? await clipOfPage(String(b.id || ""), String(b.url || ""), { seconds: Number(b.seconds) || 8 }) : await pictureOfPage(String(b.id || ""), String(b.url || ""))); }
358
+ if (u.pathname === "/api/posts/media/remove" && req.method === "POST") { const b = await readBody(req); return json(res, removeMedia(String(b.id || ""), String(b.m || ""))); }
359
+ if (u.pathname === "/api/posts/media/folder" && req.method === "POST") { const b = await readBody(req), d = b.id ? mediaDir(String(b.id)) : ""; return json(res, d && existsSync(d) ? { ok: openUrl(d), folder: d } : { error: "This draft has no pictures or video yet." }); }
334
360
  // reads your LinkedIn only on your click (confirmed), never by itself
335
361
  if (u.pathname === "/api/posts/voice" && req.method === "POST") { const b = await readBody(req); if (b.confirmed !== true) return json(res, { error: "Reading your LinkedIn posts needs your click." }); return json(res, await voiceFromLinkedIn()); }
336
362
  // Reports (reports.mjs): what runs wrote up in their .symbiot/; read by id, never by path.
@@ -344,6 +370,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
344
370
  if (u.pathname === "/api/work") return json(res, workScene());
345
371
  if (u.pathname === "/api/work/go" && req.method === "POST") return json(res, workGo());
346
372
  if (u.pathname === "/api/firststeps") return json(res, firstSteps()); // Settings' first steps: what's set up, in order
373
+ if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
347
374
  if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
348
375
  if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
349
376
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
@@ -438,7 +465,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
438
465
  const how = opened || RELAUNCH || process.env.SYMBIOT_NO_OPEN === "1" ? "" : openApp(url); opened = true; // only pop a window the first time (never in tests, never after an update)
439
466
  console.log(`\n${c.g("●")} ${c.b("Symbiot")} is running at ${c.b(url)}`);
440
467
  console.log(RELAUNCH ? c.d(" Restarted after an update; the open window reloads itself.") : how ? c.d(` Opened in a ${how}.`) : c.d(" Open that URL in your browser."));
441
- console.log(c.d(" Leave this running; press Ctrl+C to stop (or click Quit in the window)."));
468
+ console.log(c.d(" Leave this running; press Ctrl+C to stop (or click the X in the window's top corner)."));
442
469
  });
443
470
  server.on("error", (e) => {
444
471
  // Stable port busy (an older instance still exiting during an update, or a
@@ -453,6 +480,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
453
480
  startPhone(); // Watch on your phone: the computer listens if it's switched on, the phone asks if it's paired
454
481
  setInterval(() => { try { startWaiting(); } catch {} }, 20000).unref(); // a run that waits for your step starts once the file it names changes
455
482
  setInterval(() => { try { lanesTick(); } catch {} }, 20000).unref(); // agents hand work to other lanes, and hear back when it's done
483
+ setTimeout(() => { orcaRelink().catch(() => {}); }, 15000).unref(); // a lane folder a handover renamed, added again in Orca where it is now
456
484
  // an agent's allow list that stays inside your work is turned on by itself, and the agent carries on
457
485
  setTimeout(() => { try { autoAllowSweep(); } catch {} }, 4000).unref();
458
486
  setInterval(() => { try { autoAllowSweep(); } catch {} }, 20000).unref();