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/CHANGELOG.md +22 -0
- package/README.md +49 -10
- package/adapt.mjs +1 -1
- package/agents.mjs +62 -6
- package/checks.mjs +16 -1
- package/handback.mjs +16 -6
- package/handover.mjs +41 -2
- package/headless.mjs +55 -3
- package/home.mjs +123 -14
- package/index.mjs +41 -6
- package/lanes.mjs +132 -5
- package/package.json +2 -2
- package/post.mjs +310 -41
- package/sandbox.mjs +1 -1
- package/server.mjs +36 -8
- package/ui.mjs +192 -66
- package/watch.mjs +5 -2
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
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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`)
|
|
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
|
-
|
|
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
|
|
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
|
|
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();
|