symbiot 0.63.6 → 0.63.8
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 +41 -0
- package/README.md +39 -5
- package/agents.mjs +52 -11
- package/browser.html +21 -3
- package/capture.mjs +761 -0
- package/demo.mjs +171 -0
- package/guard.mjs +23 -5
- package/handback.mjs +22 -8
- package/handover.mjs +6 -2
- package/headless.mjs +281 -80
- package/home.mjs +23 -41
- package/index.mjs +65 -11
- package/lanes.mjs +99 -9
- package/links.mjs +52 -2
- package/marketing.mjs +40 -7
- package/mdns.mjs +6 -4
- package/package.json +6 -3
- package/phone.mjs +79 -12
- package/plain.mjs +48 -0
- package/reports.mjs +15 -1
- package/server.mjs +89 -42
- package/tray.mjs +44 -4
- package/ui.mjs +317 -47
package/server.mjs
CHANGED
|
@@ -25,20 +25,21 @@ import { computeDrift } from "./drift.mjs";
|
|
|
25
25
|
import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
|
|
26
26
|
import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
|
|
27
27
|
import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
|
|
28
|
-
import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, setBrowserHub, siteUrl, browserHub } from "./headless.mjs";
|
|
28
|
+
import { mapPage, wholePage, pressRegion, typeRegion, chooseRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite, openSymbiotBrowser, closeSymbiotBrowser, onBrowserClosed, setBrowserHub, siteUrl, browserHub } from "./headless.mjs";
|
|
29
29
|
import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
|
|
30
30
|
import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
|
|
31
|
-
import { linksState, linkSite, checkLink, unlinkSite, addSite, signInAsked } from "./links.mjs";
|
|
31
|
+
import { linksState, linkSite, checkLink, unlinkSite, addSite, signInAsked, sitesCheck } from "./links.mjs";
|
|
32
32
|
import { testWeeks, testInstalls, postsState, draftPosts, approvePost, editPost, skipPost, voiceFromLinkedIn, addMedia, removeMedia, mediaFile, mediaDir, pictureOfPage, clipOfPage, openUrl } from "./post.mjs";
|
|
33
33
|
import { mindState, forget } from "./mind.mjs";
|
|
34
|
-
import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
|
|
34
|
+
import { lanesTick, lanesState, partlyDone, handingOf, loadLedger, orcaRelink } from "./lanes.mjs";
|
|
35
35
|
import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
|
|
36
36
|
import { adaptState, noteUse } from "./adapt.mjs";
|
|
37
37
|
import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding, startOnboarding, phoneSetupFirst } from "./home.mjs";
|
|
38
38
|
import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName, setDraftMedia } from "./marketing.mjs";
|
|
39
|
-
import { trays, trayMedia, setBlur, renderCapture } from "./tray.mjs";
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
39
|
+
import { trays, trayMedia, setBlur, addBlur, removeBlur, setReviewed, renderCapture } from "./tray.mjs";
|
|
40
|
+
import { captureProduct, captureProducts } from "./capture.mjs";
|
|
41
|
+
import { listReports, readReport, reportImage, markAllRead, phoneReports, phoneReport } from "./reports.mjs";
|
|
42
|
+
import { computerCopy, phoneState, setPhoneLink, newCode, unpairPhone, setRelay, findComputers, pairComputer, forgetComputer, pollComputer, queueChange, dismissNote, setPhoneApprove, startPhone } from "./phone.mjs";
|
|
42
43
|
import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
|
|
43
44
|
import { runChecks, checksState, markClashesSeen } from "./checks.mjs";
|
|
44
45
|
|
|
@@ -50,6 +51,24 @@ function updateCmd(latest, current, platform = process.platform) {
|
|
|
50
51
|
const target = latest && semverGt(latest, current) ? latest : "latest";
|
|
51
52
|
return { target, cmd: (platform === "win32" ? "npm i -g " : "npm install -g ") + "symbiot@" + target + " --prefer-online" };
|
|
52
53
|
}
|
|
54
|
+
// npm lists a new version minutes before its download is there: 1 to 5 minutes for
|
|
55
|
+
// most releases, 11 for 0.59.0 (~/.symbiot/agent.log, 0.52.0 to 0.63.3). Five tries 30s
|
|
56
|
+
// apart gave up on 0.59.0, 0.62.0 and 0.63.3 and sent you to a terminal. So the update
|
|
57
|
+
// looks for the download itself before it runs npm, and keeps looking (and trying
|
|
58
|
+
// again on a 404) for up to 20 minutes. Only a 404 waits: anything else (offline, say)
|
|
59
|
+
// goes on to npm, whose own error says what's wrong.
|
|
60
|
+
const UPDATE_EVERY = Number(process.env.SYMBIOT_UPDATE_EVERY) || 30000, UPDATE_WAIT = 20 * 60 * 1000;
|
|
61
|
+
async function downloadReady(version, registry = REGISTRY, fetchFn = fetch) {
|
|
62
|
+
if (!/^\d+\.\d+\.\d+/.test(String(version))) return true; // "latest": npm picks it, nothing to look at
|
|
63
|
+
try { return (await fetchFn(`${registry}/symbiot/-/symbiot-${encodeURIComponent(version)}.tgz`, { method: "HEAD", signal: AbortSignal.timeout(10000) })).status !== 404; } catch { return true; }
|
|
64
|
+
}
|
|
65
|
+
// This update's own lines in agent.log (an earlier run's 404 isn't this one's), and
|
|
66
|
+
// the line that says what went wrong.
|
|
67
|
+
function updateOutcome(log) {
|
|
68
|
+
const mine = String(log || "").slice(String(log || "").lastIndexOf("=== symbiot update"));
|
|
69
|
+
const why = (mine.match(/npm (?:error|ERR!) (?!code\b|syscall\b|errno\b|path\b|dest\b|404\s*$|A complete log)([^\n]+)/m) || [])[1] || "";
|
|
70
|
+
return { notYet: /E404|ETARGET|notarget|No matching version|is not in this registry/i.test(mine), why: why.trim().slice(0, 200) };
|
|
71
|
+
}
|
|
53
72
|
|
|
54
73
|
// ---- what's new: the changelog's releases between two versions -------------------
|
|
55
74
|
// CHANGELOG.md's "## 0.45.0 — 2026-10-06" sections newer than `from` and up to
|
|
@@ -247,8 +266,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
247
266
|
setScanOptions({ cache: true }); REPO_STATE.onChange = () => { try { homeState({ fresh: true }); } catch {} };
|
|
248
267
|
if (!SANDBOX) { refreshRevoked().catch(() => {}); setInterval(() => refreshRevoked().catch(() => {}), 6 * 3600 * 1000).unref(); } // cancelled Pro keys, about daily
|
|
249
268
|
{ const t0 = Date.now(); refreshRepos().then((l) => console.log(`Found ${l.length} project${l.length === 1 ? "" : "s"} in ${((Date.now() - t0) / 1000).toFixed(1)}s.`)); }
|
|
250
|
-
let UPDATING = null; // an update in flight: { target, attempt, retrying? }
|
|
251
|
-
|
|
269
|
+
let UPDATING = null; // an update in flight: { target, attempt, since, retrying? }
|
|
270
|
+
let UPDATE_FAILED = null; // the last one that didn't go through: { target, error, at }
|
|
271
|
+
let HUB_SEEN = 0; // when the Symbiot Browser's own page last asked for the sites (browser.html)
|
|
272
|
+
onBrowserClosed(() => { sitesCheck(); }); // its last window closed by its X: checked, as Done does
|
|
252
273
|
let NEWEST_WIN = ""; // the Symbiot window opened last: older ones close themselves (one window, not a pile)
|
|
253
274
|
process.on("exit", () => closeAway()); // Away's windows go with the app (a quit, an update's restart)
|
|
254
275
|
const server = createServer(async (req, res) => {
|
|
@@ -333,7 +354,10 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
333
354
|
const e = track("ollama pull " + model, "ollama pull " + shSingle(model), homedir(), (code) => { if (code === 0) useOllamaModel(model); });
|
|
334
355
|
return json(res, { started: true, model, id: e ? e.id : "" });
|
|
335
356
|
}
|
|
336
|
-
if (u.pathname === "/api/agents")
|
|
357
|
+
if (u.pathname === "/api/agents") { // handing: what it handed to other lanes, on the newest block per folder: where each stands, and how far the run on it has got
|
|
358
|
+
const seen = new Set(), map = laneMap(), ledger = loadLedger(), list = agentsList().map((a) => { if (!a.path || seen.has(a.path)) return a; seen.add(a.path); let h = []; try { h = handingOf(a.path, { map, ledger }); } catch {} return h.length ? { ...a, handing: h } : a; });
|
|
359
|
+
return json(res, await withReleases(list));
|
|
360
|
+
}
|
|
337
361
|
// the answer, saved in the folder that asked; it says which lane that is, so "Sent to …"
|
|
338
362
|
// names where it went (a Marketing answer said "Sent to Paperclip Steve")
|
|
339
363
|
if (u.pathname === "/api/agents/answer" && req.method === "POST") { const b = await readBody(req), path = String(b.path || ""), r = answerQuestions(path, b.answers, { rerun: !!b.rerun }); return json(res, r && r.ok ? { ...r, lane: laneOfFolder(path) } : r); }
|
|
@@ -366,15 +390,19 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
366
390
|
// A web page in the hidden browser (headless.mjs): mapped by itself, no
|
|
367
391
|
// screen needed. Press acts on the real site, signed in as you, so it's
|
|
368
392
|
// confirmed like a click; Sign in opens a window the user signs in with.
|
|
369
|
-
if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name, { whole: b.whole === true }))); }
|
|
393
|
+
if (u.pathname === "/api/screens/map" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await mapPage(b.site, b.name, { whole: b.whole === true, who: String(b.who || "") }))); }
|
|
370
394
|
// press / type: refused unless confirmed, or the page's site is one you trust
|
|
371
|
-
if (u.pathname === "/api/screens/press" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await pressRegion(String(b.id || ""), String(b.region || ""), { confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
|
|
395
|
+
if (u.pathname === "/api/screens/press" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await pressRegion(String(b.id || ""), String(b.region || ""), { confirmed: b.confirmed === true, noSend: b.noSend === true, who: String(b.who || "") }))); }
|
|
372
396
|
// scroll and whole page: they only look, so they never ask
|
|
373
|
-
if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || "")))); }
|
|
374
|
-
if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down")))); }
|
|
375
|
-
if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
|
|
397
|
+
if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || ""), { who: String(b.who || "") }))); }
|
|
398
|
+
if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down"), { who: String(b.who || "") }))); }
|
|
399
|
+
if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true, who: String(b.who || "") }))); }
|
|
400
|
+
// its own route, so an app from before it answers 404 instead of retyping the whole field
|
|
401
|
+
if (u.pathname === "/api/screens/replace" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true, replace: String(b.replace == null ? "" : b.replace), who: String(b.who || "") }))); }
|
|
402
|
+
// choose: an option in a menu (a <select>, or the site's own), asked like press and type
|
|
403
|
+
if (u.pathname === "/api/screens/choose" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await chooseRegion(String(b.id || ""), String(b.region || ""), b.option, { confirmed: b.confirmed === true, who: String(b.who || "") }))); }
|
|
376
404
|
// upload: a file of yours to the page's file box, asked like press and type
|
|
377
|
-
if (u.pathname === "/api/screens/upload" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await uploadRegion(String(b.id || ""), String(b.region || ""), Array.isArray(b.files) ? b.files.map(String) : String(b.files || ""), { confirmed: b.confirmed === true }))); }
|
|
405
|
+
if (u.pathname === "/api/screens/upload" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await uploadRegion(String(b.id || ""), String(b.region || ""), Array.isArray(b.files) ? b.files.map(String) : String(b.files || ""), { confirmed: b.confirmed === true, who: String(b.who || "") }))); }
|
|
378
406
|
if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
|
|
379
407
|
if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
|
|
380
408
|
if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
|
|
@@ -383,10 +411,13 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
383
411
|
if (u.pathname === "/api/mind") return json(res, mindState());
|
|
384
412
|
if (u.pathname === "/api/mind/forget" && req.method === "POST") { const b = await readBody(req); return json(res, forget(String(b.id || ""))); }
|
|
385
413
|
// Links (links.mjs): one click per standard work site: sign in, trust it, watch it.
|
|
386
|
-
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)
|
|
414
|
+
if (u.pathname === "/api/links") { if (u.searchParams.get("hub") === "1") HUB_SEEN = Date.now(); let reach = null; try { reach = linkReach(); } catch {} return json(res, { ...linksState(), ...(reach ? { reach } : {}) }); } // reach: which sites agent runs can use too (agents.mjs)
|
|
387
415
|
if (u.pathname === "/api/links/link" && req.method === "POST") { const b = await readBody(req); return json(res, await linkSite(String(b.id || ""), b.here ? { open: async (url) => ({ ok: true, url }) } : undefined)); } // here: from the Symbiot Browser, which opens the site itself
|
|
388
416
|
if (u.pathname === "/api/browser/open" && req.method === "POST") { const b = await readBody(req); if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { ok: false, note: "not opened (SYMBIOT_NO_OPEN)", page: browserHub() }); return json(res, await openSymbiotBrowser(b.url ? siteUrl(String(b.url)) : "")); }
|
|
389
|
-
|
|
417
|
+
// Done: it closes, and every site is checked (links.mjs sitesCheck). Its page's X sends
|
|
418
|
+
// closed: the same, unless the page is back within a moment (a reload, not a close).
|
|
419
|
+
if (u.pathname === "/api/browser/done" && req.method === "POST") { if (process.env.SYMBIOT_NO_OPEN === "1") return json(res, { closed: 0 }); const r = await closeSymbiotBrowser(); sitesCheck(); return json(res, { ...r, checking: true }); }
|
|
420
|
+
if (u.pathname === "/api/browser/closed" && req.method === "POST") { const at = Date.now(); if (process.env.SYMBIOT_NO_OPEN !== "1") setTimeout(() => { if (HUB_SEEN > at) return; closeSymbiotBrowser().catch(() => {}).then(() => sitesCheck()); }, 3000).unref(); return json(res, { ok: true }); }
|
|
390
421
|
// any site, added in Settings → Connections or the Symbiot Browser's address bar, or a card's / reply's "Sign in to <site>": linked and opened to sign in
|
|
391
422
|
if (u.pathname === "/api/links/site" && req.method === "POST") { const b = await readBody(req), here = b.here ? { open: async (url) => ({ ok: true, url }) } : undefined; return json(res, b.id ? await linkSite(String(b.id), here) : await addSite(String(b.site || ""), here)); }
|
|
392
423
|
if (u.pathname === "/api/links/check" && req.method === "POST") { const b = await readBody(req); return json(res, await checkLink(String(b.id || ""))); }
|
|
@@ -416,10 +447,15 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
416
447
|
if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
|
|
417
448
|
// the pick tray (tray.mjs): its captures and their blur boxes, a capture (only from inside
|
|
418
449
|
// a tray), a box switched on or off and the capture blurred again, a capture as a post's media
|
|
419
|
-
if (u.pathname === "/api/marketing/tray") return json(res, { trays: trays() });
|
|
450
|
+
if (u.pathname === "/api/marketing/tray") { let products = []; try { products = captureProducts(); } catch {} return json(res, { trays: trays(), products }); }
|
|
420
451
|
if (u.pathname === "/api/marketing/tray/media") { const m = trayMedia(String(u.searchParams.get("rel") || "")); 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; }
|
|
421
452
|
if (u.pathname === "/api/marketing/tray/blur" && req.method === "POST") { const b = await readBody(req), r = setBlur(String(b.tray || ""), String(b.name || ""), b.id, !!b.on); if (r.error) return json(res, r); const d = await renderCapture(String(b.tray), String(b.name)); return json(res, { ...r, rendered: !!d.ok, ...(d.error ? { note: d.error } : {}) }); }
|
|
422
|
-
if (u.pathname === "/api/marketing/tray/use" && req.method === "POST") { const b = await readBody(req); return json(res, setDraftMedia(String(b.rel || ""), [].concat(b.file || []).map(String))); }
|
|
453
|
+
if (u.pathname === "/api/marketing/tray/use" && req.method === "POST") { const b = await readBody(req); return json(res, setDraftMedia(String(b.rel || ""), [].concat(b.file || []).map(String))); } // one to look over first is refused there (marketing.mjs lookOver)
|
|
454
|
+
// a box of your own (drawn on it), one gone, and a capture looked over (tray.mjs); then it's blurred again
|
|
455
|
+
if (u.pathname === "/api/marketing/tray/box" && req.method === "POST") { const b = await readBody(req), r = b.remove != null ? removeBlur(String(b.tray || ""), String(b.name || ""), b.remove) : addBlur(String(b.tray || ""), String(b.name || ""), b.box); if (r.error) return json(res, r); const d = await renderCapture(String(b.tray), String(b.name)); return json(res, { ...r, rendered: !!d.ok, ...(d.error ? { note: d.error } : {}) }); }
|
|
456
|
+
if (u.pathname === "/api/marketing/tray/reviewed" && req.method === "POST") { const b = await readBody(req); return json(res, setReviewed(String(b.tray || ""), String(b.name || ""))); }
|
|
457
|
+
// Symbiot's own capture of a product, for its pick tray (capture.mjs): `symbiot capture`, the lane's first run, or the page's Capture
|
|
458
|
+
if (u.pathname === "/api/marketing/capture" && req.method === "POST") { const b = await readBody(req); return json(res, await captureProduct({ product: String(b.product || ""), url: String(b.url || ""), window: String(b.window || ""), screens: [].concat(b.screens || []).map(String), clips: b.clips !== false, ...(Number(b.seconds) > 0 ? { seconds: Math.min(20, Number(b.seconds)) } : {}) })); }
|
|
423
459
|
if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
|
|
424
460
|
if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""))); }
|
|
425
461
|
if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
|
|
@@ -433,8 +469,9 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
433
469
|
// reads your LinkedIn only on your click (confirmed), never by itself
|
|
434
470
|
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()); }
|
|
435
471
|
// Reports (reports.mjs): what runs wrote up in their .symbiot/; read by id, never by path.
|
|
436
|
-
|
|
437
|
-
if (u.pathname === "/api/reports
|
|
472
|
+
// on a phone, its computer's newest reports too (from the copy it keeps)
|
|
473
|
+
if (u.pathname === "/api/reports") return json(res, { reports: [...listReports(), ...phoneReports(computerCopy())].sort((a, b) => b.mtime - a.mtime) });
|
|
474
|
+
if (u.pathname === "/api/reports/read") { const id = u.searchParams.get("id") || ""; return json(res, id.startsWith("pc:") ? phoneReport(computerCopy(), id) : readReport(id)); }
|
|
438
475
|
// A report's image (an <img> src, so its token is in ?t=): by the report's id and a src it shows, from its allowed folders only.
|
|
439
476
|
if (u.pathname === "/api/reports/image") { const f = reportImage(u.searchParams.get("id") || "", u.searchParams.get("src") || ""); if (f.error) { res.writeHead(404, { "content-type": "text/plain" }); res.end(f.error); return; } res.writeHead(200, { "content-type": f.type, "cache-control": "private, no-cache", "x-content-type-options": "nosniff" }); res.end(readFileSync(f.file)); return; }
|
|
440
477
|
if (u.pathname === "/api/reports/seen" && req.method === "POST") return json(res, markAllRead());
|
|
@@ -516,7 +553,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
516
553
|
// a sandbox (symbiot app --fresh) never offers an update: it would replace your real install.
|
|
517
554
|
// A headless browser (an agent's screenshot of Home) never becomes the newest window:
|
|
518
555
|
// that closed the user's real one, again and again (2026-10-08, "symbiot keeps crashing").
|
|
519
|
-
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING
|
|
556
|
+
if (u.pathname === "/api/ping") { if (u.searchParams.get("fresh") === "1" && !SANDBOX) await checkLatest(); const w = u.searchParams.get("w") || ""; if (/^[a-z0-9]{6,20}$/.test(w) && u.searchParams.get("new") === "1" && !headlessAgent(req.headers["user-agent"])) NEWEST_WIN = w; return json(res, { ...(NEWEST_WIN ? { window: NEWEST_WIN } : {}), version: VERSION, started: SERVER_STARTED, latest: LATEST_VERSION, newer: !SANDBOX && !DESKTOP && semverGt(LATEST_VERSION, VERSION), ...(DESKTOP ? { desktop: true } : {}), ...(UPDATING ? { updating: { ...UPDATING, every: UPDATE_EVERY, wait: UPDATE_WAIT } } : {}), ...(UPDATE_FAILED ? { updateFailed: UPDATE_FAILED } : {}), ...(IN_TERMUX ? { termux: true } : {}), ...(SANDBOX ? { sandbox: true } : {}) }); }
|
|
520
557
|
// What's new: after an update, since the version you last saw (until you click Got it);
|
|
521
558
|
// ?latest=1, what the update on offer brings, from its package on npm
|
|
522
559
|
if (u.pathname === "/api/whatsnew") {
|
|
@@ -550,26 +587,36 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
550
587
|
// relaunch, so it takes over instead of finding us and bowing out.
|
|
551
588
|
// SYMBIOT_UPDATE_CMD replaces the install (the tests use a no-op).
|
|
552
589
|
if (SANDBOX) return json(res, { error: "This is a sandbox (symbiot app --fresh): update your real Symbiot instead." });
|
|
590
|
+
// One at a time: a second click (another window, or one while it waits) started a
|
|
591
|
+
// second npm install over the first, and they collided (ENOTEMPTY, 0.61.1).
|
|
592
|
+
// (an install still running after 10 minutes has hung: a click starts it afresh)
|
|
593
|
+
if (UPDATING && (UPDATING.retrying || Date.now() - UPDATING.at < 10 * 60 * 1000)) return json(res, { started: true, already: true, target: UPDATING.target });
|
|
553
594
|
const { target, cmd } = updateCmd(LATEST_VERSION, VERSION);
|
|
554
|
-
const inst = process.env.SYMBIOT_UPDATE_CMD || cmd;
|
|
555
|
-
//
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
595
|
+
const inst = process.env.SYMBIOT_UPDATE_CMD || cmd, since = Date.now();
|
|
596
|
+
// not downloadable yet (see downloadReady): look again in 30s, for up to 20 minutes, and say so
|
|
597
|
+
const again = (n, error) => {
|
|
598
|
+
if (Date.now() - since + UPDATE_EVERY > UPDATE_WAIT) { UPDATING = null; UPDATE_FAILED = { target, error, at: Date.now() }; return null; }
|
|
599
|
+
UPDATING = { target, retrying: true, attempt: n + 1, since, at: Date.now() }; setTimeout(() => attempt(n + 1), UPDATE_EVERY).unref(); return null;
|
|
600
|
+
};
|
|
601
|
+
const attempt = async (n) => {
|
|
602
|
+
if (!(await downloadReady(target))) return again(n, `npm lists ${target}, but its download still wasn't there after 20 minutes.`);
|
|
603
|
+
const e = track("symbiot update", inst, homedir(), (code) => {
|
|
604
|
+
if (code !== 0) {
|
|
605
|
+
const o = updateOutcome(readRunLog(join(homedir(), ".symbiot", "agent.log")).slice(-6000));
|
|
606
|
+
if (o.notYet) again(n, o.why || `npm couldn't find ${target}'s download.`);
|
|
607
|
+
else { UPDATING = null; UPDATE_FAILED = { target, error: o.why || `npm stopped (exit code ${code}).`, at: Date.now() }; }
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
server.close(); if (server.closeAllConnections) server.closeAllConnections();
|
|
611
|
+
try { const ch = spawn(process.execPath, process.argv.slice(1), { detached: true, stdio: "ignore", env: { ...process.env, SYMBIOT_RELAUNCH: "1" }, cwd: homedir() }); ch.unref(); } catch {}
|
|
612
|
+
setTimeout(() => process.exit(0), 1200);
|
|
613
|
+
});
|
|
614
|
+
if (!e) { UPDATING = null; UPDATE_FAILED = { target, error: "Symbiot couldn't start npm.", at: Date.now() }; }
|
|
615
|
+
return e;
|
|
616
|
+
};
|
|
617
|
+
UPDATING = { target, attempt: 1, since, at: Date.now() }; UPDATE_FAILED = null;
|
|
618
|
+
const e = await attempt(1);
|
|
619
|
+
return json(res, { started: true, id: e ? e.id : "", target, ...(UPDATING && UPDATING.retrying ? { waiting: true } : {}) });
|
|
573
620
|
}
|
|
574
621
|
if (u.pathname === "/api/quit") { stopVoices(); res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
|
|
575
622
|
} catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
|
|
@@ -629,4 +676,4 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
|
|
|
629
676
|
setInterval(() => { try { awaitTick(); } catch {} }, 60000).unref(); // a reply an agent's email waits on: found, and handed on
|
|
630
677
|
}
|
|
631
678
|
|
|
632
|
-
export { headlessAgent, updateCmd, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
|
|
679
|
+
export { headlessAgent, updateCmd, downloadReady, updateOutcome, changesSince, whatsNew, tarFile, registryChangelog, BROWSER_KEEP, startApp, askRunningApp, isAppRunningWeekly, openApp };
|
package/tray.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { join, dirname, basename } from "node:path";
|
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, realpathSync, renameSync, rmSync, mkdtempSync } from "node:fs";
|
|
14
14
|
import { tmpdir } from "node:os";
|
|
15
15
|
import { spawn, spawnSync } from "node:child_process";
|
|
16
|
-
import { MARKETING_DIR, MEDIA_TYPES, draftFiles, parseDraft, inLane } from "./marketing.mjs";
|
|
16
|
+
import { MARKETING_DIR, MEDIA_TYPES, draftFiles, parseDraft, inLane, reviewOf } from "./marketing.mjs";
|
|
17
17
|
|
|
18
18
|
const NAME = /^\w[\w.-]{0,100}$/; // a capture's file: no slashes, no dot first
|
|
19
19
|
const IMG = /\.(?:png|jpe?g|gif|webp)$/i, VID = /\.(?:mp4|webm|mov)$/i;
|
|
@@ -44,12 +44,15 @@ function trayOf(dir, rel) {
|
|
|
44
44
|
const use = (it.kind === "still" ? files.find((f) => IMG.test(f)) : files.find((f) => VID.test(f))) || show;
|
|
45
45
|
const spec = NAME.test(`${it.name}.blur.json`) ? readJson(join(root, `${it.name}.blur.json`)) : null;
|
|
46
46
|
const [w, h] = spec && Array.isArray(spec.size) ? spec.size.map(Number) : [0, 0];
|
|
47
|
-
const regions = spec && Array.isArray(spec.regions) && w > 0 && h > 0 ? spec.regions.filter((r) => r && r.id != null).map((r) => ({ id: r.id, kind: String(r.kind || "box"), hint: String(r.hint || ""), on: !!r.on,
|
|
47
|
+
const regions = spec && Array.isArray(spec.regions) && w > 0 && h > 0 ? spec.regions.filter((r) => r && r.id != null).map((r) => ({ id: r.id, kind: String(r.kind || "box"), hint: String(r.hint || ""), on: !!r.on, ...(r.hand ? { hand: true } : {}),
|
|
48
48
|
x: r.x / w, y: r.y / h, w: r.w / w, h: r.h / h, ...(r.from != null || r.to != null ? { from: Number(r.from) || 0, to: Number(r.to) || 0 } : {}) })) : [];
|
|
49
49
|
let at = 0; try { at = show ? statSync(join(root, show)).mtimeMs : 0; } catch {}
|
|
50
50
|
return { name: it.name, kind: String(it.kind || (spec && spec.kind) || "still"), caption: String(it.caption || (spec && spec.caption) || ""), screen: String(it.screen || ""), for: (Array.isArray(it.for) ? it.for : []).map(String),
|
|
51
|
-
show: show ? `${rel}/${show}` : "", showKind: VID.test(show) ? "video" : "image", use: use ? `${rel}/${use}` : "", blur: !!spec, regions, parts: Array.isArray(it.parts) ? it.parts.map(String) : [], at
|
|
51
|
+
show: show ? `${rel}/${show}` : "", showKind: VID.test(show) ? "video" : "image", use: use ? `${rel}/${use}` : "", blur: !!spec, regions, parts: Array.isArray(it.parts) ? it.parts.map(String) : [], at,
|
|
52
|
+
...(spec && spec.desktop ? { desktop: true } : {}), ...(spec && Array.isArray(spec.size) ? { size: spec.size.map(Number) } : {}) };
|
|
52
53
|
});
|
|
54
|
+
// to look over before it's used: its own reason, or a reel's from the clips in it
|
|
55
|
+
for (const it of items) { const why = lookFirst(root, it); if (why) it.review = why; }
|
|
53
56
|
return { rel, folder: dirname(rel), items };
|
|
54
57
|
}
|
|
55
58
|
// Every tray in the lane, each with its product's drafts (not those in the tray) to use a
|
|
@@ -75,6 +78,43 @@ function setBlur(tray, name, id, on, { dir = MARKETING_DIR } = {}) {
|
|
|
75
78
|
try { writeFileSync(f, JSON.stringify(spec, null, 2)); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
|
|
76
79
|
return { ok: true, on: !!on, ids };
|
|
77
80
|
}
|
|
81
|
+
// ---- yours: a box drawn, and a capture looked over --------------------------------------
|
|
82
|
+
// A box of your own over a capture (x, y, w, h as fractions of it, as the page draws it),
|
|
83
|
+
// blurred: on a clip, all the way through. { ok, id } or { error }.
|
|
84
|
+
function specOf(dir, tray, name) {
|
|
85
|
+
if (!isTray(dir, tray) || !NAME.test(String(name || ""))) return { error: "That capture isn't in a tray any more." };
|
|
86
|
+
const f = join(dir, tray, `${name}.blur.json`), spec = readJson(f);
|
|
87
|
+
if (!spec || !Array.isArray(spec.regions)) return { error: "That capture has no blur boxes to add to." };
|
|
88
|
+
return { f, spec };
|
|
89
|
+
}
|
|
90
|
+
const saveSpec = (f, spec) => { try { writeFileSync(f, JSON.stringify(spec, null, 2)); return null; } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; } };
|
|
91
|
+
function addBlur(tray, name, box, { dir = MARKETING_DIR } = {}) {
|
|
92
|
+
const o = specOf(dir, tray, name); if (o.error) return o;
|
|
93
|
+
const [W, H] = Array.isArray(o.spec.size) ? o.spec.size.map(Number) : [0, 0];
|
|
94
|
+
if (!(W > 0 && H > 0)) return { error: "Couldn't tell how big the capture is." };
|
|
95
|
+
const n = (v) => Math.min(1, Math.max(0, Number(v)));
|
|
96
|
+
const b = box || {}, x = n(b.x), y = n(b.y), w = Math.min(1 - x, n(b.w)), h = Math.min(1 - y, n(b.h));
|
|
97
|
+
if (![b.x, b.y, b.w, b.h].every((v) => Number.isFinite(Number(v))) || w * W < 4 || h * H < 4) return { error: "Draw the box over what to blur: drag across it." };
|
|
98
|
+
const id = Math.max(0, ...o.spec.regions.map((r) => Number(r && r.id) || 0)) + 1;
|
|
99
|
+
o.spec.regions.push({ id, kind: "yours", x: Math.round(x * W), y: Math.round(y * H), w: Math.round(w * W), h: Math.round(h * H), hint: "", on: true, hand: true });
|
|
100
|
+
return saveSpec(o.f, o.spec) || { ok: true, id };
|
|
101
|
+
}
|
|
102
|
+
// A box you drew, gone. One Symbiot found stays (switch it off instead), so it can go back on.
|
|
103
|
+
function removeBlur(tray, name, id, { dir = MARKETING_DIR } = {}) {
|
|
104
|
+
const o = specOf(dir, tray, name); if (o.error) return o;
|
|
105
|
+
const r = o.spec.regions.find((x) => x && String(x.id) === String(id));
|
|
106
|
+
if (!r) return { error: "That blur box isn't there any more." };
|
|
107
|
+
if (!r.hand) return { error: "Symbiot found that one: switch it off instead, so it can go back on." };
|
|
108
|
+
o.spec.regions = o.spec.regions.filter((x) => x !== r);
|
|
109
|
+
return saveSpec(o.f, o.spec) || { ok: true };
|
|
110
|
+
}
|
|
111
|
+
// A capture to look over before it's used (marketing.mjs reviewOf): its own reason, or a reel's from its clips.
|
|
112
|
+
const lookFirst = (root, it) => reviewOf(root, it.name, it.kind, it.parts);
|
|
113
|
+
function setReviewed(tray, name, { dir = MARKETING_DIR, now = Date.now() } = {}) {
|
|
114
|
+
const o = specOf(dir, tray, name); if (o.error) return o;
|
|
115
|
+
o.spec.reviewed = now;
|
|
116
|
+
return saveSpec(o.f, o.spec) || { ok: true };
|
|
117
|
+
}
|
|
78
118
|
// The capture rendered again from its original with its boxes as they are now, and every
|
|
79
119
|
// reel it's in joined again (a box blurred in a clip is blurred in its reel too). One at a
|
|
80
120
|
// time a capture: two clicks don't write the same files at once. { ok, files } or { error }.
|
|
@@ -151,4 +191,4 @@ function ffmpeg(args, { timeout = 180000 } = {}) {
|
|
|
151
191
|
});
|
|
152
192
|
}
|
|
153
193
|
|
|
154
|
-
export { trayDirs, trayMedia, trayOf, trays, setBlur, renderCapture };
|
|
194
|
+
export { trayDirs, trayMedia, trayOf, trays, setBlur, addBlur, removeBlur, setReviewed, renderCapture };
|