symbiot 0.54.2 → 0.54.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/README.md +34 -3
- package/agents.mjs +2 -2
- package/checks.mjs +16 -1
- package/handback.mjs +16 -6
- package/headless.mjs +55 -3
- package/home.mjs +88 -13
- package/index.mjs +40 -5
- package/lanes.mjs +132 -5
- package/package.json +1 -1
- package/post.mjs +244 -33
- package/sandbox.mjs +1 -1
- package/server.mjs +25 -5
- package/ui.mjs +81 -25
- package/watch.mjs +5 -2
package/lanes.mjs
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
// it starts when it's free), done (reported back) or error (reported back too).
|
|
16
16
|
// chain: how many handovers in a row led here; past MAX_CHAIN it stops, so two
|
|
17
17
|
// lanes can't hand the same thing back and forth forever.
|
|
18
|
-
import { join, basename } from "node:path";
|
|
19
|
-
import {
|
|
18
|
+
import { join, basename, dirname, resolve, relative } from "node:path";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, chmodSync, statSync } from "node:fs";
|
|
20
21
|
import { createHash, randomBytes } from "node:crypto";
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
21
23
|
import { CONFIG_DIR, loadTasks, clipWords } from "./core.mjs";
|
|
22
|
-
import { runHandoff, runningHandoff, waitingFor, agentQuestions } from "./agents.mjs";
|
|
24
|
+
import { runHandoff, runningHandoff, waitingFor, agentQuestions, findOrcaCli, installAllowlist } from "./agents.mjs";
|
|
23
25
|
import { addTask, pushTasks } from "./tasks.mjs";
|
|
24
26
|
import { actNow } from "./mind.mjs";
|
|
25
27
|
import { repoPathMap } from "./scan.mjs";
|
|
@@ -149,7 +151,7 @@ function report(e, o, { run = runHandoff, running = runningHandoff, waiting = wa
|
|
|
149
151
|
|
|
150
152
|
// One pass: start what's been handed over, from every lane, then report back
|
|
151
153
|
// what's done. The app runs it every 20 seconds.
|
|
152
|
-
function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now() } = {}) {
|
|
154
|
+
function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, running = runningHandoff, add = addTask, push = pushTasks, tasks, now = Date.now(), relink = orcaRelink } = {}) {
|
|
153
155
|
const ledger = loadLedger();
|
|
154
156
|
let acts = []; try { acts = readdirSync(ACT_DIR).filter((d) => d.startsWith("act-")).map((d) => join(ACT_DIR, d)); } catch {}
|
|
155
157
|
const started = [];
|
|
@@ -161,8 +163,133 @@ function lanesTick({ map = repoPathMap(), act = actNow, run = runHandoff, runnin
|
|
|
161
163
|
if (report(e, o, { run, running, now })) { e.reportedAt = now; if (e.status !== "error") e.status = "done"; e.result = o.text; reported.push(e); }
|
|
162
164
|
}
|
|
163
165
|
if (started.length || reported.length) saveLedger(ledger);
|
|
166
|
+
if (reported.length) relink({ map }).catch(() => {}); // a handover may have renamed a lane's folder
|
|
164
167
|
return { started, reported };
|
|
165
168
|
}
|
|
169
|
+
|
|
170
|
+
// ---- a handover that couldn't start: yours to unblock, on Home ---------------------
|
|
171
|
+
// A handover that errored (an ops run limited to its folder handed "needs a run that
|
|
172
|
+
// can edit ~/Company" to ops, its own lane; a lane that doesn't exist; too long a
|
|
173
|
+
// chain) went back to the agent that asked, and stopped there: Home said nothing
|
|
174
|
+
// needed you. Now it's a question on Home (homeState) for STUCK_FOR, until you
|
|
175
|
+
// answer it or something like it goes through another way. Allow starts it as a
|
|
176
|
+
// run of its own, allowed to read, edit and move files in the folders it names
|
|
177
|
+
// (that run only: its .claude/settings.local.json), and its result goes back to the
|
|
178
|
+
// agent that asked, as any handover's does; Skip lets it go.
|
|
179
|
+
const STUCK_FOR = 3 * 86400000;
|
|
180
|
+
const tilde = (p) => { const h = homedir(); return p === h ? "~" : p.startsWith(h + "/") ? "~" + p.slice(h.length) : p; };
|
|
181
|
+
// Two handovers about the same thing: most of the shorter one's words in the other.
|
|
182
|
+
function alike(a, b) {
|
|
183
|
+
const x = keyWords(a), y = keyWords(b), [s, l] = x.size <= y.size ? [x, y] : [y, x]; if (s.size < 3) return false;
|
|
184
|
+
let n = 0; for (const w of s) if (l.has(w)) n++;
|
|
185
|
+
return n / s.size >= 0.5;
|
|
186
|
+
}
|
|
187
|
+
// The folders a handover names that a run would need: ones on this computer, not
|
|
188
|
+
// the asking run's own, not Symbiot's, not your home itself or a hidden one in it
|
|
189
|
+
// (~/.ssh). A file named in one counts as the folder it's in. Outermost only, 3 at most.
|
|
190
|
+
function namedDirs(text, own = "", { exists = (p) => { try { return statSync(p); } catch { return null; } } } = {}) {
|
|
191
|
+
const home = homedir(), out = [];
|
|
192
|
+
const roots = ["~", "/home", "/Users", "/srv", "/opt", "/mnt", "/media", home].map((r) => r.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")).join("|");
|
|
193
|
+
for (const m of String(text || "").matchAll(new RegExp(`(?:^|[\\s\`'"(])((?:${roots})\\/[^\\s\`'",;)]+)`, "g"))) {
|
|
194
|
+
let p = resolve(m[1].replace(/^~(?=\/)/, home).replace(/[.:]+$/, "")); const st = exists(p); if (!st) continue;
|
|
195
|
+
if (!st.isDirectory()) p = dirname(p);
|
|
196
|
+
const rel = relative(home, p);
|
|
197
|
+
if (p === home || p === "/" || (!rel.startsWith("..") && rel.split("/")[0].startsWith(".")) || p.startsWith(CONFIG_DIR) || (own && (p === own || p.startsWith(own + "/")))) continue;
|
|
198
|
+
out.push(p);
|
|
199
|
+
}
|
|
200
|
+
return [...new Set(out)].filter((p, i, all) => !all.some((q) => q !== p && p.startsWith(q + "/"))).slice(0, 3);
|
|
201
|
+
}
|
|
202
|
+
// It went through since another way: the same thing handed over again (or done,
|
|
203
|
+
// after it), or the run that asked has run again and ticked all it had.
|
|
204
|
+
function pastIt(e, hs) {
|
|
205
|
+
if (hs.some((x) => x !== e && x.status !== "error" && Math.max(x.at || 0, x.reportedAt || 0) > e.at && alike(x.text, e.text))) return true;
|
|
206
|
+
const md = readSym(e.from.path, "TASKS.md");
|
|
207
|
+
return lastRunStart(e.from.path) > e.at && /^- \[x\]/im.test(md) && !/^\s*- \[ \]/m.test(md);
|
|
208
|
+
}
|
|
209
|
+
// The errored handovers that still wait on you, newest first:
|
|
210
|
+
// [{ id, from, text, error, at, dirs, q, options }].
|
|
211
|
+
function stuckHandovers({ ledger = loadLedger(), now = Date.now(), dirsOf = namedDirs } = {}) {
|
|
212
|
+
const hs = ledger.handoffs, out = [];
|
|
213
|
+
hs.forEach((e) => {
|
|
214
|
+
if (e.status !== "error" || e.skipped || now - (e.at || 0) > STUCK_FOR) return;
|
|
215
|
+
if (pastIt(e, hs)) return;
|
|
216
|
+
const dirs = dirsOf(e.text, e.from.path);
|
|
217
|
+
out.unshift({ id: e.id, from: e.from, text: e.text, error: e.error || "", at: e.at, dirs,
|
|
218
|
+
q: dirs.length ? `Allow this run access to ${dirs.map(tilde).join(" and ")}?` : `${e.from.lane}'s handover didn't start (${String(e.error || "").replace(/[.:]\s.*$|\.$/, "")}). Start it as a run of its own?`,
|
|
219
|
+
options: [dirs.length ? "Allow (recommended)" : "Start it (recommended)", "Skip"] });
|
|
220
|
+
});
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
// The rules a run gets for the folders you allowed it: read, edit and move files
|
|
224
|
+
// there (git mv too), nothing wide.
|
|
225
|
+
const dirRules = (dirs) => dirs.flatMap((d) => [`Read(/${d}/**)`, `Edit(/${d}/**)`, `Write(/${d}/**)`]).concat(dirs.length ? ["Bash(mv:*)", "Bash(git mv:*)", "Bash(mkdir:*)", "Bash(ls:*)", "Bash(find:*)"] : []);
|
|
226
|
+
function grantDirs(dir, dirs) {
|
|
227
|
+
if (!dirs.length) return null;
|
|
228
|
+
try { writeFileSync(join(dir, ".symbiot", "allowlist.proposed.json"), JSON.stringify({ permissions: { allow: dirRules(dirs), additionalDirectories: dirs } }, null, 2) + "\n"); } catch { return null; }
|
|
229
|
+
return installAllowlist(dir);
|
|
230
|
+
}
|
|
231
|
+
// Allow (or Start it): a run of its own on what was handed over, with what you
|
|
232
|
+
// said added, allowed into the folders it names. The ledger's entry becomes that
|
|
233
|
+
// handover, started, so its result goes back to the agent that asked. { ok, job, dirs } or { error }.
|
|
234
|
+
function allowHandover(id, { note = "", act = actNow, run = runHandoff, now = Date.now(), dirsOf = namedDirs } = {}) {
|
|
235
|
+
const ledger = loadLedger(), e = ledger.handoffs.find((x) => x.id === id);
|
|
236
|
+
if (!e || e.status !== "error" || e.skipped) return { error: "That handover isn't waiting on you any more." };
|
|
237
|
+
const dirs = dirsOf(e.text, e.from.path), said = String(note || "").trim().slice(0, 2000);
|
|
238
|
+
const r = act(e.text + (said ? `\n\nThe user said: ${said}` : ""), { now, title: `handed over by ${e.from.lane}`,
|
|
239
|
+
context: `${e.from.lane}'s agent (in ${e.from.path}) handed this over, and carries on once it's done.${dirs.length ? ` The user allowed this run to read, edit and move files in ${dirs.join(", ")}.` : ""}`,
|
|
240
|
+
run: (dir, o) => { grantDirs(dir, dirs); return run(dir, o); } });
|
|
241
|
+
if (!r || r.error) return { error: (r && r.error) || "The run didn't start." };
|
|
242
|
+
Object.assign(e, { status: "started", to: { lane: OPS, path: r.dir }, job: r.job, at: now, allowedAt: now });
|
|
243
|
+
delete e.error; delete e.reportedAt; delete e.result;
|
|
244
|
+
saveLedger(ledger);
|
|
245
|
+
return { ok: true, job: r.job, dirs };
|
|
246
|
+
}
|
|
247
|
+
// Skip: it stops asking, and the agent that handed it over reads that you skipped it.
|
|
248
|
+
function skipHandover(id, { now = Date.now() } = {}) {
|
|
249
|
+
const ledger = loadLedger(), e = ledger.handoffs.find((x) => x.id === id);
|
|
250
|
+
if (!e || e.status !== "error") return { error: "That handover isn't waiting on you any more." };
|
|
251
|
+
e.skipped = now; saveLedger(ledger);
|
|
252
|
+
const file = join(e.from.path, ".symbiot", "ANSWERS.md");
|
|
253
|
+
try { if (existsSync(join(e.from.path, ".symbiot"))) writeFileSync(file, `${(existsSync(file) ? readFileSync(file, "utf8") : "# Answers\n").replace(/\s*$/, "")}\n\n### Handed over to ${e.to.lane}: ${clipWords(firstLine(e.text), 120)}\nThe user skipped it: don't hand it over again.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`); } catch {}
|
|
254
|
+
return { ok: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- Orca, kept pointing at the lanes ---------------------------------------------
|
|
258
|
+
// A handover can rename a lane's folder (CallForge AI → dailify), and the run that
|
|
259
|
+
// did it can't tell Orca (its CLI is outside what a run may do), so Orca keeps a
|
|
260
|
+
// path that's gone. After a handover comes back, and when the app starts, each repo
|
|
261
|
+
// Orca has at a path that's gone is added again where it is now: the scanned repo
|
|
262
|
+
// with the GitHub remote Orca kept for it, else the one with its name. Orca's CLI
|
|
263
|
+
// can't drop the old entry: it shows as missing there until you remove it.
|
|
264
|
+
const remoteKey = (url) => String(url || "").trim().replace(/^[a-z+]+:\/\//i, "").replace(/^[^@/]+@/, "").replace(/:(?!\d)/, "/").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
|
|
265
|
+
const gitRemote = (path) => new Promise((res) => execFile("git", ["-C", path, "remote", "get-url", "origin"], { timeout: 5000, encoding: "utf8" }, (e, out) => res(e ? "" : out)));
|
|
266
|
+
const runOrca = (cli, args) => new Promise((res) => execFile(cli, args, { timeout: 20000, encoding: "utf8" }, (e, out) => res(e ? "" : out)));
|
|
267
|
+
// Orca's repos ({ path, displayName, gitRemoteIdentity }) whose folder is gone, each
|
|
268
|
+
// with where it is now: [{ from, to, name }]. Only a single match moves it.
|
|
269
|
+
async function orcaMoves(repos, map, { remoteOf = gitRemote, exists = existsSync } = {}) {
|
|
270
|
+
const known = new Set(repos.map((r) => r.path)), free = Object.values(map).filter((p) => !known.has(p) && exists(p)), remotes = new Map();
|
|
271
|
+
const remote = async (p) => { if (!remotes.has(p)) remotes.set(p, remoteKey(await remoteOf(p))); return remotes.get(p); };
|
|
272
|
+
const moves = [];
|
|
273
|
+
for (const r of repos) {
|
|
274
|
+
if (!r.path || exists(r.path)) continue;
|
|
275
|
+
const key = remoteKey((r.gitRemoteIdentity || {}).canonicalKey), hit = [];
|
|
276
|
+
if (key) for (const p of free) if ((await remote(p)) === key) hit.push(p);
|
|
277
|
+
if (!hit.length) { const names = [r.displayName, basename(r.path)].filter(Boolean).map((s) => s.toLowerCase()); hit.push(...free.filter((p) => names.includes(basename(p).toLowerCase()))); }
|
|
278
|
+
if (hit.length === 1 && !moves.some((m) => m.to === hit[0])) moves.push({ from: r.path, to: hit[0], name: r.displayName || basename(hit[0]) });
|
|
279
|
+
}
|
|
280
|
+
return moves;
|
|
281
|
+
}
|
|
282
|
+
// Add each moved repo again in Orca, where it is now. Nothing when Orca isn't
|
|
283
|
+
// installed or isn't running (its CLI can't reach it), and never from a sandbox
|
|
284
|
+
// (symbiot app --fresh): that Orca is your real one. { moves: [{ from, to, name, ok }] }
|
|
285
|
+
async function orcaRelink({ map, cli, orca = runOrca, remoteOf } = {}) {
|
|
286
|
+
if (process.env.SYMBIOT_SANDBOX) return { moves: [] };
|
|
287
|
+
cli = cli ?? findOrcaCli(); if (!cli) return { moves: [] };
|
|
288
|
+
let repos = []; try { repos = JSON.parse(await orca(cli, ["repo", "list", "--json"])).result.repos || []; } catch { return { moves: [] }; }
|
|
289
|
+
const moves = await orcaMoves(repos, map || repoPathMap(), { remoteOf });
|
|
290
|
+
for (const m of moves) m.ok = /"ok":\s*true/.test(await orca(cli, ["repo", "add", "--path", m.to, "--json"]));
|
|
291
|
+
return { moves };
|
|
292
|
+
}
|
|
166
293
|
// The Agents tab's list: newest first. Each shows its first line; full, all of a
|
|
167
294
|
// longer one, and result, all of what came back, open under it. What reads the
|
|
168
295
|
// same (one lane to the same lane, the same first line: a run that handed it
|
|
@@ -178,4 +305,4 @@ function lanesState() {
|
|
|
178
305
|
return { handoffs: out.slice(0, 30) };
|
|
179
306
|
}
|
|
180
307
|
|
|
181
|
-
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone };
|
|
308
|
+
export { LEDGER, MAX_CHAIN, loadLedger, laneOf, lastRunStart, dispatch, outcome, report, lanesTick, lanesState, aboutIt, partlyDone, remoteKey, orcaMoves, orcaRelink, stuckHandovers, allowHandover, skipHandover, namedDirs, alike };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "symbiot",
|
|
3
|
-
"version": "0.54.
|
|
3
|
+
"version": "0.54.3",
|
|
4
4
|
"description": "Your week, written from your real work. Reads your local git and writes it up with the AI of your choice \u2014 Claude, OpenAI, Gemini, or a local model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/post.mjs
CHANGED
|
@@ -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? }] }
|
|
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 }
|
|
20
24
|
// status: waiting (on the Dashboard), 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") };
|
|
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";
|
|
@@ -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
|
|
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").`;
|
|
197
203
|
// Draft this week's 3 posts and put them on the Dashboard (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 = {}) {
|
|
@@ -253,7 +272,8 @@ function postsState(paths = PATHS, { linked = () => (loadConfig().linked || {})
|
|
|
253
272
|
const d = loadPosts(paths), voice = loadVoice(paths.voice).length, connected = !!resolveProvider();
|
|
254
273
|
let linkedin = false; try { linkedin = !!linked().linkedin; } catch {}
|
|
255
274
|
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
|
|
275
|
+
voice: { count: voice, file: paths.voice }, connected, linkedin, canDraft: connected && (linkedin || voice > 0), share: SHARE_URL, labels: KIND_LABEL,
|
|
276
|
+
lastUrl: d.lastUrl, canClip: canClip(), clip: CLIP };
|
|
257
277
|
}
|
|
258
278
|
// One waiting draft, by id (or the start of one), and the store it's in.
|
|
259
279
|
function waiting(id, paths) {
|
|
@@ -281,13 +301,203 @@ function skipPost(id, { now = Date.now(), paths = PATHS } = {}) {
|
|
|
281
301
|
}
|
|
282
302
|
// Approve: your yes to this one post. It's logged, then copied to your clipboard
|
|
283
303
|
// (`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.
|
|
304
|
+
// null) for you to paste into LinkedIn's share box (`share`), with its pictures or
|
|
305
|
+
// video (`files`, in `folder`) to add there. Symbiot doesn't post or schedule it.
|
|
286
306
|
function approvePost(id, { copy = copyText, now = Date.now(), paths = PATHS } = {}) {
|
|
287
307
|
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);
|
|
308
|
+
w.p.status = "approved"; w.p.approved = now; savePosts(w.d, paths); logAction("approved", w.p, now, paths, mediaNote(w.p));
|
|
289
309
|
const copied = copy ? copy(w.p.text) : "";
|
|
290
|
-
|
|
310
|
+
const files = mediaFiles(w.p, paths), what = mediaWords(w.p.media);
|
|
311
|
+
return { ok: true, post: w.p, copied, share: SHARE_URL, files, folder: files.length ? mediaDir(w.p.id, paths) : "",
|
|
312
|
+
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.` };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ---- pictures and videos -----------------------------------------------------------
|
|
316
|
+
// A post on LinkedIn takes one video, or up to 20 pictures, not both. A draft can
|
|
317
|
+
// carry its own: a picture or video of yours, a picture of a page (the hidden
|
|
318
|
+
// browser, at twice the pixels), or a short clip of one (recorded there, scrolling
|
|
319
|
+
// slowly down, and made an MP4 by ffmpeg). Each is a copy, kept yours only in
|
|
320
|
+
// post-media/<post id>/ in Symbiot's config folder. Symbiot attaches nothing:
|
|
321
|
+
// LinkedIn's share box can't be filled in from outside, so Approve gives you the
|
|
322
|
+
// files (and the app opens their folder) to add there yourself.
|
|
323
|
+
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"] };
|
|
324
|
+
const MAX_PICTURES = 20, MAX_BYTES = { picture: 20 * 1024 * 1024, video: 500 * 1024 * 1024 };
|
|
325
|
+
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.";
|
|
326
|
+
// What a file is, by its first bytes (not its name): a key of MEDIA, or "".
|
|
327
|
+
function mediaType(buf) {
|
|
328
|
+
if (!Buffer.isBuffer(buf) || buf.length < 12) return "";
|
|
329
|
+
if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
|
|
330
|
+
if (buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg";
|
|
331
|
+
if (buf.subarray(0, 4).toString("latin1") === "GIF8") return "gif";
|
|
332
|
+
const box = buf.subarray(4, 8).toString("latin1");
|
|
333
|
+
if (box === "ftyp") return buf.subarray(8, 10).toString("latin1") === "qt" ? "mov" : "mp4";
|
|
334
|
+
if (["moov", "mdat", "wide"].includes(box)) return "mov";
|
|
335
|
+
if (buf.readUInt32BE(0) === 0x1a45dfa3) return "webm";
|
|
336
|
+
return "";
|
|
337
|
+
}
|
|
338
|
+
const mediaDir = (postId, paths = PATHS) => join(paths.media, String(postId).replace(/[^\w-]/g, ""));
|
|
339
|
+
const mediaFiles = (p, paths = PATHS) => (p.media || []).map((m) => join(mediaDir(p.id, paths), m.file));
|
|
340
|
+
// "2 pictures", "its video": what a post carries, in words ("" for nothing)
|
|
341
|
+
function mediaWords(media = []) {
|
|
342
|
+
const v = media.filter((m) => m.kind === "video").length, n = media.length - v;
|
|
343
|
+
return v ? "video" : n ? (n === 1 ? "picture" : `${n} pictures`) : "";
|
|
344
|
+
}
|
|
345
|
+
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 } : {}) })) } : {});
|
|
346
|
+
// Add a picture or video (a Buffer, or base64) to a waiting draft. `from`: yours, page or clip.
|
|
347
|
+
function addMedia(id, data, { name = "", from = "yours", url = "", now = Date.now(), paths = PATHS } = {}) {
|
|
348
|
+
const w = waiting(id, paths); if (w.error) return w;
|
|
349
|
+
const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data || ""), "base64"), ext = mediaType(buf);
|
|
350
|
+
if (!ext) return { error: NOT_MEDIA };
|
|
351
|
+
const kind = MEDIA[ext][0], have = w.p.media || [];
|
|
352
|
+
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.` };
|
|
353
|
+
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." };
|
|
354
|
+
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." };
|
|
355
|
+
if (have.length >= MAX_PICTURES) return { error: `LinkedIn takes up to ${MAX_PICTURES} pictures a post.` };
|
|
356
|
+
const mid = randomBytes(4).toString("hex");
|
|
357
|
+
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 };
|
|
358
|
+
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}` }; }
|
|
359
|
+
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 } : {}) } });
|
|
360
|
+
return { ok: true, post: w.p, media: m };
|
|
361
|
+
}
|
|
362
|
+
function removeMedia(id, mediaId, { now = Date.now(), paths = PATHS } = {}) {
|
|
363
|
+
const w = waiting(id, paths); if (w.error) return w;
|
|
364
|
+
const m = (w.p.media || []).find((x) => x.id === mediaId); if (!m) return { error: "That picture or video isn't on this draft." };
|
|
365
|
+
try { rmSync(join(mediaDir(w.p.id, paths), m.file), { force: true }); } catch {}
|
|
366
|
+
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 } });
|
|
367
|
+
return { ok: true, post: w.p };
|
|
368
|
+
}
|
|
369
|
+
// A draft's picture or video, for the app to show: { file, type }, or null.
|
|
370
|
+
function mediaFile(postId, mediaId, paths = PATHS) {
|
|
371
|
+
const p = loadPosts(paths).posts.find((x) => x.id === postId), m = p && (p.media || []).find((x) => x.id === mediaId);
|
|
372
|
+
const file = m && join(mediaDir(p.id, paths), m.file);
|
|
373
|
+
return file && existsSync(file) ? { file, type: (MEDIA[m.file.split(".").pop()] || [])[1] || "application/octet-stream" } : null;
|
|
374
|
+
}
|
|
375
|
+
// The folders of drafts no longer kept (posts.json keeps the last KEEP), and a clip's leftovers, go.
|
|
376
|
+
function pruneMedia(d, paths = PATHS) {
|
|
377
|
+
const ids = new Set(d.posts.map((p) => p.id));
|
|
378
|
+
let dirs = []; try { dirs = readdirSync(paths.media); } catch { return; }
|
|
379
|
+
for (const x of dirs) if (!ids.has(x)) try { rmSync(join(paths.media, x), { recursive: true, force: true }); } catch {}
|
|
380
|
+
}
|
|
381
|
+
// The page you gave, kept as the next one's default.
|
|
382
|
+
function rememberUrl(url, paths) { const d = loadPosts(paths); d.lastUrl = url; savePosts(d, paths); }
|
|
383
|
+
// A picture of a page (headless.mjs pagePicture), added to a waiting draft. Only on your click.
|
|
384
|
+
async function pictureOfPage(id, input, { shoot = pagePicture, now = Date.now(), paths = PATHS } = {}) {
|
|
385
|
+
const w = waiting(id, paths); if (w.error) return w;
|
|
386
|
+
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." };
|
|
387
|
+
rememberUrl(String(input).trim(), paths);
|
|
388
|
+
const r = (await shoot(url)) || { error: "Nothing came back from the page." };
|
|
389
|
+
if (r.error) return { error: r.error };
|
|
390
|
+
return addMedia(id, r.png, { name: r.title || url, from: "page", url: r.url || url, now, paths });
|
|
391
|
+
}
|
|
392
|
+
// A clip of a page: recorded (headless.mjs pageClip), made an MP4 (`encode`), added. Needs ffmpeg.
|
|
393
|
+
let FFMPEG = null;
|
|
394
|
+
const canClip = () => (FFMPEG === null ? (FFMPEG = hasCmd("ffmpeg")) : FFMPEG);
|
|
395
|
+
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.";
|
|
396
|
+
async function clipOfPage(id, input, { seconds = 8, record = pageClip, encode = toVideo, now = Date.now(), paths = PATHS } = {}) {
|
|
397
|
+
const w = waiting(id, paths); if (w.error) return w;
|
|
398
|
+
if (encode === toVideo && !canClip()) return { error: NO_FFMPEG };
|
|
399
|
+
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." };
|
|
400
|
+
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." };
|
|
401
|
+
rememberUrl(String(input).trim(), paths);
|
|
402
|
+
const r = (await record(url, seconds)) || { error: "Nothing came back from the page." };
|
|
403
|
+
if (r.error) return { error: r.error };
|
|
404
|
+
const v = encode(r.frames, r.end, paths);
|
|
405
|
+
if (v.error) return { error: v.error };
|
|
406
|
+
return addMedia(id, v.mp4, { name: r.title || url, from: "clip", url: r.url || url, now, paths });
|
|
407
|
+
}
|
|
408
|
+
// ffmpeg's concat list for frames that each show until the next one (the last
|
|
409
|
+
// until `end`), times in seconds: so a page that sat still stays still that long.
|
|
410
|
+
function concatList(frames, end) {
|
|
411
|
+
const name = (i) => `f${String(i).padStart(5, "0")}.jpg`, out = ["ffconcat version 1.0"];
|
|
412
|
+
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)}`));
|
|
413
|
+
if (frames.length) out.push(`file ${name(frames.length - 1)}`); // ffmpeg keeps the last one's duration only when it's listed again
|
|
414
|
+
return out.join("\n") + "\n";
|
|
415
|
+
}
|
|
416
|
+
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",
|
|
417
|
+
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-movflags", "+faststart", "-an", "clip.mp4"];
|
|
418
|
+
// The frames as an MP4 that plays anywhere (H.264, yuv420p): { mp4 } or { error }.
|
|
419
|
+
function toVideo(frames, end, paths = PATHS) {
|
|
420
|
+
const dir = join(paths.media, `.clip-${randomBytes(4).toString("hex")}`);
|
|
421
|
+
try {
|
|
422
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
423
|
+
frames.forEach((f, i) => writeFileSync(join(dir, `f${String(i).padStart(5, "0")}.jpg`), f.jpeg));
|
|
424
|
+
writeFileSync(join(dir, "list.ffconcat"), concatList(frames, end));
|
|
425
|
+
const r = spawnSync("ffmpeg", FFMPEG_ARGS, { cwd: dir, encoding: "utf8", timeout: 180000 });
|
|
426
|
+
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"}` };
|
|
427
|
+
return { mp4: readFileSync(join(dir, "clip.mp4")) };
|
|
428
|
+
} catch (e) { return { error: `Couldn't make the video: ${e.message}` }; }
|
|
429
|
+
finally { try { rmSync(dir, { recursive: true, force: true }); } catch {} }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---- a picture by itself ---------------------------------------------------------------
|
|
433
|
+
// A draft whose picture idea (show) names a screen of an app you run here arrives
|
|
434
|
+
// with a picture of it, taken in the hidden browser as it's drafted: keep it or
|
|
435
|
+
// remove it (it saves that step most weeks). The apps (localApps): each one
|
|
436
|
+
// listening on localhost, as the repo it was started in (its process's folder),
|
|
437
|
+
// and Symbiot's own app, open, as the symbiot repo: its screens by name (the Reports
|
|
438
|
+
// view → #reports). Only a draft about that repo, whose idea is a picture of a
|
|
439
|
+
// screen (not a clip, not a command), and nothing on failure: the idea stays.
|
|
440
|
+
const SCREEN_WORD = /\b(screens?|pages?|views?|dashboards?|tabs?|panels?|panes?|home ?page|ui|app|window|cards?|blobs?|map)\b/i;
|
|
441
|
+
const NOT_A_PICTURE = /\b(clip|video|recording|gif|terminal|command|cli|commit|diff|code|log|chart|graph)\b/i;
|
|
442
|
+
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]];
|
|
443
|
+
const isSymbiot = (path) => { try { return JSON.parse(readFileSync(join(path, "package.json"), "utf8")).name === "symbiot"; } catch { return false; } };
|
|
444
|
+
// What's listening on localhost: [{ port, pid }]. ss on Linux, else lsof.
|
|
445
|
+
function listening() {
|
|
446
|
+
const out = [], local = /^(127\.0\.0\.1|0\.0\.0\.0|\*|\[::1?\]|::1?|localhost)$/;
|
|
447
|
+
if (hasCmd("ss")) {
|
|
448
|
+
for (const l of sh("ss -ltnpH 2>/dev/null").split("\n")) {
|
|
449
|
+
const f = l.trim().split(/\s+/), addr = f[3] || "", at = addr.lastIndexOf(":"), pid = (l.match(/pid=(\d+)/) || [])[1];
|
|
450
|
+
if (at > 0 && pid && local.test(addr.slice(0, at))) out.push({ port: Number(addr.slice(at + 1)), pid: Number(pid) });
|
|
451
|
+
}
|
|
452
|
+
} else if (hasCmd("lsof")) {
|
|
453
|
+
let pid = 0; for (const l of sh("lsof -nP -iTCP -sTCP:LISTEN -Fpn 2>/dev/null").split("\n")) {
|
|
454
|
+
if (l[0] === "p") pid = Number(l.slice(1));
|
|
455
|
+
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 }); }
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return out.filter((x) => x.port > 0 && x.pid > 0);
|
|
459
|
+
}
|
|
460
|
+
function cwdOf(pid) {
|
|
461
|
+
try { return readlinkSync(`/proc/${pid}/cwd`); } catch {}
|
|
462
|
+
return (sh(`lsof -a -p ${Number(pid)} -d cwd -Fn 2>/dev/null`).split("\n").find((l) => l[0] === "n") || "").slice(1);
|
|
463
|
+
}
|
|
464
|
+
// The apps running here, by repo: [{ repo, url, symbiot? }]; url carries no token.
|
|
465
|
+
async function localApps(repos = [], { ports = listening, cwd = cwdOf, app = symbiotApp } = {}) {
|
|
466
|
+
const out = [];
|
|
467
|
+
for (const r of repos) {
|
|
468
|
+
if (isSymbiot(r.path)) { const a = await app(); if (a) out.push({ repo: r.name, url: a.url, open: a.open, symbiot: true }); continue; }
|
|
469
|
+
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];
|
|
470
|
+
if (hit) out.push({ repo: r.name, url: `http://localhost:${hit.port}/` });
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
// Symbiot's own app, if it's open: { url, open } (open has its token, to load it).
|
|
475
|
+
// Whether it's listening, by a local connection: post.mjs never sends anything out.
|
|
476
|
+
async function symbiotApp() {
|
|
477
|
+
const cfg = loadConfig(), port = Number(process.env.SYMBIOT_PORT || cfg.appPort) || 7391; if (!cfg.appToken) return null;
|
|
478
|
+
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)); });
|
|
479
|
+
return up ? { url: `http://127.0.0.1:${port}/`, open: `http://127.0.0.1:${port}/?t=${encodeURIComponent(cfg.appToken)}` } : null;
|
|
480
|
+
}
|
|
481
|
+
// The page a draft's idea names, in an app of its repo's: { url, open } or null.
|
|
482
|
+
function screenFor(post, apps, facts) {
|
|
483
|
+
const show = String(post.show || ""); if (!show || NOT_A_PICTURE.test(show) || !SCREEN_WORD.test(show)) return null;
|
|
484
|
+
const repos = new Set((post.facts || []).map((n) => facts[n - 1] && facts[n - 1].repo).filter(Boolean));
|
|
485
|
+
const a = apps.find((x) => repos.has(x.repo)); if (!a) return null;
|
|
486
|
+
if (!a.symbiot) { const path = (show.match(/(?:^|\s)(\/[\w./-]*[\w/])/) || [])[1] || ""; return { url: a.url.replace(/\/$/, "") + (path || "/"), open: a.url.replace(/\/$/, "") + (path || "/") }; }
|
|
487
|
+
const s = (SYMBIOT_SCREENS.find(([, re]) => re.test(show)) || [])[0];
|
|
488
|
+
return { url: a.url + (s ? "#" + s : ""), open: a.open + (s ? "#" + s : "") };
|
|
489
|
+
}
|
|
490
|
+
// Take each one's picture (one at a time: the hidden browser is one). How many came.
|
|
491
|
+
async function picturesFor(posts, facts, { apps = [], shoot = pagePicture, now = Date.now(), paths = PATHS } = {}) {
|
|
492
|
+
let n = 0;
|
|
493
|
+
for (const p of posts) {
|
|
494
|
+
const at = screenFor(p, apps, facts); if (!at) continue;
|
|
495
|
+
const r = await shoot(at.open).catch(() => null);
|
|
496
|
+
if (!r || r.error || !r.png) continue;
|
|
497
|
+
const m = addMedia(p.id, r.png, { name: r.title || at.url, from: "page", url: at.url, now, paths });
|
|
498
|
+
if (m.ok) { p.media = m.post.media; n++; }
|
|
499
|
+
}
|
|
500
|
+
return n;
|
|
291
501
|
}
|
|
292
502
|
|
|
293
503
|
// ---- clipboard, and opening the share box, from a terminal -----------------------
|
|
@@ -319,4 +529,5 @@ const CUSTOMER = [
|
|
|
319
529
|
];
|
|
320
530
|
function maybeCustomer(text) { const s = String(text || ""); const hit = CUSTOMER.find(([, re]) => re.test(s)); return hit ? hit[0] : ""; }
|
|
321
531
|
|
|
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
|
|
532
|
+
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,
|
|
533
|
+
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"));
|